Files
systemic-game-design-course/course/harness.md
T
Lucas WintherandClaude Opus 5.5 bcdaca0cd2 feat: module 04 interceptors
Trigger multipliers as aura data on cards, snapshotted by an Aura Update
(Other) after each Death Creation Step and on summon, and applied by one
generic routine (activations) when each queued trigger starts. Baron
Rivendare is content only (stacking "max"); Brann Bronzebeard is the
content-only Expansion Drop. Adds play (now required) and Battlecry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:54:37 +01:00

94 lines
6.7 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).
### 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(...) -> Result<String, AssembleError>` |
### `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"`, 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[]): SpellGrid` exposing all of Part II plus `cast`, `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`.