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

104 lines
4.4 KiB
TypeScript

// Play a designer's scenario on a staged SpellGrid reference (tools/run-scenario.mjs copies
// this file into the staged directory and runs it there with tsx).
//
// npx tsx run.ts <level.json> <inputs.json>
//
// The inputs are command-line commands, as `src/cli.ts` reads them: `w a s d`, `c <card> [x y]`,
// and the answers to Assemble's questions, one input each. After the start and after each
// input it prints the 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 rules live here: it calls the
// harness, as the command line does, and draws with the command line's `render`. An input the
// rules don't allow is reported and play goes on; an engine error in the middle of a Sequence
// (the Death Phase limit, say) ends the run with exit code 1, without that input's log.
import { readFileSync } from "node:fs";
import { render } from "./src/cli";
import { newSpellGrid, type LevelDef } from "./src/harness";
const DIRS = { w: "up", a: "left", s: "down", d: "right" } as const;
/** A coordinate or a chooser's number, as the command line reads one. */
const NUMBER = /^\d{1,9}$/;
const level = JSON.parse(readFileSync(process.argv[2], "utf8")) as LevelDef;
const inputs = JSON.parse(readFileSync(process.argv[3], "utf8")) as string[];
let next = 0;
const out = (s: string) => process.stdout.write(`${s}\n`);
/** The next input, echoed after its prompt; undefined when there are none left. */
function read(prompt: string): string | undefined {
const line = inputs[next];
if (line === undefined) return undefined;
next++;
out(`${prompt}${line}`);
return line;
}
/** The level, or a readable error: an unknown card, a card with no noun on the grid, ... */
function start() {
try {
return newSpellGrid(level, []);
} catch (e) {
process.stderr.write(`The level does not start: ${e instanceof Error ? e.message : String(e)}\n`);
process.exit(1);
}
}
const g = start();
g.setChooser((kind, options) => {
out(`Choose the ${kind}:\n${options.map((o, i) => ` ${i + 1}) ${o}`).join("\n")}`);
const answer = read("? ")?.trim() ?? "";
if (options.includes(answer)) return answer;
return (NUMBER.test(answer) ? options[Number(answer) - 1] : undefined) ?? options[0];
});
const show = () => out(`${render(g, level)}\nHeroes: ${g.health(g.hero(0))} Health (yours), ${g.health(g.hero(1))} Health (enemy)`);
out("== start");
show();
for (;;) {
if (g.won() || g.lost()) {
out(g.won() ? "You win!" : "Nothing is YOU. You lose.");
const left = inputs.length - next;
if (left > 0) out(`${left} input${left === 1 ? "" : "s"} not run: the level is over.`);
break;
}
out("");
const line = read("> ");
if (line === undefined) {
out("(end of inputs)");
break;
}
const [command, ...args] = line.trim().split(/\s+/);
g.clearLog();
// A move the rules don't allow (a card not in the hand, a full tile) is asked about first,
// as the command line does, and reported; the game goes on.
let problem: string | null = null;
let act: (() => void) | undefined;
if (Object.hasOwn(DIRS, command)) act = () => g.step(DIRS[command as keyof typeof DIRS]);
else if (command === "c" && args.length > 0) {
const n = args.length;
const at = n >= 3 && NUMBER.test(args[n - 2]) && NUMBER.test(args[n - 1]) ? ([Number(args[n - 2]), Number(args[n - 1])] as const) : undefined;
const card = (at === undefined ? args : args.slice(0, n - 2)).join(" ");
const target = at === undefined ? null : g.minionAt(...at);
if (at === undefined) [problem, act] = [g.checkCast(card), () => g.cast(card)];
else if (target !== null) [problem, act] = [g.checkCast(card, target), () => g.cast(card, target)];
else [problem, act] = [g.checkPlay(card, ...at), () => void g.play(card, ...at)];
} else out("Commands: w a s d (move), c <card> [x y] (cast or play)");
if (problem !== null) out(`Error: ${problem}`);
else {
try {
act?.();
} catch (e) {
// Any other error is the engine's, in the middle of a Sequence (a Death Phase limit, a
// card the engine cannot find): the game is left half-way through a rule, so stop here.
out(`Error: ${e instanceof Error ? e.message : String(e)}`);
out("The Sequence did not finish, so the game cannot go on.");
process.exitCode = 1;
break;
}
}
const tree = g.logTree();
if (tree !== "") out(`Log:\n${tree.trimEnd()}`);
show();
}