1import { createGame } from '../engine.js';2import { createStateCodec } from './state-codec.js';3import { searchStates } from './search.js';45const ACTIONS = Object.freeze([6 ['U', 0, -1],7 ['D', 0, 1],8 ['L', -1, 0],9 ['R', 1, 0],10]);1112function 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}1718function 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}3334function 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}3839function actionStep(action) {40 if (action === 'W') return null;41 const entry = ACTIONS.find(([name]) => name === action);42 return entry ? entry.slice(1) : null;43}4445function replaySolution(level, actions, transitionMode = 'public') {46 const game = createGame(level);47 let playerWordMoved = false;48 let anyWordMovement = false;49 for (const action of actions) {50 const step = actionStep(action);51 const before = transitionMode === 'public' ? game.getState() : null;52 const result = transitionMode === 'fast'53 ? action === 'W' ? game.waitFast() : game.stepFast(step[0], step[1])54 : action === 'W' ? game.wait() : game.step(step[0], step[1]);55 if (!result.accepted) continue;56 if (transitionMode === 'fast') {57 playerWordMoved ||= result.wordMotion.player.length > 0;58 anyWordMovement ||= result.wordMotion.player.length > 0 || result.wordMotion.automaton.length > 0;59 } else {60 playerWordMoved ||= playerPushedWord(before, result);61 anyWordMovement ||= anyWordMoved(before, result);62 }63 }64 return { playerWordMoved, anyWordMovement };65}6667function publicTransition(game, snapshot, action) {68 game.restore(snapshot);69 const before = game.getState();70 const step = actionStep(action);71 const event = action === 'W' ? game.wait() : game.step(step[0], step[1]);72 return { accepted: event.accepted, state: game.getState(), meta: { before, event } };73}7475function fastTransition(game, snapshot, action) {76 game.restore(snapshot);77 if (action === 'W') {78 const event = game.waitFast();79 return { accepted: event.accepted, state: event.state, meta: { event } };80 }81 const [dx, dy] = actionStep(action);82 const event = game.stepFast(dx, dy);83 return { accepted: event.accepted, state: event.state, meta: { event } };84}8586/**87 * Solve one authored level with the public engine transition boundary.88 *89 * `transitionMode: 'public'` is the default authoritative fallback. `fast`90 * uses the engine's event-free stepFast/waitFast API, including rule traces91 * and word-motion provenance. `forbid` is complete in fast mode; public mode92 * reports UNSUPPORTED because public events do not expose every transient rule93 * evaluation phase yet.94 *95 * `routeBypass` and `routeHandsOff` describe only the returned shortest route.96 * To ask whether any winning route avoids player word motion, run a second97 * search with `wordMotion: 'player'` (or `all` for HANDS-OFF) and inspect that98 * search's status. They are not existential claims about the unconstrained99 * level's solution graph.100 */101export function solveLevel(level, {102 cap = 300_000,103 maxVisited = Infinity,104 forbid,105 wordMotion = 'allow',106 transitionMode = 'public',107} = {}) {108 if (!Number.isSafeInteger(cap) || cap < 1) throw new RangeError('solveLevel cap must be a positive safe integer');109 if (!['allow', 'player', 'all', 'forbid'].includes(wordMotion)) throw new TypeError("solveLevel wordMotion must be 'allow', 'player', or 'all'");110 const wordMotionMode = wordMotion === 'forbid' ? 'player' : wordMotion;111 if (!['public', 'fast'].includes(transitionMode)) return { status: 'UNSUPPORTED', reason: 'unknown transition mode', transitionMode };112113 const game = createGame(level);114 if (transitionMode === 'fast' && (typeof game.stepFast !== 'function' || typeof game.waitFast !== 'function')) {115 return { status: 'UNSUPPORTED', reason: 'fast transition API is not available', transitionMode };116 }117 const initial = game.getState();118 const forbidden = normalizeForbid(forbid);119 const initialForbidden = forbidden.filter(rule => initial.rules.some(candidate => candidate.text === rule));120 if (initialForbidden.length) {121 return { status: 'FORBIDDEN', path: '', actions: [], explored: 0, visited: 0, forbidden: initialForbidden };122 }123 if (forbidden.length && transitionMode === 'public') {124 return { status: 'UNSUPPORTED', reason: 'transient forbidden-rule traces require the fast transition boundary', path: '', actions: [], explored: 0, visited: 0, forbidden };125 }126127 const codec = createStateCodec(level);128 const actions = level.automata?.length ? [...ACTIONS.map(([name]) => name), 'W'] : ACTIONS.map(([name]) => name);129 const transition = transitionMode === 'fast'130 ? (snapshot, action) => fastTransition(game, snapshot, action)131 : (snapshot, action) => publicTransition(game, snapshot, action);132 const result = searchStates({133 initial,134 codec,135 actions,136 cap,137 maxVisited,138 transition,139 filter: ({ result: candidate }) => {140 const event = candidate.meta.event;141 if (forbidden.length && transitionMode === 'fast' && event.ruleTrace.some(trace => trace.rules.some(rule => forbidden.includes(rule)))) return false;142 if (wordMotionMode === 'allow') return true;143 if (transitionMode === 'fast') {144 if (wordMotionMode === 'player') return event.wordMotion.player.length === 0;145 return event.wordMotion.player.length === 0 && event.wordMotion.automaton.length === 0;146 }147 if (wordMotionMode === 'player') return !playerPushedWord(candidate.meta.before, event);148 return !anyWordMoved(candidate.meta.before, event);149 },150 });151152 const solved = result.status === 'solved';153 const actionsFound = solved ? result.path : [];154 const classification = solved ? replaySolution(level, actionsFound, transitionMode) : { playerWordMoved: false, anyWordMovement: false };155 const status = result.status === 'solved' ? 'SOLVED' : result.status === 'limit' ? 'LIMIT' : 'UNSOLVED';156 return {157 status,158 path: actionsFound.join(''),159 actions: actionsFound,160 explored: result.attempts,161 expanded: result.expanded,162 visited: result.visited,163 ...(result.reason ? { limitReason: result.reason } : {}),164 wordMotionMode,165 ...(solved ? {166 routeBypass: !classification.playerWordMoved,167 routeHandsOff: !classification.anyWordMovement,168 wordMovement: classification,169 state: result.state,170 } : {}),171 };172}173174export { ACTIONS, anyWordMoved, playerPushedWord };175export default solveLevel;