- 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 <noreply@anthropic.com>
146 lines
11 KiB
Markdown
146 lines
11 KiB
Markdown
# Module 10 scenarios: SpellGrid
|
||
|
||
Each heading is a test name. The Rust test lives in `tests/rust/test_10.rs` and the TypeScript test in `tests/ts/test_10.test.ts`; both state exactly what is written here.
|
||
|
||
SpellGrid is the course's own small game: Part I's card engine and Part II's grid engine in one level. The harness adds one constructor, `newSpellGrid(level, cards)` (Rust: `new_spell_grid(level, cards)`), returning a `SpellGrid`:
|
||
|
||
- Every Part II function works as in Modules 6–9: `place`, `rules`, `activeRules`, `hasProperty`, `move`, `posOf`, `kindsAt`, `step`, `won`, `lost`.
|
||
- The level format gains two optional fields (`course/schema/level.schema.json`): `cards`, a list of `{ card, x, y }`, and `hand`, a list of card names. Each entry of `cards` is a minion in play for player 0 **and** a grid object at (x, y) whose noun is the card's `"noun"` field (`"noun": "KEG"` makes an object of kind `"keg"`). Each name in `hand` is a card in player 0's hand.
|
||
- `cards` (the constructor's second argument) is a list of extra card definitions, added to the course's cards. Every test here passes an empty list.
|
||
- One id space: a minion's id is its grid object's id. `place`, `posOf`, `hasProperty` and `health` all take it, and `log()` reports it. A hero, or a card that isn't on the grid, gets an id no grid object has.
|
||
- `cast(card, target?)` (Rust: `cast(card, Some(target))` or `None`) casts a spell from player 0's hand, as one Sequence. The card leaves the hand. Casting a card that isn't in the hand is an error, and so is casting a spell that hits a chosen minion (Spark) without a target; neither changes anything.
|
||
- `play(card, x, y)` plays a minion from player 0's hand onto the empty tile (x, y), as one Sequence (Module 4's `play`: it enters the board, then its Battlecry resolves), and returns its id. Playing onto a tile that holds anything is an error.
|
||
- `setChooser(choose)` is Module 5's: it makes Assemble's choices (`"base"`, `"donor"`, `"half"`).
|
||
- `health(id)`, `hero(player)`, `nameOf(id)`, `log()` and `clearLog()` are Part I's, with ids translated as above. `minionAt(x, y)` is the id of the minion on that tile, or `null` (Rust: `Option<Id>`). `hand()` lists the card names in player 0's hand, in order.
|
||
- `withNaiveAuraTiming()` is a test hook (Rust: `with_naive_aura_timing()`). It reverses the two steps at the end of an outermost Phase, so that the Aura Update runs *before* the Death Creation Step and not after it. Only `test_10_level_3_naive_would_differ` calls it.
|
||
|
||
The three seams between the engines, each tested below:
|
||
|
||
1. **Rules become modifiers.** A rule in force `<TRIGGER> IS DOUBLE` (`DEATHRATTLE IS DOUBLE`) is a trigger multiplier like Baron Rivendare's (`times: 2`, `stacking: "max"`), for every player's minions. `DEATHRATTLE IS NOT DOUBLE` caps that trigger at one activation, whatever else multiplies it. The rule takes effect at the next Aura Update, like a minion's aura.
|
||
2. **Hazards mark, the death step removes.** A minion that a hazard (or `OPEN`/`SHUT`, or `X IS NOT X`) destroys is marked pending destroy, in one Sequence. Module 3's Death Creation Step removes it, and its Deathrattle resolves.
|
||
3. **Grid changes reparse.** A minion that leaves play leaves the grid, and an effect that writes on the grid (`spawnText`: "put the word W on this minion's tile") writes there. Both happen when the Sequence ends, and then the rules are parsed again, before anything else.
|
||
|
||
Casting is not a turn: there is no win check until the next `step`.
|
||
|
||
SpellGrid's own cards (`"game": "spellgrid"` in `course/catalog.json`; not Hearthstone cards):
|
||
|
||
| Card | Type | Stats | Noun | Text |
|
||
|---|---|---|---|---|
|
||
| Powder Keg | minion, 2 | 1/1 | KEG | Deathrattle: Deal 1 damage to all minions. |
|
||
| Bramble Wall | minion, 1 | 0/3 | BRAMBLE | (none) |
|
||
| Thorn Hedge | minion, 1 | 0/2 | HEDGE | (none) |
|
||
| Grave Warden | minion, 3 | 1/1 | WARDEN | Your minions trigger their Deathrattles twice. (Baron-like, with 1 Health) |
|
||
| Ember Moth | minion, 1 | 1/1 | MOTH | Taunt. Deathrattle: Deal 2 damage to the enemy hero. (`split` 1) |
|
||
| Rune Scribe | minion, 2 | 1/1 | SCRIBE | Deathrattle: Write WIN on this tile. (`split` 0) |
|
||
| Tin Knight | minion, 2 | 1/3 | KNIGHT | (none) |
|
||
| Spark | spell, 1 | | | Deal 1 damage to a minion. |
|
||
| Graft | spell, 2 | | | Assemble a Deathrattle minion. |
|
||
|
||
Graft is Module 5's `assemble` effect: the chooser picks a base from the Deathrattle minions among the course's cards, a donor, and a half, and the Assembled minion goes to the hand. It keeps the base's name, stats and noun. Whirlwind ("Deal 1 damage to ALL minions.") is the Hearthstone card from Module 3.
|
||
|
||
New words: the nouns `KEG`, `BRAMBLE`, `HEDGE`, `WARDEN`, `MOTH`, `SCRIBE`, `KNIGHT` and `DEATHRATTLE`, and the property `DOUBLE`. A sentence like `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` is three text objects, as in Modules 6–9.
|
||
|
||
### test_10_spatial_rule_feeds_interceptor
|
||
|
||
- Setup: a level 6 wide and 3 high with `DEATHRATTLE (0,0) IS (1,0)` and nothing at (2,0); cards Powder Keg at (0,2) and Bramble Wall at (2,2); hand `["Spark"]`.
|
||
- Assertions 1: `activeRules()` doesn't contain `"DEATHRATTLE IS DOUBLE"`.
|
||
- Action 1: `place("text:DOUBLE", 2, 0)`.
|
||
- Assertions 2: `activeRules()` contains `"DEATHRATTLE IS DOUBLE"`.
|
||
- Action 2: `clearLog()`, then `cast("Spark", keg)`.
|
||
- Assertions 3:
|
||
- The log holds exactly two `Triggered { source: keg, trigger: "deathrattle" }`.
|
||
- `health(bramble)` is 1: two Deathrattles of 1 damage each.
|
||
- `posOf(keg)` is `null` and `posOf(bramble)` is (2,2).
|
||
- `hand()` is empty.
|
||
|
||
### test_10_not_cancels_spatial_modifier
|
||
|
||
- Setup: a level 6 wide and 4 high with `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)` and `DEATHRATTLE (0,1) IS (1,1) NOT (2,1) DOUBLE (3,1)`; cards Grave Warden at (0,3), Powder Keg at (1,3) and Bramble Wall at (2,3); hand `["Spark"]`.
|
||
- Assertions 1: `rules()` contains `"DEATHRATTLE IS DOUBLE"` and `"DEATHRATTLE IS NOT DOUBLE"`; `activeRules()` contains `"DEATHRATTLE IS NOT DOUBLE"` but not `"DEATHRATTLE IS DOUBLE"`.
|
||
- Action: `clearLog()`, then `cast("Spark", keg)`.
|
||
- Assertions 2:
|
||
- The log holds exactly one `Triggered { source: keg, trigger: "deathrattle" }`, although the Grave Warden was in play when the keg died: the negated rule caps the card's aura too.
|
||
- `health(bramble)` is 2.
|
||
- `posOf(warden)` is `null`: the Deathrattle's 1 damage killed it, in the next Death Creation Step.
|
||
|
||
### test_10_grid_and_card_doublers_take_max
|
||
|
||
- Setup: a level 6 wide and 3 high with `DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)`; cards Grave Warden at (0,2), Powder Keg at (1,2) and Bramble Wall at (2,2); hand `["Spark"]`.
|
||
- Action: `clearLog()`, then `cast("Spark", keg)`.
|
||
- Assertions:
|
||
- The log holds exactly two `Triggered { source: keg, trigger: "deathrattle" }`: the rule's multiplier and the Warden's both have `stacking: "max"`, so together they double, not quadruple.
|
||
- `health(bramble)` is 1.
|
||
|
||
### test_10_hazard_feeds_death_step
|
||
|
||
- Setup: a level 5 wide and 4 high with `KEG (0,0) IS (1,0) YOU (2,0) AND (3,0) MELT (4,0)` and `LAVA (0,1) IS (1,1) HOT (2,1)`; an object `lava` at (2,3); cards Powder Keg at (1,3) and Bramble Wall at (4,3).
|
||
- Action: `clearLog()`, then `step("right")`.
|
||
- Assertions:
|
||
- The log is exactly `[Destroyed { id: keg }, DeathPhase { round: 1 }, Triggered { source: keg, trigger: "deathrattle" }, Damaged { target: bramble, amount: 1 }]`: the keg melted, as a pending destroy that Module 3's Death Creation Step removed, and its Deathrattle resolved in the Death Phase.
|
||
- `posOf(keg)` is `null`, `kindsAt(2,3)` is `["lava"]` and `health(bramble)` is 2.
|
||
- `lost()` is true: the keg was the only `YOU` object.
|
||
|
||
### test_10_spawned_text_triggers_reparse
|
||
|
||
- Setup: a level 5 wide and 3 high with `BABA (0,0) IS (1,0) YOU (2,0)` and `BABA (0,1) IS (1,1)`; card Rune Scribe at (2,1), on the empty end of `BABA IS`; hand `["Spark"]`. Then `place("baba", 4, 2)`.
|
||
- Assertions 1: `rules()` doesn't contain `"BABA IS WIN"`.
|
||
- Action 1: `cast("Spark", scribe)`.
|
||
- Assertions 2:
|
||
- `kindsAt(2,1)` is `["text:WIN"]`: the scribe's Deathrattle wrote `WIN` on its tile, and the scribe left the grid.
|
||
- `rules()` contains `"BABA IS WIN"` and `hasProperty(baba, "WIN")` is true, straight after the cast: the grid was reparsed when the Sequence ended.
|
||
- `won()` is false: casting is not a turn.
|
||
- Action 2: `step("left")`.
|
||
- Assertions 3: `won()` is true.
|
||
|
||
### test_10_targeted_spell_needs_target
|
||
|
||
- Setup: a level 3 wide and 1 high with card Bramble Wall at (0,0); hand `["Spark"]`.
|
||
- Action 1: `cast("Spark")`, with no target.
|
||
- Assertions 1: it is an error (Rust: a panic; TypeScript: it throws), and nothing changed: `hand()` is still `["Spark"]` and `health(bramble)` is 3. Spark's effect hits a chosen minion, so a cast without one would waste the card.
|
||
- Action 2: `cast("Spark", bramble)`.
|
||
- Assertions 2: `health(bramble)` is 2 and `hand()` is empty.
|
||
|
||
### test_10_level_1_solution
|
||
|
||
Level `levels/l1-double-tap.json`. The Bramble Wall (3 Health) is `STOP` and blocks the only way to the flag. Whirlwind and one Powder Keg Deathrattle do 2 damage, one short.
|
||
|
||
- Inputs: `step("up")`, `step("up")` (pushing `DOUBLE` through the gap in the wall to finish `DEATHRATTLE IS DOUBLE`), then `cast("Whirlwind")`.
|
||
- Assertions 1: `activeRules()` contains `"DEATHRATTLE IS DOUBLE"` before the cast. After it, the keg's Deathrattle has triggered twice and `posOf(bramble)` is `null`.
|
||
- Inputs: `step("down")`, `step("down")`, `step("left")`, `step("down")`, `step("down")`, `step("right")`.
|
||
- Assertions 2: `won()` is false, and `kindsAt(5,6)` is `["baba"]`: Baba stands where the bramble was.
|
||
- Input: `step("right")`.
|
||
- Assertions 3: `won()` is true.
|
||
|
||
### test_10_level_2_solution
|
||
|
||
Level `levels/l2-spliced-corpse.json`. `BABA IS` ends on a lava tile. `MOTH IS PUSH AND MELT` is written, but there is no moth: the hand holds only Graft.
|
||
|
||
- Setup: `setChooser` answering `"Ember Moth"` for the base, `"Rune Scribe"` for the donor and `"bottom"` for the half.
|
||
- Input: `cast("Graft")`.
|
||
- Assertions 1: `hand()` is `["Ember Moth (Assembled)"]`: the moth's noun and Taunt, the scribe's Deathrattle.
|
||
- Input: `play("Ember Moth (Assembled)", 4, 3)`, returning `moth`.
|
||
- Assertions 2: `kindsAt(4,3)` is `["moth"]`, and `nameOf(moth)` is `"Ember Moth (Assembled)"`.
|
||
- Inputs: `step("left")`, then `step("left")`, which pushes the moth onto the lava.
|
||
- Assertions 3:
|
||
- `posOf(moth)` is `null` and `kindsAt(2,3)` is `["lava", "text:WIN"]`: the moth melted and its Deathrattle wrote `WIN` where it died.
|
||
- `rules()` contains `"BABA IS WIN"`, and `won()` is true **after that same step**: the reparse after the Sequence came before the win check.
|
||
|
||
### test_10_level_3_solution
|
||
|
||
Level `levels/l3-simultaneous-paradox.json`. The player is a Tin Knight (`KNIGHT IS YOU`, 3 Health). The Thorn Hedge (2 Health) is `STOP` and blocks the flag. The Grave Warden and the Powder Keg have 1 Health each.
|
||
|
||
- Input: `cast("Whirlwind")`.
|
||
- Assertions 1:
|
||
- The keg's Deathrattle triggered exactly once: the Warden died in the same Death Creation Step, and the Aura Update after that step removed its aura before the Death Phase.
|
||
- `health(knight)` is 1, and `posOf(hedge)`, `posOf(keg)` and `posOf(warden)` are `null`.
|
||
- Inputs: `step("right")` five times.
|
||
- Assertions 2: `won()` is true.
|
||
|
||
### test_10_level_3_naive_would_differ
|
||
|
||
The same level and the same inputs, on a SpellGrid with `withNaiveAuraTiming()` called first.
|
||
|
||
- Assertions:
|
||
- The keg's Deathrattle triggered twice: the Warden's aura was still in the snapshot when the Death Phase began.
|
||
- `posOf(knight)` is `null`, `lost()` is true and `won()` is false: the second Deathrattle killed the knight along with the hedge. This is the trap the level is built on.
|