Files
systemic-game-design-course/course/harness.md
T
2026-10-28 23:57:32 +01:00

5.3 KiB

Harness contract

The harness is the thin adapter between the course tests and your engine. Tests only ever call harness functions. TypeScript signatures are canonical; Rust uses the same names in snake_case on pub struct Game / pub struct Baba / pub struct SpellGrid in src/harness.rs, with pub type Id = u32;, pub type Player = u8;, and events as pub enum GameEvent. Rust has no default arguments, so optional parameters (player, target) are explicit there.

Rust tests: use engine::harness::*;. TS tests: from "../src/harness".

Each module adds functions; start-module prints the line for the module you start.

Part I (Game): additions per module

  • M1: newGame(opts?: { decks?: [string[], string[]] }): Game; summon(card: string, player?: 0|1): Id (to right end of board); addToHand(card, player?), addToDeck(card, player?); play(handId: Id): void (Expansion Drop); zoneOf(id): "deck"|"hand"|"board"|"graveyard"; board(player): Id[] (left to right); hand(player): Id[]; orderOfPlay(id): number; nameOf(id): string.
  • M2: hero(player): Id; dealDamage(target: Id, amount: number): void (one Sequence); cast(card: string, player?, target?: Id): void; health(id), attack(id), armor(player); log(): GameEvent[]; clearLog().
  • M3: destroy(target: Id): void (marks pending-destroy, one Sequence).
  • M5: registerCards(defs: CardDef[]): void; assemble(base: string, donor: string, half: "top"|"bottom"): string (registers and returns new card name "<base> (Assembled)"; throws AssembleError if text unchanged).

Side by side (Part I)

Module TypeScript Rust (impl Game unless noted)
M1 newGame(opts?) new_game(opts: GameOpts) -> Game (free function)
M1 summon(card, player?): Id summon(&mut self, card: &str, player: Player) -> Id
M1 addToHand(card, player?) add_to_hand(&mut self, card: &str, player: Player) -> Id
M1 addToDeck(card, player?) add_to_deck(&mut self, card: &str, player: Player) -> Id
M1 play(handId) play(&mut self, hand_id: Id)
M1 zoneOf(id) zone_of(&self, id: Id) -> Zone
M1 board(player) / hand(player) board(&self, player) -> Vec<Id> / hand(...)
M1 orderOfPlay(id) order_of_play(&self, id: Id) -> u32
M1 nameOf(id) name_of(&self, id: Id) -> String
M2 hero(player) hero(&self, player) -> Id
M2 dealDamage(target, amount) deal_damage(&mut self, target: Id, amount: i32)
M2 cast(card, player?, target?) cast(&mut self, card: &str, player: Player, target: Option<Id>)
M2 health(id), attack(id), armor(player) health, attack, armor
M2 log(), clearLog() log(&self) -> Vec<GameEvent>, clear_log(&mut self)
M3 destroy(target) destroy(&mut self, target: Id)
M5 registerCards(defs) register_cards(&mut self, defs: Vec<CardDef>)
M5 assemble(base, donor, half) (throws AssembleError) assemble(...) -> Result<String, AssembleError>

GameEvent

Tagged by type: Summoned{id}, Damaged{target, amount}, Triggered{source, trigger} (trigger: "deathrattle"|"battlecry"|"damaged"|...), Destroyed{id} (removed in a Death Creation Step), DeathPhase{round}, Drew{player, id}, Fatigue{player}, ArmorGained{player, amount}, StatsChanged{id, attack, health}, SpellCast{card}.

Drawing from an empty deck logs Fatigue{player} and does nothing else; the engine must not panic.

Part II (Baba): additions per module

  • M6: newBaba(level: LevelDef): Baba; place(kind: string, x, y): Id (kind "baba" or "text:BABA"); rules(): string[] (sorted, normalised "BABA IS YOU", includes "TEXT IS PUSH").
  • M7: hasProperty(id, prop: string): boolean; activeRules(): string[] (after NOT/identity post-pass).
  • M8: move(dir: "up"|"down"|"left"|"right"): void (movement only, then reparse); posOf(id): [number, number] | null (null once destroyed); kindsAt(x, y): string[] (sorted).
  • M9: step(dir): void (full simplified turn); won(): boolean; lost(): boolean (no YOU objects remain).

Grid coordinates: x grows right, y grows down; vertical sentences read top-to-bottom.

Side by side (Part II)

Module TypeScript Rust (impl Baba unless noted)
M6 newBaba(level) new_baba(level: LevelDef) -> Baba (free function)
M6 place(kind, x, y): Id place(&mut self, kind: &str, x: i32, y: i32) -> Id
M6 rules() rules(&self) -> Vec<String>
M7 hasProperty(id, prop) has_property(&self, id: Id, prop: &str) -> bool
M7 activeRules() active_rules(&self) -> Vec<String>
M8 move(dir) move_dir(&mut self, dir: Dir) (move is a Rust keyword)
M8 posOf(id) pos_of(&self, id: Id) -> Option<(i32, i32)>
M8 kindsAt(x, y) kinds_at(&self, x: i32, y: i32) -> Vec<String>
M9 step(dir) step(&mut self, dir: Dir)
M9 won(), lost() won(&self) -> bool, lost(&self) -> bool

Part III (SpellGrid)

  • M10: newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid exposing all of Part II plus cast, health, log, hero, won. Minions are grid objects whose noun is their card's "noun" field.

Rust: new_spell_grid(level: LevelDef, cards: Vec<CardDef>) -> SpellGrid.