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,55 @@
|
||||
#!/usr/bin/env node
|
||||
// Search a SpellGrid level (Module 10) for solutions, by brute force, with the course's
|
||||
// TypeScript reference engine. Used in Module 11 to check that a level has the solution
|
||||
// its designer meant and no other.
|
||||
//
|
||||
// node tools/solve-spellgrid.mjs [level] [--dir <dir>] [--max N] [--naive] [--never-rule "X IS Y"] [--all]
|
||||
// npm run solve:spellgrid -- l1 --never-rule "DEATHRATTLE IS DOUBLE"
|
||||
//
|
||||
// Stages the Module 10 TypeScript reference with its cards and levels, as play-spellgrid
|
||||
// does, copies tools/solver/solve.ts beside it and runs it there. `level` is a level of
|
||||
// course/10-spellgrid/levels by name or prefix (`l1`), or a path to a level file; default `l1`.
|
||||
// The other options are the solver's: see tools/solver/solve.ts.
|
||||
import { cpSync, existsSync, readdirSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fail, isMain } from "./lib.mjs";
|
||||
import { resolveLevel, stagePlay } from "./play-spellgrid.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const WITH_VALUE = ["--max", "--never-rule"];
|
||||
const SWITCHES = ["--naive", "--all"];
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const opts = { level: "l1", dir: null, solver: [] };
|
||||
const rest = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--dir") opts.dir = argv[++i];
|
||||
else if (WITH_VALUE.includes(argv[i])) opts.solver.push(argv[i], argv[++i]);
|
||||
else if (SWITCHES.includes(argv[i])) opts.solver.push(argv[i]);
|
||||
else rest.push(argv[i]);
|
||||
}
|
||||
[opts.level = "l1"] = rest;
|
||||
return opts;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const dir = resolve(stagePlay("ts", opts.dir));
|
||||
const level = resolveLevel(join(dir, "levels"), opts.level);
|
||||
if (!level) {
|
||||
const names = readdirSync(join(dir, "levels")).filter((f) => f.endsWith(".json")).map((f) => basename(f, ".json"));
|
||||
fail(`no level "${opts.level}". Levels: ${names.join(", ")}`);
|
||||
}
|
||||
cpSync(join(here, "solver", "solve.ts"), join(dir, "solve.ts"));
|
||||
if (!existsSync(join(dir, "node_modules"))) {
|
||||
console.error(`Installing the TypeScript reference's dependencies in ${dir} ...`);
|
||||
const r = spawnSync("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, stdio: ["ignore", "inherit", "inherit"] });
|
||||
if (r.status !== 0) fail("npm install failed");
|
||||
}
|
||||
const r = spawnSync("npx", ["tsx", "solve.ts", level.path, ...opts.solver], { cwd: dir, stdio: "inherit" });
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) main();
|
||||
@@ -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);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { toolsDir } from "./helpers.mjs";
|
||||
import { parseArgs } from "../solve-spellgrid.mjs";
|
||||
|
||||
test("solve_spellgrid_parses_args", () => {
|
||||
assert.deepEqual(parseArgs([]), { level: "l1", dir: null, solver: [] });
|
||||
assert.deepEqual(parseArgs(["l3", "--naive", "--max", "9", "--dir", "x", "--never-rule", "A IS B", "--all"]), {
|
||||
level: "l3",
|
||||
dir: "x",
|
||||
solver: ["--naive", "--max", "9", "--never-rule", "A IS B", "--all"],
|
||||
});
|
||||
});
|
||||
|
||||
test("solve_spellgrid_solver_uses_only_the_harness", () => {
|
||||
// The solver is copied into a staged reference: it may import the harness and nothing else of the engine.
|
||||
const path = join(toolsDir, "solver", "solve.ts");
|
||||
assert.ok(existsSync(path));
|
||||
const imports = [...readFileSync(path, "utf8").matchAll(/from "([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(imports.sort(), ["./src/harness", "node:fs"]);
|
||||
});
|
||||
Reference in new issue
Block a user