// 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 kinds, 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 [--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 [--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.kindsAt(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([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);