Files
systemic-game-design-course/course/harness.md
T
Lucas WintherandClaude Opus 5.5 bcdaca0cd2 feat: module 04 interceptors
Trigger multipliers as aura data on cards, snapshotted by an Aura Update
(Other) after each Death Creation Step and on summon, and applied by one
generic routine (activations) when each queued trigger starts. Baron
Rivendare is content only (stacking "max"); Brann Bronzebeard is the
content-only Expansion Drop. Adds play (now required) and Battlecry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:54:37 +01:00

6.7 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).

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(...) -> 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.

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.