The solver's position key reads stackAt (each tile oldest first). New tool tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each level's shortest solution, and that it exhausts without its idea (neverRule) or on the naive engine. Levels 4-10, one course idea each, with test_10_level_N_solution in both tracks, scenarios and Lesson 10's list; l1 no longer matches l10, and proofs.json is not listed as a level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 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,stackAt,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 four 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. - Summoned minions enter the grid. In the same pass, after the text is written and before the dead leave: a minion that a minion's ability summoned, whose card has a
"noun", becomes an object of that noun on its summoner's tile (where the summoner stands, or stood if it died in this Sequence), with the newest id. A minion summoned by a spell, or whose card has no noun, is in play but not on the grid. The log is unchanged:Summoned { id }reports the new object's id.
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) |
| Clay Golem | minion, 2 | 2/2 | GOLEM | Deathrattle: Summon a Clay Shard. (split 1) |
| Clay Shard | minion, 1 | 1/1 | SHARD | (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, GOLEM, SHARD 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_summoned_minion_enters_grid
- Setup: a level 5 wide and 3 high with
SHARD (0,0) IS (1,0) PUSH (2,0); card Clay Golem at (2,2); hand["Spark", "Spark"]. - Action:
cast("Spark", golem), thenclearLog(), thencast("Spark", golem). - Assertions, with
shardthe minion at (2,2):- The log is exactly
[SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: shard }]: the log reports the shard by its grid id. kindsAt(2,2)is["shard"]: the shard stands on the golem's tile, and the golem left the grid.posOf(golem)isnull.nameOf(shard)is"Clay Shard"andhealth(shard)is 1.activeRules()contains"SHARD IS PUSH"andhasProperty(shard, "PUSH")is true, straight after the cast: the shard was placed before the reparse.shardis greater thangolem,hero(0)andhero(1), and the nextplace("rock", 4, 2)returnsshard + 1: the shard has the newest id.
- The log is exactly
test_10_summon_without_noun_stays_off_grid
- Setup: a level 3 wide and 1 high with no objects; hand
["Graft", "Spark", "Spark"];setChooseranswering"Clay Golem"for the base,"Harvest Golem"for the donor and"bottom"for the half. - Action 1:
cast("Graft"). - Assertions 1:
hand()is["Spark", "Spark", "Clay Golem (Assembled)"]: the golem's noun with Harvest Golem's Deathrattle, "Summon a 2/1 Damaged Golem." - Action 2:
play("Clay Golem (Assembled)", 1, 0), returninggolem;cast("Spark", golem);clearLog();cast("Spark", golem). - Assertions 2, with
tokenthe id in the log's last event:- The log is exactly
[SpellCast { card: "Spark" }, Damaged { target: golem, amount: 1 }, Destroyed { id: golem }, DeathPhase { round: 1 }, Triggered { source: golem, trigger: "deathrattle" }, Summoned { id: token }]. nameOf(token)is"Damaged Golem"andhealth(token)is 1: it is in play.posOf(token)isnull,kindsAt(1,0)is empty andminionAt(1,0)isnull: Damaged Golem has no noun, so it is not on the grid.
- The log is exactly
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.
test_10_level_4_solution
Level levels/l4-crossed-out.json. The Thorn Hedge (2 Health) is STOP and blocks the flag; the hand holds one Spark. DEATHRATTLE IS DOUBLE is written down a column and DEATHRATTLE IS NOT DOUBLE along a row, sharing the DEATHRATTLE. The keg is at (1,6).
- Assertions 1:
rules()contains"DEATHRATTLE IS DOUBLE", andactiveRules()doesn't: the negated rule cancels it. - Inputs:
step("right"),step("down"), which pushes the row'sDOUBLEout of line. - Assertions 2:
rules()no longer contains"DEATHRATTLE IS NOT DOUBLE", andactiveRules()contains"DEATHRATTLE IS DOUBLE". - Inputs:
step("right"),step("right"), thencast("Spark", keg). - Assertions 3: the keg's Deathrattle has triggered twice, and
posOf(hedge)isnull. - Inputs:
step("right"),step("up"). - Assertions 4:
won()is true.
test_10_level_5_solution
Level levels/l5-sealed-keg.json. Walled off at the top: KEG IS KEG, KEG IS FLAG, FLAG IS WIN. There is no flag. Below: KEG IS with a gap and then KEG, a loose NOT, and the keg at (1,5).
- Inputs:
step("left"),step("left"),step("down"),step("left"). - Assertions 1:
kindsAt(1,5)is["keg"]: the lock holds. - Input:
step("up"), which pushesNOTinto the gap. - Assertions 2:
activeRules()contains"KEG IS NOT KEG"and not"KEG IS KEG".- The keg's Deathrattle has triggered once,
posOf(keg)isnull, andkindsAt(1,5)is["flag"]: the keg was deleted, and because the negation also cancelled the lock,KEG IS FLAGstill made its flag.
- Input:
step("left"). - Assertions 3:
won()is true.
test_10_level_6_solution
Level levels/l6-melt-before-defeat.json. The player is a Tin Knight (KNIGHT IS YOU, 3 Health) at (0,6). A Powder Keg (PUSH and MELT) stands on a Clay Shard (SHARD IS DEFEAT) at (3,6), with lava and then the flag beyond. LAVA IS is missing its HOT.
- Assertions 1:
stackAt(3,6)is["keg", "shard"]. - Inputs:
step("right"),step("right"),step("up")(finishingLAVA IS HOT),step("down"). - Assertions 2:
activeRules()contains"LAVA IS HOT". - Input:
step("right"): the knight pushes the keg into the lava and steps onto the shard's tile. - Assertions 3:
- The keg's Deathrattle triggered once, and
posOf(keg)isnull. posOf(knight)is[3, 6],kindsAt(3,6)is["knight"],health(knight)is 2 andlost()is false. HOT/MELT runs before DEFEAT: the keg melts, its Deathrattle kills the shard, and when DEFEAT runs there is nothingDEFEATon the knight's tile.
- The keg's Deathrattle triggered once, and
- Inputs:
step("right"),step("right"). - Assertions 4:
won()is true.
test_10_level_7_solution
Level levels/l7-clay-key.json. The flag is behind a door (DOOR IS SHUT AND STOP). SHARD IS PUSH is written, and SHARD IS waits for its OPEN. There is no shard: a Clay Golem stands at (4,5), and the hand holds two Sparks.
- Inputs:
step("right"),step("up")(finishingSHARD IS OPEN),step("right"). - Assertions 1:
activeRules()contains"SHARD IS OPEN". - Inputs:
cast("Spark", golem)twice. - Assertions 2:
posOf(golem)isnull,kindsAt(4,5)is["shard"], and the minion there is a"Clay Shard": seam 4 put the Deathrattle's token where the golem died. - Inputs:
step("right"),step("right"), pushing the shard into the door. - Assertions 3:
kindsAt(6,5)is[]: the shard and the door destroyed each other. - Inputs:
step("right"),step("up"). - Assertions 4:
won()is true.
test_10_level_8_solution
Level levels/l8-nothing-dies.json. SHARD IS waits for its WIN, and there is no shard. The hand holds one Graft, and nothing on the level can deal damage.
- Setup:
setChooseranswering"Clay Golem"for the base,"Novice Engineer"for the donor and"top"for the half. - Input:
step("left"). - Assertions 1:
activeRules()contains"SHARD IS WIN". - Inputs:
cast("Graft"),clearLog(), thenplay("Clay Golem (Assembled)", 4, 3), returninggolem. - Assertions 2: the log contains
Triggered{source: golem, trigger: "battlecry"},posOf(golem)is[4, 3]andstackAt(4,3)is["golem", "shard"]. The top half swap made "Deathrattle: Summon a Clay Shard." into "Battlecry: Summon a Clay Shard.", and seam 4 puts the shard on the living golem's tile. - Input:
step("right"). - Assertions 3:
won()is true.
test_10_level_9_solution
Level levels/l9-skull-river.json. A river of skulls (SKULL IS DEFEAT) two tiles wide lies between Baba and the flag. Two rocks (ROCK IS PUSH) stand in a corridor in a line, and SKULL IS waits for its SINK.
- Input:
step("right"): Baba pushes both rocks at once. - Assertions 1:
kindsAt(4,5)is["rock", "skull"]andkindsAt(3,5)is["rock"]. - Inputs:
step("down"),step("left")(finishingSKULL IS SINK). - Assertions 2:
activeRules()contains"SKULL IS SINK", andkindsAt(4,5)is[]: the skull took the rock with it at the end of that turn. - Inputs:
step("up"),step("right"),step("right"),step("right"). - Assertions 3:
kindsAt(5,5)is[]: the second rock sank in the second skull. - Inputs:
step("right"),step("right"). - Assertions 4:
won()is true.
test_10_level_10_solution
Level levels/l10-friendly-fire.json. The player is a Clay Shard (SHARD IS YOU, 1 Health) at (1,6). The Thorn Hedge (2 Health) is STOP and blocks the flag; a Powder Keg and a Tin Knight (3 Health) are in play, and the hand holds Whirlwind. A KNIGHT text lies below SHARD.
- Inputs:
step("left"),step("up"), which pushesKNIGHTinto the sentence andSHARDout of it. - Assertions 1:
activeRules()contains"KNIGHT IS YOU"and not"SHARD IS YOU". - Inputs:
step("up"), thencast("Whirlwind"). - Assertions 2:
posOf(shard)andposOf(hedge)arenull,health(knight)is 1, andlost()is false. Whirlwind and the keg's Deathrattle deal 2 damage to every minion: the shard dies, and so would the player if it were still the shard. - Inputs:
step("right"),step("up"). - Assertions 3:
won()is true.