Files
systemic-game-design-course/course/harness.md
T
Lucas WintherandClaude Opus 5.5 e04dc1a11f feat: module 05 assemble
Cards become JSON data in course/cards (one file per card, validated
against card.schema.json and the catalog); the M5 references load them
via registerCards and interpret effects, replacing content closures.
Adds assemble(base, donor, half), the assemble/buffHand effects with an
injected chooser, Fleshshape as the data-only Expansion Drop, and stages
course/cards in verify-references and start-module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 03:17:21 +01:00

7.4 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; required from M4); 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).
  • M4: no new functions; play(handId) (the M1 Expansion Drop) is now required: it runs one Sequence in which the minion enters the right end of its owner's board (Summoned{id}), then its Battlecry resolves (Triggered{source, trigger: "battlecry"}). The M4 tests also use Baron Rivendare, Novice Engineer and Twisting Nether (the M3 Expansion Drop card).
  • 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); cost(id): number; setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void (makes Assemble's choices). CardDef is the card format of course/schema/card.schema.json; newGame loads every card file in the workspace's cards/ directory.

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)
M4 play(handId) now required (one Sequence; Battlecry) play(&mut self, hand_id: Id)
M5 registerCards(defs) register_cards(&mut self, defs: Vec<CardDef>)
M5 assemble(base, donor, half) (throws AssembleError) assemble(&mut self, base: &str, donor: &str, half: Half) -> Result<String, AssembleError> (Half::Top / Half::Bottom)
M5 cost(id) cost(&self, id: Id) -> i32
M5 setChooser(choose) set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)

From Module 5, Rust's CardDef must implement serde::Deserialize for the card format: the tests parse cards/*.json with serde_json (add serde with the derive feature and serde_json to Cargo.toml). The TypeScript tests import the files with Vite's import.meta.glob.

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.

Rust shape (settled in Module 2), defined in src/harness.rs with #[derive(Debug, Clone, PartialEq, Eq)]:

pub enum GameEvent {
    Summoned { id: Id },
    Damaged { target: Id, amount: i32 },
    Triggered { source: Id, trigger: String },
    Destroyed { id: Id },
    DeathPhase { round: u32 },
    Drew { player: Player, id: Id },
    Fatigue { player: Player },
    ArmorGained { player: Player, amount: i32 },
    StatsChanged { id: Id, attack: i32, health: i32 },
    SpellCast { card: String },
}

In TypeScript each event is a plain object with a type field, for example { type: "Damaged", target, amount }. Health, Attack, Armor and damage amounts are i32 in Rust (Health can go to 0 or below). Each player has a hero with 30 Health from the start of the game; hero(player) returns its id. Heroes are in play but are not in board(player), which lists minions only. cast takes the card name; a spell is not an entity.

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.