27 KiB
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'sscenarios.md. - Test-only fictional cards are named
Test <Name>and flagged"fictional": truein 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 labelledHint 1 — nudge,Hint 2 — concept,Hint 3 — pseudo-code. - Code shown in lessons: a
```rustblock immediately followed by a```tsblock (the site turns adjacent pairs into tabs). - Grid coordinates:
xgrows right,ygrows down; vertical sentences read top-to-bottom.
Review Focus
- A learner starts module N's tests with a module N−1 harness —
start-modulemust 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 teststart_module_refuses_skipping). - Drawing from an empty deck — tests that draw must set up decks; engines must not panic: harness
drawon empty deck logsFatigueand does nothing else (M2 testtest_02_empty_deck_draw_is_fatigue). - 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 testsync_rewrites_and_checks_links). - Coupling audit false positives on common words (
PUSH,IS,Test) — audit matches only names fromcourse/catalog.jsonwith ≥2 characters that are not Baba operator/property words, as whole identifiers/strings (Task 1 testaudit_ignores_grammar_words). - 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)"; throwsAssembleErrorif 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? }.Clauseis 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.splitis 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", scriptstest: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 allcourse/*/);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, runscargo test/npx vitest run, reports per module). -
tools/lib.mjsexportsmoduleDirs(): {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 testtest_01_a, ts lacking it → exit 1, message namestest_01_a);audit_flags_card_name_in_engine("Baron Rivendare"string andbaron_rivendareidentifier in engine dir → flagged with file:line);audit_ignores_content_dir;audit_ignores_grammar_words(PUSH,IS,YOUin 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+), TStest\(["'](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
harnessfiles with M1 functions as stubs (todo!()/throw new Error("not implemented")). Rust tests importuse engine::harness::*;; TS tests importfrom "../src/harness". Tests live in workspacetests/. -
Step 1: Create templates; copy into a temp dir; run
cargo buildandnpx 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 ahearthstone.wiki.ggsource; everyTestcard hasfictional: 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. Commitfeat: 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_orderencodes 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.pyDeathrattlebranch and SabberStone tag mismatch (EXTRA_MINION_DEATHRATTLES_BASEvsEXTRA_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 insrc/content/. - E. Run
node tools/verify-references.mjs NN— Expected: PASS for modules 01..NN in both languages. Runnode tools/coupling-audit.mjs course/NN-slug/reference/rustand/ts— Expected: clean. - F. Expansion Drop: commit the reference in a temp git repo, add the expansion card in
content/only, run its tests andtools/expansion-check.mjs— Expected: PASS. Expansion card code goes inexpansion/solution/(not merged into the next module's reference start unless the next module needs it). - G. Write
lesson.mdwith 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(summonTest FillerA 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 ownboard(p));test_01_names(nameOfreturns 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 5Test Filler;cast("Whirlwind")→ everyDamagedevent precedes everyTriggeredevent; 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, stillzoneOf == "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 beforeArmorGainedin 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 lastDamaged; 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 → bothDestroyedbefore eitherTriggered, 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.pyaction_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 thatDestroyed(Baron)precedes Hoarder's singleTriggered). - 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 viaplay); 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/*.jsonviaregisterCards; reference usesserde_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 assertsregisterCardsaccepts every catalog card);test_05_assemble_bottom_half(Harvest Golemtext=[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 aTest 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 includeBABA 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:
HOTword 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 — assertactiveRulescontainsBABA IS ROCKand notBABA IS BABA);test_07_text_is_not_push. - M7 expansion:
ALLmeta-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 → bothposOf == 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 completeBABA IS ROCK→ afterstep,kindsAtwhere Babas stood containrock);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 DOUBLEwith 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; readcourse/NN/lesson.md+scenarios.md; ask Predict and wait; runtools/start-module.mjs, confirm RED; never write implementation code in the learner'sengineDirs/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.mdlink → rewritten to/course/02-x/; dead link → throws) andsync_pairs_code_tabs(adjacent rust+ts blocks →<Tabs>MDX withRust/TypeScriptitems; 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.