Files
systemic-game-design-course/course/10-spellgrid/scenarios.md
T
Lucas WintherandClaude Opus 5.5 6f576940b7 fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)
- 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>
2026-10-29 05:11:09 +01:00

146 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.