Files
systemic-game-design-course/tools/solver/solve.ts
T
Lucas WintherandClaude Opus 5.5 570e5d6826 feat: age-aware solver, verify-levels, seven solver-proved SpellGrid levels (l4-l10)
The solver's position key reads stackAt (each tile oldest first). New tool
tools/verify-levels.mjs checks course/10-spellgrid/levels/proofs.json: each
level's shortest solution, and that it exhausts without its idea (neverRule)
or on the naive engine. Levels 4-10, one course idea each, with
test_10_level_N_solution in both tracks, scenarios and Lesson 10's list;
l1 no longer matches l10, and proofs.json is not listed as a level.

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

162 lines
6.7 KiB
TypeScript

// A brute-force solver for SpellGrid levels, run inside a staged Module 10 TypeScript
// reference by tools/solve-spellgrid.mjs. Breadth-first over the player's inputs.
//
// It never copies engine state. Every position is reached by replaying its inputs on a
// fresh level, which needs nothing from the engine but determinism. A position is
// remembered by a key built from what the harness shows (every tile's objects, oldest
// first, because age breaks ties in movement, hazards and the win check; each minion's
// card and Health; the hand; the heroes), plus the choices made for Assemble, because
// two Assembled cards can share a name. Two input sequences with the same key
// are treated as one position, so the search finds one shortest solution per winning
// position, not every sequence of inputs that wins.
//
// npx tsx solve.ts <level.json> [--max N] [--naive] [--never-rule "X IS Y"] [--all]
//
// --max N stop after N inputs (default 12)
// --naive the level-3 test hook: Aura Update before the Death Creation Step
// --never-rule R discard every position in which rule R is in force: "is the level
// solvable without ever forming R?"
// --all keep searching after the first depth that has a solution
import { readFileSync } from "node:fs";
import { newSpellGrid, type LevelDef, type SpellGrid } from "./src/harness";
type Dir = "up" | "down" | "left" | "right";
type Input =
| { type: "step"; dir: Dir }
| { type: "cast"; card: string; at?: [number, number]; picks: number[] }
| { type: "play"; card: string; at: [number, number] };
const args = process.argv.slice(2);
const flag = (name: string): boolean => args.includes(name);
const option = (name: string): string | undefined => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
const levelPath = args.find((a) => a.endsWith(".json"));
if (levelPath === undefined) {
process.stderr.write('usage: npx tsx solve.ts <level.json> [--max N] [--naive] [--never-rule "X IS Y"] [--all]\n');
process.exit(2);
}
const level = JSON.parse(readFileSync(levelPath, "utf8")) as LevelDef;
const maxDepth = Number(option("--max") ?? 12);
const neverRule = option("--never-rule");
/** Apply one input. Returns how many options each Assemble choice offered, in order. */
function apply(g: SpellGrid, input: Input): number[] {
const offered: number[] = [];
if (input.type === "step") g.step(input.dir);
else if (input.type === "play") g.play(input.card, ...input.at);
else {
let next = 0;
g.setChooser((_kind, options) => {
offered.push(options.length);
return options[input.picks[next++] ?? 0];
});
const target = input.at === undefined ? null : g.minionAt(...input.at);
if (input.at !== undefined && target === null) throw new Error("no minion on the target tile");
g.cast(input.card, target ?? undefined);
}
return offered;
}
function replay(inputs: Input[]): SpellGrid {
const g = newSpellGrid(level, []);
if (flag("--naive")) g.withNaiveAuraTiming();
for (const input of inputs) apply(g, input);
return g;
}
function key(g: SpellGrid, assembled: string): string {
const tiles: string[] = [];
for (let y = 0; y < level.height; y++) {
for (let x = 0; x < level.width; x++) {
const kinds = g.stackAt(x, y);
if (kinds.length === 0) continue;
const minion = g.minionAt(x, y);
tiles.push(`${x},${y}:${kinds.join("+")}${minion === null ? "" : `#${g.nameOf(minion)}/${g.health(minion)}`}`);
}
}
return [tiles.join("|"), g.hand().join(","), assembled, g.health(g.hero(0)), g.health(g.hero(1))].join("~");
}
const show = (input: Input): string =>
input.type === "step"
? input.dir
: input.type === "play"
? `play ${input.card} at (${input.at})`
: `cast ${input.card}${input.at ? ` at (${input.at})` : ""}${input.picks.length > 0 ? ` choosing ${input.picks.map((p) => p + 1).join(", ")}` : ""}`;
/** Every input worth trying from the position `inputs` reaches. */
function moves(g: SpellGrid, inputs: Input[]): Input[] {
const out: Input[] = (["up", "down", "left", "right"] as const).map((dir) => ({ type: "step", dir }));
const minions: [number, number][] = [];
const empty: [number, number][] = [];
for (let y = 0; y < level.height; y++) {
for (let x = 0; x < level.width; x++) {
if (g.minionAt(x, y) !== null) minions.push([x, y]);
if (g.kindsAt(x, y).length === 0) empty.push([x, y]);
}
}
for (const card of new Set(g.hand())) {
if (g.checkCast(card) === null) {
// Assemble asks the chooser; try every answer to every question it asks.
const pending: number[][] = [[]];
while (pending.length > 0) {
const picks = pending.pop()!;
const offered = apply(replay(inputs), { type: "cast", card, picks });
if (offered.length > picks.length) {
for (let i = 0; i < offered[picks.length]; i++) pending.push([...picks, i]);
} else out.push({ type: "cast", card, picks });
}
}
for (const at of minions) {
if (g.checkCast(card, g.minionAt(...at)!) === null) out.push({ type: "cast", card, at, picks: [] });
}
for (const at of empty) if (g.checkPlay(card, ...at) === null) out.push({ type: "play", card, at });
}
return out;
}
let frontier: { inputs: Input[]; assembled: string }[] = [{ inputs: [], assembled: "" }];
const seen = new Set<string>([key(replay([]), "")]);
let solutions = 0;
let exhausted = false;
for (let depth = 1; depth <= maxDepth; depth++) {
const next: typeof frontier = [];
for (const node of frontier) {
for (const input of moves(replay(node.inputs), node.inputs)) {
const inputs = [...node.inputs, input];
let g: SpellGrid;
try {
g = replay(inputs);
} catch {
continue; // not a legal input here
}
if (neverRule !== undefined && g.activeRules().includes(neverRule)) continue;
if (g.won()) {
solutions++;
console.log(`solution in ${depth}: ${inputs.map(show).join("; ")}`);
continue;
}
if (g.lost()) continue;
const assembled = node.assembled + (input.type === "cast" && input.picks.length > 0 ? `[${input.picks}]` : "");
const k = key(g, assembled);
if (seen.has(k)) continue;
seen.add(k);
next.push({ inputs, assembled });
}
}
console.log(`${depth} input${depth === 1 ? "" : "s"}: ${next.length} new positions, ${seen.size} in all`);
frontier = next;
if (frontier.length === 0) {
exhausted = true;
break;
}
if (solutions > 0 && !flag("--all")) break;
}
if (solutions === 0) {
console.log(
exhausted
? "no solution: every reachable position was searched"
: `no solution within ${maxDepth} inputs (the search was cut off, not exhausted)`,
);
}
process.exit(solutions > 0 ? 0 : 1);