Files
systemic-game-design-course/tools/test/run-scenario.test.mjs
T
Lucas WintherandClaude Opus 5.5 85959ba5a0
CI / Tool tests and sync-check (push) Successful in 16s
CI / Verify references (all modules, Rust and TS) (push) Successful in 2m4s
CI / Verify Expansion Drop solutions (all modules, Rust and TS) (push) Successful in 2m1s
CI / Coupling audit on references (push) Successful in 5s
CI / Differential fuzzing of the Rust and TS references (300 seeds) (push) Successful in 53s
CI / Mutation check (tools/mutants.json against the highest TS reference) (push) Successful in 34s
CI / Verify the SpellGrid level proofs (solver) (push) Successful in 5m25s
CI / Site build (push) Successful in 15s
CI / Naive engine tests (Module 0) (push) Successful in 20s
fix: final review (engine errors end a Sequence cleanly; nesting guard labelled as the course's; Node 22.15; minor lesson and tool fixes)
I1: run-scenario and both SpellGrid CLIs check moves first and treat any other engine
error as fatal (same text in both tracks); trigger blocks close on error (try/finally,
catch_unwind in Rust); unknown summons rejected. I2, I3, M1-M7, T3, T9a-c, T10a-b.

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

165 lines
9.9 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { run, toolsDir } from "./helpers.mjs";
import { mergeSeams, mergeWords, scenarioProblems } from "../run-scenario.mjs";
const course = join(toolsDir, "..", "course");
const example = join(course, "10-spellgrid", "designer-example");
/** Write `scenario` to a temp file and run the tool on it. */
function scenario(s) {
const file = join(mkdtempSync(join(tmpdir(), "sdc-scn-")), "scenario.json");
writeFileSync(file, typeof s === "string" ? s : JSON.stringify(s));
return run("run-scenario.mjs", [file]);
}
const text = (word, x, y) => ({ kind: `text:${word}`, x, y });
// BABA IS YOU on row 0, WOUND IS DOUBLE on row 1, Baba at (0,3).
const LEVEL = {
width: 5,
height: 4,
objects: [text("BABA", 0, 0), text("IS", 1, 0), text("YOU", 2, 0), text("WOUND", 0, 1), text("IS", 1, 1), text("DOUBLE", 2, 1), { kind: "baba", x: 0, y: 3 }],
cards: [],
hand: [],
};
const TOTEM = {
name: "Test Totem",
type: "minion",
cost: 1,
attack: 0,
health: 3,
noun: "ROCK",
split: 0,
text: [{ kind: "trigger", on: "selfDamaged" }, { kind: "effect", effect: { type: "dealDamage", target: "enemyHero", amount: 1 } }],
};
test("run_scenario_reports_schema_errors_readably", () => {
const bad = { ...TOTEM, text: [TOTEM.text[0], { kind: "effect", effect: { type: "dealDamage", target: "everyone", amount: 1 } }] };
const r = scenario({ cards: [bad], level: LEVEL, inputs: [] });
assert.equal(r.code, 1, r.out);
assert.match(r.out, /is not a valid scenario:\n/);
assert.match(r.out, /cards\[0\] \("Test Totem"\)\.text\[1\]\.effect\.target: not in enum \["self",/);
assert.doesNotMatch(r.out, /oneOf/);
const shape = scenario({ level: { width: 3 }, inputs: "d", seams: { triggerWords: { HURT: "whenever" } } });
assert.equal(shape.code, 1);
assert.match(shape.out, /level: missing "height"/);
assert.match(shape.out, /inputs: expected a list of command lines/);
assert.match(shape.out, /seams\.triggerWords\.HURT: "whenever" is not a trigger/);
const json = scenario("{ not json");
assert.equal(json.code, 1);
assert.match(json.out, /is not JSON: /);
});
test("run_scenario_merges_words_into_the_rules", () => {
const r = scenario({
words: [{ name: "WOUND", role: "noun" }],
seams: { triggerWords: { WOUND: "selfDamaged" } },
level: LEVEL,
inputs: [],
});
assert.equal(r.code, 0, r.out);
assert.match(r.out, /^Rules: BABA IS YOU, TEXT IS PUSH, WOUND IS DOUBLE$/m);
// Without the word, the text spells nothing.
const without = scenario({ level: LEVEL, inputs: [] });
assert.match(without.out, /^Rules: BABA IS YOU, TEXT IS PUSH$/m);
});
test("run_scenario_prints_the_log_as_a_tree", () => {
const level = { ...LEVEL, cards: [{ card: "Test Totem", x: 3, y: 3 }], hand: ["Whirlwind"] };
const r = scenario({ cards: [TOTEM], words: [{ name: "WOUND", role: "noun" }], seams: { triggerWords: { WOUND: "selfDamaged" } }, level, inputs: ["c Whirlwind", "w"] });
assert.equal(r.code, 0, r.out);
// WOUND IS DOUBLE: the totem's damage trigger activates twice, each with its Damage one level down.
assert.match(r.out, /\n> c Whirlwind\nLog:\nSpellCast\{card: "Whirlwind"\}\nDamaged\{target: (\d+), amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged\{target: \d+, amount: 1\}\nTriggered\{source: \1, trigger: "damaged"\}\n {2}Damaged/);
assert.match(r.out, /Minions: Test Totem \(3,3\) 2 Health\nHand: empty\nHeroes: 30 Health \(yours\), 28 Health \(enemy\)\n/);
assert.match(r.out, /\n> w\n/);
assert.match(r.out, /\(end of inputs\)\n$/);
});
test("run_scenario_validates_words_and_seams", () => {
const base = [{ name: "BABA", role: "noun" }, { name: "DOUBLE", role: "property" }];
assert.deepEqual(mergeWords(base, [{ name: "WOUND", role: "noun" }, { name: "BABA", role: "noun" }]).map((w) => w.name), ["BABA", "DOUBLE", "WOUND"]);
assert.throws(() => mergeWords(base, [{ name: "BABA", role: "property" }]), /BABA is already a noun/);
const seams = { triggerWords: { DEATHRATTLE: "deathrattle" }, multiplierWords: { DOUBLE: { times: 2, stacking: "max" } } };
assert.deepEqual(mergeSeams(seams, { triggerWords: { WOUND: "selfDamaged" } }).triggerWords, { DEATHRATTLE: "deathrattle", WOUND: "selfDamaged" });
assert.throws(() => mergeSeams(seams, { triggerWords: { DEATHRATTLE: "battlecry" } }), /DEATHRATTLE already names "deathrattle"/);
const ctx = { words: base, cardNames: ["Whirlwind"] };
const problems = (s) => scenarioProblems({ level: LEVEL, inputs: [], ...s }, ctx);
assert.deepEqual(problems({}), []);
assert.match(problems({ words: [{ name: "wound", role: "noun" }] }).join("\n"), /words\[0\]\.name: "wound" must be capital letters/);
assert.match(problems({ words: [{ name: "AND", role: "operator" }] }).join("\n"), /words\[0\]\.role: must be "noun" or "property"/);
assert.match(problems({ seams: { triggerWords: { HURT: "selfDamaged" } } }).join("\n"), /seams\.triggerWords\.HURT: HURT is not a noun/);
assert.match(problems({ seams: { multiplierWords: { DOUBLE: { times: 1, stacking: "max" } } } }).join("\n"), /seams\.multiplierWords\.DOUBLE\.times: below minimum 2/);
assert.match(problems({ cards: [{ ...TOTEM, name: "Whirlwind" }] }).join("\n"), /cards\[0\] \("Whirlwind"\): the course already has a card named "Whirlwind"/);
assert.match(problems({ extra: 1 }).join("\n"), /unknown key "extra"/);
});
test("run_scenario_designer_example_output_is_current", () => {
// The capstone's worked example shows the tool's real output; this keeps it from going stale.
const r = run("run-scenario.mjs", [join(example, "scenario.json")]);
assert.equal(r.code, 0, r.out);
assert.equal(r.out, readFileSync(join(example, "output.txt"), "utf8"));
// The scenario carries the example's data files: the three cards, the word and its seam.
const json = (f) => JSON.parse(readFileSync(join(example, f), "utf8"));
const s = json("scenario.json");
assert.deepEqual(s.cards, ["ash-totem", "thorn-idol", "scorch"].map((c) => json(`cards/${c}.json`)));
assert.deepEqual(s.words, json("words.json"));
assert.deepEqual(s.seams, json("seams.json"));
});
// Two designer cards that the engine cannot finish with (the final review's I1).
const ZAP = { name: "Zap", type: "spell", cost: 1, split: 0, text: [{ kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }] };
const ROW = { ...LEVEL, width: 6, height: 3, objects: [{ kind: "baba", x: 0, y: 0 }, text("BABA", 0, 2), text("IS", 1, 2), text("YOU", 2, 2)], hand: ["Zap", "Zap", "Zap"] };
test("run_scenario_rejects_a_summon_of_an_unknown_card", () => {
// Its trigger would fail in the middle of a Sequence; the check finds it before anything runs.
const golem = { ...TOTEM, name: "Bad Golem", text: [TOTEM.text[0], { kind: "effect", effect: { type: "summon", card: "No Such Card", count: 1 } }] };
const r = scenario({ cards: [golem, ZAP], level: { ...ROW, cards: [{ card: "Bad Golem", x: 3, y: 0 }] }, inputs: ["c Zap", "c Zap"] });
assert.equal(r.code, 1, r.out);
assert.match(r.out, /is not a valid scenario:\n {2}- cards\[0\] \("Bad Golem"\)\.text\[1\]\.effect\.card: no card is named "No Such Card"/);
assert.doesNotMatch(r.out, /== start/);
// A summon of a course card, or of another card of the scenario, is fine.
const ctx = { words: [], cardNames: ["Clay Golem"] };
const summon = (card) => ({ ...golem, text: [TOTEM.text[0], { kind: "effect", effect: { type: "summon", card, count: 1 } }] });
assert.deepEqual(scenarioProblems({ cards: [summon("Clay Golem"), ZAP], level: LEVEL, inputs: [] }, ctx), []);
assert.deepEqual(scenarioProblems({ cards: [summon("Bad Golem")], level: LEVEL, inputs: [] }, ctx), []);
});
test("run_scenario_stops_when_a_sequence_does_not_finish", () => {
// Each Loop Ember's Deathrattle summons the next and hits it: the Death Phase limit.
const ember = {
name: "Loop Ember", type: "minion", cost: 1, attack: 1, health: 1, noun: "ROCK", split: 1,
text: [{ kind: "trigger", on: "deathrattle" }, { kind: "effect", effect: { type: "summon", card: "Loop Ember", count: 1 } }, { kind: "effect", effect: { type: "dealDamage", target: "allMinions", amount: 1 } }],
};
const r = scenario({ cards: [ember, ZAP], level: { ...ROW, cards: [{ card: "Loop Ember", x: 3, y: 0 }] }, inputs: ["c Zap", "d", "c Zap"] });
assert.equal(r.code, 1, r.out);
// The error, a plain word that the game stops, and nothing after: no log of the half-done
// Sequence (that is what crashed before), no further inputs.
assert.match(r.out, /\n> c Zap\nError: Death Phase limit: more than 64 Death Phases in one Sequence\nThe Sequence did not finish, so the game cannot go on\.\n$/);
assert.doesNotMatch(r.out, /Log:|has no id|> d/);
});
test("run_scenario_times_out_readably", () => {
const r = run("run-scenario.mjs", [join(example, "scenario.json")], { RUN_SCENARIO_TIMEOUT_MS: "1" });
assert.equal(r.code, 1, r.out);
assert.match(r.out, /The scenario did not finish within 0\.001 s, so it was stopped\. A card may set itself off without end/);
});
test("run_scenario_compares_card_names_as_file_names", () => {
// Card files are named in lower case, so "graft" would overwrite the course's Graft.
const ctx = { words: [], cardNames: ["Graft"], cardFiles: ["graft.json"] };
const problems = (cards) => scenarioProblems({ cards, level: LEVEL, inputs: [] }, ctx).join("\n");
assert.match(problems([{ ...TOTEM, name: "graft" }]), /cards\[0\] \("graft"\): its file would be cards\/graft\.json, which a course card already has/);
assert.match(problems([TOTEM, { ...TOTEM, name: "test totem" }]), /cards\[1\] \("test totem"\): "Test Totem" and "test totem" would both be cards\/test-totem\.json/);
assert.equal(problems([TOTEM]), "");
// Through the tool, against the real cards/.
const r = scenario({ cards: [{ ...TOTEM, name: "GRAFT" }], level: LEVEL, inputs: [] });
assert.equal(r.code, 1, r.out);
assert.match(r.out, /its file would be cards\/graft\.json/);
});