clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / tools / solve.mjs
solve.mjs 7.2K
1#!/usr/bin/env node2// Level solver for pack authors. Runs the real engine, so results match play.3//4// `--forbid` may be repeated. The normal route uses the fast transition5// boundary, so the check includes transient rule phases as well as settled6// rules. BYPASS and HANDS-OFF are existential searches and therefore run as7// separate constrained solves.8import { readFileSync } from 'node:fs';9import { basename } from 'node:path';10import { createGame } from '../src/engine.js';11import { levels as coreLevels } from '../src/levels.js';12import { loadMod } from '../src/mods.js';13import { solveLevel } from '../src/solver/solve-level.js';1415const args = process.argv.slice(2);16const optionNames = new Set(['max', 'max-visited', 'level', 'forbid', 'replay']);17const options = new Map();18const positionals = [];19for (let index = 0; index < args.length; index += 1) {20 const arg = args[index];21 if (!arg.startsWith('--')) { positionals.push(arg); continue; }22 const name = arg.slice(2);23 if (!optionNames.has(name) || index + 1 >= args.length || args[index + 1].startsWith('--')) {24 console.error(`invalid or missing value for ${arg}`);25 process.exit(2);26 }27 const values = options.get(name) || [];28 values.push(args[++index]);29 options.set(name, values);30}31const opt = name => options.get(name)?.[0];32const file = positionals[0];33// An explicit --max is the author/CI invocation override. Without it, a level34// solverBudget overrides the default budget for that proof.35const DEFAULT_BUDGET = 2_000_000;36const maxOption = opt('max');37const MAX = Number(maxOption ?? DEFAULT_BUDGET);38if (!Number.isInteger(MAX) || MAX < 1 || MAX > 50000000) { console.error('--max must be an integer from 1 to 50000000'); process.exit(2); }39const maxVisited = opt('max-visited') === undefined ? Infinity : Number(opt('max-visited'));40if (maxVisited !== Infinity && (!Number.isInteger(maxVisited) || maxVisited < 1 || maxVisited > 50000000)) { console.error('--max-visited must be an integer from 1 to 50000000'); process.exit(2); }41const only = opt('level');42const forbid = (options.get('forbid') || []).map(value => value.toUpperCase().replace(/\s+/g, ' ').trim());43const replay = opt('replay');44if (!file || positionals.length !== 1) { console.error('usage: node tools/solve.mjs <pack.cjs> [--level id] [--max n] [--max-visited n]'); process.exit(2); }4546const mod = file === 'core'47 ? { manifest: { name: 'Miau core (src/levels.js)', id: 'core' }, levels: coreLevels }48 : loadMod(readFileSync(file, 'utf8'), { filename: basename(file) });49if (only && !mod.levels.some(level => level.id === only)) {50 console.error(`no level with id "${only}" (ids: ${mod.levels.map(level => level.id).join(', ')})`);51 process.exit(2);52}5354function drawState(state, label) {55 const grid = Array.from({ length: state.height }, () => Array(state.width).fill(' . '));56 for (const object of state.objects) {57 const text = object.kind === 'word' ? object.text.slice(0, 3).padEnd(3) : object.noun.slice(0, 2).toLowerCase().padEnd(3);58 grid[object.y][object.x] = text;59 }60 console.log(`\n${label} rules: ${state.rules.map(rule => rule.text).join(', ') || '(none)'}${state.won ? ' WON' : ''}`);61 for (const row of grid) console.log(row.join(''));62}6364function replayLevel(level, path) {65 const game = createGame(level);66 drawState(game.getState(), `start (${level.id})`);67 const directions = { U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] };68 for (const [index, action] of [...path.toUpperCase()].entries()) {69 const result = action === 'W' ? game.wait() : directions[action] ? game.step(...directions[action]) : null;70 if (!result) { console.error(`invalid replay action "${action}" (use U, D, L, R, or W)`); process.exit(2); }71 drawState(game.getState(), `${index + 1}: ${action}${result.accepted ? '' : ' (blocked)'}`);72 }73}7475if (replay) {76 const level = mod.levels.find(candidate => candidate.id === only) || mod.levels[0];77 replayLevel(level, replay);78 process.exit(0);79}8081let bad = 0;82console.log(`${mod.manifest.name} (${mod.manifest.id}), ${mod.levels.length} levels`);83for (const level of mod.levels) {84 if (only && level.id !== only) continue;85 const metadataErrors = [];86 if (level.solverBudget !== undefined && (!Number.isSafeInteger(level.solverBudget) || level.solverBudget < 1)) {87 metadataErrors.push('solverBudget must be a positive integer');88 }89 if (level.allowBypass !== undefined && typeof level.allowBypass !== 'boolean') metadataErrors.push('allowBypass must be boolean');90 if (level.allowHandsOff !== undefined && typeof level.allowHandsOff !== 'boolean') metadataErrors.push('allowHandsOff must be boolean');91 if (level.mustForm !== undefined && (!Array.isArray(level.mustForm) || level.mustForm.some(rule => typeof rule !== 'string' || !rule.trim()))) {92 metadataErrors.push('mustForm must be an array of non-empty rule texts');93 }94 if (metadataErrors.length) {95 bad++;96 console.error(` ${(level.id || '?').padEnd(28)} INVALID ${metadataErrors.join('; ')}`);97 continue;98 }99 const cap = maxOption === undefined ? (level.solverBudget ?? DEFAULT_BUDGET) : MAX;100 const result = solveLevel(level, { cap, maxVisited, forbid, transitionMode: 'fast' });101 const flags = [];102 const warnings = [];103 if (result.status === 'SOLVED') {104 if (result.path.length === 0) flags.push('WON-AT-START');105 else if (result.path.length < 6) flags.push('TRIVIAL');106 const bypass = result.routeBypass107 ? result108 : solveLevel(level, { cap, maxVisited, forbid, transitionMode: 'fast', wordMotion: 'player' });109 if (bypass.status === 'SOLVED') flags.push(`BYPASS(${bypass.path.length}: ${bypass.path})`);110 const handsOff = result.routeHandsOff111 ? result112 : bypass.status === 'SOLVED' && bypass.routeHandsOff113 ? bypass114 : bypass.status === 'UNSOLVED'115 ? bypass116 : solveLevel(level, { cap, maxVisited, forbid, transitionMode: 'fast', wordMotion: 'all' });117 if (handsOff.status === 'SOLVED') flags.push(`HANDS-OFF(${handsOff.path.length}: ${handsOff.path})`);118 if (bypass.status === 'LIMIT') {119 warnings.push(`BYPASS search LIMIT after ${bypass.explored} states (unproven)`);120 bad++;121 } else if (bypass.status === 'SOLVED' && !level.allowBypass) warnings.push('BYPASS not allowed (set allowBypass: true if intended)');122 if (handsOff.status === 'LIMIT') {123 warnings.push(`HANDS-OFF search LIMIT after ${handsOff.explored} states (unproven)`);124 bad++;125 } else if (handsOff.status === 'SOLVED' && !level.allowHandsOff) warnings.push('HANDS-OFF not allowed (set allowHandsOff: true if intended)');126 } else bad++;127 for (const rule of level.mustForm || []) {128 const required = solveLevel(level, { cap, maxVisited, forbid: rule, transitionMode: 'fast' });129 if (required.status === 'SOLVED') {130 flags.push(`SKIPPABLE(${rule})`);131 bad++;132 } else if (required.status === 'LIMIT') {133 flags.push(`LIMIT(${rule})`);134 bad++;135 }136 }137 const detail = result.status === 'SOLVED' ? `${result.path.length} steps ${result.path}` : `after ${result.explored} states${result.limitReason === 'visited' ? ' (visited cap)' : ''}`;138 console.log(` ${(level.id || '?').padEnd(28)} ${result.status.padEnd(8)} ${detail}${flags.length ? ` ${flags.join(' ')}` : ''}${warnings.length ? ` WARNING: ${warnings.join('; ')}` : ''}`);139}140process.exit(bad ? 1 : 0);