- 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]>
156 lines
15 KiB
Markdown
156 lines
15 KiB
Markdown
# 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 use `Baron Rivendare`, `Novice Engineer` and `Twisting 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)"`; throws `AssembleError` if text unchanged); `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)` |
|
|
| 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)]`:
|
|
|
|
```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 },
|
|
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).
|
|
- 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`:
|
|
|
|
```rust
|
|
#[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`:
|
|
|
|
```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; 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.
|
|
|
|
## Part III (`SpellGrid`)
|
|
|
|
- M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast(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 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`, `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.
|