# 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 `" (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. - M12: `orderAttack(attacker: Id, defender: Id): void` (one Sequence; throws an `Error` whose message is `canAttack`'s reason if the attack is illegal, and changes nothing); `canAttack(attacker: Id, defender: Id): string | null` (why the attack is illegal, or `null`); `endTurn(player: Player): void` (one Sequence: `player`'s end of turn triggers resolve). The M12 tests also use `Test Bell` (`course/12-combat/cards/`) and `Drakkari Enchanter`. The name is `orderAttack` because `attack(id)` (M2) already reads a character's Attack. ### 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` / `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)` | | M2 | `health(id)`, `attack(id)`, `armor(player)` | `health`, `attack`, `armor` | | M2 | `log()`, `clearLog()` | `log(&self) -> Vec`, `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)` | | M5 | `assemble(base, donor, half)` (throws `AssembleError`) | `assemble(&mut self, base: &str, donor: &str, half: Half) -> Result` (`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)` | | M12 | `orderAttack(attacker, defender)` (throws `Error`) | `order_attack(&mut self, attacker: Id, defender: Id) -> Result<(), String>` | | M12 | `canAttack(attacker, defender): string \| null` | `can_attack(&self, attacker: Id, defender: Id) -> Option` | | M12 | `endTurn(player)` | `end_turn(&mut self, player: Player)` | 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. From Module 12, an attack is one Sequence. It is legal when the attacker is a minion in play, the defender is an enemy minion in play or the enemy hero, the attacker's Attack is more than 0, and, while the defender's side has a minion with the keyword `taunt`, the defender is such a minion. The reason `canAttack` gives contains `Taunt` when Taunt is what forbids the attack, and `Attack` when 0 Attack does. The attacker deals its Attack to the defender and the defender deals its Attack to the attacker at the same time: one Damage action with two targets, each with its own amount, its `Damaged` events in order of play like any area damage. A hero has no Attack and deals nothing back, and neither does a 0 Attack minion. The triggers answering the defender's `Damaged` event are queued before those answering the attacker's, whatever their order of play; otherwise the Queue is as before. Combat damage goes through Armor and redirects like any damage. `endTurn(player)` raises the end of `player`'s turn: each trigger of that player's minions listening for `endOfTurn` responds, in order of play (`Triggered{source, trigger: "endOfTurn"}`), and a trigger multiplier for `endOfTurn` applies as for any trigger. The course has no mana, no limit on attacks per turn and no summoning sickness. ### `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)]`: ```rust 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). - M11: no new function; the contract of `rules()`, `activeRules()`, `hasProperty` and `step` grows. A word with role `condition` (`ON`) may stand between a sentence's subjects and `IS`, followed by nouns joined by `AND`: `KEY ON ROCK AND FLAG IS WIN`. `rules()` and `activeRules()` write the condition between the subject and `IS`, its nouns in the order written. A rule with a condition applies to an object only while the condition holds for that object, checked against the board every time the rule is used (`hasProperty`, and so movement, each hazard and the win check; conversion). Resolution pairs a negation or an identity lock only with rules whose conditions are the same; otherwise negation is settled per object. 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` | | M7 | `hasProperty(id, prop)` | `has_property(&self, id: Id, prop: &str) -> bool` | | M7 | `activeRules()` | `active_rules(&self) -> Vec` | | 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` | | M8 | `stackAt(x, y)` | `stack_at(&self, x: i32, y: i32) -> Vec` | | 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`: ```rust #[derive(Debug, Clone, Default, serde::Deserialize)] pub struct LevelDef { pub width: i32, pub height: i32, #[serde(default)] pub objects: Vec, } #[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`: ```rust #[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 `Y`s 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 `Y`s 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. From M11, `ON` with nouns `N1 … Nk` holds for an object when the other objects on its tile can supply a different object for each `Ni` that `Ni` names (`ON ROCK AND ROCK` needs two rocks; `ON ALL` is satisfied by any other object that isn't text, the course's choice: the game needs one of every object in ALL); float levels don't matter. Two rules have the same conditions when they have the same condition words, each with the same nouns in any order. Stacked text yields every reading: a stack of nouns after `ON` gives one rule per noun, and `ON` stacked on `AND` is read both ways. In SpellGrid, a rule with a condition gives no aura (seam 1). ## 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) -> SpellGrid`. | TypeScript | Rust (`impl SpellGrid`) | |---|---| | `newSpellGrid(level, cards)` | `new_spell_grid(level: LevelDef, cards: Vec) -> 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)` | | `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` | | `hand(): string[]` | `hand(&self) -> Vec` | | `log()`, `clearLog()` | `log(&self) -> Vec`, `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` with `LevelCard { card: String, x: i32, y: i32 }`, and `hand: Vec`, 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 four seams: (1) a rule in force ` IS ` (`DEATHRATTLE IS DOUBLE`) is an aura like Baron Rivendare's for both players (`DOUBLE`: twice, `stacking: "max"`), and ` IS NOT ` 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. (4) In that same pass, after the text and before the dead leave the grid, each minion that a minion's triggered ability summoned, whose card has a `"noun"`, becomes an object of that noun on its summoner's tile (the summoner may have died in this Sequence), with a new id from the board: `log()` reports that id. A minion summoned by a spell, or whose card has no noun, is in play but not on the grid. 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.