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>
65 lines
2.9 KiB
JavaScript
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`);
|
|
});
|