- commit
- 2ba316c9424d46fd0a6789dd44ba4b24b3faea2d
- parent
- 83f1452e3a234e993ffc105b1de373507084b689
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 122 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 85 | +++++++++++++++++++++ |
@@ -0,0 +1,122 @@
1+/**2+ * Memory-bounded breadth-first search over compact, settled solver states.3+ *4+ * `codec` owns the level-specific state representation. The search queue never5+ * retains decoded snapshots or partial paths; it keeps one compact blob and a6+ * parent link per visited state. The transition callback remains responsible7+ * for authoritative engine semantics.8+ */9+10+function asActions(actions) {11+ if (!Array.isArray(actions) || actions.length === 0)12+ throw new TypeError('search actions must be a non-empty array');13+ return actions.slice();14+}15+16+function isRecord(value) {17+ return value !== null && typeof value === 'object';18+}19+20+function reconstructPath(nodes, index) {21+ const path = [];22+ let cursor = index;23+ while (cursor >= 0) {24+ const node = nodes[cursor];25+ if (node.parent >= 0) path.push(node.action);26+ cursor = node.parent;27+ }28+ path.reverse();29+ return path;30+}31+32+function resultFor(status, fields = {}) {33+ return Object.freeze({ status, path: [], attempts: 0, expanded: 0, visited: 0, ...fields });34+}35+36+/**37+ * Search a level's settled state graph in breadth-first order.38+ *39+ * @param {object} options40+ * @param {object} options.initial Initial settled snapshot. A winning initial41+ * state is returned immediately without passing through the frontier codec.42+ * @param {{ encode: Function, decode: Function, key: Function }} options.codec43+ * @param {Array<unknown>} options.actions Actions attempted in this order for44+ * every frontier state.45+ * @param {(snapshot: object, action: unknown) => {accepted?: boolean, state?: object, meta?: object}} options.transition46+ * @param {number} [options.cap=100000] Maximum number of attempted actions.47+ * @param {(context: {snapshot: object, action: unknown, result: object}) => boolean} [options.filter]48+ * Optional post-transition filter. Rejected transitions and filtered49+ * transitions both count toward `attempts`.50+ * @param {(snapshot: object, result?: object) => boolean} [options.isWin]51+ * Terminal predicate. Defaults to `snapshot.won === true`.52+ * @returns {{status: 'solved'|'exhausted'|'limit', path: unknown[], attempts: number,53+ * expanded: number, visited: number, state?: object, meta?: object}}54+ */55+export function searchStates({56+ initial,57+ codec,58+ actions,59+ transition,60+ cap = 100_000,61+ filter,62+ isWin = snapshot => snapshot?.won === true,63+} = {}) {64+ if (!isRecord(initial)) throw new TypeError('search initial snapshot is required');65+ if (!codec || typeof codec.encode !== 'function' || typeof codec.decode !== 'function' || typeof codec.key !== 'function') {66+ throw new TypeError('search codec must provide encode, decode, and key');67+ }68+ const moveSet = asActions(actions);69+ if (typeof transition !== 'function') throw new TypeError('search transition must be a function');70+ if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError('search cap must be a non-negative safe integer');71+ const allowTransition = filter || (() => true);72+ if (typeof allowTransition !== 'function' || typeof isWin !== 'function') throw new TypeError('search filter and isWin must be functions');73+74+ if (isWin(initial)) return resultFor('solved', { path: [], attempts: 0, expanded: 0, visited: 0, state: initial });75+76+ const initialBlob = codec.encode(initial);77+ const initialKey = codec.key(initialBlob);78+ const nodes = [{ blob: initialBlob, parent: -1, action: undefined }];79+ const seen = new Set([initialKey]);80+ let head = 0;81+ let attempts = 0;82+ let expanded = 0;83+84+ while (head < nodes.length) {85+ const nodeIndex = head++;86+ const node = nodes[nodeIndex];87+ const snapshot = codec.decode(node.blob);88+ expanded += 1;89+90+ for (const action of moveSet) {91+ if (attempts >= cap) return resultFor('limit', { attempts, expanded, visited: nodes.length });92+ attempts += 1;93+ const result = transition(snapshot, action);94+ if (!isRecord(result)) throw new TypeError('search transition must return an object');95+ if (!result.accepted) continue;96+ if (!isRecord(result.state)) throw new TypeError('accepted search transition must return a state');97+ if (!allowTransition({ snapshot, action, result })) continue;98+99+ if (isWin(result.state, result)) {100+ return resultFor('solved', {101+ path: [...reconstructPath(nodes, nodeIndex), action],102+ attempts,103+ expanded,104+ visited: nodes.length,105+ state: result.state,106+ ...(result.meta === undefined ? {} : { meta: result.meta }),107+ });108+ }109+110+ const blob = codec.encode(result.state);111+ const key = codec.key(blob);112+ if (seen.has(key)) continue;113+ seen.add(key);114+ nodes.push({ blob, parent: nodeIndex, action });115+ }116+ }117+118+ return resultFor('exhausted', { attempts, expanded, visited: nodes.length });119+}120+121+export const search = searchStates;122+export default searchStates;@@ -0,0 +1,85 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createGame } from '../src/engine.js';4+import { createStateCodec } from '../src/solver/state-codec.js';5+import { searchStates } from '../src/solver/search.js';6+7+const level = {8+ id: 'search-fixture', width: 4, height: 1,9+ objects: [10+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 0 },11+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 3, y: 0 },12+ ],13+};14+15+function initialState() {16+ return createGame(level).getState();17+}18+19+function transition(snapshot, action) {20+ if (action !== 'L' && action !== 'R') return { accepted: false };21+ const state = structuredClone(snapshot);22+ const player = state.objects.find(object => object.id === 'player');23+ const nextX = player.x + (action === 'R' ? 1 : -1);24+ if (nextX < 0 || nextX >= level.width) return { accepted: false };25+ player.x = nextX;26+ state.won = player.x === 3;27+ return { accepted: true, state, meta: { action } };28+}29+30+function options(overrides = {}) {31+ return {32+ initial: initialState(),33+ codec: createStateCodec(level),34+ actions: ['L', 'R'],35+ transition,36+ ...overrides,37+ };38+}39+40+test('returns the shortest path and reconstructs it from parent links', () => {41+ const result = searchStates(options());42+ assert.equal(result.status, 'solved');43+ assert.deepEqual(result.path, ['R', 'R', 'R']);44+ assert.equal(result.state.won, true);45+ assert.ok(result.attempts > result.path.length);46+});47+48+test('counts attempted transitions and stops at the configured cap', () => {49+ const result = searchStates(options({ cap: 2 }));50+ assert.equal(result.status, 'limit');51+ assert.equal(result.attempts, 2);52+});53+54+test('prunes duplicate compact identities', () => {55+ const result = searchStates(options({56+ initial: { ...initialState(), objects: initialState().objects.map(object => object.id === 'flag' ? { ...object, x: 0 } : object) },57+ isWin: () => false,58+ transition: (snapshot, action) => {59+ const result = transition(snapshot, action);60+ if (result.state) result.state.won = false;61+ return result;62+ },63+ }));64+ assert.equal(result.status, 'exhausted');65+ assert.equal(result.visited, 4);66+ assert.equal(result.attempts, 8);67+});68+69+test('reports a win at the initial state without encoding a terminal snapshot', () => {70+ const initial = { ...initialState(), won: true };71+ const result = searchStates(options({ initial }));72+ assert.equal(result.status, 'solved');73+ assert.deepEqual(result.path, []);74+ assert.equal(result.attempts, 0);75+});76+77+test('filters accepted transitions before they enter or solve the frontier', () => {78+ const result = searchStates(options({79+ filter: ({ action }) => action !== 'R',80+ cap: 20,81+ }));82+ assert.equal(result.status, 'exhausted');83+ assert.equal(result.visited, 1);84+ assert.equal(result.attempts, 2);85+});