- commit
- c5ec50eef8d5e420e6b43ca7df43f7f94f05ea8c
- parent
- 428011bbb1c134f29563174d12aac8924896ef80
- author
- Dami <dami.etoile@gmail.com>
| A | tests/ | 129 | ++++++++++++++++++++++++++++++ |
| A | tools/ | 102 | ++++++++++++++++++++++++ |
@@ -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+});@@ -0,0 +1,102 @@
1+2+// 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);