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

179 lines
9.6 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.
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) and `cardNames` (the names of its cards).
*/
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 ?? [];
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 (ctx.cardNames.includes(card?.name)) problems.push(`${at}: the course already has a card named ${JSON.stringify(card.name)}; give yours another name`);
if (cards.findIndex((c) => c?.name === card?.name) < i) problems.push(`${at}: two scenario cards are named ${JSON.stringify(card.name)}`);
});
}
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;
}
/** 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 names of the cards in a staged reference's cards/. */
const cardNames = (dir) =>
readdirSync(join(dir, "cards")).filter((f) => f.endsWith(".json")).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 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) });
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;
}
}
const r = spawnSync(join(dir, "node_modules", ".bin", "tsx"), ["run.ts", "scenario/level.json", "scenario/inputs.json"], { cwd: dir, stdio: "inherit" });
code = r.status ?? 1;
} finally {
rmSync(dir, { recursive: true, force: true });
process.exitCode = code;
}
}
if (isMain(import.meta.url)) main();