clone

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

Apply transient rule and motion constraints in fast solver mode

Dami committed 7c70071
commit
7c7007138ae74ff3a055ef396525d0da6b669f01
parent
ec6995e4ddab9970f21f86534304cb62c5ef0798
author
Dami <dami.etoile@gmail.com>
2 files changed, +171 -24
Msrc/solver/solve-level.js78+++++++++++++++-----
Mtests/solver-level.test.js117+++++++++++++++++++++++++++++-
M src/solver/solve-level.js +57 -21
@@ -42,17 +42,24 @@ function actionStep(action) {
4242 return entry ? entry.slice(1) : null;4343 }4444 45-function replaySolution(level, actions) {45+function replaySolution(level, actions, transitionMode = 'public') {4646 const game = createGame(level);4747 let playerWordMoved = false;4848 let anyWordMovement = false;4949 for (const action of actions) {50- const before = game.getState();5150 const step = actionStep(action);52- const result = action === 'W' ? game.wait() : game.step(step[0], step[1]);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]);5355 if (!result.accepted) continue;54- playerWordMoved ||= playerPushedWord(before, result);55- anyWordMovement ||= anyWordMoved(before, result);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+ }5663 }5764 return { playerWordMoved, anyWordMovement };5865 }
@@ -65,14 +72,31 @@ function publicTransition(game, snapshot, action) {
6572 return { accepted: event.accepted, state: game.getState(), meta: { before, event } };6673 }6774 75+function 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+}85+6886 /**6987 * Solve one authored level with the public engine transition boundary.7088 *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.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.76100 */77101 export function solveLevel(level, {78102 cap = 300_000,
@@ -83,37 +107,49 @@ export function solveLevel(level, {
83107 if (!Number.isSafeInteger(cap) || cap < 1) throw new RangeError('solveLevel cap must be a positive safe integer');84108 if (!['allow', 'player', 'all', 'forbid'].includes(wordMotion)) throw new TypeError("solveLevel wordMotion must be 'allow', 'player', or 'all'");85109 const wordMotionMode = wordMotion === 'forbid' ? 'player' : wordMotion;86- if (transitionMode !== 'public') return { status: 'UNSUPPORTED', reason: 'fast transition mode is not available', transitionMode };110+ if (!['public', 'fast'].includes(transitionMode)) return { status: 'UNSUPPORTED', reason: 'unknown transition mode', transitionMode };87111 88112 const game = createGame(level);113+ if (transitionMode === 'fast' && (typeof game.stepFast !== 'function' || typeof game.waitFast !== 'function')) {114+ return { status: 'UNSUPPORTED', reason: 'fast transition API is not available', transitionMode };115+ }89116 const initial = game.getState();90117 const forbidden = normalizeForbid(forbid);91118 const initialForbidden = forbidden.filter(rule => initial.rules.some(candidate => candidate.text === rule));92119 if (initialForbidden.length) {93120 return { status: 'FORBIDDEN', path: '', actions: [], explored: 0, visited: 0, forbidden: initialForbidden };94121 }95- if (forbidden.length) {122+ if (forbidden.length && transitionMode === 'public') {96123 return { status: 'UNSUPPORTED', reason: 'transient forbidden-rule traces require the fast transition boundary', path: '', actions: [], explored: 0, visited: 0, forbidden };97124 }98125 99126 const codec = createStateCodec(level);100127 const actions = level.automata?.length ? [...ACTIONS.map(([name]) => name), 'W'] : ACTIONS.map(([name]) => name);128+ const transition = transitionMode === 'fast'129+ ? (snapshot, action) => fastTransition(game, snapshot, action)130+ : (snapshot, action) => publicTransition(game, snapshot, action);101131 const result = searchStates({102132 initial,103133 codec,104134 actions,105135 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,136+ transition,137+ filter: ({ result: candidate }) => {138+ const event = candidate.meta.event;139+ if (forbidden.length && transitionMode === 'fast' && event.ruleTrace.some(trace => trace.rules.some(rule => forbidden.includes(rule)))) return false;140+ if (wordMotionMode === 'allow') return true;141+ if (transitionMode === 'fast') {142+ if (wordMotionMode === 'player') return event.wordMotion.player.length === 0;143+ return event.wordMotion.player.length === 0 && event.wordMotion.automaton.length === 0;144+ }145+ if (wordMotionMode === 'player') return !playerPushedWord(candidate.meta.before, event);146+ return !anyWordMoved(candidate.meta.before, event);147+ },112148 });113149 114150 const solved = result.status === 'solved';115151 const actionsFound = solved ? result.path : [];116- const classification = solved ? replaySolution(level, actionsFound) : { playerWordMoved: false, anyWordMovement: false };152+ const classification = solved ? replaySolution(level, actionsFound, transitionMode) : { playerWordMoved: false, anyWordMovement: false };117153 const status = result.status === 'solved' ? 'SOLVED' : result.status === 'limit' ? 'LIMIT' : 'UNSOLVED';118154 return {119155 status,
@@ -124,8 +160,8 @@ export function solveLevel(level, {
124160 visited: result.visited,125161 wordMotionMode,126162 ...(solved ? {127- bypass: !classification.playerWordMoved,128- handsOff: !classification.anyWordMovement,163+ routeBypass: !classification.playerWordMoved,164+ routeHandsOff: !classification.anyWordMovement,129165 wordMovement: classification,130166 state: result.state,131167 } : {}),
M tests/solver-level.test.js +114 -3
@@ -30,13 +30,77 @@ const wonAtStart = {
3030 ],3131 };3232 33+const automatonWordLevel = {34+ id: 'solve-automaton-word', width: 6, height: 5,35+ objects: [36+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },37+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },38+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },39+ { id: 'clock-word', kind: 'word', text: 'CLOCK', x: 0, y: 1 },40+ { id: 'clock-is', kind: 'word', text: 'IS', x: 1, y: 1 },41+ { id: 'clock-tick', kind: 'word', text: 'TICK', x: 2, y: 1 },42+ { id: 'clock-and', kind: 'word', text: 'AND', x: 3, y: 1 },43+ { id: 'clock-push', kind: 'word', text: 'PUSH', x: 4, y: 1 },44+ { id: 'flag-word', kind: 'word', text: 'FLAG', x: 0, y: 2 },45+ { id: 'flag-is', kind: 'word', text: 'IS', x: 1, y: 2 },46+ { id: 'flag-win', kind: 'word', text: 'WIN', x: 2, y: 2 },47+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 4 },48+ { id: 'clock', kind: 'object', noun: 'CLOCK', x: 1, y: 3 },49+ { id: 'loose-word', kind: 'word', text: 'ROCK', x: 2, y: 3 },50+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 5, y: 4 },51+ ],52+ automata: [{53+ id: 'clockwork', subject: 'CLOCK', property: 'TICK',54+ pattern: [{ action: 'move', dx: 1, dy: 0 }],55+ }],56+};57+58+const transientForbidLevel = {59+ id: 'solve-transient-forbid', width: 7, height: 3,60+ objects: [61+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },62+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },63+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },64+ { id: 'water-word', kind: 'word', text: 'WATER', x: 0, y: 1 },65+ { id: 'water-is', kind: 'word', text: 'IS', x: 1, y: 1 },66+ { id: 'water-sink', kind: 'word', text: 'SINK', x: 2, y: 1 },67+ { id: 'flag-word', kind: 'word', text: 'FLAG', x: 4, y: 0 },68+ { id: 'flag-is', kind: 'word', text: 'IS', x: 5, y: 0 },69+ { id: 'flag-win', kind: 'word', text: 'WIN', x: 6, y: 0 },70+ { id: 'rock-word', kind: 'word', text: 'ROCK', x: 2, y: 2 },71+ { id: 'rock-is', kind: 'word', text: 'IS', x: 3, y: 2 },72+ { id: 'water', kind: 'object', noun: 'WATER', x: 4, y: 2 },73+ { id: 'stop', kind: 'word', text: 'STOP', x: 5, y: 2 },74+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 5, y: 2 },75+ { id: 'player', kind: 'object', noun: 'MIAU', x: 6, y: 2 },76+ ],77+};78+79+const publicWordPushLevel = {80+ id: 'solve-public-word-push', width: 9, height: 2,81+ objects: [82+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },83+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },84+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },85+ { id: 'flag-word', kind: 'word', text: 'FLAG', x: 3, y: 0 },86+ { id: 'flag-is', kind: 'word', text: 'IS', x: 4, y: 0 },87+ { id: 'flag-win', kind: 'word', text: 'WIN', x: 5, y: 0 },88+ { id: 'text-word', kind: 'word', text: 'TEXT', x: 6, y: 0 },89+ { id: 'text-is', kind: 'word', text: 'IS', x: 7, y: 0 },90+ { id: 'text-push', kind: 'word', text: 'PUSH', x: 8, y: 0 },91+ { id: 'rock', kind: 'word', text: 'ROCK', x: 1, y: 1 },92+ { id: 'flag', kind: 'object', noun: 'FLAG', x: 7, y: 1 },93+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 1 },94+ ],95+};96+3397 test('solves through the public engine with stable U D L R action order', () => {3498 const result = solveLevel(walkLevel);3599 assert.equal(result.status, 'SOLVED');36100 assert.equal(result.path, 'RR');37101 assert.deepEqual(result.actions, ['R', 'R']);38- assert.equal(result.bypass, true);39- assert.equal(result.handsOff, true);102+ assert.equal(result.routeBypass, true);103+ assert.equal(result.routeHandsOff, true);40104 assert.ok(result.explored > 0);41105 });42106
@@ -61,7 +125,7 @@ test('rejects an initially forbidden rule and marks transient forbid unsupported
61125 test('can restrict player word motion while retaining movement provenance', () => {62126 const result = solveLevel(walkLevel, { wordMotion: 'player' });63127 assert.equal(result.status, 'SOLVED');64- assert.equal(result.bypass, true);128+ assert.equal(result.routeBypass, true);65129 assert.equal(result.wordMovement.playerWordMoved, false);66130 assert.equal(result.wordMotionMode, 'player');67131 });
@@ -72,6 +136,53 @@ test('returns LIMIT at the transition cap', () => {
72136 assert.equal(result.explored, 1);73137 });74138 139+test('fast mode keeps the public route and status parity', () => {140+ const publicResult = solveLevel(walkLevel);141+ const fastResult = solveLevel(walkLevel, { transitionMode: 'fast' });142+ assert.equal(fastResult.status, publicResult.status);143+ assert.equal(fastResult.path, publicResult.path);144+ assert.equal(fastResult.routeBypass, publicResult.routeBypass);145+ assert.equal(fastResult.routeHandsOff, publicResult.routeHandsOff);146+});147+148+test('fast mode rejects a genuinely formed transient forbidden rule', () => {149+ const game = createGame(transientForbidLevel);150+ assert.equal(game.getState().rules.some(rule => rule.text === 'ROCK IS STOP'), false);151+ const event = game.stepFast(-1, 0);152+ assert.equal(event.ruleTrace.some(trace => trace.rules.includes('ROCK IS STOP')), true);153+ assert.equal(game.getState().rules.some(rule => rule.text === 'ROCK IS STOP'), false);154+ assert.equal(event.state.won, true);155+156+ const result = solveLevel(transientForbidLevel, {157+ transitionMode: 'fast',158+ forbid: 'ROCK IS WIN',159+ });160+ assert.equal(result.status, 'SOLVED');161+ assert.equal(result.path, 'L');162+ assert.equal(solveLevel(transientForbidLevel, {163+ transitionMode: 'fast',164+ forbid: 'ROCK IS STOP',165+ }).status, 'UNSOLVED');166+});167+168+test('public replay classifies a player-pushed word from pre-step state', () => {169+ const result = solveLevel(publicWordPushLevel);170+ assert.equal(result.status, 'SOLVED');171+ assert.equal(result.path, 'RRRRRRR');172+ assert.equal(result.routeBypass, false);173+ assert.equal(result.wordMovement.playerWordMoved, true);174+});175+176+test('fast mode distinguishes automaton-only word motion from hands-off', () => {177+ const result = solveLevel(automatonWordLevel, { transitionMode: 'fast' });178+ assert.equal(result.status, 'SOLVED');179+ assert.equal(result.path, 'RRRRR');180+ assert.equal(result.routeBypass, true);181+ assert.equal(result.routeHandsOff, false);182+ assert.equal(result.wordMovement.playerWordMoved, false);183+ assert.equal(result.wordMovement.anyWordMovement, true);184+});185+75186 test('representative core route matches a direct replay', () => {76187 const result = solveLevel(walkLevel);77188 const game = createGame(walkLevel);