feat: Stress it beat for Modules 6-10, Module 11 (Break your own engine), level solver tool
- lessons 8 and 9 updated for the once-per-move rule, per-object negated conversion and the new ordering tests - tools/solve-spellgrid.mjs: breadth-first search over a SpellGrid level with the TypeScript reference; --never-rule and --naive - README, harness.md and the mentor skill know the new beat and module Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
e907f17937
commit
4cb0f3a61f
13 files changed
+771
-19
No files matched your search
@@ -0,0 +1,160 @@
|
||||
// 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 <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.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<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);
|
||||
Reference in new issue
Block a user