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>
This commit is contained in:
1 parent
372c99c306
commit
21fb40c6f5
81 files changed
+1880
-528
No files matched your search
@@ -0,0 +1,64 @@
|
||||
// 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`);
|
||||
});
|
||||
Reference in new issue
Block a user