- commit
- e6736ab18f149303984c3bd23215f9dcc5c4198b
- parent
- 981a78442a20d63a4a903cd30311326cdab2ab4f
- author
- Dami <dami.etoile@gmail.com>
| A | mods/ | 43 | ++++++++++++++++++++++ |
| M | src/ | 58 | ++++++++++++++++++++++++++++++ |
| M | src/ | 5 | +++ |
| M | tests/ | 57 | +++++++++++++++++++++++++++++ |
| M | tests/ | 15 | ++++++++ |
@@ -0,0 +1,43 @@
1+// Minimal playable automata proof. The fox carries TICK through its noun rule;2+// the host engine owns movement, pushes, collisions, and the win condition.3+const { word, object, level } = require('@miau/engine');4+5+module.exports = {6+ manifest: {7+ id: 'automata-example',8+ name: 'Clockwork Fox',9+ version: '1.0.0',10+ engine: 1,11+ author: 'Miau examples',12+ },13+ vocabulary: ['MIAU', 'FENN', 'FLAG'],14+ palette: {15+ MIAU: '🐈',16+ FENN: { glyph: '🦊', faces: 'right' },17+ FLAG: '🚩',18+ },19+ levels: [level(10, 5, [20+ word('MIAU', 0, 0, 'miau-subject'), word('IS', 1, 0, 'miau-is'), word('YOU', 2, 0, 'miau-you'),21+ word('FLAG', 4, 0, 'flag-subject'), word('IS', 5, 0, 'flag-is'), word('WIN', 6, 0, 'flag-win'),22+ word('FENN', 0, 1, 'fenn-subject'), word('IS', 1, 1, 'fenn-is'), word('TICK', 2, 1, 'fenn-tick'),23+ object('MIAU', 0, 3, 'miau-player'),24+ object('FENN', 4, 3, 'fenn-fox'),25+ object('FLAG', 8, 3, 'flag-goal'),26+ ], {27+ id: 'fox-keeps-time',28+ title: 'The fox keeps time',29+ hint: 'Walk MIAU to FLAG while FENN repeats its little patrol.',30+ automata: [{31+ id: 'fenn-clock',32+ property: 'TICK',33+ pattern: [34+ { action: 'move', dx: -1, dy: 0, cue: '🦊', sound: 'tick' },35+ { action: 'move', dx: -1, dy: 0 },36+ { action: 'wait', cue: '…' },37+ { action: 'move', dx: 1, dy: 0, sound: 'tick' },38+ { action: 'move', dx: 1, dy: 0 },39+ { action: 'wait' },40+ ],41+ }],42+ })],43+};@@ -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'),@@ -35,6 +35,11 @@ function normalizePack(pack) {
3535 copy.manifest = { ...copy.manifest, engine: copy.manifest?.engine ?? MOD_SCHEMA_VERSION };3636 copy.levels = Array.isArray(copy.levels) ? copy.levels.map(level => ({3737 ...level,38+ ...(Array.isArray(level.automata) ? { automata: level.automata.map(automaton => ({39+ ...automaton,40+ ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),41+ ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),42+ })) } : {}),3843 objects: Array.isArray(level.objects) ? level.objects.map(object => ({3944 ...object,4045 kind: object.kind || (object.text !== undefined ? 'word' : 'object'),@@ -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' },@@ -31,6 +31,21 @@ test('shared packs preserve bounded directional palette faces', () => {
3131 assert.equal(decoded.palette.FLAG, '🚩');3232 });3333 34+test('shared packs preserve normalized declarative automata', () => {35+ const automata = {36+ ...pack,37+ levels: [{ ...pack.levels[0], automata: [{38+ id: 'cat-patrol', property: 'move', phase: 0,39+ pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait' }],40+ }] }],41+ };42+ const decoded = decodeSharedPack(encodeSharedPack(automata));43+ assert.deepEqual(decoded.levels[0].automata, [{44+ id: 'cat-patrol', property: 'MOVE', phase: 0,45+ pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait' }],46+ }]);47+});48+3449 test('encoding is stable and CommonJS export is deterministic data only', () => {3550 const first = encodeSharedPack(pack);3651 const second = encodeSharedPack({ levels: pack.levels, palette: pack.palette, manifest: pack.manifest });