1import test from 'node:test';2import assert from 'node:assert/strict';3import { createGame } from '../src/engine.js';4import { createStateCodec, StateCodecError } from '../src/solver/state-codec.js';56const level = {7 id: 'codec-fixture', width: 6, height: 2,8 objects: [9 { id: 'rock', kind: 'object', noun: 'ROCK', x: 1, y: 0 },10 { id: 'rule-rock', kind: 'word', text: 'ROCK', x: 0, y: 1 },11 { id: 'rule-is', kind: 'word', text: 'IS', x: 1, y: 1 },12 { id: 'rule-gem', kind: 'word', text: 'GEM', x: 2, y: 1 },13 { id: 'player', kind: 'object', noun: 'MIAU', x: 2, y: 0 },14 ],15 automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }, { action: 'move', dx: 1, dy: 0 }] }],16};1718function state(overrides = {}) {19 return {20 ...createGame(level).getState(),21 automata: [{ id: 'clock', phase: 1 }],22 moves: 7,23 ...overrides,24 };25}2627test('codec round trips a game snapshot into a restore-compatible state', () => {28 const codec = createStateCodec(level);29 const snapshot = state();30 const decoded = codec.decode(codec.encode(snapshot));31 assert.deepEqual(decoded.objects, snapshot.objects);32 assert.deepEqual(decoded.automata, [{ id: 'clock', phase: 1 }]);33 assert.equal(decoded.moves, 0);34 assert.equal(decoded.won, false);35 assert.equal(decoded.levelId, level.id);36 const restored = createGame(level);37 assert.deepEqual(restored.restore(decoded).automata.map(system => ({ id: system.id, phase: system.phase })), decoded.automata);38});3940test('codec identity is stable when snapshot objects are reordered', () => {41 const codec = createStateCodec(level);42 const snapshot = state();43 const reordered = { ...snapshot, objects: [...snapshot.objects].reverse() };44 assert.deepEqual(codec.encode(snapshot), codec.encode(reordered));45 assert.equal(codec.key(snapshot), codec.key({ ...reordered, moves: 999 }));46 assert.notEqual(codec.key(snapshot), codec.key({ ...snapshot, automata: [{ id: 'clock', phase: 0 }] }));47});4849test('codec preserves removed objects and transformed noun tokens', () => {50 const codec = createStateCodec(level);51 const initial = state();52 const snapshot = { ...initial, objects: initial.objects.filter(object => object.id !== 'rule-gem') };53 const decoded = codec.decode(codec.encode(snapshot));54 assert.equal(decoded.objects.some(object => object.id === 'rule-gem'), false);55 assert.equal(decoded.objects.find(object => object.id === 'rock').noun, 'GEM');56 assert.notEqual(codec.key(initial), codec.key(snapshot));57 const priorNoun = { ...snapshot, objects: snapshot.objects.map(object => object.id === 'rock' ? { ...object, noun: 'ROCK' } : object) };58 assert.notEqual(codec.key(snapshot), codec.key(priorNoun));59});6061test('codec requires an explicit registry for dynamic mechanic state', () => {62 const codec = createStateCodec(level);63 assert.throws(() => codec.encode(state({ mechanics: { gates: { open: true } } })), error => error instanceof StateCodecError && /unregistered mechanic state/.test(error.message));64 const registered = createStateCodec(level, { mechanics: [{ id: 'gates', fields: { open: 'boolean', uses: 'u16' } }] });65 const snapshot = state({ mechanics: { gates: { open: true, uses: 2 } } });66 assert.deepEqual(registered.decode(registered.encode(snapshot)).mechanics, snapshot.mechanics);67 const reordered = createStateCodec(level, { mechanics: [{ id: 'gates', fields: { uses: 'u16', open: 'boolean' } }] });68 assert.deepEqual(registered.encode(snapshot), reordered.encode(snapshot));69 assert.throws(() => registered.encode({ ...snapshot, mechanics: { gates: { open: true, uses: 2, extra: 1 } } }), /unregistered mechanic field/);70});7172test('registered gate and crumble values distinguish otherwise identical boards', () => {73 const codec = createStateCodec(level, { mechanics: [74 { id: 'gates', fields: { open: 'boolean' } },75 { id: 'crumble', fields: { usesRemaining: 'u16', crumbled: 'boolean' } },76 ] });77 const base = state({ mechanics: { gates: { open: false }, crumble: { usesRemaining: 1, crumbled: false } } });78 assert.notEqual(codec.key(base), codec.key({ ...base, mechanics: { ...base.mechanics, gates: { open: true } } }));79 assert.notEqual(codec.key(base), codec.key({ ...base, mechanics: { ...base.mechanics, crumble: { usesRemaining: 0, crumbled: true } } }));80});8182test('registered mechanic fields retain scalar validation errors', () => {83 const codec = createStateCodec(level, { mechanics: [{ id: 'gates', fields: { open: 'boolean', uses: 'u16', total: 'u32' } }] });84 const base = { open: false, uses: 1, total: 2 };85 assert.throws(() => codec.encode(state({ mechanics: { gates: { ...base, open: 1 } } })), /snapshot\.mechanics\.gates\.open must be boolean/);86 assert.throws(() => codec.encode(state({ mechanics: { gates: { ...base, uses: -1 } } })), /snapshot\.mechanics\.gates\.uses must be an unsigned 16-bit integer/);87 assert.throws(() => codec.encode(state({ mechanics: { gates: { ...base, total: 1.5 } } })), /snapshot\.mechanics\.gates\.total must be an unsigned 32-bit integer/);88});8990test('codec rejects unregistered top-level and authored object fields', () => {91 const codec = createStateCodec(level);92 assert.throws(() => codec.encode({ ...state(), gateOpen: true }), /unregistered snapshot field/);93 assert.throws(() => codec.encode({ ...state(), objects: state().objects.map(object => ({ ...object, usesRemaining: 1 })) }), /unregistered object field/);94 assert.throws(() => codec.encode({ ...state(), won: true }), /winning snapshots are terminal/);95});9697test('codec round trip preserves the next turn after SINK removes an entity', () => {98 const sinkLevel = {99 id: 'codec-sink', width: 4, height: 3,100 objects: [101 { id: 'miau-word', kind: 'word', text: 'MIAU', x: 0, y: 0 },102 { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },103 { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },104 { id: 'water-word', kind: 'word', text: 'WATER', x: 0, y: 1 },105 { id: 'water-is', kind: 'word', text: 'IS', x: 1, y: 1 },106 { id: 'water-sink', kind: 'word', text: 'SINK', x: 2, y: 1 },107 { id: 'left-miau', kind: 'object', noun: 'MIAU', x: 0, y: 2 },108 { id: 'right-miau', kind: 'object', noun: 'MIAU', x: 2, y: 2 },109 { id: 'water', kind: 'object', noun: 'WATER', x: 1, y: 2 },110 ],111 };112 const direct = createGame(sinkLevel);113 const first = direct.step(1, 0);114 assert.equal(first.accepted, true);115 assert.equal(direct.getState().objects.some(object => object.id === 'left-miau'), false);116 const codec = createStateCodec(sinkLevel);117 const restored = createGame(sinkLevel);118 restored.restore(codec.decode(codec.encode(direct.getState())));119 const directNext = direct.step(1, 0);120 const restoredNext = restored.step(1, 0);121 assert.deepEqual(restoredNext, directNext);122 assert.deepEqual(restored.getState(), { ...direct.getState(), moves: 0 });123});124125function assertNextTurnParity(levelDefinition, snapshot, action) {126 const codec = createStateCodec(levelDefinition);127 // The frontier codec intentionally resets the presentation-only move128 // counter. Normalize the direct branch to the same settled solver baseline129 // while comparing gameplay state and the next event.130 const solverBaseline = { ...snapshot, moves: 0 };131 const direct = createGame(levelDefinition);132 direct.restore(solverBaseline);133 const roundTripped = createGame(levelDefinition);134 roundTripped.restore(codec.decode(codec.encode(solverBaseline)));135136 const directResult = action(direct);137 const roundTrippedResult = action(roundTripped);138 assert.deepEqual(roundTrippedResult, directResult);139 assert.deepEqual(roundTripped.getState(), direct.getState());140}141142test('compact round trip preserves the next noun-chain turn', () => {143 const chainLevel = {144 id: 'codec-chain', width: 6, height: 3,145 objects: [146 { id: 'baba-subject', kind: 'word', text: 'BABA', x: 0, y: 0 },147 { id: 'baba-is', kind: 'word', text: 'IS', x: 1, y: 0 },148 { id: 'baba-rock', kind: 'word', text: 'ROCK', x: 2, y: 0 },149 { id: 'rock-subject', kind: 'word', text: 'ROCK', x: 0, y: 1 },150 { id: 'rock-is', kind: 'word', text: 'IS', x: 1, y: 1 },151 { id: 'rock-flag', kind: 'word', text: 'FLAG', x: 2, y: 1 },152 { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 2 },153 { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 2 },154 { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 2 },155 { id: 'baba', kind: 'object', noun: 'BABA', x: 4, y: 2 },156 { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 2 },157 ],158 };159 const snapshot = createGame(chainLevel).getState();160 assert.equal(snapshot.objects.find(object => object.id === 'baba').noun, 'ROCK');161 assertNextTurnParity(chainLevel, snapshot, game => game.step(1, 0));162});163164test('compact round trip preserves the next automaton wait phase', () => {165 const automatonLevel = {166 id: 'codec-clock', width: 6, height: 4,167 objects: [168 { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 0 },169 { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 0 },170 { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 0 },171 { id: 'clock-subject', kind: 'word', text: 'CLOCK', x: 0, y: 1 },172 { id: 'clock-is', kind: 'word', text: 'IS', x: 1, y: 1 },173 { id: 'clock-tick', kind: 'word', text: 'TICK', x: 2, y: 1 },174 { id: 'player', kind: 'object', noun: 'MIAU', x: 0, y: 3 },175 { id: 'clock', kind: 'object', noun: 'CLOCK', x: 2, y: 3 },176 ],177 automata: [{ id: 'clock', subject: 'CLOCK', property: 'TICK', phase: 1, pattern: [{ action: 'wait' }, { action: 'wait' }] }],178 };179 const snapshot = createGame(automatonLevel).getState();180 assert.equal(snapshot.automata[0].phase, 1);181 assertNextTurnParity(automatonLevel, snapshot, game => game.wait());182});183184test('compact round trip preserves the next turn after a word-rule edit', () => {185 const editLevel = {186 id: 'codec-word-edit', width: 6, height: 4,187 objects: [188 { id: 'miau-subject', kind: 'word', text: 'MIAU', x: 0, y: 3 },189 { id: 'miau-is', kind: 'word', text: 'IS', x: 1, y: 3 },190 { id: 'miau-you', kind: 'word', text: 'YOU', x: 2, y: 3 },191 { id: 'rock-subject', kind: 'word', text: 'ROCK', x: 0, y: 1 },192 { id: 'rock-is', kind: 'word', text: 'IS', x: 1, y: 1 },193 { id: 'rock-stop', kind: 'word', text: 'STOP', x: 2, y: 1 },194 { id: 'player', kind: 'object', noun: 'MIAU', x: 1, y: 2 },195 ],196 };197 const game = createGame(editLevel);198 const edited = game.step(0, -1);199 assert.equal(edited.accepted, true);200 assert.equal(game.has('ROCK', 'STOP'), false);201 assertNextTurnParity(editLevel, game.getState(), next => next.step(1, 0));202});