- commit
- 7c7007138ae74ff3a055ef396525d0da6b669f01
- parent
- ec6995e4ddab9970f21f86534304cb62c5ef0798
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 78 | +++++++++++++++----- |
| M | tests/ | 117 | +++++++++++++++++++++++++++++- |
@@ -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 } : {}),@@ -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);