clone

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

Verify compact state parity after gameplay transitions

Dami committed 163c207
commit
163c20756a671a08c904e58b9c8352864772442a
parent
d11fa16b104f41b5bbc77b4d109d1033de1b07e7
author
Dami <dami.etoile@gmail.com>
2 files changed, +108 -1
Mopenspec/changes/scale-solver-state-search/tasks.md2+-
Mtests/solver-codec.test.js107++++++++++++++++++++++++++++++
M openspec/changes/scale-solver-state-search/tasks.md +1 -1
M tests/solver-codec.test.js +107 -0
@@ -85,3 +85,110 @@ test('codec rejects unregistered top-level and authored object fields', () => {
8585 assert.throws(() => codec.encode({ ...state(), objects: state().objects.map(object => ({ ...object, usesRemaining: 1 })) }), /unregistered object field/);8686 assert.throws(() => codec.encode({ ...state(), won: true }), /winning snapshots are terminal/);8787 });88+89+test('codec round trip preserves the next turn after SINK removes an entity', () => {90+ const sinkLevel = {91+ id: 'codec-sink', width: 4, height: 3,92+ objects: [93+ { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },94+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },95+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },96+ { id: 'water-word', kind: 'word', text: 'WATER', x: 0, y: 1 },97+ { id: 'water-is', kind: 'word', text: 'IS', x: 1, y: 1 },98+ { id: 'water-sink', kind: 'word', text: 'SINK', x: 2, y: 1 },99+ { id: 'left-miau', kind: 'object', noun: 'MIAU', x: 0, y: 2 },100+ { id: 'right-miau', kind: 'object', noun: 'MIAU', x: 2, y: 2 },101+ { id: 'water', kind: 'object', noun: 'WATER', x: 1, y: 2 },102+ ],103+ };104+ const direct = createGame(sinkLevel);105+ const first = direct.step(1, 0);106+ assert.equal(first.accepted, true);107+ assert.equal(direct.getState().objects.some(object => object.id === 'left-miau'), false);108+ const codec = createStateCodec(sinkLevel);109+ const restored = createGame(sinkLevel);110+ restored.restore(codec.decode(codec.encode(direct.getState())));111+ const directNext = direct.step(1, 0);112+ const restoredNext = restored.step(1, 0);113+ assert.deepEqual(restoredNext, directNext);114+ assert.deepEqual(restored.getState(), { ...direct.getState(), moves: 0 });115+});116+117+function assertNextTurnParity(levelDefinition, snapshot, action) {118+ const codec = createStateCodec(levelDefinition);119+ // The frontier codec intentionally resets the presentation-only move120+ // counter. Normalize the direct branch to the same settled solver baseline121+ // while comparing gameplay state and the next event.122+ const solverBaseline = { ...snapshot, moves: 0 };123+ const direct = createGame(levelDefinition);124+ direct.restore(solverBaseline);125+ const roundTripped = createGame(levelDefinition);126+ roundTripped.restore(codec.decode(codec.encode(solverBaseline)));127+128+ const directResult = action(direct);129+ const roundTrippedResult = action(roundTripped);130+ assert.deepEqual(roundTrippedResult, directResult);131+ assert.deepEqual(roundTripped.getState(), direct.getState());132+}133+134+test('compact round trip preserves the next noun-chain turn', () => {135+ const chainLevel = {136+ id: 'codec-chain', width: 6, height: 3,137+ objects: [138+ { id: 'baba-subject', kind: 'word', text: 'BABA', x: 0, y: 0 },139+ { id: 'baba-is', kind: 'word', text: 'IS', x: 1, y: 0 },140+ { id: 'baba-rock', kind: 'word', text: 'ROCK', x: 2, y: 0 },141+ { id: 'rock-subject', kind: 'word', text: 'ROCK', x: 0, y: 1 },142+ { id: 'rock-is', kind: 'word', text: 'IS', x: 1, y: 1 },143+ { id: 'rock-flag', kind: 'word', text: 'FLAG', x: 2, y: 1 },144+ { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 2 },145+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 2 },146+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 2 },147+ { id: 'baba', kind: 'object', noun: 'BABA', x: 4, y: 2 },148+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 2 },149+ ],150+ };151+ const snapshot = createGame(chainLevel).getState();152+ assert.equal(snapshot.objects.find(object => object.id === 'baba').noun, 'ROCK');153+ assertNextTurnParity(chainLevel, snapshot, game => game.step(1, 0));154+});155+156+test('compact round trip preserves the next automaton wait phase', () => {157+ const automatonLevel = {158+ id: 'codec-clock', width: 6, height: 4,159+ objects: [160+ { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 0 },161+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },162+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },163+ { id: 'clock-subject', kind: 'word', text: 'CLOCK', x: 0, y: 1 },164+ { id: 'clock-is', kind: 'word', text: 'IS', x: 1, y: 1 },165+ { id: 'clock-tick', kind: 'word', text: 'TICK', x: 2, y: 1 },166+ { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 3 },167+ { id: 'clock', kind: 'object', noun: 'CLOCK', x: 2, y: 3 },168+ ],169+ automata: [{ id: 'clock', subject: 'CLOCK', property: 'TICK', phase: 1, pattern: [{ action: 'wait' }, { action: 'wait' }] }],170+ };171+ const snapshot = createGame(automatonLevel).getState();172+ assert.equal(snapshot.automata[0].phase, 1);173+ assertNextTurnParity(automatonLevel, snapshot, game => game.wait());174+});175+176+test('compact round trip preserves the next turn after a word-rule edit', () => {177+ const editLevel = {178+ id: 'codec-word-edit', width: 6, height: 4,179+ objects: [180+ { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 3 },181+ { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 3 },182+ { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 3 },183+ { id: 'rock-subject', kind: 'word', text: 'ROCK', x: 0, y: 1 },184+ { id: 'rock-is', kind: 'word', text: 'IS', x: 1, y: 1 },185+ { id: 'rock-stop', kind: 'word', text: 'STOP', x: 2, y: 1 },186+ { id: 'player', kind: 'object', noun: 'MIAU', x: 1, y: 2 },187+ ],188+ };189+ const game = createGame(editLevel);190+ const edited = game.step(0, -1);191+ assert.equal(edited.accepted, true);192+ assert.equal(game.has('ROCK', 'STOP'), false);193+ assertNextTurnParity(editLevel, game.getState(), next => next.step(1, 0));194+});