- 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>
12 KiB
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 }, andhand, a list of card names. Each entry ofcardsis 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 inhandis 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,hasPropertyandhealthall take it, andlog()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))orNone) 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'splay: 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()andclearLog()are Part I's, with ids translated as above.minionAt(x, y)is the id of the minion on that tile, ornull(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. Onlytest_10_level_3_naive_would_differcalls it.
The three seams between the engines, each tested below:
- 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 DOUBLEcaps that trigger at one activation, whatever else multiplies it. The rule takes effect at the next Aura Update, like a minion's aura. - Hazards mark, the death step removes. A minion that a hazard (or
OPEN/SHUT, orX IS NOT X) destroys is marked pending destroy, in one Sequence. Module 3's Death Creation Step removes it, and its Deathrattle resolves. - 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(), thencast("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)isnullandposOf(bramble)is (2,2).hand()is empty.
- The log holds exactly two
test_10_not_cancels_spatial_modifier
- Setup: a level 6 wide and 4 high with
DEATHRATTLE (0,0) IS (1,0) DOUBLE (2,0)andDEATHRATTLE (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(), thencast("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)isnull: the Deathrattle's 1 damage killed it, in the next Death Creation Step.
- The log holds exactly one
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(), thencast("Spark", keg). - Assertions:
- The log holds exactly two
Triggered { source: keg, trigger: "deathrattle" }: the rule's multiplier and the Warden's both havestacking: "max", so together they double, not quadruple. health(bramble)is 1.
- The log holds exactly two
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)andLAVA (0,1) IS (1,1) HOT (2,1); an objectlavaat (2,3); cards Powder Keg at (1,3) and Bramble Wall at (4,3). - Action:
clearLog(), thenstep("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)isnull,kindsAt(2,3)is["lava"]andhealth(bramble)is 2.lost()is true: the keg was the onlyYOUobject.
- The log is exactly
test_10_spawned_text_triggers_reparse
- Setup: a level 5 wide and 3 high with
BABA (0,0) IS (1,0) YOU (2,0)andBABA (0,1) IS (1,1); card Rune Scribe at (2,1), on the empty end ofBABA IS; hand["Spark"]. Thenplace("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 wroteWINon its tile, and the scribe left the grid.rules()contains"BABA IS WIN"andhasProperty(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"]andhealth(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 andhand()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")(pushingDOUBLEthrough the gap in the wall to finishDEATHRATTLE IS DOUBLE), thencast("Whirlwind"). - Assertions 1:
activeRules()contains"DEATHRATTLE IS DOUBLE"before the cast. After it, the keg's Deathrattle has triggered twice andposOf(bramble)isnull. - Inputs:
step("down"),step("down"),step("left"),step("down"),step("down"),step("right"). - Assertions 2:
won()is false, andkindsAt(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:
setChooseranswering"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), returningmoth. - Assertions 2:
kindsAt(4,3)is["moth"], andnameOf(moth)is"Ember Moth (Assembled)". - Inputs:
step("left"), thenstep("left"), which pushes the moth onto the lava. - Assertions 3:
posOf(moth)isnullandkindsAt(2,3)is["lava", "text:WIN"]: the moth melted and its Deathrattle wroteWINwhere it died.rules()contains"BABA IS WIN", andwon()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, andposOf(hedge),posOf(keg)andposOf(warden)arenull.
- 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)isnull,lost()is true andwon()is false: the second Deathrattle killed the knight along with the hedge. This is the trap the level is built on.