Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
21 KiB
Course v2 extensions: design
Date: 2026-11-11. Branch: extend/course-v2 (from revise/edge-cases-and-sources).
0. Intent
The first revision found three reference-engine bugs and six unpinned ordering decisions by testing the tests. This round closes the gaps the course itself admits (two references never compared; interceptor kinds named but not built; an engine one card can crash; Assemble accepting meaningless text), extends the subject by two modules (conditions, combat) and one seam, and makes the course easier to use (log tree, undo, more levels, a designer deliverable, CI for the new checks, a playable site).
Anti-goals: no renumbering of Modules 00–10; no change that makes an existing learner workspace at Modules 1–10 fail tests it used to pass, except where §4 says a module's contract grows; no real-game claim without a fetched source.
1. Conventions (binding on every task)
- Two tracks, one behaviour. Every engine change is made in the Rust and the TypeScript reference of every module that has the code. Module references are cumulative full copies: a change introduced "from Module N" goes into the references of N and every later module that has a
reference/, both tracks. Keep the two tracks'words.json/seams.jsonbyte-identical. - Tests. Named
test_NN_…(Expansion Drop:test_NNx_…), the same names intests/rust/*.rs,tests/ts/*.test.tsand as### test_…headings inscenarios.md(drop:expansion/README.md), written in the style of the neighbouring tests and scenarios. TDD: each new test is shown failing before the change (at least one track) and passing after. - Zero-Coupling Law. Engine code (
src/engine/) never names a card or a noun.tools/coupling-audit.mjsmust printokfor every reference. - Verification, all of it, before a task is reported done:
node tools/verify-references.mjs,node tools/verify-references.mjs --expansion,npm run check,npm run test:tools,npm run test:site, andfor d in course/*/reference/*/; do node tools/coupling-audit.mjs "$d"; done. From Task 1 on alsonode tools/diff-tracks.mjs --seeds 200. - Lessons. Plain, short sentences in the style of the existing
lesson.mdfiles. Beats in order: Observe, Predict, Spec, Build, Name it, Expansion Drop, Stress it, Further reading. Every claim about Hearthstone or Baba Is You carries a link to a page that was fetched in this task, with any quotation copied exactly and at most 125 characters; anything the course decides for itself is labelled as the course's choice, in a "How the real game differs" box where one fits. If a source cannot be fetched, say so in the text rather than quoting from memory. Counts of tests in a lesson ("Make the six tests pass") and code quoted from a reference must match the files. - Docs that travel with code. A change to a harness contract updates
course/harness.md; a new card updatescourse/catalog.json(and passestools/test/schema.test.mjs); a new tool is listed inREADME.mdand has a test intools/test/. - Git. Commit on
extend/course-v2; do not push; every commit message ends withCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. - No subagents, no scope creep. Build what the task says. Problems found outside the task go in the report.
2. Tools
2.1 tools/diff-tracks.mjs (differential fuzzer)
Stages the Rust and TypeScript references of one module (default: the highest-numbered module whose reference has a SpellGrid CLI) with cards and levels, as play-spellgrid does. For each seed it generates a random SpellGrid level (size 4–8 square-ish; text drawn from the staged words.json plus IS, AND, NOT, biased so that whole sentences occur; objects of the nouns; cards and hand drawn from the staged cards/ that have a noun or are spells) and a random script of 5–25 CLI commands (moves, casts with and without targets, plays, chooser answers). It pipes the script to both CLIs and compares stdout. Output: per seed nothing on agreement; on the first divergence the seed, the level JSON path, the script and a unified diff of the two outputs. Flags: --seeds N (default 100), --seed S (one seed), --module NN, --dir, --keep. Exit 1 on any divergence. The generator is a seeded PRNG in the tool; the same seed always gives the same case. The Rust binary is built once per run.
A level or script that makes both CLIs print the same error is agreement. A crash (panic, uncaught exception, timeout of 10 s) on one side only is a divergence; on both sides it is reported separately as both crashed and exits 1.
Divergences found are fixed in this round: decide the right behaviour from the lessons and harness.md, fix the wrong track (or both), add a regression test in both tracks. If the right behaviour is undetermined, pick the simpler one, document it in the owning lesson's "Decisions in the reference", and say so in the report. The known register_assembled difference is in scope.
2.2 tools/mutation-check.mjs
Reads tools/mutants.json: a list of { name, file, find, replace, expect: [test names] } against the TypeScript reference of the highest module with a reference. For each, stages the reference with all cumulative tests, applies the textual replacement (fail if find does not occur exactly once), runs vitest, and asserts that at least the expect tests fail and the run is otherwise sane. Restores the file. Exit 1 if any mutant survives or any find is stale. Initial list: the nine rows of Module 13's table (end-of-Phase order, four turn mutants, hazards reversed, DEFEAT before HOT/MELT, live auras, plus one new mutant per feature added in this round that has an order: redirect-before-armor (§3.2), conditions evaluated at use (§4.1)).
2.3 tools/verify-levels.mjs and the solver
course/10-spellgrid/levels/proofs.json: one entry per level, { level, shortest, neverRule?, naiveUnsolvable? }. The tool runs tools/solver/solve.ts and checks: a solution exists with exactly shortest inputs and none shorter; with neverRule, the search exhausts with no solution (within --max 60); with naiveUnsolvable, likewise under --naive. The solver's position key becomes age-aware: the harness gains stackAt(x, y): string[] (kinds on the tile, oldest first), and the key uses it instead of kindsAt.
2.4 tools/run-scenario.mjs (designer path)
node tools/run-scenario.mjs scenario.json. A scenario is data: { cards?: CardDef[], words?: WordDef[], seams?: {triggerWords?, multiplierWords?}, level: LevelDef, inputs: string[] } where inputs are CLI command lines. The tool stages the TypeScript SpellGrid reference, adds the cards to cards/, merges the words and seams into content's JSON files, and prints after each input the board, rules, minions, hand and the Part I log as an indented tree (§3.1). It validates cards against course/schema/card.schema.json first and reports schema errors readably. No engine file is touched: what a designer can express is exactly what an Expansion Drop can.
2.5 CI
.gitea/workflows/ci.yml gains jobs: diff-tracks (--seeds 300), mutation-check, verify-levels. The existing jobs are unchanged. (No runner is available to this work; the workflow is validated by running each job's commands locally.)
3. Engine changes inside existing modules
3.1 Log tree (from Module 2)
Engine events carry the nesting depth at which they were recorded (0 for the outermost action's own events; +1 inside each resolving trigger). GameEvent as the tests see it is unchanged. Harness: logTree(): string / log_tree(&self) -> String, one event per line, two spaces of indent per level, each line the event in the form used by scenarios.md (Triggered{source: 3, trigger: "deathrattle"}), ending with a newline. New test test_02_log_tree_shows_nesting on the Echo / Armorsmith / Reactor scenario; Module 3's Death Phase events are at depth 0 and their Deathrattles' effects at depth 1 (test_03_log_tree_death_phase). Both SpellGrid CLIs print the tree of the Sequence(s) the last command ran, under a Log: line. Lesson 2's Stress it question 3 now points at the harness function and asks the learner to build theirs before reading the reference's.
3.2 Replacement effects (from Module 4)
Module 4 gains a second interceptor. One engine step, "apply a Damage to one target", consults modifiers before the world changes, in a fixed order written as a list (like END_OF_PHASE): DAMAGE_STEPS = [redirect, armor].
- Redirect is aura data:
{type: "damageRedirect", from: "friendlyHero", to: "self"}(schema and both tracks' card types). While in the aura snapshot, damage that would be dealt to the aura owner's hero is dealt to the aura's minion instead. A redirect applies at most once to one Damage (the rewritten Damage is not offered to redirects again: MTG 614.5's guard); with several, the oldest minion's (order of play) wins. TheDamagedevent names the minion that took it. - Armor is a rule of heroes: damage to a hero lowers Armor first, then Health. One
Damaged{target: hero, amount}event with the full amount (taking damage "still counts"), preceded byArmorLost{player, amount}(newGameEvent) when Armor absorbed any.
Card: the implementer fetches https://hearthstone.wiki.gg/wiki/Bolf_Ramshield and uses the card's exact current text and stats if it is a hero-damage redirect; otherwise a fictional Test Bodyguard 1/9 "Whenever your hero would take damage, this minion takes it instead." Tests (Module 4 main suite): test_04_armor_absorbs_before_health, test_04_redirect_replaces_hero_damage (hero untouched, minion damaged, a hero-damage trigger does not fire, the minion's own damage trigger does), test_04_redirect_applies_once (two redirectors: the older takes all of it), test_04_redirect_before_armor (hero with Armor and a redirector: Armor untouched). Lesson 4: a new section after Name it, "A second interceptor: replacement", with the rules, the list, the tests and MTG 614 as the name; Module 2's "How the real game differs" and Stress it question 4 updated to point here. The redirect aura obeys the Aura Update schedule like any aura.
3.3 Robustness (from Module 5, where a test can write the card)
- Nesting guard. A trigger of a minion is not queued for an event raised while that same (minion, trigger) is resolving.
test_05_trigger_does_not_nest_in_itself:Test Bomb1/50 "Whenever a minion takes damage, deal 1 damage to all minions." registered in the test; two Bombs;dealDamage(bomb1, 1); the Sequence ends; exactTriggeredcount asserted and explained in the scenario. - Death Phase cap. More than 64 Death Phases in one Sequence is an error: TypeScript throws
Errorand Rust panics, both with a message containingDeath Phase limit.test_05_death_phase_limit:Test Ember1/1 "Deathrattle: Summon a Test Ember. Deal 1 damage to all minions." - Well-formed Assemble.
wellFormed(text, type)in the engine: for a minion, no effect clause before the first trigger and every trigger followed by at least one effect clause before the next trigger, aura, keyword or the end.assemble(harness and effect) fails withAssembleErrorfor an ill-formed result, and theassembleeffect's"donor"and"half"options exclude combinations that would be ill-formed (so do"base"options that would have no donor left).test_05_assemble_rejects_ill_formed_textcovers Loot Hoarder with Baron Rivendare's top and bottom halves and checks an offer list. Every card incourse/cardsandcourse/10-spellgrid/cardsmust itself be well-formed (a tools test). - Summon count. A
summoneffect withcount > 1places its tokens left to right in summon order (position recomputed per token).test_05_multi_summon_order.
Lessons 2, 3 and 5: the Stress it answers that say "the reference doesn't have it" now say where it is and since when.
3.4 Undo (from Module 9)
Baba.undo(): boolean / undo(&mut self) -> bool: restores the level to the state before the last step or move (false and no change if there is none; repeated calls walk back). The engine snapshots, per the lesson's list: every object's id, kind and position in age order, the id counter, and won; rules are recompiled, not saved. place clears the history. test_09_undo_restores_everything: a turn that moves, converts and destroys; after undo, every posOf, kindsAt, stackAt, rules(), won() equals the values before, ids included; then the same step again gives the same result as the first time. SpellGrid does not undo (its harness function throws / panics with a message containing not supported): Part I has state the snapshot does not cover; Module 13 says so. stackAt (§2.3) is added to the Baba harness in the same change (from Module 8, where kindsAt appears).
3.5 Summoned minions enter the grid (Module 10: a fourth seam)
When a Sequence ends, for each Summoned event whose minion has no grid object and whose card has a noun: place an object of that noun, mapped to the minion, on the tile of the event's source if the source is (or, having died in this Sequence, was) on the grid, before dead minions' objects are removed and before the reparse. A minion summoned by a spell, or whose card has no noun, stays off the grid as before. Engine events therefore record the summoner (Summoned.by, internal; the harness GameEvent is unchanged). New SpellGrid cards: Clay Golem 2/2 noun GOLEM "Deathrattle: Summon a Clay Shard."; Clay Shard 1/1 noun SHARD. Tests: test_10_summoned_minion_enters_grid (the shard stands on the golem's tile, is named by SHARD IS … rules at once, has the newest id), test_10_summon_without_noun_stays_off_grid (Harvest Golem). Lesson 10: the seam diagram and list become four; Stress it question 3 becomes a pointer to the built seam and keeps the open questions.
3.6 More levels
Seven new levels l4–l10 in course/10-spellgrid/levels/, each built around one named idea from the course, each with test_10_level_N_solution in both tracks and a proofs.json entry with a neverRule (or naiveUnsolvable) that the solver confirms. Ideas, one each: NOT broken to enable a doubler; X IS X lock removed by X IS NOT X; hazard order observable through Deathrattles (Module 10 Stress it 1); a summoned shard used as a PUSH block or key (§3.5); Graft with a top-half swap; PULL-free push chain into SINK; a level where the obvious Whirlwind kills the player unless a rule is rewritten first. Each level 7×7 or smaller where possible, shortest solution at most 14 inputs, solvable by the solver in under 60 s. The lesson lists them with a one-line statement of the idea and no solution.
4. New modules
Module 11 (course/11-break-your-engine) is renamed to 13 first, with every link and mention updated. New modules get the full module shape: lesson.md, scenarios.md, tests/{rust,ts}, reference/{rust,ts} (a copy of the previous module's final reference plus the module's change, with README and .course.json), expansion/ (README, tests, solution), and a line in course/harness.md. start-module, verify-references, sync-check, the site sync and the mentor skill must work with them; start-module 13 fails with a message saying Module 13 has no tests to start.
4.1 Module 11: Conditions (Part II)
NOUN ON NOUN IS …: the rule applies to an object only while the condition holds for that object. Grammar: subjects [ON noun {AND noun}] IS complements; the condition belongs to every subject of the sentence. Rules carry conditions: [{word, nouns}]. What a condition word means is content: a function (level, object, nouns) -> bool registered by word, as hazards and responses are; the engine knows only that a word with role condition may stand between subjects and IS. ON: some other object named by each listed noun shares the object's tile (float level ignored unless the fetched wiki page says otherwise; record what it says).
Evaluation is at use: hasProperty, conversion, hazards and the win check evaluate conditions against the board as it is when they ask. Resolution (NOT, identity) pairs a negation only with rules whose conditions are identical; otherwise negation is settled per object at use, among the rules whose conditions hold for it. rules() / activeRules() print BABA ON LAVA IS MELT.
Tests (test_11_…), at least: condition gates a property; it follows the object as it moves within one turn (true after movement, checked by hazards); AND in the condition requires both; a conditional negation beats an unconditional rule only where it holds; a conditional conversion; conditions and stacked text; ON with a group noun. Expansion Drop: NEAR (the eight neighbours and the same tile, or as the fetched page defines it) as one word and one content function. Lesson theme: the third time a modifier is read. Baron: at a scheduled snapshot. NOT: at compile time. A condition: every time someone asks; what that costs (no table lookup, order-of-evaluation inside a turn becomes visible) and what the real game does about it (quote the Order of Operations page on conditions reparsing between stages). Name it: guards, or rules as predicates.
4.2 Module 12: Combat (Part I)
attack(attacker, defender) is one Sequence: legality, then both deal their Attack to each other simultaneously (one Damage action with two targets and per-target amounts), then triggers, then the usual end of Phase. Rules, each from the fetched Advanced rulebook or labelled as the course's: a minion with 0 Attack cannot attack; with an enemy taunt minion in play only taunt minions may be attacked (error otherwise; canAttack(attacker, defender): string | null gives the reason); heroes can be attacked and deal no damage back; the defender's damage triggers queue before the attacker's regardless of order of play (the exception Modules 1 and 2 list as unmodelled). endTurn(player) is one Sequence that raises the endOfTurn event for that player's minions, making Drakkari Enchanter's existing data live. No mana, no attack-once-per-turn, no summoning sickness: labelled simplifications.
Tests (test_12_…), at least: simultaneous combat damage and both die in one Death Creation Step; defender triggers first with an older attacker; taunt restricts targets; attacking a hero with Armor (uses §3.2); a redirect does not apply to combat damage aimed at a minion; end of turn trigger fires once, twice with Drakkari, and Drakkari's own timing obeys the Aura Update schedule. A fictional Test Bell "At the end of your turn, deal 1 damage to the enemy hero." if no course card has an end-of-turn trigger. Expansion Drop: one real Hearthstone minion, text and stats fetched, expressible in the existing vocabulary, whose test needs combat (a taunt minion with a damage trigger is the shape to look for). Lesson theme: an exception to an ordering rule is itself data (a priority on the queue entry), tying back to Liquid Fire's priority field cited in Module 2. Name it: priority within a queue.
5. Site and designer path
5.1 Playable SpellGrid
A page /play/ on the course site: level picker (all levels), the grid drawn as text tiles, keyboard (arrows/WASD) and buttons for moves, hand cards as buttons (click a card, then a tile if it needs one), chooser prompts as buttons, the rules in force, minions with Health, the Part I log of the last input as a collapsible tree (§3.1), Undo by replaying inputs, Restart. It runs the TypeScript SpellGrid reference unmodified in the browser: the site's sync step copies the reference's src/engine and src/harness.ts into a generated directory and supplies a browser content module that provides cards, words, seams, hazards and movement via static imports instead of node:fs. No rules are reimplemented in the page. Works without JavaScript frameworks beyond what Astro/Starlight already ships; usable on a phone; honours light and dark themes. Each of Lessons 10–12 links to it. npm run test:site gains a test that the generated game module solves l3 with its scripted inputs under Node.
5.2 Designer capstone
course/10-spellgrid/designer-capstone.md, linked from Lesson 10 and the README's Designer path: design three cards and one word within the existing vocabulary, write them as data, write five predictions as scenarios for tools/run-scenario.mjs, run them, and explain each surprise in terms of a named pattern. It includes one complete worked example (cards, word, scenario file under course/10-spellgrid/designer-example/, the tool's actual output) and a rubric.
6. Left to the builder
File-internal structure, helper names, exact wording of lessons within §1.5, the layouts of new levels, and any choice the lessons and this document leave open, provided it is recorded where the course records such choices ("Decisions in the reference", "How the real game differs").