clone

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

Add canonical compact codec for solver states

Dami committed f9c0fb3
commit
f9c0fb3fec919fd0cf42509a50020336fc39fa8b
parent
e34bf0fde4f4add1369890b248e4cc76c8556205
author
Dami <dami.etoile@gmail.com>
3 files changed, +308 -3
Mopenspec/changes/scale-solver-state-search/tasks.md6+-
Asrc/solver/state-codec.js218++++++++++++++++++++++++++++++
Atests/solver-codec.test.js87++++++++++++
M openspec/changes/scale-solver-state-search/tasks.md +3 -3
A src/solver/state-codec.js +218 -0
@@ -0,0 +1,218 @@
1+/**2+ * Compact, deterministic snapshots for solver frontiers.3+ *4+ * The codec is deliberately level-bound: authored object and automaton IDs5+ * define the wire order, so equivalent snapshots encode identically even when6+ * callers provide their objects in a different order. Dynamic mechanic state7+ * is accepted only through an explicit registry passed to createStateCodec.8+ */9+10+const MAGIC = [0x4d, 0x53, 0x43, 0x31]; // MSC111+const MAX_U16 = 0xffff;12+const MAX_U32 = 0xffffffff;13+const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;14+15+export class StateCodecError extends TypeError {16+ constructor(message) {17+ super(message);18+ this.name = 'StateCodecError';19+ }20+}21+22+function authoredId(object, index) { return object.id || `o${index}`; }23+function compareId(a, b) { return a < b ? -1 : a > b ? 1 : 0; }24+function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }25+function assertU16(value, label) { if (!Number.isInteger(value) || value < 0 || value > MAX_U16) throw new StateCodecError(`${label} must be an unsigned 16-bit integer`); }26+function assertU32(value, label) { if (!Number.isInteger(value) || value < 0 || value > MAX_U32) throw new StateCodecError(`${label} must be an unsigned 32-bit integer`); }27+28+class Writer {29+ constructor() { this.bytes = []; }30+ u8(value) { if (!Number.isInteger(value) || value < 0 || value > 255) throw new StateCodecError('byte value is out of range'); this.bytes.push(value); }31+ u16(value) { assertU16(value, 'value'); this.u8(value >>> 8); this.u8(value & 0xff); }32+ u32(value) { assertU32(value, 'value'); this.u8(value >>> 24); this.u8(value >>> 16); this.u8(value >>> 8); this.u8(value); }33+ done() { return Uint8Array.from(this.bytes); }34+}35+36+class Reader {37+ constructor(bytes) { this.bytes = bytes; this.index = 0; }38+ take() { if (this.index >= this.bytes.length) throw new StateCodecError('truncated state codec payload'); return this.bytes[this.index++]; }39+ u16() { return (this.take() << 8) | this.take(); }40+ u32() { return (((this.take() * 0x1000000) + (this.take() << 16) + (this.take() << 8) + this.take()) >>> 0); }41+ done() { if (this.index !== this.bytes.length) throw new StateCodecError('state codec payload has trailing bytes'); }42+}43+44+function normalizeMechanics(registry) {45+ if (registry === undefined) return [];46+ if (!Array.isArray(registry)) throw new StateCodecError('mechanics registry must be an array');47+ const ids = new Set();48+ return registry.map((mechanic, index) => {49+ if (!isRecord(mechanic) || typeof mechanic.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(mechanic.id)) throw new StateCodecError(`mechanics[${index}] needs a stable id`);50+ if (ids.has(mechanic.id)) throw new StateCodecError(`mechanics registry repeats ${mechanic.id}`);51+ ids.add(mechanic.id);52+ if (!isRecord(mechanic.fields) || !Object.keys(mechanic.fields).length) throw new StateCodecError(`mechanics.${mechanic.id} needs fields`);53+ const fields = Object.entries(mechanic.fields).map(([name, type]) => {54+ if (!/^[a-z][a-zA-Z0-9_]*$/.test(name) || !['boolean', 'u16', 'u32'].includes(type)) throw new StateCodecError(`mechanics.${mechanic.id}.${name} has an unsupported field type`);55+ return { name, type };56+ });57+ return { id: mechanic.id, fields: fields.sort((a, b) => compareId(a.name, b.name)) };58+ }).sort((a, b) => compareId(a.id, b.id));59+}60+61+function writeField(writer, value, type, label) {62+ if (type === 'boolean') {63+ if (typeof value !== 'boolean') throw new StateCodecError(`${label} must be boolean`);64+ writer.u8(value ? 1 : 0);65+ } else if (type === 'u16') {66+ assertU16(value, label); writer.u16(value);67+ } else {68+ assertU32(value, label); writer.u32(value);69+ }70+}71+72+function readField(reader, type) {73+ if (type === 'boolean') {74+ const value = reader.take();75+ if (value > 1) throw new StateCodecError('invalid boolean mechanic field');76+ return value === 1;77+ }78+ return type === 'u16' ? reader.u16() : reader.u32();79+}80+81+export function createStateCodec(level, options = {}) {82+ if (!isRecord(level) || !Array.isArray(level.objects)) throw new StateCodecError('level with authored objects is required');83+ const authored = level.objects.map((object, index) => ({ ...object, id: authoredId(object, index), kind: object.kind || (object.text !== undefined ? 'word' : 'object') }));84+ const byId = new Map();85+ for (const object of authored) {86+ if (byId.has(object.id)) throw new StateCodecError(`level repeats authored object id ${object.id}`);87+ byId.set(object.id, object);88+ }89+ const automata = (Array.isArray(level.automata) ? level.automata.map(system => ({ id: system.id, patternLength: system.pattern?.length || 0 })) : [])90+ .sort((a, b) => compareId(a.id, b.id));91+ const automataById = new Map(automata.map(system => [system.id, system]));92+ const mechanics = normalizeMechanics(options.mechanics);93+ const mechanicsById = new Map(mechanics.map(mechanic => [mechanic.id, mechanic]));94+ const tokens = [...new Set([95+ ...(Array.isArray(level.vocabulary) ? level.vocabulary : []),96+ ...authored.flatMap(object => object.kind === 'word' ? [object.text] : [object.noun]),97+ ...automata.flatMap(system => {98+ const source = level.automata.find(candidate => candidate.id === system.id);99+ return [source?.subject, source?.property];100+ }),101+ ].filter(token => typeof token === 'string').map(token => token.toUpperCase()))].sort();102+ const tokenIndex = new Map(tokens.map((token, index) => [token.toUpperCase(), index]));103+104+ function normalizeSnapshot(input) {105+ if (!isRecord(input)) throw new StateCodecError('snapshot must be an object');106+ const allowedSnapshotFields = new Set(['width', 'height', 'levelId', 'title', 'hint', 'objects', 'moves', 'won', 'rules', 'sentences', 'automata', 'mechanics']);107+ for (const name of Object.keys(input)) if (!allowedSnapshotFields.has(name)) throw new StateCodecError(`unregistered snapshot field: ${name}`);108+ if (input.width !== level.width || input.height !== level.height) throw new StateCodecError('snapshot board shape does not match level');109+ if (!Array.isArray(input.objects)) throw new StateCodecError('snapshot.objects must be an array');110+ const seen = new Set();111+ const objects = input.objects.map((object, index) => {112+ if (!isRecord(object) || typeof object.id !== 'string' || !byId.has(object.id)) throw new StateCodecError(`snapshot.objects[${index}] is not authored`);113+ for (const name of Object.keys(object)) if (!['id', 'kind', 'noun', 'text', 'x', 'y'].includes(name)) throw new StateCodecError(`unregistered object field: ${object.id}.${name}`);114+ if (seen.has(object.id)) throw new StateCodecError(`snapshot repeats object ${object.id}`);115+ seen.add(object.id);116+ const source = byId.get(object.id);117+ if (object.kind !== source.kind) throw new StateCodecError(`snapshot object kind changed for ${object.id}`);118+ assertU16(object.x, `${object.id}.x`); assertU16(object.y, `${object.id}.y`);119+ if (object.x >= level.width || object.y >= level.height) throw new StateCodecError(`snapshot object ${object.id} is outside level`);120+ if (source.kind === 'word') {121+ if (String(object.text).toUpperCase() !== String(source.text).toUpperCase()) throw new StateCodecError(`snapshot word text changed for ${object.id}`);122+ return { id: object.id, kind: 'word', text: source.text, noun: undefined, x: object.x, y: object.y };123+ }124+ const noun = String(object.noun || '').toUpperCase();125+ if (!TOKEN_RE.test(noun) || !tokenIndex.has(noun)) throw new StateCodecError(`snapshot noun is not in the authored vocabulary: ${object.id}`);126+ return { id: object.id, kind: 'object', noun, text: undefined, x: object.x, y: object.y };127+ });128+ const phases = new Map();129+ if (!Array.isArray(input.automata) || input.automata.length !== automata.length) throw new StateCodecError('snapshot.automata does not match level definitions');130+ for (const runtime of input.automata) {131+ if (!isRecord(runtime)) throw new StateCodecError('snapshot automaton runtime must be an object');132+ for (const name of Object.keys(runtime)) if (!['id', 'subject', 'property', 'pattern', 'phase', 'active', 'carriers', 'instanceIds', 'next'].includes(name)) throw new StateCodecError(`unregistered automaton field: ${runtime.id || '?'}.${name}`);133+ const system = automataById.get(runtime?.id);134+ if (!system || phases.has(runtime.id) || !Number.isInteger(runtime.phase) || runtime.phase < 0 || runtime.phase >= system.patternLength) throw new StateCodecError(`snapshot has an invalid automaton phase: ${runtime?.id || '?'}`);135+ phases.set(runtime.id, runtime.phase);136+ }137+ const mechanicState = {};138+ if (!isRecord(input.mechanics) && input.mechanics !== undefined) throw new StateCodecError('snapshot.mechanics must be an object');139+ const suppliedMechanics = input.mechanics || {};140+ for (const id of Object.keys(suppliedMechanics)) if (!mechanicsById.has(id)) throw new StateCodecError(`unregistered mechanic state: ${id}`);141+ for (const mechanic of mechanics) {142+ const value = suppliedMechanics[mechanic.id];143+ if (!isRecord(value)) throw new StateCodecError(`snapshot.mechanics.${mechanic.id} is required`);144+ const fields = {};145+ for (const field of mechanic.fields) {146+ if (!(field.name in value)) throw new StateCodecError(`snapshot.mechanics.${mechanic.id}.${field.name} is required`);147+ writeField(new Writer(), value[field.name], field.type, `snapshot.mechanics.${mechanic.id}.${field.name}`);148+ fields[field.name] = value[field.name];149+ }150+ for (const name of Object.keys(value)) if (!mechanic.fields.some(field => field.name === name)) throw new StateCodecError(`unregistered mechanic field: ${mechanic.id}.${name}`);151+ mechanicState[mechanic.id] = fields;152+ }153+ if (input.moves !== undefined) assertU32(input.moves, 'snapshot.moves');154+ if (input.won === true) throw new StateCodecError('winning snapshots are terminal and cannot enter the solver frontier');155+ return { objects, phases, mechanicState };156+ }157+158+ function encode(input) {159+ const snapshot = normalizeSnapshot(input);160+ const writer = new Writer();161+ MAGIC.forEach(byte => writer.u8(byte));162+ writer.u16(level.width); writer.u16(level.height);163+ const objectsById = new Map(snapshot.objects.map(object => [object.id, object]));164+ for (const source of authored) {165+ const object = objectsById.get(source.id);166+ writer.u8(object ? 1 : 0);167+ if (!object) continue;168+ writer.u16(object.x); writer.u16(object.y);169+ if (source.kind === 'object') writer.u16(tokenIndex.get(object.noun));170+ }171+ for (const system of automata) writer.u16(snapshot.phases.get(system.id));172+ for (const mechanic of mechanics) for (const field of mechanic.fields) writeField(writer, snapshot.mechanicState[mechanic.id][field.name], field.type, `snapshot.mechanics.${mechanic.id}.${field.name}`);173+ return writer.done();174+ }175+176+ function decode(bytes) {177+ if (!(bytes instanceof Uint8Array)) throw new StateCodecError('codec payload must be Uint8Array');178+ const reader = new Reader(bytes);179+ if (MAGIC.some(byte => reader.take() !== byte)) throw new StateCodecError('unsupported state codec payload');180+ const width = reader.u16(); const height = reader.u16();181+ if (width !== level.width || height !== level.height) throw new StateCodecError('state codec board shape does not match level');182+ const objects = [];183+ for (const source of authored) {184+ const present = reader.take();185+ if (present > 1) throw new StateCodecError('invalid object presence flag');186+ if (!present) continue;187+ const x = reader.u16(); const y = reader.u16();188+ if (x >= level.width || y >= level.height) throw new StateCodecError(`decoded object ${source.id} is outside level`);189+ if (source.kind === 'word') objects.push({ id: source.id, kind: 'word', text: source.text, noun: undefined, x, y });190+ else {191+ const noun = tokens[reader.u16()];192+ if (!noun) throw new StateCodecError(`decoded noun is not in the authored vocabulary: ${source.id}`);193+ objects.push({ id: source.id, kind: 'object', noun, text: undefined, x, y });194+ }195+ }196+ const automataState = automata.map(system => {197+ const phase = reader.u16();198+ if (phase >= system.patternLength) throw new StateCodecError(`decoded automaton phase is invalid: ${system.id}`);199+ return { id: system.id, phase };200+ });201+ const mechanicState = {};202+ for (const mechanic of mechanics) {203+ mechanicState[mechanic.id] = {};204+ for (const field of mechanic.fields) mechanicState[mechanic.id][field.name] = readField(reader, field.type);205+ }206+ reader.done();207+ return { width, height, levelId: level.id, moves: 0, won: false, objects, automata: automataState, ...(mechanics.length ? { mechanics: mechanicState } : {}) };208+ }209+210+ function key(input) {211+ const bytes = input instanceof Uint8Array ? input : encode(input);212+ let binary = '';213+ for (const byte of bytes) binary += String.fromCharCode(byte);214+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');215+ }216+217+ return Object.freeze({ encode, decode, key, tokens: Object.freeze([...tokens]), objectIds: Object.freeze(authored.map(object => object.id)), automataIds: Object.freeze(automata.map(system => system.id)), mechanicIds: Object.freeze(mechanics.map(mechanic => mechanic.id)) });218+}
A tests/solver-codec.test.js +87 -0
@@ -0,0 +1,87 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createGame } from '../src/engine.js';4+import { createStateCodec, StateCodecError } from '../src/solver/state-codec.js';5+6+const 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+};17+18+function state(overrides = {}) {19+ return {20+ ...createGame(level).getState(),21+ automata: [{ id: 'clock', phase: 1 }],22+ moves: 7,23+ ...overrides,24+ };25+}26+27+test('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+});39+40+test('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+});48+49+test('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+});60+61+test('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+});71+72+test('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+});81+82+test('codec rejects unregistered top-level and authored object fields', () => {83+ const codec = createStateCodec(level);84+ assert.throws(() => codec.encode({ ...state(), gateOpen: true }), /unregistered snapshot field/);85+ assert.throws(() => codec.encode({ ...state(), objects: state().objects.map(object => ({ ...object, usesRemaining: 1 })) }), /unregistered object field/);86+ assert.throws(() => codec.encode({ ...state(), won: true }), /winning snapshots are terminal/);87+});