218 lines
27 KiB
Markdown
218 lines
27 KiB
Markdown
# 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`.
|