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

27 KiB
Raw Blame History

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.