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
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>
104 lines
4.4 KiB
TypeScript
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();
|
|
}
|