clone

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

Run author solver CLI on compact fast search

Dami committed c5ec50e
commit
c5ec50eef8d5e420e6b43ca7df43f7f94f05ea8c
parent
428011bbb1c134f29563174d12aac8924896ef80
author
Dami <dami.etoile@gmail.com>
2 files changed, +231 -0
Atests/solver-cli.test.js129++++++++++++++++++++++++++++++
Atools/solve.mjs102++++++++++++++++++++++++
A tests/solver-cli.test.js +129 -0
@@ -0,0 +1,129 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';4+import { join } from 'node:path';5+import { tmpdir } from 'node:os';6+import { spawnSync } from 'node:child_process';7+8+const root = new URL('..', import.meta.url);9+const cli = new URL('../tools/solve.mjs', import.meta.url);10+11+function run(args) {12+ const result = spawnSync(process.execPath, [cli.pathname, ...args], {13+ cwd: root.pathname,14+ encoding: 'utf8',15+ });16+ return { ...result, output: `${result.stdout}${result.stderr}` };17+}18+19+function fixture(source) {20+ const directory = mkdtempSync(join(tmpdir(), 'miau-solver-cli-'));21+ const file = join(directory, 'fixture.cjs');22+ writeFileSync(file, source);23+ return { file, cleanup: () => rmSync(directory, { recursive: true, force: true }) };24+}25+26+const simplePack = `module.exports = {27+ manifest: { id: 'cli-fixture', name: 'CLI fixture', version: '1.0.0', engine: 1 },28+ levels: [{ id: 'walk', width: 7, height: 2, objects: [29+ { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 },30+ { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 },31+ { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },32+ { id: 'fw', kind: 'word', text: 'FLAG', x: 4, y: 0 },33+ { id: 'fi', kind: 'word', text: 'IS', x: 5, y: 0 },34+ { id: 'fn', kind: 'word', text: 'WIN', x: 6, y: 0 },35+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 1 },36+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 3, y: 1 },37+ ] }],38+};`;39+40+const wonPack = `module.exports = {41+ manifest: { id: 'cli-won', name: 'Won fixture', version: '1.0.0', engine: 1 },42+ levels: [{ id: 'won', width: 3, height: 1, objects: [43+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 0 },44+ { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 },45+ { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 },46+ { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },47+ { id: 'mw2', kind: 'word', text: 'MIAU', x: 0, y: 0 },48+ { id: 'mi2', kind: 'word', text: 'IS', x: 1, y: 0 },49+ { id: 'mw3', kind: 'word', text: 'WIN', x: 2, y: 0 },50+ ] }],51+};`;52+53+const transientPack = `module.exports = {54+ manifest: { id: 'cli-transient', name: 'Transient fixture', version: '1.0.0', engine: 1 },55+ levels: [{ id: 'sink-rule', width: 7, height: 3, objects: [56+ { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 }, { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 }, { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },57+ { id: 'ww', kind: 'word', text: 'WATER', x: 0, y: 1 }, { id: 'wi', kind: 'word', text: 'IS', x: 1, y: 1 }, { id: 'ws', kind: 'word', text: 'SINK', x: 2, y: 1 },58+ { id: 'fw', kind: 'word', text: 'FLAG', x: 4, y: 0 }, { id: 'fi', kind: 'word', text: 'IS', x: 5, y: 0 }, { id: 'fn', kind: 'word', text: 'WIN', x: 6, y: 0 },59+ { id: 'rw', kind: 'word', text: 'ROCK', x: 2, y: 2 }, { id: 'ri', kind: 'word', text: 'IS', x: 3, y: 2 },60+ { id: 'water', kind: 'object', noun: 'WATER', x: 4, y: 2 }, { id: 'stop', kind: 'word', text: 'STOP', x: 5, y: 2 },61+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 5, y: 2 }, { id: 'player', kind: 'object', noun: 'MIAU', x: 6, y: 2 },62+ ] }],63+};`;64+65+test('CLI preserves pack and level selection, route output, and replay', () => {66+ const pack = fixture(simplePack);67+ try {68+ const result = run([pack.file, '--level', 'walk']);69+ assert.equal(result.status, 0);70+ assert.match(result.stdout, /CLI fixture \(cli-fixture\), 1 levels/);71+ assert.match(result.stdout, /SOLVED\s+3 steps\s+RRR/);72+ const replay = run([pack.file, '--level', 'walk', '--replay', 'RRR']);73+ assert.equal(replay.status, 0);74+ assert.match(replay.stdout, /start \(walk\)/);75+ assert.match(replay.stdout, /1: R/);76+ } finally {77+ pack.cleanup();78+ }79+});80+81+test('CLI reports LIMIT and keeps non-solved exit behavior', () => {82+ const result = run(['core', '--level', 'two-things-can-win', '--max', '1']);83+ assert.equal(result.status, 1);84+ assert.match(result.stdout, /LIMIT\s+after 1 states/);85+ const visited = run(['--level', 'two-things-can-win', '--max-visited', '1', 'core']);86+ assert.equal(visited.status, 1);87+ assert.match(visited.stdout, /LIMIT\s+after 1 states \(visited cap\)/);88+ const missing = run(['core', '--forbid']);89+ assert.equal(missing.status, 2);90+ assert.match(missing.stderr, /missing value/);91+});92+93+test('CLI reports existential BYPASS and HANDS-OFF flags', () => {94+ const result = run(['core', '--level', 'two-things-can-win', '--max', '5000']);95+ assert.equal(result.status, 0);96+ assert.match(result.stdout, /BYPASS\(6: RRRRRR\)/);97+ assert.match(result.stdout, /HANDS-OFF\(6: RRRRRR\)/);98+});99+100+test('CLI reports already-won levels without searching', () => {101+ const pack = fixture(wonPack);102+ try {103+ const result = run([pack.file, '--level', 'won']);104+ assert.equal(result.status, 0);105+ assert.match(result.stdout, /SOLVED\s+0 steps\s+\s+WON-AT-START/);106+ } finally {107+ pack.cleanup();108+ }109+});110+111+test('CLI accepts repeatable forbid values and rejects an initial forbidden rule', () => {112+ const result = run(['core', '--level', 'two-things-can-win', '--forbid', 'FLAG IS WIN', '--forbid', 'ROCK IS WIN']);113+ assert.equal(result.status, 1);114+ assert.match(result.stdout, /FORBIDDEN\s+after 0 states/);115+});116+117+test('CLI forbid checks a transient formed then sunk rule', () => {118+ const pack = fixture(transientPack);119+ try {120+ const solved = run([pack.file, '--level', 'sink-rule']);121+ assert.equal(solved.status, 0);122+ assert.match(solved.stdout, /SOLVED\s+1 steps\s+L/);123+ const forbidden = run([pack.file, '--level', 'sink-rule', '--forbid', 'ROCK IS STOP']);124+ assert.equal(forbidden.status, 1);125+ assert.match(forbidden.stdout, /UNSOLVED\s+after/);126+ } finally {127+ pack.cleanup();128+ }129+});
A tools/solve.mjs +102 -0
@@ -0,0 +1,102 @@
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.8+import { readFileSync } from 'node:fs';9+import { basename } from 'node:path';10+import { createGame } from '../src/engine.js';11+import { levels as coreLevels } from '../src/levels.js';12+import { loadMod } from '../src/mods.js';13+import { solveLevel } from '../src/solver/solve-level.js';14+15+const args = process.argv.slice(2);16+const optionNames = new Set(['max', 'max-visited', 'level', 'forbid', 'replay']);17+const options = new Map();18+const positionals = [];19+for (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+}31+const opt = name => options.get(name)?.[0];32+const file = positionals[0];33+const MAX = Number(opt('max') ?? 300000);34+if (!Number.isInteger(MAX) || MAX < 1 || MAX > 50000000) { console.error('--max must be an integer from 1 to 50000000'); process.exit(2); }35+const maxVisited = opt('max-visited') === undefined ? Infinity : Number(opt('max-visited'));36+if (maxVisited !== Infinity && (!Number.isInteger(maxVisited) || maxVisited < 1 || maxVisited > 50000000)) { console.error('--max-visited must be an integer from 1 to 50000000'); process.exit(2); }37+const only = opt('level');38+const forbid = (options.get('forbid') || []).map(value => value.toUpperCase().replace(/\s+/g, ' ').trim());39+const replay = opt('replay');40+if (!file || positionals.length !== 1) { console.error('usage: node tools/solve.mjs <pack.cjs> [--level id] [--max n] [--max-visited n]'); process.exit(2); }41+42+const mod = file === 'core'43+ ? { manifest: { name: 'Miau core (src/levels.js)', id: 'core' }, levels: coreLevels }44+ : loadMod(readFileSync(file, 'utf8'), { filename: basename(file) });45+if (only && !mod.levels.some(level => level.id === only)) {46+ console.error(`no level with id "${only}" (ids: ${mod.levels.map(level => level.id).join(', ')})`);47+ process.exit(2);48+}49+50+function drawState(state, label) {51+ const grid = Array.from({ length: state.height }, () => Array(state.width).fill(' . '));52+ for (const object of state.objects) {53+ const text = object.kind === 'word' ? object.text.slice(0, 3).padEnd(3) : object.noun.slice(0, 2).toLowerCase().padEnd(3);54+ grid[object.y][object.x] = text;55+ }56+ console.log(`\n${label} rules: ${state.rules.map(rule => rule.text).join(', ') || '(none)'}${state.won ? ' WON' : ''}`);57+ for (const row of grid) console.log(row.join(''));58+}59+60+function replayLevel(level, path) {61+ const game = createGame(level);62+ drawState(game.getState(), `start (${level.id})`);63+ const directions = { U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] };64+ for (const [index, action] of [...path.toUpperCase()].entries()) {65+ const result = action === 'W' ? game.wait() : directions[action] ? game.step(...directions[action]) : null;66+ if (!result) { console.error(`invalid replay action "${action}" (use U, D, L, R, or W)`); process.exit(2); }67+ drawState(game.getState(), `${index + 1}: ${action}${result.accepted ? '' : ' (blocked)'}`);68+ }69+}70+71+if (replay) {72+ const level = mod.levels.find(candidate => candidate.id === only) || mod.levels[0];73+ replayLevel(level, replay);74+ process.exit(0);75+}76+77+let bad = 0;78+console.log(`${mod.manifest.name} (${mod.manifest.id}), ${mod.levels.length} levels`);79+for (const level of mod.levels) {80+ if (only && level.id !== only) continue;81+ const result = solveLevel(level, { cap: MAX, maxVisited, forbid, transitionMode: 'fast' });82+ const flags = [];83+ if (result.status === 'SOLVED') {84+ if (result.path.length === 0) flags.push('WON-AT-START');85+ else if (result.path.length < 6) flags.push('TRIVIAL');86+ const bypass = result.routeBypass87+ ? result88+ : solveLevel(level, { cap: MAX, maxVisited, forbid, transitionMode: 'fast', wordMotion: 'player' });89+ if (bypass.status === 'SOLVED') flags.push(`BYPASS(${bypass.path.length}: ${bypass.path})`);90+ const handsOff = result.routeHandsOff91+ ? result92+ : bypass.status === 'SOLVED' && bypass.routeHandsOff93+ ? bypass94+ : bypass.status === 'UNSOLVED'95+ ? bypass96+ : solveLevel(level, { cap: MAX, maxVisited, forbid, transitionMode: 'fast', wordMotion: 'all' });97+ if (handsOff.status === 'SOLVED') flags.push(`HANDS-OFF(${handsOff.path.length}: ${handsOff.path})`);98+ } else bad++;99+ const detail = result.status === 'SOLVED' ? `${result.path.length} steps ${result.path}` : `after ${result.explored} states${result.limitReason === 'visited' ? ' (visited cap)' : ''}`;100+ console.log(` ${(level.id || '?').padEnd(28)} ${result.status.padEnd(8)} ${detail}${flags.length ? ` ${flags.join(' ')}` : ''}`);101+}102+process.exit(bad ? 1 : 0);