Files
systemic-game-design-course/tools/run-scenario.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

213 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Run a designer's scenario on SpellGrid (Module 10): new cards, words and seam words as data,
// a level, and the inputs to play on it.
//
// node tools/run-scenario.mjs <scenario.json>
// npm run run:scenario -- course/10-spellgrid/designer-example/scenario.json
//
// A scenario is a JSON object
// { "cards"?: [card, ...], "words"?: [{ "name": "WOUND", "role": "noun" }, ...],
// "seams"?: { "triggerWords"?: { "WOUND": "selfDamaged" }, "multiplierWords"?: { ... } },
// "level": level, "inputs": ["d", "c Whirlwind", ...] }
// with cards in the course's card format (course/schema/card.schema.json), a level in the
// level format (course/schema/level.schema.json), and inputs as the SpellGrid command line reads
// them (`w a s d`, `c <card> [x y]`, and an answer to each Assemble question). The scenario is
// checked first, and every problem is listed. Then the Module 10 TypeScript reference is staged
// with its cards, as play-spellgrid does, the scenario's cards are added to cards/, its words and
// seam words are merged into src/content/words.json and seams.json, and tools/scenario/run.ts
// plays the inputs there. It prints, at the start and after each input, the Part I log of that
// input as an indented tree, the board, the rules, the minions, the hand and both heroes' Health.
// No engine file is touched: a scenario can say exactly what an Expansion Drop can.
//
// An engine error in the middle of a Sequence ends the run there (exit code 1). A scenario that
// runs longer than 60 s (RUN_SCENARIO_TIMEOUT_MS to change it) is stopped with a message.
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { basename, join } from "node:path";
import { courseRoot, fail, isMain } from "./lib.mjs";
import { stagePlay } from "./play-spellgrid.mjs";
import { validate } from "./schema.mjs";
const MODULE = "10";
const KEYS = ["cards", "words", "seams", "level", "inputs"];
const ROLES = ["noun", "property"];
const schema = (name) => JSON.parse(readFileSync(join(courseRoot(), "schema", `${name}.schema.json`), "utf8"));
/** The trigger kinds a card or a seam word may name, from the card schema. */
const triggers = (cardSchema) => cardSchema.properties.text.items.oneOf.find((b) => b.properties.on).properties.on.enum;
/**
* Everything wrong with a scenario, as readable lines (empty if nothing). `ctx` is what the staged
* reference already has: `words` (its words.json), `cardNames` (the names of its cards) and
* `cardFiles` (the file names in its cards/; by default, those of `cardNames`).
*/
export function scenarioProblems(s, ctx) {
if (typeof s !== "object" || s === null || Array.isArray(s)) return ["the scenario must be a JSON object"];
const cardSchema = schema("card");
const problems = [];
for (const key of Object.keys(s)) if (!KEYS.includes(key)) problems.push(`unknown key "${key}" (a scenario has ${KEYS.join(", ")})`);
if (!("level" in s)) problems.push('missing "level"');
else problems.push(...validate(schema("level"), s.level, "level"));
if (!Array.isArray(s.inputs) || !s.inputs.every((i) => typeof i === "string")) problems.push("inputs: expected a list of command lines, such as [\"d\", \"c Whirlwind\"]");
const cards = s.cards ?? [];
const files = ctx.cardFiles ?? ctx.cardNames.map(fileName);
const known = [...ctx.cardNames, ...(Array.isArray(cards) ? cards.map((c) => c?.name) : [])];
if (!Array.isArray(cards)) problems.push("cards: expected a list of cards");
else {
cards.forEach((card, i) => {
const at = `cards[${i}]${typeof card?.name === "string" ? ` (${JSON.stringify(card.name)})` : ""}`;
problems.push(...validate(cardSchema, card, "$").map((e) => `${at}${e.slice(1)}`));
if (typeof card?.name !== "string") return;
// A card is written to cards/<file name>, and file names are in lower case: "graft" would
// overwrite the course's Graft, so names are compared as file names.
const file = fileName(card.name);
if (ctx.cardNames.includes(card.name)) problems.push(`${at}: the course already has a card named ${JSON.stringify(card.name)}; give yours another name`);
else if (files.includes(file)) problems.push(`${at}: its file would be cards/${file}, which a course card already has; give yours another name`);
const first = cards.findIndex((c) => typeof c?.name === "string" && fileName(c.name) === file);
if (first < i) problems.push(`${at}: ${JSON.stringify(cards[first].name)} and ${JSON.stringify(card.name)} would both be cards/${file}; give one another name`);
// A summon of a card that exists nowhere fails in the middle of a Sequence: say so now.
(Array.isArray(card.text) ? card.text : []).forEach((clause, j) => {
const named = clause?.effect?.type === "summon" ? clause.effect.card : undefined;
if (typeof named === "string" && !known.includes(named)) {
problems.push(`${at}.text[${j}].effect.card: no card is named ${JSON.stringify(named)} (neither a course card nor one of the scenario's)`);
}
});
});
}
const words = s.words ?? [];
const before = problems.length;
if (!Array.isArray(words)) problems.push("words: expected a list of words");
else {
words.forEach((w, i) => {
const at = `words[${i}]`;
if (typeof w !== "object" || w === null) return problems.push(`${at}: expected { "name": ..., "role": ... }`);
for (const key of Object.keys(w)) if (!["name", "role", "selects"].includes(key)) problems.push(`${at}: unknown key "${key}"`);
if (typeof w.name !== "string" || !/^[A-Z]+$/.test(w.name)) problems.push(`${at}.name: ${JSON.stringify(w.name)} must be capital letters, such as "WOUND"`);
if (!ROLES.includes(w.role)) problems.push(`${at}.role: must be "noun" or "property" (IS, AND and NOT belong to the engine)`);
if ("selects" in w && w.role !== "noun") problems.push(`${at}.selects: only a noun selects`);
});
}
let merged = ctx.words;
if (problems.length === before) {
try {
merged = mergeWords(ctx.words, words);
} catch (e) {
problems.push(`words: ${e.message}`);
}
}
const roleOf = (name) => merged.find((w) => w.name === name)?.role;
const seams = s.seams ?? {};
if (typeof seams !== "object" || seams === null || Array.isArray(seams)) problems.push("seams: expected an object");
else {
for (const key of Object.keys(seams)) if (!["triggerWords", "multiplierWords"].includes(key)) problems.push(`seams: unknown key "${key}"`);
const kinds = triggers(cardSchema);
for (const [word, on] of Object.entries(seams.triggerWords ?? {})) {
const at = `seams.triggerWords.${word}`;
if (roleOf(word) !== "noun") problems.push(`${at}: ${word} is not a noun; add it to "words" with role "noun"`);
if (!kinds.includes(on)) problems.push(`${at}: ${JSON.stringify(on)} is not a trigger (one of ${kinds.join(", ")})`);
}
const multiplier = cardSchema.properties.text.items.oneOf.find((b) => b.properties.modifier).properties.modifier.oneOf[0];
for (const [word, m] of Object.entries(seams.multiplierWords ?? {})) {
const at = `seams.multiplierWords.${word}`;
if (roleOf(word) !== "property") problems.push(`${at}: ${word} is not a property; add it to "words" with role "property"`);
const shape = { type: "object", required: ["times", "stacking"], properties: { times: multiplier.properties.times, stacking: multiplier.properties.stacking } };
problems.push(...validate(shape, m, at));
}
}
return problems;
}
/** `base` words with `added` appended; a word already there with the same role is skipped. Throws on a clash. */
export function mergeWords(base, added) {
const out = [...base];
for (const w of added) {
const old = out.find((o) => o.name === w.name);
if (old === undefined) out.push(w);
else if (old.role !== w.role || old.selects !== w.selects) throw new Error(`${w.name} is already a ${old.role}${old.selects ? ` that selects ${old.selects}` : ""}`);
}
return out;
}
/** `base` seams with `added` merged in; a word that already crosses the seam must keep its meaning. Throws on a clash. */
export function mergeSeams(base, added) {
const out = { triggerWords: { ...base.triggerWords }, multiplierWords: { ...base.multiplierWords } };
for (const part of ["triggerWords", "multiplierWords"]) {
for (const [word, meaning] of Object.entries(added[part] ?? {})) {
const old = out[part][word];
if (old !== undefined && JSON.stringify(old) !== JSON.stringify(meaning)) throw new Error(`${word} already names ${JSON.stringify(old)}`);
out[part][word] = meaning;
}
}
return out;
}
/** How long the scenario may run: 60 s, or RUN_SCENARIO_TIMEOUT_MS milliseconds if set. */
function timeoutMs() {
const ms = Number(process.env.RUN_SCENARIO_TIMEOUT_MS ?? 60_000);
if (!Number.isInteger(ms) || ms < 1) fail(`RUN_SCENARIO_TIMEOUT_MS: expected a whole number of milliseconds, got ${process.env.RUN_SCENARIO_TIMEOUT_MS}`);
return ms;
}
/** A file name for a card: its name in lower case, words joined by "-". */
const fileName = (name) => `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.json`;
/** The card files in a staged reference's cards/. */
const cardFiles = (dir) => readdirSync(join(dir, "cards")).filter((f) => f.endsWith(".json"));
/** The names of the cards in a staged reference's cards/. */
const cardNames = (dir) => cardFiles(dir).map((f) => JSON.parse(readFileSync(join(dir, "cards", f), "utf8")).name);
function main() {
const [file, ...rest] = process.argv.slice(2);
if (file === undefined || rest.length > 0) fail("usage: run-scenario.mjs <scenario.json>");
let s;
try {
s = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
fail(existsSync(file) ? `${file} is not JSON: ${e.message}` : `no file ${file}`);
}
const ms = timeoutMs();
const dir = stagePlay("ts", null, MODULE);
const content = join(dir, "src", "content");
let code = 1;
try {
const words = JSON.parse(readFileSync(join(content, "words.json"), "utf8"));
const problems = scenarioProblems(s, { words, cardNames: cardNames(dir), cardFiles: cardFiles(dir) });
if (problems.length > 0) {
console.error(`${basename(file)} is not a valid scenario:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
return;
}
for (const card of s.cards ?? []) writeFileSync(join(dir, "cards", fileName(card.name)), `${JSON.stringify(card, null, 2)}\n`);
writeFileSync(join(content, "words.json"), `${JSON.stringify(mergeWords(words, s.words ?? []), null, 2)}\n`);
const seams = JSON.parse(readFileSync(join(content, "seams.json"), "utf8"));
writeFileSync(join(content, "seams.json"), `${JSON.stringify(mergeSeams(seams, s.seams ?? {}), null, 2)}\n`);
mkdirSync(join(dir, "scenario"));
writeFileSync(join(dir, "scenario", "level.json"), JSON.stringify(s.level));
writeFileSync(join(dir, "scenario", "inputs.json"), JSON.stringify(s.inputs));
copyFileSync(new URL("./scenario/run.ts", import.meta.url), join(dir, "run.ts"));
if (!existsSync(join(dir, "node_modules"))) {
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
if (r.status !== 0) {
console.error(`npm install failed:\n${r.stdout}${r.stderr}`);
return;
}
}
// One node process (tsx as a loader, not tsx's own launcher), so the timeout's kill stops it all.
const r = spawnSync(process.execPath, ["--import", "tsx", "run.ts", "scenario/level.json", "scenario/inputs.json"], { cwd: dir, stdio: "inherit", timeout: ms, killSignal: "SIGKILL" });
if (r.error?.code === "ETIMEDOUT") {
console.error(`\nThe scenario did not finish within ${ms / 1000} s, so it was stopped. A card may set itself off without end (a trigger whose effect raises the event it listens for, again and again).`);
return;
}
code = r.status ?? 1;
} finally {
rmSync(dir, { recursive: true, force: true });
process.exitCode = code;
}
}
if (isMain(import.meta.url)) main();