clone

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

Add sequential bundled-world solver gate

Dami committed 6f9d78f
commit
6f9d78ffd0466c0aa4c69facc6eaa56a888ca13f
parent
72d29c603dcfa5f4fd452f8ba03e5269b1bb11d1
author
Dami <dami.etoile@gmail.com>
6 files changed, +158 -0
Mdocs/modding.md16++++++++
Mpackage.json1+
Msrc/levels.js1+
Asrc/solver/check-level.js59++++++++++++++++++++++++++++++
Atests/solver-world-check.test.js38+++++++++++++++++++
Atools/check-worlds.mjs43++++++++++++++++++++++
M docs/modding.md +16 -0
M package.json +1 -0
M src/levels.js +1 -0
A src/solver/check-level.js +59 -0
@@ -0,0 +1,59 @@
1+import { solveLevel } from './solve-level.js';2+3+export const DEFAULT_SOLVER_BUDGET = 2_000_000;4+5+/**6+ * Run the authoring checks that a bundled-world gate needs without parsing CLI7+ * output. HANDS-OFF is reported as a warning because a level may intentionally8+ * be solvable by moving no words at all; BYPASS is a hard authoring contract.9+ */10+export function checkLevel(level, {11+ cap = level.solverBudget ?? DEFAULT_SOLVER_BUDGET,12+ maxVisited = Infinity,13+ strictBypass = true,14+} = {}) {15+ const result = solveLevel(level, { cap, maxVisited, transitionMode: 'fast' });16+ const checks = { result, bypass: null, handsOff: null, mustForm: [] };17+ const issues = [];18+ const warnings = [];19+20+ if (result.status !== 'SOLVED') issues.push(`${result.status} after ${result.explored} states`);21+ if (result.status === 'SOLVED') {22+ checks.bypass = result.routeBypass23+ ? result24+ : solveLevel(level, { cap, maxVisited, transitionMode: 'fast', wordMotion: 'player' });25+ checks.handsOff = result.routeHandsOff26+ ? result27+ : checks.bypass.status === 'SOLVED' && checks.bypass.routeHandsOff28+ ? checks.bypass29+ : checks.bypass.status === 'UNSOLVED'30+ ? checks.bypass31+ : solveLevel(level, { cap, maxVisited, transitionMode: 'fast', wordMotion: 'all' });32+33+ if (checks.bypass.status === 'LIMIT') {34+ issues.push(`BYPASS search LIMIT after ${checks.bypass.explored} states`);35+ } else if (checks.bypass.status === 'SOLVED' && !level.allowBypass) {36+ const message = 'BYPASS route exists but allowBypass is not enabled';37+ (strictBypass ? issues : warnings).push(message);38+ }39+ if (checks.handsOff.status === 'LIMIT') {40+ warnings.push(`HANDS-OFF search LIMIT after ${checks.handsOff.explored} states`);41+ } else if (checks.handsOff.status === 'SOLVED' && !level.allowHandsOff) {42+ warnings.push('HANDS-OFF route exists but allowHandsOff is not enabled');43+ }44+ }45+46+ // A failed normal proof already blocks the pack; avoid spending its budget47+ // again on necessity diagnostics that cannot change the gate result.48+ if (result.status !== 'SOLVED') return { ok: issues.length === 0, cap, checks, issues, warnings };49+ for (const rule of level.mustForm || []) {50+ const check = solveLevel(level, { cap, maxVisited, forbid: rule, transitionMode: 'fast' });51+ checks.mustForm.push({ rule, result: check });52+ if (check.status === 'SOLVED') issues.push(`SKIPPABLE(${rule})`);53+ else if (check.status === 'LIMIT') issues.push(`LIMIT(${rule})`);54+ }55+56+ return { ok: issues.length === 0, cap, checks, issues, warnings };57+}58+59+export default checkLevel;
A tests/solver-world-check.test.js +38 -0
@@ -0,0 +1,38 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { checkLevel } from '../src/solver/check-level.js';4+5+const walkLevel = metadata => ({6+ id: 'walk', width: 7, height: 2, ...metadata,7+ objects: [8+ { id: 'mw', kind: 'word', text: 'MIAU', x: 0, y: 0 },9+ { id: 'mi', kind: 'word', text: 'IS', x: 1, y: 0 },10+ { id: 'my', kind: 'word', text: 'YOU', x: 2, y: 0 },11+ { id: 'fw', kind: 'word', text: 'FLAG', x: 4, y: 0 },12+ { id: 'fi', kind: 'word', text: 'IS', x: 5, y: 0 },13+ { id: 'fn', kind: 'word', text: 'WIN', x: 6, y: 0 },14+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 1 },15+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 3, y: 1 },16+ ],17+});18+19+test('world proof check returns structured solved and warning results', () => {20+ const result = checkLevel(walkLevel({ allowBypass: true }), { cap: 100 });21+ assert.equal(result.ok, true);22+ assert.equal(result.checks.result.status, 'SOLVED');23+ assert.equal(result.checks.bypass.status, 'SOLVED');24+ assert.match(result.warnings.join('; '), /HANDS-OFF/);25+});26+27+test('world proof check gates a disallowed bypass without parsing CLI output', () => {28+ const result = checkLevel(walkLevel(), { cap: 100 });29+ assert.equal(result.ok, false);30+ assert.match(result.issues.join('; '), /BYPASS/);31+});32+33+test('world proof check gates an inconclusive mustForm search', () => {34+ const result = checkLevel(walkLevel({ mustForm: ['MIAU IS WIN'] }), { cap: 1 });35+ assert.equal(result.ok, false);36+ assert.match(result.issues.join('; '), /LIMIT after 1 states/);37+ assert.equal(result.checks.mustForm.length, 0);38+});
A tools/check-worlds.mjs +43 -0
@@ -0,0 +1,43 @@
1+#!/usr/bin/env node2+// Bundled-world CI gate. Keep this deliberately sequential: a proof's solver3+// budget is the authored resource limit, and running several large frontiers4+// at once hides memory regressions.5+import { readFileSync } from 'node:fs';6+import { resolve } from 'node:path';7+import { levels as coreLevels } from '../src/levels.js';8+import { loadMod } from '../src/mods.js';9+import { checkLevel } from '../src/solver/check-level.js';10+11+const root = resolve(new URL('..', import.meta.url).pathname);12+const packs = [13+ { manifest: { id: 'core', name: 'Miau core' }, levels: coreLevels },14+ ...['crate-works', 'tidewater', 'clockwork'].map(id => {15+ const filename = resolve(root, `mods/worlds/${id}.cjs`);16+ return loadMod(readFileSync(filename, 'utf8'), { filename });17+ }),18+];19+20+let failures = 0;21+for (const pack of packs) {22+ console.log(`${pack.manifest.name} (${pack.manifest.id}), ${pack.levels.length} levels`);23+ for (const level of pack.levels) {24+ const checked = checkLevel(level);25+ const result = checked.checks.result;26+ const details = result.status === 'SOLVED'27+ ? `${result.path.length} steps ${result.path}`28+ : `after ${result.explored} states`;29+ const suffix = [30+ ...checked.issues.map(issue => `ERROR: ${issue}`),31+ ...checked.warnings.map(warning => `WARNING: ${warning}`),32+ ].join(' ');33+ console.log(` ${level.id.padEnd(28)} ${result.status.padEnd(8)} ${details}${suffix ? ` ${suffix}` : ''}`);34+ if (!checked.ok) failures++;35+ }36+}37+38+if (failures) {39+ console.error(`Bundled-world check failed: ${failures} level${failures === 1 ? '' : 's'}`);40+ process.exitCode = 1;41+} else {42+ console.log('Bundled-world check passed');43+}