feat: module 05 assemble
Cards become JSON data in course/cards (one file per card, validated against card.schema.json and the catalog); the M5 references load them via registerCards and interpret effects, replacing content closures. Adds assemble(base, donor, half), the assemble/buffHand effects with an injected chooser, Fleshshape as the data-only Expansion Drop, and stages course/cards in verify-references and start-module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55fe5a71d7
commit
e04dc1a11f
73 files changed
+6010
-9
No files matched your search
@@ -32,6 +32,14 @@ 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")}`);
|
||||
}
|
||||
|
||||
// Harness additions: module-local note, else the "- M<N>:" line of course/harness.md.
|
||||
const note = join(mod.path, "harness-adds.md");
|
||||
let adds = existsSync(note) ? readFileSync(note, "utf8").trim() : "";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Test Fixture","type":"minion","cost":1,"attack":1,"health":1,"split":0,"text":[]}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { validate } from "../schema.mjs";
|
||||
import { loadCatalog } from "../lib.mjs";
|
||||
@@ -52,6 +52,52 @@ test("card_schema_accepts_fixtures", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Card data the course ships: course/cards (every card the references load) 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);
|
||||
}
|
||||
return dirs.flatMap((d) => readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)));
|
||||
}
|
||||
|
||||
test("course_cards_valid_and_match_catalog", () => {
|
||||
const files = courseCardFiles();
|
||||
assert.ok(files.length >= 20, `found ${files.length} card files`);
|
||||
const cat = new Map(loadCatalog().map((e) => [e.name, e]));
|
||||
const seen = new Set();
|
||||
for (const f of files) {
|
||||
const c = json(f);
|
||||
assert.deepEqual(validate(cardSchema, c), [], f);
|
||||
assert.equal(basename(f), c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".json", `${f}: file name is the card's slug`);
|
||||
assert.ok(!seen.has(c.name), `duplicate card file for ${c.name}`);
|
||||
seen.add(c.name);
|
||||
const m = cat.get(c.name);
|
||||
assert.ok(m && m.kind === "card", `${c.name} in catalog`);
|
||||
assert.deepEqual(
|
||||
[c.name, c.type, c.cost, c.attack, c.health, c.fictional ?? false],
|
||||
[m.name, m.type, m.cost, m.attack, m.health, m.fictional ?? false],
|
||||
`${f} matches catalog`,
|
||||
);
|
||||
assert.ok(c.split <= c.text.length, `${f}: split within text`);
|
||||
}
|
||||
// Every Hearthstone (and fictional) card in the catalog has its data.
|
||||
for (const e of cat.values()) if (e.kind === "card" && !e.game) assert.ok(seen.has(e.name), `no card file for ${e.name}`);
|
||||
});
|
||||
|
||||
test("card_schema_assemble_effect", () => {
|
||||
const spell = (effect) => ({ name: "X", type: "spell", cost: 3, split: 0, text: [{ kind: "effect", effect }] });
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "deathrattle" } })), []);
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "assemble", filter: {} })), []);
|
||||
assert.deepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2, health: 2 })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble" })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "assemble", filter: { trigger: "bogus" } })), []);
|
||||
assert.notDeepEqual(validate(cardSchema, spell({ type: "buffHand", attack: 2 })), []);
|
||||
});
|
||||
|
||||
test("card_schema_rejects_bad", () => {
|
||||
const bad = { name: "X", type: "minion", cost: 1, split: 0, text: [{ kind: "trigger", on: "bogus" }] };
|
||||
assert.notDeepEqual(validate(cardSchema, bad), []);
|
||||
|
||||
@@ -14,6 +14,13 @@ test("start_module_copies_tests", () => {
|
||||
assert.match(r.out, /newGame, summon, play/);
|
||||
});
|
||||
|
||||
test("start_module_copies_course_cards", () => {
|
||||
const ws = copyFixture("ws-start");
|
||||
const r = run("start-module.mjs", ["01", ws], env);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.ok(existsSync(join(ws, "cards/test-fixture.json")));
|
||||
});
|
||||
|
||||
test("start_module_uses_track", () => {
|
||||
const ws = copyFixture("ws-start-ts");
|
||||
assert.equal(run("start-module.mjs", ["01", ws], env).code, 0);
|
||||
|
||||
@@ -46,6 +46,17 @@ test("verify_stage_copies_all_earlier_module_tests", async () => {
|
||||
assert.ok(existsSync(join(dir, "Cargo.toml")));
|
||||
});
|
||||
|
||||
test("verify_stage_copies_course_cards", async () => {
|
||||
// From Module 5 the references load card data from <crate or package root>/cards.
|
||||
process.env.COURSE_ROOT = root;
|
||||
const { planVerification, stage } = await import("../verify-references.mjs");
|
||||
const { existsSync } = await import("node:fs");
|
||||
for (const lang of ["rust", "ts"]) {
|
||||
const dir = stage(planVerification(moduleDirs(), 1)[0], lang);
|
||||
assert.ok(existsSync(join(dir, "cards", "test-fixture.json")), lang);
|
||||
}
|
||||
});
|
||||
|
||||
test("verify_ts_default_installs_dependencies_first", async () => {
|
||||
// A staged reference has no node_modules; a bare `npx vitest` fetches the
|
||||
// latest vitest, which cannot load the reference's vitest.config.ts.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/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.
|
||||
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 { moduleDirs, testFiles, fail } from "./lib.mjs";
|
||||
import { courseRoot, 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).
|
||||
@@ -35,6 +36,8 @@ export function stage(plan, lang) {
|
||||
for (const t of plan.testModules) {
|
||||
for (const f of testFiles(t.path, lang)) cpSync(f, join(dir, "tests", basename(f)));
|
||||
}
|
||||
const cards = join(courseRoot(), "cards");
|
||||
if (existsSync(cards)) cpSync(cards, join(dir, "cards"), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user