Files
systemic-game-design-course/tools/test/well-formed.test.mjs
T
Lucas WintherandClaude Opus 5.5 21fb40c6f5 feat: robustness in Module 5 (nesting guard, Death Phase limit, well-formed Assemble, multi-summon order)
A trigger is never queued while that same (minion, trigger) is resolving;
more than DEATH_PHASE_LIMIT = 64 Death Phases is an error ('Death Phase
limit'); Assemble refuses and never offers ill-formed text (wellFormed /
well_formed); summon count > 1 places tokens in summon order. Modules 05-10,
both tracks. Four new Module 5 tests, a tools test that every shipped card
is well-formed, lessons 2, 3, 5, 13 and harness.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:07:54 +01:00

65 lines
2.9 KiB
JavaScript

// Every card the course ships is well-formed, by the rule the reference engines apply to
// Assemble (Module 5: `wellFormed` in card.ts, `well_formed` in card.rs), restated here
// against the JSON so that a new card file is checked without building an engine.
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { courseRoot, moduleDirs } from "../lib.mjs";
/**
* For a minion, every effect clause belongs to a trigger (none before the first trigger,
* or after an aura or keyword), and every trigger has at least one effect before the next
* trigger, aura, keyword or the end. A spell passes.
*/
function wellFormed(text, type) {
if (type !== "minion") return true;
let open = "none"; // the trigger being listed: "none", "empty" (no effect yet) or "filled"
for (const clause of text) {
if (clause.kind === "effect") {
if (open === "none") return false;
open = "filled";
} else {
if (open === "empty") return false;
open = clause.kind === "trigger" ? "empty" : "none";
}
}
return open !== "empty";
}
/** Every card file the course ships: course/cards, each module's cards/, each Expansion Drop solution's cards/. */
function shippedCards() {
const dirs = [
join(courseRoot(), "cards"),
...moduleDirs().flatMap((m) => [join(m.path, "cards"), join(m.path, "expansion", "solution", "cards")]),
].filter((d) => existsSync(d));
return dirs.flatMap((d) =>
readdirSync(d)
.filter((f) => f.endsWith(".json"))
.map((f) => ({ path: join(d, f), def: JSON.parse(readFileSync(join(d, f), "utf8")) })),
);
}
const trigger = { kind: "trigger", on: "deathrattle" };
const effect = { kind: "effect", effect: { type: "draw", count: 1 } };
const aura = { kind: "aura", modifier: { type: "damageRedirect", from: "friendlyHero", to: "self" } };
const keyword = { kind: "keyword", keyword: "taunt" };
test("well_formed_rule", () => {
for (const text of [[], [aura], [keyword, trigger, effect], [trigger, effect, effect, trigger, effect, aura]]) {
assert.equal(wellFormed(text, "minion"), true, JSON.stringify(text));
}
// Loot Hoarder with Baron Rivendare's top half, then his bottom half (Module 5).
for (const text of [[effect], [trigger, aura], [trigger], [trigger, trigger, effect], [trigger, effect, keyword, effect]]) {
assert.equal(wellFormed(text, "minion"), false, JSON.stringify(text));
}
assert.equal(wellFormed([effect], "spell"), true);
});
test("shipped_cards_are_well_formed", () => {
const cards = shippedCards();
assert.ok(cards.some((c) => c.def.name === "Loot Hoarder"), "course/cards was not read");
assert.ok(cards.some((c) => c.def.name === "Graft"), "course/10-spellgrid/cards was not read");
for (const { path, def } of cards) assert.ok(wellFormed(def.text, def.type), `${path}: ill-formed text`);
});