Files
systemic-game-design-course/docs/superpowers/plans/2026-10-28-systemic-design-course.md
T
2026-10-28 23:52:02 +01:00

218 lines
27 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.
# Systemic Game Design Course Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the complete course (lessons, failing tests and reference solutions for Modules 0–10 in Rust and TypeScript, tooling, mentor skill, website, deploy) described in the design.
**Architecture:** `course/NN-slug/` holds each module's single-source lesson, language-neutral scenarios, tests per language and a cumulative reference engine per language. Tests talk only to a per-module *harness* (documented once in `course/harness.md`). Node scripts in `tools/` keep the two languages in sync and run the audits. The site is generated from `course/**/lesson.md` at build time.
**Tech Stack:** Rust (stable, edition 2021, `cargo test`, `serde_json` from M5), TypeScript (Node 20+, `vitest`, ESM), Node scripts (`.mjs`, `node:test`), Astro Starlight, nginx:alpine container, Gitea Actions.
**Spec:** `docs/superpowers/specs/2026-10-28-systemic-design-course-design.md`. Research facts every lesson must match: `docs/research/hearthstone.md`, `docs/research/baba-is-you.md`, `docs/research/architecture-readings.md`.
## Global Constraints
- Licence: MIT for everything (prose and code). Course is always free.
- Real Hearthstone card text may be quoted verbatim; site has a disclaimer page (not affiliated with Blizzard or Hempuli; trademarks of their owners; quoted for educational commentary).
- Every "the real game does X" sentence in a lesson links to a source recorded in `docs/research/`. Deliberate deviations go in a `> **How the real game differs**` blockquote.
- Excluded sources (never cited): Patashu reddit post, both Assemble reddit threads, Medium "Events in ECS", MTG Fandom pages, fandom Hearthstone rulebook URL (use hearthstone.wiki.gg), Liquid Fire *Drawing Cards*.
- Third-party engine code (Fireplace, SabberStone, Argentum, phase-rs, baba-is-auto) is linked with file paths, never copied.
- Zero-Coupling Law: engine-core code never names a specific card or keyword-instance; only `content/` may.
- Each module's Rust and TS tests have identical test names, matching `### test_…` headings in that module's `scenarios.md`.
- Test-only fictional cards are named `Test <Name>` and flagged `"fictional": true` in the catalog. SpellGrid's own cards are flagged `"game": "spellgrid"`.
- Lesson beats, in order, as H2 headings: `Observe`, `Predict`, `Spec`, `Build`, `Name it`, `Expansion Drop`, `Further reading`. Hints/discussion in `<details><summary>…</summary>` blocks, three hint tiers labelled `Hint 1 — nudge`, `Hint 2 — concept`, `Hint 3 — pseudo-code`.
- Code shown in lessons: a ```` ```rust ```` block immediately followed by a ```` ```ts ```` block (the site turns adjacent pairs into tabs).
- Grid coordinates: `x` grows right, `y` grows down; vertical sentences read top-to-bottom.
## Review Focus
1. **A learner starts module N's tests with a module N−1 harness** — `start-module` must copy only the module's tests and print which harness functions the module adds; it must refuse if the previous module's tests are absent from the workspace (Task 1 test `start_module_refuses_skipping`).
2. **Drawing from an empty deck** — tests that draw must set up decks; engines must not panic: harness `draw` on empty deck logs `Fatigue` and does nothing else (M2 test `test_02_empty_deck_draw_is_fatigue`).
3. **Lesson markdown that renders on Gitea but breaks the site (or vice versa)** — relative links, `<details>`, adjacent code pairs. The sync script must rewrite links between lessons and fail the build on dead links (Task 15 test `sync_rewrites_and_checks_links`).
4. **Coupling audit false positives on common words** (`PUSH`, `IS`, `Test`) — audit matches only names from `course/catalog.json` with ≥2 characters that are not Baba operator/property words, as whole identifiers/strings (Task 1 test `audit_ignores_grammar_words`).
5. **Assemble producing an identical text** (donor half equals base half) must be rejected, not silently accepted (M5 test `test_05_assemble_must_change_text`).
---
## Shared contracts (read by every task)
### Harness (canonical copy lives in `course/harness.md`, written in Task 2)
TS signatures shown; Rust uses the same names in snake_case on `pub struct Game` / `pub struct Baba` / `pub struct SpellGrid` in `src/harness.rs`, `Id = u32`, `Player = u8`, events as `pub enum GameEvent`.
**Part I (`Game`)** — added per module:
- M1: `newGame(opts?: { decks?: [string[], string[]] }): Game`; `summon(card: string, player?: 0|1): Id` (to right end of board); `addToHand(card, player?)`, `addToDeck(card, player?)`; `play(handId: Id): void` (Expansion Drop); `zoneOf(id): "deck"|"hand"|"board"|"graveyard"`; `board(player): Id[]` (left→right); `hand(player): Id[]`; `orderOfPlay(id): number`; `nameOf(id): string`.
- M2: `hero(player): Id`; `dealDamage(target: Id, amount: number): void` (one Sequence); `cast(card: string, player?, target?: Id): void`; `health(id)`, `attack(id)`, `armor(player)`; `log(): GameEvent[]`; `clearLog()`.
- M3: `destroy(target: Id): void` (marks pending-destroy, one Sequence).
- M5: `registerCards(defs: CardDef[]): void`; `assemble(base: string, donor: string, half: "top"|"bottom"): string` (registers and returns new card name `"<base> (Assembled)"`; throws `AssembleError` if text unchanged).
`GameEvent` (tagged by `type`): `Summoned{id}`, `Damaged{target, amount}`, `Triggered{source, trigger}` (`trigger`: `"deathrattle"|"battlecry"|"damaged"|…`), `Destroyed{id}` (removed in a Death Creation Step), `DeathPhase{round}`, `Drew{player, id}`, `Fatigue{player}`, `ArmorGained{player, amount}`, `StatsChanged{id, attack, health}`, `SpellCast{card}`.
**Part II (`Baba`)**:
- M6: `newBaba(level: LevelDef): Baba`; `place(kind: string, x, y): Id` (kind `"baba"` or `"text:BABA"`); `rules(): string[]` (sorted, normalised `"BABA IS YOU"`, includes `"TEXT IS PUSH"`).
- M7: `hasProperty(id, prop: string): boolean`; `activeRules(): string[]` (after NOT/identity post-pass).
- M8: `move(dir: "up"|"down"|"left"|"right"): void` (movement only, then reparse); `posOf(id): [number, number] | null` (null once destroyed); `kindsAt(x, y): string[]` (sorted).
- M9: `step(dir): void` (full simplified turn); `won(): boolean`; `lost(): boolean` (no YOU objects remain).
**Part III (`SpellGrid`)** — M10: `newSpellGrid(level: LevelDef, cards: CardDef[]): SpellGrid` exposing all of Part II plus `cast`, `health`, `log`, `hero`, `won`. Minions are grid objects whose noun is their card's `"noun"` field.
### Data formats (written in Task 3)
- `course/schema/card.schema.json`: `{ name, type: "minion"|"spell", cost, attack?, health?, noun?, text: Clause[], split: number, fictional?, game? }`. `Clause` is one of `{kind:"keyword", keyword}`, `{kind:"trigger", on}`, `{kind:"aura", modifier}`, `{kind:"effect", effect}`. A trigger clause applies to the effect clauses after it until the next trigger/keyword/aura. `split` is the index dividing top (`text[0:split]`) from bottom (`text[split:]`).
- Trigger `on`: `deathrattle`, `battlecry`, `selfDamaged`, `friendlyMinionDamaged`, `anyMinionDamaged`, `heroDamaged`, `endOfTurn`.
- Effect: `dealDamage{target, amount}`, `draw{count}`, `summon{card, count}`, `gainArmor{amount}`, `gainAttack{target, amount}`, `destroy{target}`, `spawnText{word, at:"self"}` (M10). Targets: `self`, `allMinions`, `allOtherMinions`, `allOtherCharacters`, `enemyHero`, `chosen`.
- Aura modifier: `{type:"triggerMultiplier", trigger, times, stacking:"max"|"additive"}`.
- `course/schema/level.schema.json`: `{ width, height, objects: [{kind, x, y}], cards?: [{card, x, y}] }`.
- `course/catalog.json`: every card/word the course uses `{name, kind:"card"|"word", stats?, text, source (wiki.gg URL), fictional?, game?}`.
### Workspace config
`.course.json` in a learner workspace: `{ "track": "rust"|"ts", "engineDirs": ["src/engine"], "contentDirs": ["src/content"], "module": "NN" }`.
---
### Task 1: Repo foundation and tooling
**Files:**
- Create: `README.md`, `LICENSE` (MIT, "Lucas Winther"), `.gitignore`, `package.json` (root, `"type":"module"`, scripts `test:tools`, `check`), `tools/lib.mjs`, `tools/sync-check.mjs`, `tools/coupling-audit.mjs`, `tools/expansion-check.mjs`, `tools/start-module.mjs`, `tools/verify-references.mjs`, `tools/test/*.test.mjs`, `tools/test/fixtures/**`
**Interfaces:**
- Produces CLIs: `node tools/sync-check.mjs` (exit 1 listing mismatches across all `course/*/`); `node tools/coupling-audit.mjs <workspaceDir>` (reads `.course.json`, `course/catalog.json`); `node tools/expansion-check.mjs <workspaceDir> <baseRef>`; `node tools/start-module.mjs <NN> <workspaceDir>`; `node tools/verify-references.mjs [NN]` (for each module ≤ NN, copies reference + tests of modules 01..N into a temp dir, runs `cargo test` / `npx vitest run`, reports per module).
- `tools/lib.mjs` exports `moduleDirs(): {num, slug, path}[]`, `testNames(path, lang): string[]`, `scenarioNames(path): string[]`, `loadCatalog(): Entry[]`.
- [ ] **Step 1: Write failing tool tests** in `tools/test/` with fixtures: `sync_detects_missing_ts_test` (fixture module with rust test `test_01_a`, ts lacking it → exit 1, message names `test_01_a`); `audit_flags_card_name_in_engine` (`"Baron Rivendare"` string and `baron_rivendare` identifier in engine dir → flagged with file:line); `audit_ignores_content_dir`; `audit_ignores_grammar_words` (`PUSH`, `IS`, `YOU` in engine not flagged); `expansion_check_rejects_engine_edit`; `expansion_check_accepts_content_only`; `start_module_copies_tests` ; `start_module_refuses_skipping` (module 03 requested while workspace lacks module 02 tests → exit 1 "finish module 02 first").
- [ ] **Step 2: Run** `node --test tools/test/` — Expected: FAIL (scripts missing).
- [ ] **Step 3: Implement the tools.** Test names: Rust `fn (test_\w+)`, TS `test\(["'](test_\w+)`, scenarios `^### (test_\w+)`. Audit name variants: exact string, snake_case, camelCase, PascalCase, SCREAMING_SNAKE.
- [ ] **Step 4: Run** `node --test tools/test/` — Expected: PASS.
- [ ] **Step 5: Write README** (what the course is, two paths, two tracks, quick start, licence, disclaimer link). Commit `feat: repo foundation and course tooling`.
### Task 2: Workspace templates and harness contract
**Files:**
- Create: `course/harness.md` (the Shared contracts harness section verbatim, both languages side by side, per-module "adds" tables); `workspace-template/rust/{Cargo.toml,src/lib.rs,src/harness.rs,src/engine/mod.rs,src/content/mod.rs,.course.json,README.md}`; `workspace-template/ts/{package.json,tsconfig.json,vitest.config.ts,src/harness.ts,src/engine/index.ts,src/content/index.ts,.course.json,README.md}`
**Interfaces:**
- Produces: template `harness` files with M1 functions as stubs (`todo!()` / `throw new Error("not implemented")`). Rust tests import `use engine::harness::*;`; TS tests import `from "../src/harness"`. Tests live in workspace `tests/`.
- [ ] **Step 1:** Create templates; copy into a temp dir; run `cargo build` and `npx tsc --noEmit` — Expected: both succeed.
- [ ] **Step 2:** Run `node tools/start-module.mjs 01 <tmp>` after Task 4 exists — deferred check recorded in Task 4.
- [ ] **Step 3:** Commit `feat: workspace templates and harness contract`.
### Task 3: Schemas and catalog
**Files:**
- Create: `course/schema/card.schema.json`, `course/schema/level.schema.json`, `course/catalog.json`, `tools/test/schema.test.mjs`
- [ ] **Step 1: Failing test** `catalog_valid`: every catalog card validates; every real card has a `hearthstone.wiki.gg` `source`; every `Test ` card has `fictional: true`.
- [ ] **Step 2: Fill catalog**, verifying each real card's current stats and text on hearthstone.wiki.gg (WebFetch) and recording them: Armorsmith, Acolyte of Pain, Frothing Berserker, Loot Hoarder, Baron Rivendare (1/7), Twisting Nether, Whirlwind, Unstable Ghoul, Harvest Golem, Damaged Golem, Brann Bronzebeard, Drakkari Enchanter, Novice Engineer, Dread Infernal, Fleshshape; fictional `Test Echo` (minion 1/5: "Whenever a minion takes damage, deal 1 damage to the enemy hero"), `Test Reactor` (1/5: "Whenever a hero takes damage, gain +1 Attack"), `Test Filler` (1/1 vanilla); Baba words and objects used in M6–M10; SpellGrid cards (Task 13). If the wiki's current stats differ from those assumed in this plan, the wiki wins; update scenario values accordingly.
- [ ] **Step 3: Run** `node --test tools/test/schema.test.mjs` — Expected: PASS. Commit `feat: card/level schemas and verified catalog`.
### Task 4: Module 0 — The Rewrite Trap
**Files:**
- Create: `course/00-rewrite-trap/lesson.md`, `naive-engine/rust/**`, `naive-engine/ts/**` (each ≤ ~120 lines of engine: minions, immediate damage, Deathrattle callbacks invoked recursively when HP ≤ 0)
- [ ] **Step 1: Tests in naive engine** (both langs): `test_00_naive_runs` (summon Loot Hoarder, deal 1 → it is gone, 1 card drawn) passes; `test_00_real_rules_whirlwind_order` encodes the real M2 order (Whirlwind with Acolyte of Pain 1/3 and Loot Hoarder: all damage events precede triggers; Acolyte draws; Loot Hoarder's Deathrattle draws after) and is marked expected-to-fail (`#[should_panic]` / `test.fails`) with a comment pointing to the lesson.
- [ ] **Step 2: Run** both suites — Expected: all pass (the expected-fail passes by failing).
- [ ] **Step 3: Lesson**: Observe (real Baron text, quoted), task "add Baron to the naive engine; note every file you touched", reflection questions, Fireplace `fireplace/actions.py` `Deathrattle` branch and SabberStone tag mismatch (`EXTRA_MINION_DEATHRATTLES_BASE` vs `EXTRA_DEATHRATTLES_BASE`, skipped test) as real-world traps, track choice + workspace setup instructions. No reference solution.
- [ ] **Step 4:** Commit `feat: module 0 rewrite trap`.
### Tasks 5–9: Part I modules — common steps
Each of Tasks 5–9 creates `course/NN-slug/{lesson.md, scenarios.md, tests/rust/test_NN.rs, tests/ts/test_NN.test.ts, reference/rust/**, reference/ts/**, expansion/{README.md, tests/rust/…, tests/ts/…}}` and runs these steps:
- [ ] **A. Write `scenarios.md`** — one `### test_…` heading per test with setup, action, and assertions in prose, using exact values below.
- [ ] **B. Write both test files** from scenarios; run `node tools/sync-check.mjs` — Expected: PASS.
- [ ] **C. Copy previous module's reference as the starting point**, run `node tools/verify-references.mjs NN` — Expected: FAIL on module NN tests only (RED proof).
- [ ] **D. Implement the reference** in both languages, engine in `src/engine/`, cards in `src/content/`.
- [ ] **E. Run** `node tools/verify-references.mjs NN` — Expected: PASS for modules 01..NN in both languages. Run `node tools/coupling-audit.mjs course/NN-slug/reference/rust` and `/ts` — Expected: clean.
- [ ] **F. Expansion Drop**: commit the reference in a temp git repo, add the expansion card in `content/` only, run its tests and `tools/expansion-check.mjs` — Expected: PASS. Expansion card code goes in `expansion/solution/` (not merged into the next module's reference start unless the next module needs it).
- [ ] **G. Write `lesson.md`** with the seven beats, citations, "How the real game differs" boxes, readings listed per module below.
- [ ] **H. Commit** `feat: module NN <slug>`.
### Task 5: M1 — Handles and order of play (`01-handles`)
- Tests: `test_01_ids_stable_and_order_increases` (summon `Test Filler` A then B → `orderOfPlay(A) < orderOfPlay(B)`, IDs distinct, `board(0) == [A, B]`); `test_01_zones` (`addToDeck`, `addToHand`, `summon` → `zoneOf` = deck/hand/board); `test_01_board_query_per_player` (P0 and P1 summons appear only on their own `board(p)`); `test_01_names` (`nameOf` returns the card name). Graveyard behavior is tested from M3.
- Expansion: `play(handId)` moves hand → board and assigns a fresh order of play at entry (`test_01x_play_assigns_order_on_entry`: card added to hand first, minion summoned second, then played → played card's order > summoned's).
- Readings: Wagar (framing); Argentum `docs/architecture-principles.md` §2.2.
### Task 6: M2 — The action queue, depth-first (`02-action-queue`)
- Tests: `test_02_aoe_damage_then_triggers` (Armorsmith P0 order 1, Acolyte of Pain P0 order 2, decks of 5 `Test Filler`; `cast("Whirlwind")` → every `Damaged` event precedes every `Triggered` event; Armorsmith triggers twice (once per damaged friendly minion), Acolyte once; `armor(0) == 2`; `hand(0).length == 1`; the exact interleaving of those three triggers is not asserted (the sources don't pin it) and the lesson says so); `test_02_mortally_wounded_still_triggers` (Acolyte pre-damaged to 1 health, Whirlwind → Acolyte still draws, health 0, still `zoneOf == "board"` at end of M2 since M2 has no death step yet — note in lesson that M3 adds removal); `test_02_depth_first_nesting` (Test Echo order 1, Armorsmith order 2, Test Reactor order 3; `dealDamage(armorsmith, 1)` → triggered sources in order: Echo, Reactor, Armorsmith; Reactor attack +1 before `ArmorGained` in log); `test_02_empty_deck_draw_is_fatigue`.
- Expansion: Frothing Berserker (`test_02x_frothing_counts_each_damage`: Whirlwind with Frothing + 2 others → attack +3).
- Lesson "differs" box: Redemption last, Poisonous priority, defender-first combat triggers named, not modeled. Readings: Liquid Fire *Action System* (priority-then-order-of-play sort; its reaper loop previewed for M3), Nystrom *Event Queue*, BasicMod *Action Queue*.
### Task 7: M3 — Deferred death (`03-death-step`)
- Tests: `test_03_removed_only_after_sequence` (Whirlwind kills Loot Hoarder; `Destroyed(Hoarder)` appears after the last `Damaged`; Hoarder in graveyard); `test_03_chained_death_phases` (Harvest Golem pre-damaged to 1 health; Unstable Ghoul; `dealDamage(ghoul, 3)` → log: Destroyed(Ghoul), DeathPhase 1, Triggered(Ghoul, deathrattle), Damaged(Golem,1), DeathPhase 2 … Destroyed(Golem), Triggered(Golem, deathrattle), Summoned(Damaged Golem) at Golem's former board index); `test_03_simultaneous_removal` (two Loot Hoarders die to Whirlwind → both `Destroyed` before either `Triggered`, deathrattles in order of play); `test_03_destroy_marks_pending` (`destroy(id)` on a full-health minion → removed in the death step, not immediately).
- Expansion: Twisting Nether as content (`test_03x_twisting_nether_destroys_all`).
- Readings: wiki.gg Advanced rulebook (Sequences, Phases, Death Creation Step), MTG CR 704 (official), Fireplace `game.py` `action_end`/`process_deaths`.
### Task 8: M4 — Interceptors and "trigger twice" (`04-interceptors`)
- Tests: `test_04_baron_doubles` (Baron + Loot Hoarder; kill Hoarder → 2 draws; log shows one queued trigger resolving twice, back to back); `test_04_simultaneous_death_not_doubled` (Baron + Hoarder, `cast("Twisting Nether")` → 1 draw); `test_04_two_barons_do_not_stack` (→ 2 draws); `test_04_aura_timing_order` (Aura Update (Other) runs after Death Creation Step: assert via log that `Destroyed(Baron)` precedes Hoarder's single `Triggered`).
- Engine requirement: modifiers come from `{kind:"aura", modifier}`-equivalent content data and are consulted by one generic routine; `stacking:"max"` for Baron.
- Expansion: Brann Bronzebeard + Novice Engineer (`test_04x_brann_doubles_battlecry` → 2 draws via `play`); lesson contrasts with Panharmonicon's additive stacking (Argentum).
- Readings: wiki.gg Baron Rivendare page + rulebook aura section, MTG CR 603.2d / 613 / 614 (official), Argentum `Panharmonicon.kt` + `TriggerDetector.duplicateETBOrLTBTriggers`.
### Task 9: M5 — Behavior as data and Assemble (`05-assemble`)
- Reference refactor: all M1–M4 cards now loaded from `course/cards/*.json` via `registerCards`; reference uses `serde_json` (Rust) / JSON import (TS).
- Tests: `test_05_regression_data_cards` (re-run M2–M4 scenarios using data cards — implemented by the reference passing modules 01–04; this test loads JSON and asserts `registerCards` accepts every catalog card); `test_05_assemble_bottom_half` (Harvest Golem `text=[trigger deathrattle | effect summon]`, split 1; Dread Infernal `[trigger battlecry | effect dealDamage allOtherCharacters 1]`, split 1; `assemble("Harvest Golem","Dread Infernal","bottom")` → summoned with Baron and a `Test Filler` (1/1→ dies) and a 3-health filler; kill assembled → fillers damaged twice, heroes 30→28); `test_05_assembled_keeps_base_stats` (cost 3, 2/3, name contains "Harvest Golem"); `test_05_assemble_must_change_text` (assemble Loot Hoarder with Loot Hoarder → throws); `test_05_top_half_swap` (replace top: Loot Hoarder top ← Novice Engineer top → "Battlecry: Draw a card", play it → 1 draw on play, none on death).
- Expansion: Fleshshape as data (`test_05x_fleshshape_pool_filter`: given an injected chooser, only Deathrattle minions offered as base).
- Lesson notes: the wiki doesn't define "half"; pool cost rule is part of selection, not modeled; random choice injected.
- Readings: Nystrom *Bytecode* (behaviour as data, not a VM), Argentum §1.1–1.2, Forge card-scripting wiki, wiki.gg *Assemble*.
### Tasks 10–12: Part II modules (same common steps A–H as Tasks 5–9; Part II references start fresh with a `Baba` engine in the same crate/package alongside Part I)
### Task 10: M6 + M7 — Parser and rule resolution (`06-parser`, `07-rule-resolution`)
Two modules, one task each committed separately (`feat: module 06…`, `feat: module 07…`), plus the short Part II bridge section at the top of the M6 lesson.
- M6 tests: `test_06_horizontal_rule`; `test_06_crossword_shared_is` (BABA (0,1) IS (1,1) YOU (2,1); FLAG (1,0) WIN (1,2) → rules include `BABA IS YOU`, `FLAG IS WIN`); `test_06_and_distributes` (`BABA AND ROCK IS PUSH AND STOP` → 4 rules); `test_06_stacked_text_all_combinations` (BABA and ROCK text stacked at (0,0), IS YOU → both rules); `test_06_default_text_is_push`.
- M6 expansion: `HOT` word with no parser change.
- M7 tests: `test_07_not_overrides`; `test_07_x_is_x_blocks_conversion` (`BABA IS BABA` + `BABA IS ROCK` → active rules exclude conversion); `test_07_x_is_not_x_deletes_and_unlocks` (`BABA IS NOT BABA` + `BABA IS BABA` + `BABA IS ROCK` → lock cancelled, conversion active, and Baba flagged for deletion — assert `activeRules` contains `BABA IS ROCK` and not `BABA IS BABA`); `test_07_text_is_not_push`.
- M7 expansion: `ALL` meta-noun.
- Readings: baba-is-auto `Game::ParseRules` (+ "not an authority" note), Baba wiki rule pages, Teikari article (re-verify quotes in Task 17), MTG 613.
### Task 11: M8 — Push chains (`08-push-chains`)
- Tests: `test_08_chain_moves_together` (Baba YOU, three PUSH rocks right → all shift 1); `test_08_chain_into_stop_nothing_moves` (incl. Baba); `test_08_open_shut_during_push` (KEY IS PUSH AND OPEN, DOOR IS STOP AND SHUT; push key into door → both `posOf == null`, Baba moves 1 into key's former tile; lesson marks landing tile as inferred); `test_08_text_pushed` (text blocks move via default TEXT IS PUSH and reparse changes rules).
- Expansion: `PULL`.
- Readings: Baba wiki PUSH/STOP/OPEN/SHUT, esolangs page.
### Task 12: M9 — Turn pipeline (`09-turn-pipeline`)
- Tests: `test_09_lava_saved_by_reparse`; `test_09_mid_turn_conversion` (Baba pushes ROCK text to complete `BABA IS ROCK` → after `step`, `kindsAt` where Babas stood contain `rock`); `test_09_identity_lock_blocks_conversion`; `test_09_float_separates_hazards`; `test_09_sink_destroys_both`; `test_09_you_and_win`; `test_09_defeat_beats_win`.
- Lesson shows the full real order from the wiki next to the simplified one.
- Expansion: `WEAK` (destroyed when anything else overlaps it), added as a hazard rule only.
- Readings: Baba wiki Order of Operations, baba-is-auto `Game::MovePlayer`.
### Task 13: M10 — SpellGrid capstone (`10-spellgrid`)
**Files:** module dir as above plus `course/10-spellgrid/levels/{l1-double-tap,l2-spliced-corpse,l3-simultaneous-paradox}.json`, SpellGrid cards appended to catalog (`game:"spellgrid"`), reference CLI `reference/{rust,ts}/src/bin/spellgrid` (`cargo run --bin spellgrid -- levels/l1.json`, `npx tsx src/cli.ts levels/l1.json`; WASD moves, `c <card> [x y]` casts, `q` quits; ASCII render).
- Integration seams (only these): compiled rule `DEATHRATTLE IS DOUBLE` → triggerMultiplier modifier; hazard destruction → pending-destroy into M3 step; any effect changing the grid → reparse after the Sequence.
- Tests: `test_10_spatial_rule_feeds_interceptor`; `test_10_not_cancels_spatial_modifier` (`DEATHRATTLE IS NOT DOUBLE` with a Baron-like SpellGrid card → once); `test_10_hazard_feeds_death_step`; `test_10_spawned_text_triggers_reparse`; `test_10_level_1_solution`, `test_10_level_2_solution`, `test_10_level_3_solution` (scripted inputs → `won()`); `test_10_level_3_naive_would_differ` (same inputs with modifier forced to apply to simultaneous deaths via a test-only engine flag → not won; documents the trap).
- L1 uses a fixed-damage Deathrattle minion (no randomness). Design each level on paper, then verify by its test.
- Lesson ends with the synthesis question (Baron vs `BABA IS NOT YOU`).
### Task 14: Mentor skill
**Files:** `mentor/SKILL.md`, `mentor/README.md` (install: copy `mentor/` to `~/.claude/skills/systemic-design-mentor/`; start with "start the systemic design course")
- SKILL.md frontmatter `name: systemic-design-mentor`, description triggers on starting/continuing the course. Body: teaching rules only (one module at a time; read `course/NN/lesson.md` + `scenarios.md`; ask Predict and wait; run `tools/start-module.mjs`, confirm RED; never write implementation code in the learner's `engineDirs`/`contentDirs`; release hints one tier at a time; run coupling audit and expansion check; progress in workspace `.course-progress.json` `{module, beat, hintsUsed}`).
- [ ] Verify: grep SKILL.md contains no module-specific card names (`node tools/coupling-audit.mjs`-style check using catalog) — Expected: none. Commit.
### Task 15: Website
**Files:** `site/` (Astro Starlight), `site/scripts/sync-content.mjs`, `site/scripts/sync-content.test.mjs`, `site/src/content/docs/{index.mdx,disclaimer.md,about.md}`; generated `site/src/content/docs/course/` gitignored.
- [ ] **Failing test** `sync_rewrites_and_checks_links` (fixture lesson with `../02-x/lesson.md` link → rewritten to `/course/02-x/`; dead link → throws) and `sync_pairs_code_tabs` (adjacent rust+ts blocks → `<Tabs>` MDX with `Rust`/`TypeScript` items; lone block untouched).
- [ ] Implement; `npm --prefix site run build` — Expected: build succeeds, every module page present. Commit.
### Task 16: Deploy and CI
**Files:** `deploy/Dockerfile` (multi-stage: node build of `site/` → `nginx:alpine` serving `dist`), `deploy/docker-compose.yml` (env `VIRTUAL_HOST=${COURSE_HOST}`, `LETSENCRYPT_HOST=${COURSE_HOST}`, external network `${PROXY_NETWORK:-nginx-proxy}`), `deploy/.env.example`, `deploy/README.md`, `.gitea/workflows/ci.yml` (jobs: tools tests + sync-check; verify-references all modules both langs; coupling audit on references; site build; naive-engine tests).
- [ ] Run locally: `docker build -f deploy/Dockerfile .` — Expected: success (skip with note if Docker unavailable). Run every CI command locally — Expected: pass. Commit.
### Task 17: Accuracy pass
- [ ] Re-fetch the Teikari Game Developer article and replace/confirm every quote in lessons; confirm GDC talk speaker; mark unconfirmed items.
- [ ] Grep all lessons for "Hearthstone"/"Baba" claims; each has a citation or a "differs" box. Check every external URL returns 200 (script `tools/check-links.mjs`, report only).
- [ ] Commit `docs: accuracy pass`.