- commit
- a89fe70d39423b2641ba5980209133cc810973b4
- parent
- cce73d8e9061566ca82dc7cc23d4ed7cc3d5acfe
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 136 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 83 | ++++++++++++++++++ |
@@ -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;@@ -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+});