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>
162 lines
6.7 KiB
TypeScript
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);
|