clone

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

feat(mods): validate and share declarative automata packs

Dami committed e6736ab
commit
e6736ab18f149303984c3bd23215f9dcc5c4198b
parent
981a78442a20d63a4a903cd30311326cdab2ab4f
author
Dami <dami.etoile@gmail.com>
5 files changed, +178 -0
Amods/automata-example.cjs43++++++++++++++++++++++
Msrc/mods.js58++++++++++++++++++++++++++++++
Msrc/share.js5+++
Mtests/mods.test.js57+++++++++++++++++++++++++++++
Mtests/share.test.js15++++++++
A mods/automata-example.cjs +43 -0
M src/mods.js +58 -0
@@ -10,12 +10,15 @@ export const MOD_SCHEMA_VERSION = 1;
1010 export const MAX_MOD_SOURCE_LENGTH = 512 * 1024;1111 export const MAX_MOD_LEVELS = 64;1212 export const MAX_LEVEL_OBJECTS = 4096;13+export const MAX_LEVEL_AUTOMATA = 32;14+export const MAX_AUTOMATON_PATTERN = 32;1315 1416 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;1517 const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;1618 const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;1719 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;1820 const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);21+const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);1922 2023 export class ModValidationError extends Error {2124 constructor(errors, source = null) {
@@ -108,6 +111,56 @@ function validateLevel(level, index, errors) {
108111 errors.push(issue(`${objectPath}.noun`, 'must be an uppercase noun token, not a rule token'));109112 }110113 });114+ if (level.automata !== undefined) {115+ if (!Array.isArray(level.automata)) {116+ errors.push(issue(`${path}.automata`, `must be an array of at most ${MAX_LEVEL_AUTOMATA} declarative automata`));117+ } else if (level.automata.length > MAX_LEVEL_AUTOMATA) {118+ errors.push(issue(`${path}.automata`, `must contain at most ${MAX_LEVEL_AUTOMATA} entries`));119+ } else {120+ const automataIds = new Set();121+ level.automata.forEach((automaton, automatonIndex) => {122+ const automatonPath = `${path}.automata[${automatonIndex}]`;123+ if (!isRecord(automaton)) {124+ errors.push(issue(automatonPath, 'must be an object'));125+ return;126+ }127+ for (const key of Object.keys(automaton)) {128+ if (!['id', 'subject', 'property', 'pattern', 'phase'].includes(key)) {129+ errors.push(issue(`${automatonPath}.${key}`, 'unsupported field'));130+ }131+ }132+ if (typeof automaton.id !== 'string' || !LEVEL_ID_RE.test(automaton.id)) {133+ errors.push(issue(`${automatonPath}.id`, 'must be a stable lowercase id starting with a letter'));134+ } else if (automataIds.has(automaton.id)) {135+ errors.push(issue(`${automatonPath}.id`, `duplicates another automaton id (${automaton.id})`));136+ } else automataIds.add(automaton.id);137+ const subject = typeof automaton.subject === 'string' ? automaton.subject.toUpperCase() : automaton.subject;138+ if (automaton.subject !== undefined && (!token(subject) || RESERVED.has(subject))) errors.push(issue(`${automatonPath}.subject`, 'must be an uppercase noun token, not a rule token'));139+ const property = typeof automaton.property === 'string' ? automaton.property.toUpperCase() : automaton.property;140+ if (!token(property)) errors.push(issue(`${automatonPath}.property`, 'must be an uppercase property token'));141+ if (!Array.isArray(automaton.pattern) || automaton.pattern.length < 1 || automaton.pattern.length > MAX_AUTOMATON_PATTERN) {142+ errors.push(issue(`${automatonPath}.pattern`, `must contain 1 to ${MAX_AUTOMATON_PATTERN} steps`));143+ } else automaton.pattern.forEach((step, stepIndex) => {144+ const stepPath = `${automatonPath}.pattern[${stepIndex}]`;145+ if (!isRecord(step)) {146+ errors.push(issue(stepPath, "must be { action: 'move', dx, dy } or { action: 'wait' }"));147+ return;148+ }149+ for (const key of Object.keys(step)) if (!['action', 'dx', 'dy', 'cue', 'sound'].includes(key)) errors.push(issue(`${stepPath}.${key}`, 'unsupported field'));150+ if (step.action !== 'move' && step.action !== 'wait') errors.push(issue(`${stepPath}.action`, "must be 'move' or 'wait'"));151+ if (step.action === 'move' && (!isInteger(step.dx) || !isInteger(step.dy) || Math.abs(step.dx) + Math.abs(step.dy) !== 1)) {152+ errors.push(issue(stepPath, 'move must use one cardinal step with dx/dy of -1, 0, or 1'));153+ }154+ if (step.action === 'wait' && (step.dx !== undefined || step.dy !== undefined)) errors.push(issue(stepPath, 'wait cannot include dx or dy'));155+ if (step.cue !== undefined && (typeof step.cue !== 'string' || step.cue.length === 0 || step.cue.length > 16)) errors.push(issue(`${stepPath}.cue`, 'must be a non-empty display string (16 characters maximum)'));156+ if (step.sound !== undefined && (typeof step.sound !== 'string' || !AUTOMATON_SOUNDS.has(step.sound))) errors.push(issue(`${stepPath}.sound`, 'must be a supported host sound cue'));157+ });158+ if (automaton.phase !== undefined && (!isInteger(automaton.phase) || automaton.phase < 0 || (Array.isArray(automaton.pattern) && automaton.phase >= automaton.pattern.length))) {159+ errors.push(issue(`${automatonPath}.phase`, 'must be an integer initial beat index within pattern'));160+ }161+ });162+ }163+ }111164 }112165 113166 /** Return all validation messages without throwing. */
@@ -150,6 +203,11 @@ function normalizeMod(mod) {
150203 normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;151204 normalized.levels = normalized.levels.map(level => ({152205 ...level,206+ ...(Array.isArray(level.automata) ? { automata: level.automata.map(automaton => ({207+ ...automaton,208+ ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),209+ ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),210+ })) } : {}),153211 objects: level.objects.map(object => ({154212 ...object,155213 kind: object.kind || (object.text !== undefined ? 'word' : 'object'),
M src/share.js +5 -0
M tests/mods.test.js +57 -0
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
44 import { loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';55 66 const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');7+const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');78 89 test('loads the complete example CommonJS mod', () => {910 const mod = loadMod(exampleSource, { filename: 'example.cjs' });
@@ -14,6 +15,17 @@ test('loads the complete example CommonJS mod', () => {
1415 assert.deepEqual(validateMod(mod), []);1516 });1617 18+test('loads the minimal playable fox automata proof', () => {19+ const mod = loadMod(automataExampleSource, { filename: 'automata-example.cjs' });20+ assert.equal(mod.manifest.id, 'automata-example');21+ assert.equal(mod.levels[0].id, 'fox-keeps-time');22+ assert.equal(mod.levels[0].automata[0].property, 'TICK');23+ assert.equal(mod.levels[0].automata[0].subject, undefined);24+ assert.deepEqual(mod.levels[0].automata[0].pattern[0], { action: 'move', dx: -1, dy: 0, cue: '🦊', sound: 'tick' });25+ assert.equal(mod.palette.FENN.glyph, '🦊');26+ assert.deepEqual(validateMod(mod), []);27+});28+1729 test('normalizes plain CommonJS noun and word values to uppercase', () => {1830 const mod = loadMod(`module.exports = {1931 manifest: { id: 'case-demo', name: 'Case Demo', version: '1.0.0' },
@@ -37,6 +49,51 @@ test('accepts directional palette faces and preserves plain strings', () => {
3749 assert.deepEqual(mod.palette, { CAT: { glyph: '🐈', faces: 'left' }, FLAG: '🚩' });3850 });3951 52+test('loads bounded declarative level automata and normalizes rule tokens', () => {53+ const mod = loadMod(`module.exports = {54+ manifest: { id: 'automata-demo', name: 'Automata Demo', version: '1.0.0' },55+ levels: [{ id: 'intro', width: 2, height: 1, objects: [], automata: [{56+ id: 'patrol', subject: 'cat', property: 'move', phase: 1,57+ pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait', cue: 'pause' }],58+ }] }],59+ };`);60+ assert.deepEqual(mod.levels[0].automata[0], {61+ id: 'patrol', subject: 'CAT', property: 'MOVE', phase: 1,62+ pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait', cue: 'pause' }],63+ });64+});65+66+test('accepts word-carried automata without an explicit subject', () => {67+ const mod = loadMod(`module.exports = {68+ manifest: { id: 'word-carried-clock', name: 'Word Carried Clock', version: '1.0.0' },69+ levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{70+ id: 'clock', property: 'tick', pattern: [{ action: 'move', dx: -1, dy: 0 }],71+ }] }],72+ };`);73+ assert.deepEqual(mod.levels[0].automata[0], {74+ id: 'clock', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }],75+ });76+});77+78+test('rejects unsafe or unbounded automata steps', () => {79+ assert.throws(() => loadMod(`module.exports = {80+ manifest: { id: 'bad-automata', name: 'Bad Automata', version: '1.0.0' },81+ levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{82+ id: 'bad', subject: 'YOU', property: 'MOVE', pattern: [83+ { action: 'move', dx: 2, dy: 0, cue: 'this cue is much too long', sound: 'custom-url', extra: true },84+ ],85+ }] }],86+ };`), error => {87+ assert.ok(error instanceof ModValidationError);88+ assert.match(error.message, /subject/);89+ assert.match(error.message, /move/);90+ assert.match(error.message, /cue/);91+ assert.match(error.message, /sound/);92+ assert.match(error.message, /extra/);93+ return true;94+ });95+});96+4097 test('rejects unbounded directional palette shapes', () => {4198 assert.throws(() => loadMod(`module.exports = {4299 manifest: { id: 'bad-facing', name: 'Bad Facing', version: '1.0.0' },
M tests/share.test.js +15 -0