clone

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

Add compact solver adapter with public engine fallback

Dami committed a89fe70
commit
a89fe70d39423b2641ba5980209133cc810973b4
parent
cce73d8e9061566ca82dc7cc23d4ed7cc3d5acfe
author
Dami <dami.etoile@gmail.com>
2 files changed, +219 -0
Asrc/solver/solve-level.js136++++++++++++++++++++++++++++++
Atests/solver-level.test.js83++++++++++++++++++
A src/solver/solve-level.js +136 -0
@@ -0,0 +1,136 @@
1+import { createGame } from '../engine.js';2+import { createStateCodec } from './state-codec.js';3+import { searchStates } from './search.js';4+5+const ACTIONS = Object.freeze([6+ ['U', 0, -1],7+ ['D', 0, 1],8+ ['L', -1, 0],9+ ['R', 1, 0],10+]);11+12+function normalizeForbid(forbid) {13+ if (forbid === undefined || forbid === null) return [];14+ const values = Array.isArray(forbid) ? forbid : [forbid];15+ return values.map(value => String(value).toUpperCase().replace(/\s+/g, ' ').trim()).filter(Boolean);16+}17+18+function playerPushedWord(before, result) {19+ const kind = new Map((before.objects || []).map(object => [object.id, object.kind]));20+ const you = new Set((before.objects || [])21+ .filter(object => object.kind === 'object' && (before.rules || []).some(rule => rule.subject === object.noun && rule.predicate === 'YOU'))22+ .map(object => object.id));23+ const byId = new Map((result.moved || []).map(move => [move.id, move]));24+ const root = id => {25+ for (let depth = 0, move = byId.get(id); move?.pushedBy && depth < 64; depth++) {26+ id = move.pushedBy;27+ move = byId.get(id);28+ }29+ return id;30+ };31+ return (result.moved || []).some(move => kind.get(move.id) === 'word' && you.has(root(move.id)));32+}33+34+function anyWordMoved(before, result) {35+ const kind = new Map((before.objects || []).map(object => [object.id, object.kind]));36+ return (result.moved || []).some(move => kind.get(move.id) === 'word');37+}38+39+function actionStep(action) {40+ if (action === 'W') return null;41+ const entry = ACTIONS.find(([name]) => name === action);42+ return entry ? entry.slice(1) : null;43+}44+45+function replaySolution(level, actions) {46+ const game = createGame(level);47+ let playerWordMoved = false;48+ let anyWordMovement = false;49+ for (const action of actions) {50+ const before = game.getState();51+ const step = actionStep(action);52+ const result = action === 'W' ? game.wait() : game.step(step[0], step[1]);53+ if (!result.accepted) continue;54+ playerWordMoved ||= playerPushedWord(before, result);55+ anyWordMovement ||= anyWordMoved(before, result);56+ }57+ return { playerWordMoved, anyWordMovement };58+}59+60+function publicTransition(game, snapshot, action) {61+ game.restore(snapshot);62+ const before = game.getState();63+ const step = actionStep(action);64+ const event = action === 'W' ? game.wait() : game.step(step[0], step[1]);65+ return { accepted: event.accepted, state: game.getState(), meta: { before, event } };66+}67+68+/**69+ * Solve one authored level with the public engine transition boundary.70+ *71+ * `transitionMode: 'public'` is the complete fallback mode today. `fast` is72+ * reserved for a future event-free engine path and returns UNSUPPORTED rather73+ * than silently using different semantics. `forbid` rejects initial forbidden74+ * rules and otherwise reports UNSUPPORTED because public events do not expose75+ * every transient rule evaluation phase yet.76+ */77+export function solveLevel(level, {78+ cap = 300_000,79+ forbid,80+ wordMotion = 'allow',81+ transitionMode = 'public',82+} = {}) {83+ if (!Number.isSafeInteger(cap) || cap < 1) throw new RangeError('solveLevel cap must be a positive safe integer');84+ if (!['allow', 'player', 'all', 'forbid'].includes(wordMotion)) throw new TypeError("solveLevel wordMotion must be 'allow', 'player', or 'all'");85+ const wordMotionMode = wordMotion === 'forbid' ? 'player' : wordMotion;86+ if (transitionMode !== 'public') return { status: 'UNSUPPORTED', reason: 'fast transition mode is not available', transitionMode };87+88+ const game = createGame(level);89+ const initial = game.getState();90+ const forbidden = normalizeForbid(forbid);91+ const initialForbidden = forbidden.filter(rule => initial.rules.some(candidate => candidate.text === rule));92+ if (initialForbidden.length) {93+ return { status: 'FORBIDDEN', path: '', actions: [], explored: 0, visited: 0, forbidden: initialForbidden };94+ }95+ if (forbidden.length) {96+ return { status: 'UNSUPPORTED', reason: 'transient forbidden-rule traces require the fast transition boundary', path: '', actions: [], explored: 0, visited: 0, forbidden };97+ }98+99+ const codec = createStateCodec(level);100+ const actions = level.automata?.length ? [...ACTIONS.map(([name]) => name), 'W'] : ACTIONS.map(([name]) => name);101+ const result = searchStates({102+ initial,103+ codec,104+ actions,105+ cap,106+ transition: (snapshot, action) => publicTransition(game, snapshot, action),107+ filter: wordMotionMode === 'player' || wordMotionMode === 'all'108+ ? ({ result: candidate }) => wordMotionMode === 'player'109+ ? !playerPushedWord(candidate.meta.before, candidate.meta.event)110+ : !anyWordMoved(candidate.meta.before, candidate.meta.event)111+ : undefined,112+ });113+114+ const solved = result.status === 'solved';115+ const actionsFound = solved ? result.path : [];116+ const classification = solved ? replaySolution(level, actionsFound) : { playerWordMoved: false, anyWordMovement: false };117+ const status = result.status === 'solved' ? 'SOLVED' : result.status === 'limit' ? 'LIMIT' : 'UNSOLVED';118+ return {119+ status,120+ path: actionsFound.join(''),121+ actions: actionsFound,122+ explored: result.attempts,123+ expanded: result.expanded,124+ visited: result.visited,125+ wordMotionMode,126+ ...(solved ? {127+ bypass: !classification.playerWordMoved,128+ handsOff: !classification.anyWordMovement,129+ wordMovement: classification,130+ state: result.state,131+ } : {}),132+ };133+}134+135+export { ACTIONS, anyWordMoved, playerPushedWord };136+export default solveLevel;
A tests/solver-level.test.js +83 -0
@@ -0,0 +1,83 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { solveLevel } from '../src/solver/solve-level.js';4+import { createGame } from '../src/engine.js';5+6+const walkLevel = {7+ id: 'solve-walk', width: 6, height: 2,8+ objects: [9+ { id: 'miau', kind: 'object', noun: 'MIAU', x: 0, y: 1 },10+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 2, y: 1 },11+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },12+ { id: 'is-you', kind: 'word', text: 'IS', x: 1, y: 0 },13+ { id: 'you', kind: 'word', text: 'YOU', x: 2, y: 0 },14+ { id: 'flag-word', kind: 'word', text: 'FLAG', x: 3, y: 0 },15+ { id: 'flag-is', kind: 'word', text: 'IS', x: 4, y: 0 },16+ { id: 'win', kind: 'word', text: 'WIN', x: 5, y: 0 },17+ ],18+};19+20+const wonAtStart = {21+ id: 'solve-start', width: 3, height: 1,22+ objects: [23+ { kind: 'object', noun: 'MIAU', x: 0, y: 0 },24+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },25+ { kind: 'word', text: 'IS', x: 1, y: 0 },26+ { kind: 'word', text: 'YOU', x: 2, y: 0 },27+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },28+ { kind: 'word', text: 'IS', x: 1, y: 0 },29+ { kind: 'word', text: 'WIN', x: 2, y: 0 },30+ ],31+};32+33+test('solves through the public engine with stable U D L R action order', () => {34+ const result = solveLevel(walkLevel);35+ assert.equal(result.status, 'SOLVED');36+ assert.equal(result.path, 'RR');37+ assert.deepEqual(result.actions, ['R', 'R']);38+ assert.equal(result.bypass, true);39+ assert.equal(result.handsOff, true);40+ assert.ok(result.explored > 0);41+});42+43+test('reports an initially won level without entering the codec frontier', () => {44+ const result = solveLevel(wonAtStart);45+ assert.equal(result.status, 'SOLVED');46+ assert.equal(result.path, '');47+ assert.equal(result.explored, 0);48+ assert.deepEqual(result.actions, []);49+});50+51+test('rejects an initially forbidden rule and marks transient forbid unsupported', () => {52+ const initial = solveLevel(walkLevel, { forbid: 'FLAG IS WIN' });53+ assert.equal(initial.status, 'FORBIDDEN');54+ assert.deepEqual(initial.forbidden, ['FLAG IS WIN']);55+56+ const unsupported = solveLevel(walkLevel, { forbid: 'MIAU IS WIN' });57+ assert.equal(unsupported.status, 'UNSUPPORTED');58+ assert.match(unsupported.reason, /transient/);59+});60+61+test('can restrict player word motion while retaining movement provenance', () => {62+ const result = solveLevel(walkLevel, { wordMotion: 'player' });63+ assert.equal(result.status, 'SOLVED');64+ assert.equal(result.bypass, true);65+ assert.equal(result.wordMovement.playerWordMoved, false);66+ assert.equal(result.wordMotionMode, 'player');67+});68+69+test('returns LIMIT at the transition cap', () => {70+ const result = solveLevel(walkLevel, { cap: 1 });71+ assert.equal(result.status, 'LIMIT');72+ assert.equal(result.explored, 1);73+});74+75+test('representative core route matches a direct replay', () => {76+ const result = solveLevel(walkLevel);77+ const game = createGame(walkLevel);78+ for (const action of result.actions) {79+ const event = action === 'W' ? game.wait() : game.step({ U: 0, D: 0, L: -1, R: 1 }[action], { U: -1, D: 1, L: 0, R: 0 }[action]);80+ assert.equal(event.accepted, true);81+ }82+ assert.equal(game.getState().won, true);83+});