- commit
- 5946dd7e1c5ea5b70acd0a09e9423e337611a87a
- parent
- 4c34901d3a450781fe30b47c0c27c9645e7c2e44
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 31 | +++++++++++++++++++----------- |
| M | tests/ | 8 | ++++++++ |
@@ -11,6 +11,9 @@ const MAGIC = [0x4d, 0x53, 0x43, 0x31]; // MSC1
1111 const MAX_U16 = 0xffff;1212 const MAX_U32 = 0xffffffff;1313 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;14+const SNAPSHOT_FIELDS = new Set(['width', 'height', 'levelId', 'title', 'hint', 'objects', 'moves', 'won', 'rules', 'sentences', 'automata', 'mechanics']);15+const OBJECT_FIELDS = new Set(['id', 'kind', 'noun', 'text', 'x', 'y']);16+const AUTOMATON_FIELDS = new Set(['id', 'subject', 'property', 'pattern', 'phase', 'active', 'carriers', 'instanceIds', 'next']);1417 1518 export class StateCodecError extends TypeError {1619 constructor(message) {@@ -54,21 +57,28 @@ function normalizeMechanics(registry) {
5457 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`);5558 return { name, type };5659 });57- return { id: mechanic.id, fields: fields.sort((a, b) => compareId(a.name, b.name)) };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)) };5862 }).sort((a, b) => compareId(a.id, b.id));5963 }6064 61-function writeField(writer, value, type, label) {65+function validateField(value, type, label) {6266 if (type === 'boolean') {6367 if (typeof value !== 'boolean') throw new StateCodecError(`${label} must be boolean`);64- writer.u8(value ? 1 : 0);6568 } else if (type === 'u16') {66- assertU16(value, label); writer.u16(value);69+ assertU16(value, label);6770 } else {68- assertU32(value, label); writer.u32(value);71+ assertU32(value, label);6972 }7073 }7174 75+function 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+}81+7282 function readField(reader, type) {7383 if (type === 'boolean') {7484 const value = reader.take();@@ -103,14 +113,13 @@ export function createStateCodec(level, options = {}) {
103113 104114 function normalizeSnapshot(input) {105115 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}`);116+ for (const name of Object.keys(input)) if (!SNAPSHOT_FIELDS.has(name)) throw new StateCodecError(`unregistered snapshot field: ${name}`);108117 if (input.width !== level.width || input.height !== level.height) throw new StateCodecError('snapshot board shape does not match level');109118 if (!Array.isArray(input.objects)) throw new StateCodecError('snapshot.objects must be an array');110119 const seen = new Set();111120 const objects = input.objects.map((object, index) => {112121 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}`);122+ for (const name of Object.keys(object)) if (!OBJECT_FIELDS.has(name)) throw new StateCodecError(`unregistered object field: ${object.id}.${name}`);114123 if (seen.has(object.id)) throw new StateCodecError(`snapshot repeats object ${object.id}`);115124 seen.add(object.id);116125 const source = byId.get(object.id);@@ -129,7 +138,7 @@ export function createStateCodec(level, options = {}) {
129138 if (!Array.isArray(input.automata) || input.automata.length !== automata.length) throw new StateCodecError('snapshot.automata does not match level definitions');130139 for (const runtime of input.automata) {131140 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}`);141+ for (const name of Object.keys(runtime)) if (!AUTOMATON_FIELDS.has(name)) throw new StateCodecError(`unregistered automaton field: ${runtime.id || '?'}.${name}`);133142 const system = automataById.get(runtime?.id);134143 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 || '?'}`);135144 phases.set(runtime.id, runtime.phase);@@ -144,10 +153,10 @@ export function createStateCodec(level, options = {}) {
144153 const fields = {};145154 for (const field of mechanic.fields) {146155 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}`);156+ validateField(value[field.name], field.type, `snapshot.mechanics.${mechanic.id}.${field.name}`);148157 fields[field.name] = value[field.name];149158 }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}`);159+ for (const name of Object.keys(value)) if (!mechanic.fieldNames.has(name)) throw new StateCodecError(`unregistered mechanic field: ${mechanic.id}.${name}`);151160 mechanicState[mechanic.id] = fields;152161 }153162 if (input.moves !== undefined) assertU32(input.moves, 'snapshot.moves');@@ -79,6 +79,14 @@ test('registered gate and crumble values distinguish otherwise identical boards'
7979 assert.notEqual(codec.key(base), codec.key({ ...base, mechanics: { ...base.mechanics, crumble: { usesRemaining: 0, crumbled: true } } }));8080 });8181 82+test('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+});89+8290 test('codec rejects unregistered top-level and authored object fields', () => {8391 const codec = createStateCodec(level);8492 assert.throws(() => codec.encode({ ...state(), gateOpen: true }), /unregistered snapshot field/);