- SpellGrid cards move to course/10-spellgrid/cards; start-module and verify-references stage course/cards plus module-local cards up to the module, so M5-M9 workspaces get exactly the pre-M10 card set. - cast() rejects a chosen-target spell without a target (checkCast/checkPlay); new test_10_targeted_spell_needs_target. - Rust CLI pre-validates instead of swapping the global panic hook. - Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role. - Lesson: design note on mid-movement reparse. Co-Authored-By: Claude Opus 5.5 <[email protected]>
15 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 useBaron Rivendare,Novice EngineerandTwisting 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)"; throwsAssembleErrorif text unchanged);cost(id): number;setChooser(choose: (kind: "base"|"donor"|"half", options: string[]) => string): void(makes Assemble's choices).CardDefis the card format ofcourse/schema/card.schema.json;newGameloads every card file in the workspace'scards/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", each distinct rule once, includes"TEXT IS PUSH"). - M7:
hasProperty(id, prop: string): boolean;activeRules(): string[](after NOT/identity post-pass; same format asrules()). 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). - 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 |
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. 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. 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; 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.
Part III (SpellGrid)
- M10:
newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGridexposing all of Part II pluscast(card, target?),play(card, x, y): Id,health(id),hero(player),nameOf(id),minionAt(x, y): Id | null,hand(): string[],log(),clearLog(),setChooser(choose),won(), and the test hookwithNaiveAuraTiming(). 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, step, won, lost |
the same as on Baba (move_dir for move) |
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) |
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.