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

12 KiB
Raw Blame History

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.