Files
systemic-game-design-course/course/harness.md
T
Lucas WintherandClaude Opus 5.5 820b378929 feat: undo in Module 9 and stackAt from Module 8; Lesson 8 quote matches the reference
Baba.undo() restores the level before the last step or move (board snapshot:
objects in age order with ids, the id counter, and won; rules recompiled).
place clears the history. SpellGrid's undo throws/panics 'not supported'.
stackAt / stack_at lists a tile's kinds oldest first, on Baba and SpellGrid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:20:03 +01:00

19 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(); logTree(): string (the log as an indented tree, see below).
  • 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, Bolf Ramshield and Twisting Nether (the M3 Expansion Drop card). New event ArmorLost{player, amount} (see GameEvent).
  • 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 the text is unchanged or not well-formed); 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)
M2 logTree() log_tree(&self) -> String
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.

From Module 5, Assemble's result must be well-formed. For a minion: every effect clause belongs to a trigger (none before the first trigger, or after an aura or keyword), and every trigger has at least one effect clause before the next trigger, aura, keyword or the end. assemble fails with AssembleError for a result that is unchanged or ill-formed, and the chooser of an assemble effect is offered only bases, donors and halves that give a changed, well-formed result.

From Module 5, a Sequence is bounded. A trigger of a minion is not queued for an event raised while that same trigger of that same minion is resolving. More than 64 Death Phases in one Sequence is an error: the call that started the Sequence throws an Error (TypeScript) or panics (Rust), with a message containing Death Phase limit. A summon effect with count greater than 1 places its tokens left to right in summon order.

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}, ArmorLost{player, amount} (from M4), StatsChanged{id, attack, health}, SpellCast{card}.

From M4, damage to a hero lowers its owner's Armor first, then Health. When Armor absorbs any, ArmorLost{player, amount} (the amount Armor took) is logged just before the Damaged event, and Damaged.amount is still the full amount. A damage redirect (M4) changes the target before that: the Damaged event names the minion that took the damage.

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

logTree() is the same events as log(), as text: one event per line, each line ending with a newline (an empty log gives ""). Each event is written as scenarios.md writes it, the type then its fields in the order above, strings in double quotes: Triggered{source: 3, trigger: "deathrattle"}. Each line is indented two spaces per level of nesting. The outermost action's own events are at depth 0, and so is each trigger in the Queue they fill; the events a resolving trigger causes are one level deeper than its Triggered line. The Death Creation Step and the Death Phase (from M3) run after the outermost Phase, so Destroyed, DeathPhase and each Deathrattle's Triggered are at depth 0.

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 },
    ArmorLost { player: Player, amount: i32 }, // from M4
    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", each distinct rule once, includes "TEXT IS PUSH").
  • M7: hasProperty(id, prop: string): boolean; activeRules(): string[] (after NOT/identity post-pass; same format as rules()). From M7, rules() also lists negated rules ("BABA IS NOT YOU").
  • 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); stackAt(x, y): string[] (the same kinds, oldest first).
  • M9 (requires the M7 Expansion Drop: the meta-noun ALL is in the word list): step(dir): void (full simplified turn); won(): boolean; lost(): boolean (no YOU objects remain); undo(): boolean (back to before the last step or move; false if there is none).

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>
M8 stackAt(x, y) stack_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
M9 undo() undo(&mut self) -> bool

LevelDef is the level format of course/schema/level.schema.json. In TypeScript it is { width, height, objects: { kind, x, y }[] }. In Rust it is defined in src/harness.rs (or re-exported there) and implements Default, so tests write LevelDef { width: 3, height: 3, ..Default::default() } and keep compiling when Part III adds cards:

#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct LevelDef {
    pub width: i32,
    pub height: i32,
    #[serde(default)]
    pub objects: Vec<LevelObject>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct LevelObject {
    pub kind: String,
    pub x: i32,
    pub y: i32,
}

place returns an Id, as in Part I; a Baba numbers its objects on its own. rules() is a set: a rule spelled twice on the board (stacked text can do that) appears once, and no test counts duplicates.

From M7, rules() is what the text spells before resolution, and activeRules() is the rule set in force after it: X IS NOT Y cancels every X IS Y and stays in the set itself; a surviving X IS X then cancels every conversion X IS Y (Y a noun other than X); "TEXT IS PUSH" is listed unless TEXT IS NOT PUSH cancels it. X IS NOT X stays in activeRules() as the record that every X is to vanish (the reference's choice; no test checks it; the objects are removed by the M9 turn). hasProperty(id, prop) is true if an active rule gives prop to a noun that names the object and no active negated rule takes it from one: a thing is named by its own noun, a text object by TEXT (and, after the M7 Expansion Drop, every thing by ALL). hasProperty on an id the level never issued panics (Rust) or throws (TypeScript).

From M8, move(dir) moves every object that is YOU one tile, with the objects it pushes, and then reparses the rules once. It is movement only: conversions, hazards and winning are the M9 turn. An object steps at most once per move: a YOU object that an earlier YOU object's move already carried along (pushed, or pulled) doesn't start a move of its own, and isn't moved again. Each YOU object's move is planned in full before anything changes: if any object in a push chain can't move (STOP, or the edge of the level, which blocks like STOP), nothing in that chain moves, the YOU object included. An OPEN object moving onto a SHUT one (or the reverse), pushed or not, destroys both during the move. Several YOU objects move one at a time, the one furthest ahead in dir first (the reference's choice). posOf(id) is [x, y], or null once the object has been destroyed (Rust: Option<(i32, i32)>); it panics or throws on an id the level never issued. kindsAt(x, y) lists the kinds on a tile as they are placed ("baba", "text:ROCK"), sorted; [] for an empty tile. stackAt(x, y) lists the same kinds oldest first: an object's age is the order in which objects were placed (a conversion keeps the object's age; an object a conversion adds is the newest), not when it arrived on the tile. From M8, hasProperty on a destroyed object is false.

In TypeScript a direction is the string "up" | "down" | "left" | "right" (exported as the type Dir). In Rust it is an enum, defined in the engine and re-exported from src/harness.rs:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
    Up,
    Down,
    Left,
    Right,
}

From M9, step(dir) plays one whole turn, in the course's simplified order: movement (M8's, without its reparse), parse, conversion, parse, hazards, win, and a last parse so that rules() matches the board. Conversion: every object named by an active X IS Y (Y a noun naming its own things) becomes a Y in place, keeping its id, unless an active … IS NOT Y also names the object (negation is settled per object, as in hasProperty); with several Ys it becomes the first and each other Y is a new object on its tile; X IS X blocks it; an object named by X IS NOT X is deleted (its other Ys still appear, as new objects). All conversions are decided from the board as movement left it. Hazards run in content's order (the reference: SINK, HOT/MELT, DEFEAT); each sees only the objects that share a tile on the same float level (both FLOAT or both not), and destroys everything it finds at once before the next runs. Hazards and the win check use the rules from the parse after conversion. won() becomes true when the win check finds a YOU object that is WIN, or shares its tile with a same-float WIN object, and stays true. lost() is true whenever no object is YOU. move(dir) keeps its M8 meaning. undo() puts the level back as it was before the last step or move and returns true; repeated calls walk further back; with nothing to undo it returns false and changes nothing. Afterwards every posOf, kindsAt, stackAt, rules(), activeRules() and won() is as it was, and the next new object gets the id it would have got then: a snapshot holds every object's id, kind and position in age order, the id counter and won (the rules are recompiled, not saved). place clears the history.

Part III (SpellGrid)

  • M10: newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid exposing all of Part II (except undo, which throws not supported: Part I has state the level's snapshot doesn't cover) plus cast(card, target?), play(card, x, y): Id, health(id), hero(player), nameOf(id), minionAt(x, y): Id | null, hand(): string[], log(), clearLog(), logTree(), setChooser(choose), won(), and the test hook withNaiveAuraTiming(). Minions are grid objects whose noun is their card's "noun" field.

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

TypeScript Rust (impl SpellGrid)
newSpellGrid(level, cards) new_spell_grid(level: LevelDef, cards: Vec<CardDef>) -> SpellGrid (free function)
Part II: place, rules, activeRules, hasProperty, move, posOf, kindsAt, stackAt, step, won, lost the same as on Baba (move_dir for move)
undo() undo(&mut self) -> bool: not supported; throws (Rust: panics) with a message containing not supported
cast(card, target?) cast(&mut self, card: &str, target: Option<Id>)
play(card, x, y): Id play(&mut self, card: &str, x: i32, y: i32) -> Id
health(id), hero(player), nameOf(id) health(&self, id: Id) -> i32, hero(&self, player: Player) -> Id, name_of(&self, id: Id) -> String
minionAt(x, y): Id | null minion_at(&self, x: i32, y: i32) -> Option<Id>
hand(): string[] hand(&self) -> Vec<String>
log(), clearLog() log(&self) -> Vec<GameEvent>, clear_log(&mut self)
logTree() log_tree(&self) -> String (Part I's log as a tree, with grid ids)
setChooser(choose) set_chooser(&mut self, chooser: impl FnMut(&str, &[String]) -> String + 'static)
withNaiveAuraTiming() with_naive_aura_timing(&mut self)

From M10, LevelDef has two more optional fields, cards: { card, x, y }[] and hand: string[] (Rust: cards: Vec<LevelCard> with LevelCard { card: String, x: i32, y: i32 }, and hand: Vec<String>, both #[serde(default)]). Each entry of cards is a minion in play for player 0 and a grid object at (x, y) whose kind is the card's noun in lower case. hand is player 0's hand. The constructor's cards argument is extra card definitions, added to the workspace's cards/ (the course's tests pass none).

SpellGrid has one id space: a minion's id is its grid object's id, so posOf, hasProperty and health take the same id, and log() reports it. A hero or a card in hand gets an id that no grid object has; a card played from hand gets its grid object's id (returned by play). cast(card, target?) casts a spell from player 0's hand as one Sequence (target: a minion's id); play(card, x, y) plays a minion from player 0's hand onto an empty tile as one Sequence (it enters the board, then its Battlecry). Both are errors for a card not in the hand, and play for a tile that holds anything. Casting is not a turn: won() changes only in step.

The three seams: (1) a rule in force <TRIGGER> IS <MULTIPLIER> (DEATHRATTLE IS DOUBLE) is an aura like Baron Rivendare's for both players (DOUBLE: twice, stacking: "max"), and <TRIGGER> IS NOT <MULTIPLIER> caps that trigger at one activation, whatever multiplies it; rule auras join the snapshot at the next Aura Update. (2) A minion that a turn destroys (hazards, OPEN/SHUT, X IS NOT X) is marked pending destroy in one Sequence, and the Death Creation Step removes it. (3) When a Sequence ends, text its effects asked for (spawnText) is written on the minion's tile, minions that died leave the grid, and the rules are reparsed. Trigger words (DEATHRATTLE, and BATTLECRY after the Expansion Drop) are nouns in the lexicon (words.json), so they can be the subject of a rule, but role: "meta" in course/catalog.json: they name the engine's own triggers, and the coupling audit checks only role-noun words. checkCast(card, target?) and checkPlay(card, x, y) (Rust: check_cast, check_play, returning Result<(), String>) say why cast/play would fail, or null/Ok; the command line uses them, the tests don't. withNaiveAuraTiming() reverses the two end-of-Phase steps (Aura Update, then Death Creation Step) for that game; only test_10_level_3_naive_would_differ uses it.