clone

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

main
miau-is-true / tests / engine.test.js
engine.test.js 28.3K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { createGame } from '../src/engine.js';45const word = (text, x, y, id = `${text}-${x}-${y}`) => ({ id, kind: 'word', text, x, y });6const object = (noun, x, y, id = `${noun}-${x}-${y}`) => ({ id, kind: 'object', noun, x, y });7const level = objects => ({ width: 8, height: 5, objects });89test('parses AND subjects and predicates in both axes', () => {10 const game = createGame(level([11 word('BABA', 0, 0), word('AND', 1, 0), word('ROCK', 2, 0), word('IS', 3, 0), word('YOU', 4, 0), word('AND', 5, 0), word('PUSH', 6, 0),12 object('BABA', 0, 1), object('ROCK', 2, 1),13 ]));14 assert.equal(game.has('BABA', 'YOU'), true);15 assert.equal(game.has('ROCK', 'YOU'), true);16 assert.equal(game.has('BABA', 'PUSH'), true);17 for (const rule of game.getState().rules) {18 assert.ok(Array.isArray(rule.sourceIds) && rule.sourceIds.length > 0);19 assert.ok(rule.sourceIds.every(id => typeof id === 'string'));20 assert.ok(rule.axis === 'h' || rule.axis === 'v');21 }22});2324test('sentences expose every physical duplicate and preserve maximal AND spans', () => {25 const game = createGame(level([26 word('ROCK', 0, 0, 'h-rock'), word('IS', 1, 0, 'h-is'), word('STOP', 2, 0, 'h-stop'),27 word('ROCK', 0, 2, 'v-rock'), word('IS', 0, 3, 'v-is'), word('STOP', 0, 4, 'v-stop'),28 word('ROCK', 3, 0, 'and-rock'), word('AND', 4, 0, 'and-op'), word('FLAG', 5, 0, 'and-flag'), word('IS', 6, 0, 'and-is'), word('WIN', 7, 0, 'and-win'),29 ]));30 assert.equal(game.getState().rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);31 assert.deepEqual(game.getState().sentences, [32 { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },33 { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },34 { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },35 ]);36});3738test('stacked words produce separate physical sentence spans', () => {39 const game = createGame(level([40 word('ROCK', 0, 0, 'rock-a'), word('FLAG', 0, 0, 'flag-a'),41 word('IS', 1, 0, 'is-a'), word('WIN', 2, 0, 'win-a'),42 ]));43 assert.equal(game.getState().sentences.length, 2);44 assert.deepEqual(game.getState().sentences.map(sentence => sentence.ids), [45 ['rock-a', 'is-a', 'win-a'], ['flag-a', 'is-a', 'win-a'],46 ]);47});4849test('pushes a whole chain and refuses a STOP wall atomically', () => {50 const game = createGame(level([51 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),52 word('ROCK', 0, 1), word('IS', 1, 1), word('PUSH', 2, 1),53 word('WALL', 0, 2), word('IS', 1, 2), word('STOP', 2, 2),54 object('BABA', 1, 3), object('ROCK', 2, 3), object('ROCK', 3, 3), object('WALL', 4, 3),55 ]));56 const event = game.step(1, 0);57 assert.equal(event.accepted, false);58 assert.equal(event.kind, 'step');59 assert.deepEqual(event.blocked, { id: 'ROCK-3-3', dir: [1, 0], blockerId: 'WALL-4-3', reason: 'stop' });60 assert.deepEqual(event.moved, []);61 assert.deepEqual(game.getState().objects.filter(o => o.kind === 'object').map(o => [o.noun, o.x, o.y]), [['BABA', 1, 3], ['ROCK', 2, 3], ['ROCK', 3, 3], ['WALL', 4, 3]]);62});6364test('accepted turns report movement and rule deltas', () => {65 const game = createGame(level([66 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),67 word('ROCK', 1, 1, 'rock-word'), word('IS', 2, 1, 'rock-is'), word('STOP', 3, 1, 'rock-stop'),68 object('BABA', 3, 2, 'player'),69 ]));70 const event = game.step(0, -1);71 assert.equal(event.accepted, true);72 assert.deepEqual(event.moved.map(movement => movement.id), ['rock-stop', 'player']);73 assert.deepEqual(event.moved.slice().sort((a, b) => a.id.localeCompare(b.id)), [74 { id: 'player', from: { x: 3, y: 2 }, to: { x: 3, y: 1 }, pushedBy: null },75 { id: 'rock-stop', from: { x: 3, y: 1 }, to: { x: 3, y: 0 }, pushedBy: 'player' },76 ].sort((a, b) => a.id.localeCompare(b.id)));77 assert.deepEqual(event.rulesAdded, []);78 assert.deepEqual(event.rulesRemoved.map(rule => rule.text), ['ROCK IS STOP']);79 assert.deepEqual(event.rulesRemoved[0].sourceIds, ['rock-word', 'rock-is', 'rock-stop']);80 assert.equal(event.rulesRemoved[0].axis, 'h');81 assert.equal(event.won, false);82});8384test('reparses rules after SINK removes a rule word, and undo/reset restore it', () => {85 const game = createGame(level([86 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),87 word('WATER', 3, 1), word('IS', 4, 1), word('SINK', 5, 1),88 word('ROCK', 1, 3, 'rock-word'), word('IS', 2, 3, 'rock-is'), word('STOP', 3, 3, 'stop-word'),89 object('MIAU', 0, 3, 'miau'), object('WATER', 4, 3, 'water'), object('ROCK', 1, 4, 'rock'),90 ]));91 assert.equal(game.has('ROCK', 'STOP'), true);9293 const event = game.step(1, 0);94 assert.equal(event.accepted, true);95 assert.equal(event.moved.some(entry => entry.id === 'stop-word' && entry.to.x === 4), true);96 assert.equal(event.destroyed.some(entry => entry.id === 'stop-word' && entry.cause === 'sink'), true);97 assert.deepEqual(event.rulesRemoved.map(rule => rule.text), ['ROCK IS STOP']);98 assert.equal(game.has('ROCK', 'STOP'), false);99100 const next = game.step(0, 1);101 assert.equal(next.moved.some(entry => entry.id === 'miau'), true);102 assert.equal(game.getState().objects.find(object => object.id === 'miau').y, 4);103104 const restored = game.undo();105 assert.equal(restored.restored, true);106 assert.equal(game.has('ROCK', 'STOP'), false);107 const postContact = game.getState();108 game.restore(postContact);109 assert.equal(game.has('ROCK', 'STOP'), false);110 assert.equal(game.step(0, 1).moved.some(entry => entry.id === 'miau'), true);111 game.reset();112 assert.equal(game.has('ROCK', 'STOP'), true);113 assert.equal(game.getState().objects.some(object => object.id === 'stop-word'), true);114});115116test('a WIN word destroyed by SINK cannot grant a phantom same-entity victory', () => {117 const game = createGame(level([118 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),119 word('WATER', 3, 1), word('IS', 4, 1), word('SINK', 5, 1),120 word('BABA', 1, 3), word('IS', 2, 3), word('WIN', 3, 3, 'win-word'),121 object('MIAU', 0, 3, 'pusher'), object('MIAU', 4, 2, 'visitor'),122 object('WATER', 4, 3, 'water'), object('BABA', 5, 2, 'baba'),123 ]));124 assert.equal(game.has('BABA', 'WIN'), true);125 const event = game.step(1, 0);126 assert.equal(event.accepted, true);127 assert.equal(event.moved.some(entry => entry.id === 'visitor' && entry.to.x === 5), true);128 assert.equal(event.destroyed.some(entry => entry.id === 'win-word'), true);129 assert.equal(game.has('BABA', 'WIN'), false);130 assert.equal(event.won, false);131 assert.equal(game.getState().won, false);132});133134test('dynamic rules apply after words move and noun transformation is deterministic', () => {135 const game = createGame(level([136 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),137 word('ROCK', 0, 1), word('IS', 1, 1), word('WALL', 2, 1),138 object('BABA', 0, 3), object('ROCK', 2, 3),139 ]));140 assert.equal(game.getState().objects.find(o => o.id === 'BABA-0-3').noun, 'BABA');141 assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');142 assert.equal(game.has('BABA', 'YOU'), true);143 game.step(0, -1);144 assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');145 assert.equal(game.has('BABA', 'YOU'), true);146 assert.equal(game.canUndo, true);147 game.undo();148 assert.equal(game.getState().moves, 0);149 game.reset();150 assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');151});152153test('PUSH takes precedence over STOP on the same object', () => {154 const game = createGame(level([155 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),156 word('ROCK', 0, 1), word('IS', 1, 1), word('STOP', 2, 1), word('AND', 3, 1), word('PUSH', 4, 1),157 object('MIAU', 0, 3, 'player'), object('ROCK', 1, 3, 'rock'),158 ]));159 const event = game.step(1, 0);160 assert.equal(event.accepted, true);161 assert.deepEqual(event.moved.map(entry => entry.id), ['rock', 'player']);162 assert.deepEqual(game.getState().objects.filter(object => object.kind === 'object').map(object => [object.id, object.x]), [['player', 1], ['rock', 2]]);163164 const blockedGame = createGame(level([165 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),166 word('ROCK', 0, 1), word('IS', 1, 1), word('STOP', 2, 1),167 object('MIAU', 0, 3, 'blocked-player'), object('ROCK', 1, 3, 'blocked-rock'),168 ]));169 const blocked = blockedGame.step(1, 0);170 assert.equal(blocked.accepted, false);171 assert.equal(blocked.blocked.reason, 'stop');172 assert.equal(blockedGame.getState().objects.find(object => object.id === 'blocked-rock').x, 1);173});174175test('a singleton YOU and DEFEAT entity is destroyed before victory', () => {176 const game = createGame(level([177 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0), word('WIN', 4, 0), word('AND', 5, 0), word('DEFEAT', 6, 0),178 object('MIAU', 3, 2, 'player'),179 ]));180 assert.equal(game.getState().objects.some(object => object.id === 'player'), false);181 assert.equal(game.getState().won, false);182 const blocked = game.step(1, 0);183 assert.equal(blocked.accepted, false);184 assert.equal(blocked.controlLost, true);185});186187test('X IS X suppresses every competing noun transformation', () => {188 const game = createGame(level([189 word('ROCK', 0, 0), word('IS', 1, 0), word('FLAG', 2, 0),190 word('ROCK', 0, 1), word('IS', 1, 1), word('ROCK', 2, 1),191 word('MIAU', 0, 2), word('IS', 1, 2), word('YOU', 2, 2),192 object('ROCK', 4, 3, 'rock'), object('MIAU', 0, 3, 'player'),193 ]));194 assert.equal(game.getState().objects.find(object => object.id === 'rock').noun, 'ROCK');195 const event = game.step(1, 0);196 assert.deepEqual(event.transformed, []);197 assert.equal(game.getState().objects.find(object => object.id === 'rock').noun, 'ROCK');198});199200test('competing noun transformations use spatial rule order and one link per turn', () => {201 const game = createGame(level([202 word('BABA', 0, 0), word('IS', 1, 0), word('ROCK', 2, 0),203 word('ROCK', 0, 1), word('IS', 1, 1), word('FLAG', 2, 1),204 word('ROCK', 0, 2), word('IS', 1, 2), word('WALL', 2, 2),205 word('MIAU', 0, 3), word('IS', 1, 3), word('YOU', 2, 3),206 object('BABA', 4, 4, 'baba'), object('MIAU', 0, 4, 'player'),207 ]));208 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');209 game.step(1, 0);210 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'FLAG');211});212213test('transformation events retain the selected source rule per object', () => {214 const game = createGame(level([215 word('BABA', 0, 0, 'baba-subject'), word('IS', 1, 0, 'baba-is'), word('ROCK', 2, 0, 'baba-rock'),216 word('ROCK', 0, 1, 'rock-subject'), word('IS', 1, 1, 'rock-is'), word('FLAG', 2, 1, 'rock-flag'),217 word('ROCK', 0, 2, 'rock-subject-2'), word('IS', 1, 2, 'rock-is-2'), word('WALL', 2, 2, 'rock-wall'),218 word('FLAG', 0, 3, 'flag-subject'), word('IS', 1, 3, 'flag-is'), word('GEM', 2, 3, 'flag-gem'),219 word('MIAU', 0, 4), word('IS', 1, 4), word('YOU', 2, 4),220 object('BABA', 4, 4, 'baba'), object('MIAU', 0, 4, 'player'),221 ]));222 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');223 const event = game.step(1, 0);224 assert.deepEqual(event.transformed, [{225 id: 'baba', from: 'ROCK', to: 'FLAG', sourceIds: ['rock-subject', 'rock-is', 'rock-flag'], axis: 'h',226 }]);227});228229test('rule parse complexity rejects a word move atomically', () => {230 const objects = [231 word('BABA', 0, 0, 'subject'),232 word('IS', 1, 1, 'loose-is'),233 word('MIAU', 0, 4, 'miau-subject'), word('IS', 1, 4, 'miau-is'), word('YOU', 2, 4, 'miau-you'),234 word('CLOCK', 4, 5, 'clock-subject'), word('IS', 5, 5, 'clock-is'), word('TICK', 6, 5, 'clock-tick'),235 object('MIAU', 1, 2, 'player'),236 object('CLOCK', 8, 5, 'clock'),237 ];238 for (let index = 0; index < 15; index += 1) {239 const x = 2 + index * 2;240 objects.push(word('WIN', x, 0, `predicate-win-${index}`));241 objects.push(word('PUSH', x, 0, `predicate-push-${index}`));242 if (index < 14) objects.push(word('AND', x + 1, 0, `predicate-and-${index}`));243 }244 const authored = {245 width: 31,246 height: 6,247 objects,248 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', phase: 1, pattern: [{ action: 'wait' }, { action: 'wait' }] }],249 };250 const overloaded = structuredClone(authored);251 overloaded.objects.find(object => object.id === 'loose-is').y = 0;252 assert.throws(() => createGame(overloaded), error => error.name === 'RuleParseLimitError');253 const game = createGame(authored);254 const before = game.getState();255 const rejected = game.step(0, -1);256 assert.equal(rejected.accepted, false);257 assert.deepEqual(rejected.blocked, {258 id: null,259 dir: [0, -1],260 blockerId: null,261 reason: 'rule-complexity',262 details: 'rule parse paths exceeded the limit of 16384',263 });264 assert.equal(game.canUndo, false);265 assert.deepEqual(game.getState(), before);266 assert.equal(game.getState().automata[0].phase, before.automata[0].phase);267 const restoreAttempt = structuredClone(before);268 restoreAttempt.objects.find(object => object.id === 'loose-is').y = 0;269 assert.throws(() => game.restore(restoreAttempt), error => error.name === 'RuleParseLimitError');270 assert.deepEqual(game.getState(), before);271 const playable = game.step(1, 0);272 assert.equal(playable.accepted, true);273 assert.equal(game.getState().objects.find(object => object.id === 'player').x, 2);274});275276test('a YOU object wins when a formed rule also gives it WIN', () => {277 const game = createGame(level([278 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0),279 word('WIN', 4, 1, 'win-word'), object('MIAU', 4, 2, 'player'),280 ]));281 assert.equal(game.getState().won, false);282283 const event = game.step(0, -1);284 assert.equal(event.accepted, true);285 assert.equal(game.has('MIAU', 'WIN'), true);286 assert.equal(game.getState().won, true);287});288289test('a singleton object with YOU and WIN is won on load and reset', () => {290 const game = createGame(level([291 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0), word('WIN', 4, 0),292 object('MIAU', 6, 2, 'player'),293 ]));294 assert.equal(game.getState().won, true);295 game.step(1, 0);296 const reset = game.reset();297 assert.equal(reset.kind, 'reset');298 assert.equal(game.getState().won, true);299});300301test('turn-clock automata expose active phases and repeat through wait turns', () => {302 const game = createGame({303 ...level([304 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),305 word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),306 object('MIAU', 0, 4, 'player'), object('CLOCK', 2, 2, 'clock'),307 ]),308 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [309 { action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' },310 { action: 'wait' },311 { action: 'move', dx: -1, dy: 0 },312 ] }],313 });314 assert.deepEqual(game.getState().automata, [{315 id: 'clockwork', subject: 'CLOCK', property: 'TICK', phase: 0, active: true,316 pattern: [{ action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' }, { action: 'wait' }, { action: 'move', dx: -1, dy: 0 }],317 carriers: [{ noun: 'CLOCK', instanceIds: ['clock'] }], instanceIds: ['clock'], next: { action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' },318 }]);319 const first = game.wait();320 assert.equal(first.accepted, true);321 assert.equal(first.kind, 'wait');322 assert.deepEqual(first.automated[0], { systemId: 'clockwork', phase: 0, action: 'move', active: true, id: 'clock', from: { x: 2, y: 2 }, to: { x: 3, y: 2 }, pushedBy: null, cue: '→', sound: 'tick' });323 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);324 assert.equal(game.getState().automata[0].phase, 1);325 game.wait();326 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);327 game.wait();328 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 2);329 assert.equal(game.getState().automata[0].phase, 0);330});331332test('blocked player input does not advance automata, while undo and reset restore phases', () => {333 const game = createGame({334 ...level([335 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),336 word('WALL', 0, 1), word('IS', 1, 1), word('STOP', 2, 1),337 word('CLOCK', 0, 2), word('IS', 1, 2), word('TICK', 2, 2),338 object('MIAU', 0, 3, 'player'), object('WALL', 1, 3, 'wall'), object('CLOCK', 4, 3, 'clock'),339 ]),340 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }] }],341 });342 const blocked = game.step(1, 0);343 assert.equal(blocked.accepted, false);344 assert.equal(game.getState().automata[0].phase, 0);345 const moved = game.wait();346 assert.equal(moved.accepted, true);347 assert.equal(game.getState().automata[0].phase, 0);348 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);349 const undo = game.undo();350 assert.equal(undo.accepted, true);351 assert.equal(game.getState().automata[0].phase, 0);352 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 4);353 game.wait();354 const reset = game.reset();355 assert.equal(reset.kind, 'reset');356 assert.equal(game.getState().automata[0].phase, 0);357 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 4);358});359360test('automata use deterministic STOP and PUSH collision behavior', () => {361 const game = createGame({362 ...level([363 word('MIAU', 0, 2), word('IS', 1, 2), word('YOU', 2, 2),364 word('CLOCK', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),365 word('ROCK', 0, 1), word('IS', 1, 1), word('PUSH', 2, 1),366 object('MIAU', 0, 4, 'player'), object('CLOCK', 1, 3, 'clock'), object('ROCK', 2, 3, 'rock'),367 ]),368 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: 1, dy: 0 }] }],369 });370 const event = game.wait();371 assert.deepEqual(event.moved.map(entry => entry.id), ['rock', 'clock']);372 assert.deepEqual(game.getState().objects.filter(o => o.kind === 'object').map(o => [o.id, o.x, o.y]), [['player', 0, 4], ['clock', 2, 3], ['rock', 3, 3]]);373});374375test('merged player push and automaton self-move expose the actor direction', () => {376 const game = createGame({377 ...level([378 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),379 word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1), word('AND', 3, 1), word('PUSH', 4, 1),380 object('MIAU', 0, 3, 'player'), object('CLOCK', 1, 3, 'clock'), object('ROCK', 2, 3, 'rock'),381 ]),382 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: 1, dy: 0 }] }],383 });384 const event = game.step(1, 0);385 const clockMove = event.moved.find(entry => entry.id === 'clock');386 assert.equal(clockMove.pushedBy, 'player');387 assert.deepEqual(clockMove.selfDirection, { dx: 1, dy: 0 });388});389390test('property-carried automata follow whichever noun currently has the property', () => {391 const game = createGame({392 ...level([393 word('MOUSE', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),394 word('MIAU', 0, 1), word('IS', 1, 1), word('YOU', 2, 1),395 object('MOUSE', 4, 3, 'mouse'), object('MIAU', 0, 4, 'player'),396 ]),397 automata: [{ id: 'shared-clock', property: 'TICK', pattern: ['L', 'W'] }],398 });399 const first = game.wait();400 assert.equal(first.accepted, true);401 assert.equal(game.getState().objects.find(o => o.id === 'mouse').x, 3);402 assert.equal(game.getState().automata[0].phase, 1);403 assert.equal(game.getState().automata[0].subject, undefined);404 game.wait();405 assert.equal(game.getState().objects.find(o => o.id === 'mouse').x, 3);406 assert.equal(game.getState().automata[0].phase, 0);407});408409test('automata state reports carrier nouns and IDs after a property transfer', () => {410 const game = createGame({411 ...level([412 word('MOUSE', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),413 word('BELL', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),414 object('MOUSE', 4, 3, 'carrier'), object('MIAU', 0, 4, 'player'),415 word('MIAU', 4, 0), word('IS', 5, 0), word('YOU', 6, 0),416 ]),417 automata: [{ id: 'shared-clock', property: 'TICK', pattern: [{ action: 'wait' }] }],418 });419 assert.deepEqual(game.getState().automata[0].carriers, [{ noun: 'MOUSE', instanceIds: ['carrier'] }]);420 const snapshot = game.getState();421 snapshot.objects.find(object => object.id === 'carrier').noun = 'BELL';422 game.restore(snapshot);423 assert.deepEqual(game.getState().automata[0].carriers, [{ noun: 'BELL', instanceIds: ['carrier'] }]);424 assert.deepEqual(game.getState().automata[0].instanceIds, ['carrier']);425});426427test('restore replays a same-level snapshot with automata phases and no undo entry', () => {428 const game = createGame({429 ...level([430 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),431 word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),432 object('MIAU', 0, 3, 'player'), object('CLOCK', 3, 3, 'clock'),433 ]),434 id: 'restore-fixture',435 automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }, { action: 'wait' }] }],436 });437 const initial = game.getState();438 game.wait();439 assert.equal(game.getState().automata[0].phase, 1);440 const restored = game.restore(initial);441 assert.deepEqual(restored, initial);442 assert.equal(game.canUndo, false);443 assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);444 assert.throws(() => game.restore({ ...initial, width: 7 }), /board shape/);445 assert.throws(() => game.restore({ ...initial, objects: [{ ...initial.objects[0], id: 'foreign' }] }), /not authored/);446});447448test('restore is idempotent for settled one-link transformation snapshots', () => {449 const game = createGame(level([450 word('BABA', 0, 0), word('IS', 1, 0), word('ROCK', 2, 0),451 word('ROCK', 0, 1), word('IS', 1, 1), word('FLAG', 2, 1),452 word('MIAU', 0, 2), word('IS', 1, 2), word('YOU', 2, 2),453 object('BABA', 4, 3, 'baba'), object('MIAU', 0, 3, 'player'),454 ]));455 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');456 const settled = game.getState();457 game.restore(settled);458 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');459 game.restore(game.getState());460 assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');461 const next = game.step(1, 0);462 assert.equal(next.transformed.find(entry => entry.id === 'baba').to, 'FLAG');463});464465test('WIN, SINK and DEFEAT resolve on contact', () => {466 const game = createGame(level([467 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),468 word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),469 word('WATER', 0, 2), word('IS', 1, 2), word('SINK', 2, 2),470 word('SKULL', 0, 3), word('IS', 1, 3), word('DEFEAT', 2, 3),471 object('BABA', 1, 4), object('FLAG', 2, 4),472 ]));473 game.step(1, 0);474 assert.equal(game.getState().won, true);475});476477test('DEFEAT is resolved before WIN in the same overlap', () => {478 const game = createGame(level([479 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),480 word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),481 word('SKULL', 0, 2), word('IS', 1, 2), word('DEFEAT', 2, 2),482 object('BABA', 1, 3), object('FLAG', 2, 3), object('SKULL', 2, 3),483 ]));484 const event = game.step(1, 0);485 assert.equal(game.getState().won, false);486 assert.equal(game.getState().objects.some(o => o.noun === 'BABA'), false);487 assert.deepEqual(event.destroyed, [{ id: 'BABA-1-3', noun: 'BABA', at: { x: 2, y: 3 }, cause: 'defeat' }]);488 assert.equal(event.controlLost, true);489 assert.deepEqual(event.moved, [{ id: 'BABA-1-3', from: { x: 1, y: 3 }, to: { x: 2, y: 3 }, pushedBy: null }]);490});491492test('SINK removes a YOU overlap before WIN can trigger', () => {493 const game = createGame(level([494 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),495 word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),496 word('WATER', 0, 2), word('IS', 1, 2), word('SINK', 2, 2),497 object('BABA', 3, 3), object('FLAG', 3, 3), object('WATER', 3, 3),498 ]));499 assert.equal(game.getState().won, false);500 assert.equal(game.getState().objects.some(o => o.noun === 'BABA'), false);501});502503test('SINK and DEFEAT destroy a same-entity YOU and WIN before it can win', () => {504 const rules = [505 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0), word('WIN', 4, 0),506 ];507508 const sinkGame = createGame(level([509 ...rules,510 word('WATER', 0, 1), word('IS', 1, 1), word('SINK', 2, 1),511 object('MIAU', 3, 3, 'sink-player'), object('WATER', 3, 3, 'sink-water'),512 ]));513 assert.equal(sinkGame.getState().won, false);514 assert.equal(sinkGame.getState().objects.some(o => o.id === 'sink-player'), false);515516 const defeatGame = createGame(level([517 ...rules,518 word('SKULL', 0, 1), word('IS', 1, 1), word('DEFEAT', 2, 1),519 object('MIAU', 3, 3, 'defeat-player'), object('SKULL', 3, 3, 'defeat-skull'),520 ]));521 assert.equal(defeatGame.getState().won, false);522 assert.equal(defeatGame.getState().objects.some(o => o.id === 'defeat-player'), false);523});524525test('stacked words expose each valid token in stable object order', () => {526 const game = createGame(level([527 word('BABA', 0, 0, 'a'), word('ROCK', 0, 0, 'b'), word('IS', 1, 0, 'is'), word('YOU', 2, 0, 'you'),528 object('BABA', 1, 2), object('ROCK', 2, 2),529 ]));530 assert.equal(game.has('BABA', 'YOU'), true);531 assert.equal(game.has('ROCK', 'YOU'), true);532 assert.deepEqual(game.getState().rules.map(rule => [rule.text, rule.sourceIds, rule.axis]), [533 ['BABA IS YOU', ['a', 'is', 'you'], 'h'],534 ['ROCK IS YOU', ['b', 'is', 'you'], 'h'],535 ]);536});537538test('duplicate sentences retain a deterministic source', () => {539 const game = createGame(level([540 word('BABA', 0, 0, 'first-noun'), word('IS', 1, 0, 'first-is'), word('YOU', 2, 0, 'first-you'),541 word('BABA', 0, 1, 'second-noun'), word('IS', 1, 1, 'second-is'), word('YOU', 2, 1, 'second-you'),542 object('BABA', 4, 2),543 ]));544 assert.deepEqual(game.getState().rules.map(rule => rule.sourceIds), [['first-noun', 'first-is', 'first-you']]);545});546547test('validates coordinates and duplicate ids', () => {548 assert.throws(() => createGame(level([object('BABA', 9, 0)])), /outside/);549 assert.throws(() => createGame(level([object('BABA', 0, 0, 'same'), object('ROCK', 1, 0, 'same')])), /duplicate/);550 assert.throws(() => createGame(level([object('YOU', 0, 0)])), /valid noun token/);551});552553test('reserves custom automaton properties as rule words', () => {554 assert.throws(() => createGame({555 ...level([556 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),557 object('TICK', 3, 1, 'tick-thing'),558 ]),559 automata: [{ id: 'clockwork', property: 'TICK', pattern: [{ action: 'wait' }] }],560 }), /object tick-thing noun TICK is reserved as an automaton property/);561});562563test('bounds blocks expose a null blocker and lifecycle kinds stay stable', () => {564 const game = createGame(level([565 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), object('BABA', 0, 1),566 ]));567 const blocked = game.step(-1, 0);568 assert.equal(blocked.kind, 'step');569 assert.deepEqual(blocked.blocked, { id: 'BABA-0-1', dir: [-1, 0], blockerId: null, reason: 'bounds' });570 const reset = game.reset();571 assert.equal(reset.kind, 'reset');572 const moved = game.step(1, 0);573 assert.equal(moved.accepted, true);574 const undo = game.undo();575 assert.equal(undo.kind, 'undo');576 assert.equal(undo.restored, true);577});578579test('diagnoses malformed tokens with level and object context', () => {580 assert.throws(() => createGame({ title: 'Broken Garden', width: 2, height: 2, objects: [word('', 0, 0, 'bad-word')] }), /level "Broken Garden".*word bad-word.*token/);581 assert.throws(() => createGame({ id: 'garden-7', width: 2, height: 2, objects: [object('bad noun', 0, 0, 'bad-object')] }), /level "garden-7".*object bad-object.*noun token/);582});583584test('undo keeps the complete movement history', () => {585 const game = createGame(level([586 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),587 object('BABA', 0, 1),588 ]));589 for (let i = 0; i < 101; i++) {590 assert.equal(game.step(1, 0).accepted, true);591 assert.equal(game.step(-1, 0).accepted, true);592 }593 for (let i = 0; i < 202; i++) assert.equal(game.undo().accepted, true);594 assert.equal(game.undo().accepted, false);595 assert.equal(game.getState().moves, 0);596});597598test('a new turn after Undo moves the restored objects and updates its indexes', () => {599 const game = createGame(level([600 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),601 object('BABA', 0, 1),602 ]));603 game.step(1, 0);604 game.undo();605 const result = game.step(1, 0);606 assert.equal(result.accepted, true);607 assert.deepEqual(result.moved.map(entry => entry.to), [{ x: 1, y: 1 }]);608 const restoredActor = game.getState().objects.find(entry => entry.noun === 'BABA');609 assert.deepEqual({ x: restoredActor.x, y: restoredActor.y }, { x: 1, y: 1 });610});