1import test from 'node:test';2import assert from 'node:assert/strict';3import { solveLevel } from '../src/solver/solve-level.js';4import { createGame } from '../src/engine.js';56const 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};1920const 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};3233const 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};5758const 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};7879const 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};9697test('solves through the public engine with stable U D L R action order', () => {98 const result = solveLevel(walkLevel);99 assert.equal(result.status, 'SOLVED');100 assert.equal(result.path, 'RR');101 assert.deepEqual(result.actions, ['R', 'R']);102 assert.equal(result.routeBypass, true);103 assert.equal(result.routeHandsOff, true);104 assert.ok(result.explored > 0);105});106107test('reports an initially won level without entering the codec frontier', () => {108 const result = solveLevel(wonAtStart);109 assert.equal(result.status, 'SOLVED');110 assert.equal(result.path, '');111 assert.equal(result.explored, 0);112 assert.deepEqual(result.actions, []);113});114115test('rejects an initially forbidden rule and marks transient forbid unsupported', () => {116 const initial = solveLevel(walkLevel, { forbid: 'FLAG IS WIN' });117 assert.equal(initial.status, 'FORBIDDEN');118 assert.deepEqual(initial.forbidden, ['FLAG IS WIN']);119120 const unsupported = solveLevel(walkLevel, { forbid: 'MIAU IS WIN' });121 assert.equal(unsupported.status, 'UNSUPPORTED');122 assert.match(unsupported.reason, /transient/);123});124125test('can restrict player word motion while retaining movement provenance', () => {126 const result = solveLevel(walkLevel, { wordMotion: 'player' });127 assert.equal(result.status, 'SOLVED');128 assert.equal(result.routeBypass, true);129 assert.equal(result.wordMovement.playerWordMoved, false);130 assert.equal(result.wordMotionMode, 'player');131});132133test('returns LIMIT at the transition cap', () => {134 const result = solveLevel(walkLevel, { cap: 1 });135 assert.equal(result.status, 'LIMIT');136 assert.equal(result.explored, 1);137 assert.equal(result.limitReason, 'attempts');138 const memoryBound = solveLevel(walkLevel, { maxVisited: 1 });139 assert.equal(memoryBound.status, 'LIMIT');140 assert.equal(memoryBound.limitReason, 'visited');141 assert.equal(memoryBound.visited, 1);142});143144test('fast mode keeps the public route and status parity', () => {145 const publicResult = solveLevel(walkLevel);146 const fastResult = solveLevel(walkLevel, { transitionMode: 'fast' });147 assert.equal(fastResult.status, publicResult.status);148 assert.equal(fastResult.path, publicResult.path);149 assert.equal(fastResult.routeBypass, publicResult.routeBypass);150 assert.equal(fastResult.routeHandsOff, publicResult.routeHandsOff);151});152153test('fast mode rejects a genuinely formed transient forbidden rule', () => {154 const game = createGame(transientForbidLevel);155 assert.equal(game.getState().rules.some(rule => rule.text === 'ROCK IS STOP'), false);156 const event = game.stepFast(-1, 0);157 assert.equal(event.ruleTrace.some(trace => trace.rules.includes('ROCK IS STOP')), true);158 assert.equal(game.getState().rules.some(rule => rule.text === 'ROCK IS STOP'), false);159 assert.equal(event.state.won, true);160161 const result = solveLevel(transientForbidLevel, {162 transitionMode: 'fast',163 forbid: 'ROCK IS WIN',164 });165 assert.equal(result.status, 'SOLVED');166 assert.equal(result.path, 'L');167 assert.equal(solveLevel(transientForbidLevel, {168 transitionMode: 'fast',169 forbid: 'ROCK IS STOP',170 }).status, 'UNSOLVED');171});172173test('public replay classifies a player-pushed word from pre-step state', () => {174 const result = solveLevel(publicWordPushLevel);175 assert.equal(result.status, 'SOLVED');176 assert.equal(result.path, 'RRRRRRR');177 assert.equal(result.routeBypass, false);178 assert.equal(result.wordMovement.playerWordMoved, true);179});180181test('fast mode distinguishes automaton-only word motion from hands-off', () => {182 const result = solveLevel(automatonWordLevel, { transitionMode: 'fast' });183 assert.equal(result.status, 'SOLVED');184 assert.equal(result.path, 'RRRRR');185 assert.equal(result.routeBypass, true);186 assert.equal(result.routeHandsOff, false);187 assert.equal(result.wordMovement.playerWordMoved, false);188 assert.equal(result.wordMovement.anyWordMovement, true);189});190191test('representative core route matches a direct replay', () => {192 const result = solveLevel(walkLevel);193 const game = createGame(walkLevel);194 for (const action of result.actions) {195 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]);196 assert.equal(event.accepted, true);197 }198 assert.equal(game.getState().won, true);199});