Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <[email protected]>
7.4 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", 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[]): 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.