clone

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

main
miau-is-true / src / solver / state-codec.js
state-codec.js 13.4K
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 */910const MAGIC = [0x4d, 0x53, 0x43, 0x31]; // MSC111const MAX_U16 = 0xffff;12const MAX_U32 = 0xffffffff;13const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;14const SNAPSHOT_FIELDS = new Set(['width', 'height', 'levelId', 'title', 'hint', 'objects', 'moves', 'won', 'rules', 'sentences', 'automata', 'mechanics']);15const OBJECT_FIELDS = new Set(['id', 'kind', 'noun', 'text', 'x', 'y']);16const AUTOMATON_FIELDS = new Set(['id', 'subject', 'property', 'pattern', 'phase', 'active', 'carriers', 'instanceIds', 'next']);1718export class StateCodecError extends TypeError {19 constructor(message) {20 super(message);21 this.name = 'StateCodecError';22 }23}2425function authoredId(object, index) { return object.id || `o${index}`; }26function compareId(a, b) { return a < b ? -1 : a > b ? 1 : 0; }27function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }28function assertU16(value, label) { if (!Number.isInteger(value) || value < 0 || value > MAX_U16) throw new StateCodecError(`${label} must be an unsigned 16-bit integer`); }29function assertU32(value, label) { if (!Number.isInteger(value) || value < 0 || value > MAX_U32) throw new StateCodecError(`${label} must be an unsigned 32-bit integer`); }3031class Writer {32 constructor() { this.bytes = []; }33 u8(value) { if (!Number.isInteger(value) || value < 0 || value > 255) throw new StateCodecError('byte value is out of range'); this.bytes.push(value); }34 u16(value) { assertU16(value, 'value'); this.u8(value >>> 8); this.u8(value & 0xff); }35 u32(value) { assertU32(value, 'value'); this.u8(value >>> 24); this.u8(value >>> 16); this.u8(value >>> 8); this.u8(value); }36 done() { return Uint8Array.from(this.bytes); }37}3839class Reader {40 constructor(bytes) { this.bytes = bytes; this.index = 0; }41 take() { if (this.index >= this.bytes.length) throw new StateCodecError('truncated state codec payload'); return this.bytes[this.index++]; }42 u16() { return (this.take() << 8) | this.take(); }43 u32() { return (((this.take() * 0x1000000) + (this.take() << 16) + (this.take() << 8) + this.take()) >>> 0); }44 done() { if (this.index !== this.bytes.length) throw new StateCodecError('state codec payload has trailing bytes'); }45}4647function normalizeMechanics(registry) {48 if (registry === undefined) return [];49 if (!Array.isArray(registry)) throw new StateCodecError('mechanics registry must be an array');50 const ids = new Set();51 return registry.map((mechanic, index) => {52 if (!isRecord(mechanic) || typeof mechanic.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(mechanic.id)) throw new StateCodecError(`mechanics[${index}] needs a stable id`);53 if (ids.has(mechanic.id)) throw new StateCodecError(`mechanics registry repeats ${mechanic.id}`);54 ids.add(mechanic.id);55 if (!isRecord(mechanic.fields) || !Object.keys(mechanic.fields).length) throw new StateCodecError(`mechanics.${mechanic.id} needs fields`);56 const fields = Object.entries(mechanic.fields).map(([name, type]) => {57 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`);58 return { name, type };59 });60 const sortedFields = fields.sort((a, b) => compareId(a.name, b.name));61 return { id: mechanic.id, fields: sortedFields, fieldNames: new Set(sortedFields.map(field => field.name)) };62 }).sort((a, b) => compareId(a.id, b.id));63}6465function validateField(value, type, label) {66 if (type === 'boolean') {67 if (typeof value !== 'boolean') throw new StateCodecError(`${label} must be boolean`);68 } else if (type === 'u16') {69 assertU16(value, label);70 } else {71 assertU32(value, label);72 }73}7475function writeField(writer, value, type, label) {76 validateField(value, type, label);77 if (type === 'boolean') writer.u8(value ? 1 : 0);78 else if (type === 'u16') writer.u16(value);79 else writer.u32(value);80}8182function readField(reader, type) {83 if (type === 'boolean') {84 const value = reader.take();85 if (value > 1) throw new StateCodecError('invalid boolean mechanic field');86 return value === 1;87 }88 return type === 'u16' ? reader.u16() : reader.u32();89}9091export function createStateCodec(level, options = {}) {92 if (!isRecord(level) || !Array.isArray(level.objects)) throw new StateCodecError('level with authored objects is required');93 const authored = level.objects.map((object, index) => ({ ...object, id: authoredId(object, index), kind: object.kind || (object.text !== undefined ? 'word' : 'object') }));94 const byId = new Map();95 for (const object of authored) {96 if (byId.has(object.id)) throw new StateCodecError(`level repeats authored object id ${object.id}`);97 byId.set(object.id, object);98 }99 const automata = (Array.isArray(level.automata) ? level.automata.map(system => ({ id: system.id, patternLength: system.pattern?.length || 0 })) : [])100 .sort((a, b) => compareId(a.id, b.id));101 const automataById = new Map(automata.map(system => [system.id, system]));102 const mechanics = normalizeMechanics(options.mechanics);103 const mechanicsById = new Map(mechanics.map(mechanic => [mechanic.id, mechanic]));104 const tokens = [...new Set([105 ...(Array.isArray(level.vocabulary) ? level.vocabulary : []),106 ...authored.flatMap(object => object.kind === 'word' ? [object.text] : [object.noun]),107 ...automata.flatMap(system => {108 const source = level.automata.find(candidate => candidate.id === system.id);109 return [source?.subject, source?.property];110 }),111 ].filter(token => typeof token === 'string').map(token => token.toUpperCase()))].sort();112 const tokenIndex = new Map(tokens.map((token, index) => [token.toUpperCase(), index]));113114 function normalizeSnapshot(input) {115 if (!isRecord(input)) throw new StateCodecError('snapshot must be an object');116 for (const name of Object.keys(input)) if (!SNAPSHOT_FIELDS.has(name)) throw new StateCodecError(`unregistered snapshot field: ${name}`);117 if (input.width !== level.width || input.height !== level.height) throw new StateCodecError('snapshot board shape does not match level');118 if (!Array.isArray(input.objects)) throw new StateCodecError('snapshot.objects must be an array');119 const seen = new Set();120 const objects = input.objects.map((object, index) => {121 if (!isRecord(object) || typeof object.id !== 'string' || !byId.has(object.id)) throw new StateCodecError(`snapshot.objects[${index}] is not authored`);122 for (const name of Object.keys(object)) if (!OBJECT_FIELDS.has(name)) throw new StateCodecError(`unregistered object field: ${object.id}.${name}`);123 if (seen.has(object.id)) throw new StateCodecError(`snapshot repeats object ${object.id}`);124 seen.add(object.id);125 const source = byId.get(object.id);126 if (object.kind !== source.kind) throw new StateCodecError(`snapshot object kind changed for ${object.id}`);127 assertU16(object.x, `${object.id}.x`); assertU16(object.y, `${object.id}.y`);128 if (object.x >= level.width || object.y >= level.height) throw new StateCodecError(`snapshot object ${object.id} is outside level`);129 if (source.kind === 'word') {130 if (String(object.text).toUpperCase() !== String(source.text).toUpperCase()) throw new StateCodecError(`snapshot word text changed for ${object.id}`);131 return { id: object.id, kind: 'word', text: source.text, noun: undefined, x: object.x, y: object.y };132 }133 const noun = String(object.noun || '').toUpperCase();134 if (!TOKEN_RE.test(noun) || !tokenIndex.has(noun)) throw new StateCodecError(`snapshot noun is not in the authored vocabulary: ${object.id}`);135 return { id: object.id, kind: 'object', noun, text: undefined, x: object.x, y: object.y };136 });137 const phases = new Map();138 if (!Array.isArray(input.automata) || input.automata.length !== automata.length) throw new StateCodecError('snapshot.automata does not match level definitions');139 for (const runtime of input.automata) {140 if (!isRecord(runtime)) throw new StateCodecError('snapshot automaton runtime must be an object');141 for (const name of Object.keys(runtime)) if (!AUTOMATON_FIELDS.has(name)) throw new StateCodecError(`unregistered automaton field: ${runtime.id || '?'}.${name}`);142 const system = automataById.get(runtime?.id);143 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 || '?'}`);144 phases.set(runtime.id, runtime.phase);145 }146 const mechanicState = {};147 if (!isRecord(input.mechanics) && input.mechanics !== undefined) throw new StateCodecError('snapshot.mechanics must be an object');148 const suppliedMechanics = input.mechanics || {};149 for (const id of Object.keys(suppliedMechanics)) if (!mechanicsById.has(id)) throw new StateCodecError(`unregistered mechanic state: ${id}`);150 for (const mechanic of mechanics) {151 const value = suppliedMechanics[mechanic.id];152 if (!isRecord(value)) throw new StateCodecError(`snapshot.mechanics.${mechanic.id} is required`);153 const fields = {};154 for (const field of mechanic.fields) {155 if (!(field.name in value)) throw new StateCodecError(`snapshot.mechanics.${mechanic.id}.${field.name} is required`);156 validateField(value[field.name], field.type, `snapshot.mechanics.${mechanic.id}.${field.name}`);157 fields[field.name] = value[field.name];158 }159 for (const name of Object.keys(value)) if (!mechanic.fieldNames.has(name)) throw new StateCodecError(`unregistered mechanic field: ${mechanic.id}.${name}`);160 mechanicState[mechanic.id] = fields;161 }162 if (input.moves !== undefined) assertU32(input.moves, 'snapshot.moves');163 if (input.won === true) throw new StateCodecError('winning snapshots are terminal and cannot enter the solver frontier');164 return { objects, phases, mechanicState };165 }166167 function encode(input) {168 const snapshot = normalizeSnapshot(input);169 const writer = new Writer();170 MAGIC.forEach(byte => writer.u8(byte));171 writer.u16(level.width); writer.u16(level.height);172 const objectsById = new Map(snapshot.objects.map(object => [object.id, object]));173 for (const source of authored) {174 const object = objectsById.get(source.id);175 writer.u8(object ? 1 : 0);176 if (!object) continue;177 writer.u16(object.x); writer.u16(object.y);178 if (source.kind === 'object') writer.u16(tokenIndex.get(object.noun));179 }180 for (const system of automata) writer.u16(snapshot.phases.get(system.id));181 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}`);182 return writer.done();183 }184185 function decode(bytes) {186 if (!(bytes instanceof Uint8Array)) throw new StateCodecError('codec payload must be Uint8Array');187 const reader = new Reader(bytes);188 if (MAGIC.some(byte => reader.take() !== byte)) throw new StateCodecError('unsupported state codec payload');189 const width = reader.u16(); const height = reader.u16();190 if (width !== level.width || height !== level.height) throw new StateCodecError('state codec board shape does not match level');191 const objects = [];192 for (const source of authored) {193 const present = reader.take();194 if (present > 1) throw new StateCodecError('invalid object presence flag');195 if (!present) continue;196 const x = reader.u16(); const y = reader.u16();197 if (x >= level.width || y >= level.height) throw new StateCodecError(`decoded object ${source.id} is outside level`);198 if (source.kind === 'word') objects.push({ id: source.id, kind: 'word', text: source.text, noun: undefined, x, y });199 else {200 const noun = tokens[reader.u16()];201 if (!noun) throw new StateCodecError(`decoded noun is not in the authored vocabulary: ${source.id}`);202 objects.push({ id: source.id, kind: 'object', noun, text: undefined, x, y });203 }204 }205 const automataState = automata.map(system => {206 const phase = reader.u16();207 if (phase >= system.patternLength) throw new StateCodecError(`decoded automaton phase is invalid: ${system.id}`);208 return { id: system.id, phase };209 });210 const mechanicState = {};211 for (const mechanic of mechanics) {212 mechanicState[mechanic.id] = {};213 for (const field of mechanic.fields) mechanicState[mechanic.id][field.name] = readField(reader, field.type);214 }215 reader.done();216 return { width, height, levelId: level.id, moves: 0, won: false, objects, automata: automataState, ...(mechanics.length ? { mechanics: mechanicState } : {}) };217 }218219 function key(input) {220 const bytes = input instanceof Uint8Array ? input : encode(input);221 let binary = '';222 for (const byte of bytes) binary += String.fromCharCode(byte);223 return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');224 }225226 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)) });227}