Movement planned in full, then committed: a Planner builds a plan (moving and destroyed ids) by running content's movement responses (OPEN/SHUT, PUSH, STOP) for every step, rolling back failed branches. Edge of level blocks; YOU objects move furthest-ahead first; rules are reparsed once after movement. Harness: move/move_dir(Dir), posOf, kindsAt. Expansion Drop: PULL as one word plus one content response. Co-Authored-By: Claude Opus 5.5 <[email protected]>
10 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,
}
Part III (SpellGrid)
- M10:
newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGridexposing all of Part II pluscast,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.