fix: module 10 review round 1 (module-local SpellGrid cards, cast target check, CLI without panic hook)

- SpellGrid cards move to course/10-spellgrid/cards; start-module and
  verify-references stage course/cards plus module-local cards up to the module,
  so M5-M9 workspaces get exactly the pre-M10 card set.
- cast() rejects a chosen-target spell without a target (checkCast/checkPlay);
  new test_10_targeted_spell_needs_target.
- Rust CLI pre-validates instead of swapping the global panic hook.
- Catalog: CRIER noun; DEATHRATTLE/BATTLECRY note on lexicon vs catalog role.
- Lesson: design note on mid-movement reparse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 05:11:09 +01:00
co-authored by Claude Opus 5.5
parent a0cf9be214
commit 6f576940b7
34 changed files with 295 additions and 79 deletions
+13
View File
@@ -48,6 +48,19 @@ export function scenarioNames(path, file = "scenarios.md") {
return [...names].sort();
}
/**
* The card data a workspace at module `upTo` has: course/cards (every module's, from
* Module 5), then the cards/ of each module up to `upTo` that has its own (Module 10's
* SpellGrid cards), so earlier modules never see later modules' cards.
*/
export function cardDirs(upTo) {
const shared = join(courseRoot(), "cards");
const local = moduleDirs()
.filter((m) => Number(m.num) <= Number(upTo))
.map((m) => join(m.path, "cards"));
return [shared, ...local].filter((d) => existsSync(d));
}
export function loadCatalog() {
const f = join(courseRoot(), "catalog.json");
if (!existsSync(f)) throw new Error(`catalog not found: ${f}`);
+7 -8
View File
@@ -2,7 +2,7 @@
// Copies module NN's tests for the learner's track into <workspace>/tests/.
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs";
import { cardDirs, moduleDirs, courseRoot, testFiles, fail } from "./lib.mjs";
const [num, ws] = process.argv.slice(2);
if (!/^\d\d$/.test(num || "") || !ws) fail("usage: start-module.mjs <NN> <workspaceDir>");
@@ -32,13 +32,12 @@ mkdirSync(join(ws, "tests"), { recursive: true });
for (const f of files) cpSync(f, join(ws, "tests", basename(f)));
console.log(`Copied ${files.length} test file(s) for module ${num} into ${join(ws, "tests")}`);
// The course's card data (read by your content from Module 5). Refreshed on every start;
// card files you add yourself are kept.
const cards = join(courseRoot(), "cards");
if (existsSync(cards)) {
cpSync(cards, join(ws, "cards"), { recursive: true });
console.log(`Copied the course card data into ${join(ws, "cards")}`);
}
// The course's card data (read by your content from Module 5): course/cards, plus the cards
// of each module up to this one that has its own (Module 10's SpellGrid cards). Refreshed on
// every start; card files you add yourself are kept.
const cardSources = cardDirs(num);
for (const cards of cardSources) cpSync(cards, join(ws, "cards"), { recursive: true });
if (cardSources.length) console.log(`Copied the course card data into ${join(ws, "cards")}`);
// The module's puzzle levels (Module 10's tests load them from levels/).
const levels = join(mod.path, "levels");
@@ -0,0 +1,9 @@
{
"name": "Module Fixture",
"type": "minion",
"cost": 1,
"attack": 1,
"health": 1,
"split": 0,
"text": []
}
+15 -3
View File
@@ -55,14 +55,17 @@ test("card_schema_accepts_fixtures", () => {
}
});
// Card data the course ships: course/cards (every card the references load) and the
// Card data the course ships: course/cards (every module's cards), module-local cards in
// course/NN-*/cards (Module 10's SpellGrid cards, staged from that module on), and the
// Expansion Drop answers in course/NN-*/expansion/solution/cards.
function courseCardFiles() {
const courseDir = join(here, "../../course");
const dirs = [join(courseDir, "cards")];
for (const m of readdirSync(courseDir).filter((d) => /^\d\d-/.test(d))) {
const d = join(courseDir, m, "expansion/solution/cards");
if (existsSync(d)) dirs.push(d);
for (const sub of ["cards", "expansion/solution/cards"]) {
const d = join(courseDir, m, sub);
if (existsSync(d)) dirs.push(d);
}
}
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
}
@@ -141,3 +144,12 @@ test("course_levels_valid", () => {
for (const card of level.hand ?? []) assert.ok(cards.has(card), `${f}: no card data for ${card}`);
}
});
test("spellgrid_cards_are_module_local", () => {
// course/cards reaches every workspace from Module 5; SpellGrid's cards must not reach
// Modules 5-9 (their spawnText effect and their place in Assemble pools are Module 10's).
const shared = join(here, "../../course/cards");
for (const f of readdirSync(shared).filter((f) => f.endsWith(".json"))) {
assert.notEqual(json(join(shared, f)).game, "spellgrid", `${f} belongs in course/10-spellgrid/cards`);
}
});
+14
View File
@@ -21,6 +21,20 @@ test("start_module_copies_course_cards", () => {
assert.ok(existsSync(join(ws, "cards/test-fixture.json")));
});
test("start_module_copies_module_cards_up_to_the_module", () => {
const early = copyFixture("ws-start");
assert.equal(run("start-module.mjs", ["01", early], env).code, 0);
assert.ok(existsSync(join(early, "cards/test-fixture.json")));
assert.ok(!existsSync(join(early, "cards/module-fixture.json")));
const later = copyFixture("ws-start");
mkdirSync(join(later, "tests"), { recursive: true });
writeFileSync(join(later, "tests/m02.rs"), "");
const r = run("start-module.mjs", ["03", later], env);
assert.equal(r.code, 0, r.out);
assert.ok(existsSync(join(later, "cards/test-fixture.json")));
assert.ok(existsSync(join(later, "cards/module-fixture.json")));
});
test("start_module_copies_module_levels", () => {
const ws = copyFixture("ws-start");
mkdirSync(join(ws, "tests"), { recursive: true });
+15
View File
@@ -76,3 +76,18 @@ test("verify_stage_copies_module_levels", async () => {
assert.ok(existsSync(join(stage(plans[2], "ts"), "levels", "fixture-level.json")));
assert.ok(!existsSync(join(stage(plans[1], "ts"), "levels")));
});
test("verify_stage_copies_module_cards_up_to_the_module", async () => {
// Module-local cards (Module 10's SpellGrid cards) reach that module and later ones only.
process.env.COURSE_ROOT = root;
const { planVerification, stage } = await import("../verify-references.mjs");
const { existsSync } = await import("node:fs");
const plans = planVerification(moduleDirs(), 3);
for (const lang of ["rust", "ts"]) {
assert.ok(!existsSync(join(stage(plans[0], lang), "cards", "module-fixture.json")), lang);
assert.ok(existsSync(join(stage(plans[1], lang), "cards", "module-fixture.json")), lang);
const later = stage(plans[2], lang);
assert.ok(existsSync(join(later, "cards", "module-fixture.json")), lang);
assert.ok(existsSync(join(later, "cards", "test-fixture.json")), lang);
}
});
+5 -5
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env node
// For each module <= N with a reference/: copy reference + tests of modules 01..module into a temp dir, run tests.
// course/cards (the card data, loaded by the references from Module 5) is staged as <dir>/cards,
// and each tested module's levels/ (the puzzle levels its tests load, from Module 10) as <dir>/levels.
// The card data (loaded by the references from Module 5) is staged as <dir>/cards: course/cards
// plus the cards/ of each module up to the one verified. Each tested module's levels/ (the
// puzzle levels its tests load, from Module 10) is staged as <dir>/levels.
import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { courseRoot, moduleDirs, testFiles, fail } from "./lib.mjs";
import { cardDirs, moduleDirs, testFiles, fail } from "./lib.mjs";
// A staged reference has no node_modules, so TS installs the reference's own
// pinned devDependencies first (a bare `npx vitest` would fetch an unrelated version).
@@ -38,8 +39,7 @@ export function stage(plan, lang) {
for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f)));
if (existsSync(join(t.path, "levels"))) cpSync(join(t.path, "levels"), join(dir, "levels"), { recursive: true });
}
const cards = join(courseRoot(), "cards");
if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });
for (const cards of cardDirs(plan.num)) cpSync(cards, join(dir, "cards"), { recursive: true });
return dir;
}