Files
systemic-game-design-course/tools/test/run-scenario.test.mjs
T
Lucas WintherandClaude Opus 5.5 78862d1a1f feat: mutation check, scenario runner and designer capstone; CI jobs; Module 13 brought up to date
- tools/mutation-check.mjs with tools/mutants.json: thirteen mutants against the
  Module 12 TypeScript reference (the eight of Module 13's first table, plus
  Armor before redirect, conditions at parse time, no defender priority, and the
  summon seam's noun check); all killed.
- tools/run-scenario.mjs with tools/scenario/run.ts: a designer's cards, words
  and seam words as data, played on the Module 10 TypeScript reference.
- course/10-spellgrid/designer-capstone.md and designer-example/ (output.txt
  is checked against the tool's output in the tools tests).
- CI: diff-tracks (300 seeds), mutation-check, verify-levels.
- Module 13: the mutation table is the tool's output; differential testing,
  undo and the solver sections describe the repository as it is. Lesson 10
  says what the level proofs prove for levels 6, 8 and 9.
- schema.mjs: a value that matches no oneOf branch is reported against the
  branch its const fields pick.

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

114 lines
6.2 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"));
});