- commit
- f9c0fb3fec919fd0cf42509a50020336fc39fa8b
- parent
- e34bf0fde4f4add1369890b248e4cc76c8556205
- author
- Dami <dami.etoile@gmail.com>
| M | openspec/ | 6 | +- |
| A | src/ | 218 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 87 | ++++++++++++ |
@@ -6,9 +6,9 @@
66 77 ## 2. Canonical compact codec88 9-- [ ] 2.1 Implement authored-ID slot tables and a versioned byte codec for object existence, cell, noun, and automata phase.10-- [ ] 2.2 Add encode/decode and key-stability fixtures for reordered object arrays, removed entities, changed nouns, and changed automata phases.11-- [ ] 2.3 Define the dynamic-mechanic codec registration boundary and reject unregistered state fields; prove gate and crumble example states do not collide.9+- [x] 2.1 Implement authored-ID slot tables and a versioned byte codec for object existence, cell, noun, and automata phase.10+- [x] 2.2 Add encode/decode and key-stability fixtures for reordered object arrays, removed entities, changed nouns, and changed automata phases.11+- [x] 2.3 Define the dynamic-mechanic codec registration boundary and reject unregistered state fields; prove gate and crumble example states do not collide.1212 1313 ## 3. Memory-bounded search1414 @@ -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+}@@ -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+});