clone

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

Unify level validation across engine and packs

Dami committed 2950532
commit
2950532bd6347894934f5d91bb1b270d8ccd56d0
parent
006d6caf41dde8f4aee048359f6e45fa0617cad8
author
Dami <dami.etoile@gmail.com>
9 files changed, +394 -231
Msrc/engine.js116+++++++------------
Asrc/level-schema.js176++++++++++++++++++++++++++++++
Asrc/level-view.js5+
Msrc/main.js3+-
Msrc/mods.js155+++------------------------
Msrc/pack-service.js6+-
Msrc/scenes/gift.js3+-
Msrc/share.js33+++--
Atests/level-schema.test.js128++++++++++++++++++++++
M src/engine.js +43 -73
@@ -2,89 +2,59 @@
22 import { buildIndexes } from './engine/indexes.js';33 import { parseRules, RuleParseLimitError } from './engine/rules.js';44 import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';5+import { LevelSchemaError, TOKEN_RE, normalizeLevel } from './level-schema.js';56 67 export const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);7-const MAX_AUTOMATA = 32;8-const MAX_AUTOMATON_PATTERN = 32;98 const clone = value => structuredClone(value);10-const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;11-129 function validateLevel(level) {13- if (!level || typeof level !== 'object') throw new TypeError('level must be an object');14- const levelLabel = level.title ? `${level.title}${level.id ? ` (${level.id})` : ''}` : (level.id || 'Untitled puzzle');10+ const levelLabel = level && typeof level === 'object'11+ ? (level.title ? `${level.title}${level.id ? ` (${level.id})` : ''}` : (level.id || 'Untitled puzzle'))12+ : 'Untitled puzzle';1513 const context = `level "${levelLabel}"`;16- if (!Number.isInteger(level.width) || level.width < 1) throw new RangeError(`${context}: width must be positive`);17- if (!Number.isInteger(level.height) || level.height < 1) throw new RangeError(`${context}: height must be positive`);18- if (!Array.isArray(level.objects)) throw new TypeError(`${context}: objects must be an array`);19- const ids = new Set();20- level.objects.forEach((o, i) => {21- if (!o || typeof o !== 'object') throw new TypeError(`${context}: objects[${i}] must be an object`);22- if (![o.x, o.y].every(Number.isInteger) || o.x < 0 || o.y < 0 || o.x >= level.width || o.y >= level.height) throw new RangeError(`${context}: objects[${i}] is outside level`);23- if (o.id !== undefined && (typeof o.id !== 'string' || o.id.length === 0)) throw new TypeError(`${context}: objects[${i}] id must be a non-empty string`);24- const id = o.id || `o${i}`;25- if (ids.has(id)) throw new Error(`${context}: duplicate object id: ${id}`);26- ids.add(id);27- const kind = o.kind || (o.text != null ? 'word' : 'object');28- if (kind !== 'word' && kind !== 'object') throw new TypeError(`${context}: object ${id} kind must be word or object`);29- if (kind === 'word' && (typeof o.text !== 'string' || !TOKEN_RE.test(o.text.toUpperCase()))) throw new TypeError(`${context}: word ${id} needs a non-empty uppercase token`);30- if (kind !== 'word' && (typeof o.noun !== 'string' || !TOKEN_RE.test(o.noun.toUpperCase()) || ['IS', 'AND', ...CORE_PROPERTIES].includes(o.noun.toUpperCase()))) throw new TypeError(`${context}: object ${id} needs a valid noun token`);31- });32- if (level.automata !== undefined) {33- if (!Array.isArray(level.automata) || level.automata.length > MAX_AUTOMATA) throw new TypeError(`${context}: automata must be an array of at most ${MAX_AUTOMATA} entries`);34- const automataIds = new Set();35- level.automata.forEach((system, i) => {36- if (!system || typeof system !== 'object') throw new TypeError(`${context}: automata[${i}] must be an object`);37- if (typeof system.id !== 'string' || !/^[a-z][a-z0-9_-]*$/i.test(system.id)) throw new TypeError(`${context}: automata[${i}] needs a valid id`);38- if (automataIds.has(system.id)) throw new Error(`${context}: duplicate automaton id: ${system.id}`);39- automataIds.add(system.id);40- if (system.subject !== undefined && (typeof system.subject !== 'string' || !TOKEN_RE.test(system.subject.toUpperCase()))) throw new TypeError(`${context}: automata ${system.id} needs a valid subject when provided`);41- if (typeof system.property !== 'string' || !TOKEN_RE.test(system.property.toUpperCase()) || CORE_PROPERTIES.has(system.property.toUpperCase())) throw new TypeError(`${context}: automata ${system.id} needs a custom property`);42- if (!Array.isArray(system.pattern) || !system.pattern.length || system.pattern.length > MAX_AUTOMATON_PATTERN) throw new TypeError(`${context}: automata ${system.id} needs 1 to ${MAX_AUTOMATON_PATTERN} pattern steps`);43- system.pattern.forEach((action, actionIndex) => {44- const move = typeof action === 'string' ? ({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] }[action.toUpperCase()]) : null;45- const isWait = action === 'W' || (action && typeof action === 'object' && action.action === 'wait');46- const isMove = Boolean(move) || (action && typeof action === 'object' && action.action === 'move');47- if (!isMove && !isWait) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] needs move or wait`);48- if (isMove && !move && (!Number.isInteger(action.dx) || !Number.isInteger(action.dy) || Math.abs(action.dx) + Math.abs(action.dy) !== 1)) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] move must be cardinal`);49- if (typeof action === 'object' && action.cue !== undefined && (typeof action.cue !== 'string' || action.cue.length > 16)) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] cue must be at most 16 characters`);50- if (typeof action === 'object' && action.sound !== undefined && (typeof action.sound !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(action.sound))) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] sound must be a cue name`);51- });52- if (system.phase !== undefined && (!Number.isInteger(system.phase) || system.phase < 0)) throw new TypeError(`${context}: automata ${system.id} phase must be a non-negative integer`);53- });14+ try {15+ return normalizeLevel(level, { profile: 'standalone', path: 'level' });16+ } catch (error) {17+ if (!(error instanceof LevelSchemaError)) throw error;18+ const issue = error.issues[0];19+ const index = issue.path.match(/objects\[(\d+)\]/)?.[1];20+ const id = level?.objects?.[Number(index)]?.id || `o${index ?? '?'}`;21+ const automatonIndex = issue.path.match(/automata\[(\d+)\]/)?.[1];22+ const automaton = level?.automata?.[Number(automatonIndex)];23+ const automatonId = automaton?.id || `automaton[${automatonIndex ?? '?'}]`;24+ const adapt = errorToThrow => {25+ errorToThrow.issue = issue;26+ errorToThrow.issueCode = issue.code;27+ errorToThrow.issuePath = issue.path;28+ return errorToThrow;29+ };30+ if (issue.code === 'level-shape') throw adapt(new TypeError('level must be an object'));31+ if (issue.code === 'level-width') throw adapt(new RangeError(`${context}: width must be positive`));32+ if (issue.code === 'level-height') throw adapt(new RangeError(`${context}: height must be positive`));33+ if (issue.code === 'objects-shape') throw adapt(new TypeError(`${context}: objects must be an array`));34+ if (issue.code === 'coordinate-range') throw adapt(new RangeError(`${context}: objects[${index}] is outside level`));35+ if (issue.code === 'coordinate-type') throw adapt(new TypeError(`${context}: objects[${index}] must have integer coordinates`));36+ if (issue.code === 'object-shape') throw adapt(new TypeError(`${context}: objects[${index}] must be an object`));37+ if (issue.code === 'object-id') throw adapt(new TypeError(`${context}: objects[${index}] id must be a non-empty string`));38+ if (issue.code === 'duplicate-object-id') throw adapt(new Error(`${context}: duplicate object id: ${id}`));39+ if (issue.code === 'object-kind') throw adapt(new TypeError(`${context}: object ${id} kind must be word or object`));40+ if (issue.code === 'word-token') throw adapt(new TypeError(`${context}: word ${id} needs a non-empty uppercase token`));41+ if (issue.code === 'noun-token') throw adapt(new TypeError(`${context}: object ${id} needs a valid noun token`));42+ if (issue.code === 'reserved-automaton-property') throw adapt(new TypeError(`${context}: object ${id} noun ${String(issue.value).toUpperCase()} is reserved as an automaton property`));43+ if (issue.code === 'automata-shape' || issue.code === 'automata-limit') throw adapt(new TypeError(`${context}: automata must be an array of at most 32 entries`));44+ if (issue.code === 'automaton-id') throw adapt(new TypeError(`${context}: automata[${automatonIndex ?? '?'}] needs a valid id`));45+ if (issue.code === 'duplicate-automaton-id') throw adapt(new Error(`${context}: duplicate automaton id: ${issue.value}`));46+ if (issue.code === 'automaton-subject') throw adapt(new TypeError(`${context}: automata ${automatonId} needs a valid subject when provided`));47+ if (issue.code === 'automaton-property') throw adapt(new TypeError(`${context}: automata ${automatonId} needs a custom property`));48+ if (issue.code === 'automaton-pattern') throw adapt(new TypeError(`${context}: automata ${automatonId} needs 1 to 32 pattern steps`));49+ if (issue.code === 'automaton-vector') throw adapt(new TypeError(`${context}: automata ${automatonId} pattern[${issue.path.match(/pattern\[(\d+)\]/)?.[1] ?? '?'}] move must be cardinal`));50+ if (issue.code === 'automaton-action') throw adapt(new TypeError(`${context}: automata ${automatonId} pattern[${issue.path.match(/pattern\[(\d+)\]/)?.[1] ?? '?'}] needs move or wait`));51+ throw adapt(new TypeError(`${context}: ${issue.path}: ${issue.message}`));5452 }55- const customProperties = new Set(56- (Array.isArray(level.automata) ? level.automata : [])57- .map(system => typeof system?.property === 'string' ? system.property.toUpperCase() : null)58- .filter(Boolean),59- );60- for (const object of level.objects) {61- const kind = object.kind || (object.text != null ? 'word' : 'object');62- const noun = typeof object.noun === 'string' ? object.noun.toUpperCase() : '';63- if (kind === 'object' && customProperties.has(noun)) {64- throw new TypeError(`${context}: object ${object.id || '?'} noun ${noun} is reserved as an automaton property`);65- }66- }67- return clone(level);6853 }6954 7055 export function createGame(level) {7156 const original = validateLevel(level);72- const automataDefs = (original.automata || []).map(system => ({73- ...system,74- subject: system.subject?.toUpperCase(),75- property: system.property.toUpperCase(),76- // Accept the Clockwork proposal's U/D/L/R/W shorthand at the engine77- // boundary; validated mod data uses the explicit action object shape.78- pattern: system.pattern.map(action => {79- if (typeof action === 'string') {80- const code = action.toUpperCase();81- if (code === 'W') return { action: 'wait' };82- const [dx, dy] = ({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] })[code];83- return { action: 'move', dx, dy };84- }85- return { ...action };86- }),87- }));57+ const automataDefs = (original.automata || []).map(system => ({ ...system }));8858 // Restore runs once per solver transition. Keep the authored identity and8959 // automaton lookup tables immutable so each restore only validates the9060 // supplied snapshot rather than rebuilding level metadata.
A src/level-schema.js +176 -0
@@ -0,0 +1,176 @@
1+import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';2+3+/** Shared, data-only level boundary used by the engine and pack adapters. */4+export const MAX_LEVEL_WIDTH = 128;5+export const MAX_LEVEL_HEIGHT = 128;6+export const MAX_LEVEL_OBJECTS = 4096;7+export const MAX_LEVEL_AUTOMATA = 32;8+export const MAX_AUTOMATON_PATTERN = 32;9+export const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;10+export const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;11+export const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);12+export const RESERVED_TOKENS = new Set(['IS', 'AND', ...CORE_PROPERTY_TOKENS]);13+export const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);14+15+const ACTION_KEYS = new Set(['action', 'dx', 'dy', 'cue', 'sound']);16+const AUTOMATON_KEYS = new Set(['id', 'subject', 'property', 'pattern', 'phase']);17+const LEVEL_KEYS = new Set(['id', 'title', 'hint', 'category', 'introduces', 'reuses', 'width', 'height', 'objects', 'automata', 'allowBypass', 'allowHandsOff', 'mustForm', 'solverBudget']);18+const OBJECT_KEYS = new Set(['id', 'kind', 'text', 'noun', 'x', 'y']);19+const DIRECTIONS = Object.freeze({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] });20+21+const clone = value => typeof structuredClone === 'function'22+ ? structuredClone(value)23+ : JSON.parse(JSON.stringify(value));24+25+export class LevelSchemaError extends TypeError {26+ constructor(issues) {27+ const list = Array.isArray(issues) ? issues : [issues];28+ super(list.map(entry => `${entry.path}: ${entry.message}`).join('\n'));29+ this.name = 'LevelSchemaError';30+ this.issues = list;31+ }32+}33+34+function add(issues, code, path, message, value) {35+ issues.push({ code, path, message, ...(value === undefined ? {} : { value }) });36+}37+38+function token(value) { return typeof value === 'string' && TOKEN_RE.test(value); }39+function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }40+function integer(value) { return Number.isInteger(value); }41+42+function canonicalAction(action, path, issues) {43+ const startIssueCount = issues.length;44+ if (typeof action === 'string') {45+ const code = action.toUpperCase();46+ if (code === 'W') return { action: 'wait' };47+ const direction = DIRECTIONS[code];48+ if (direction) return { action: 'move', dx: direction[0], dy: direction[1] };49+ add(issues, 'automaton-action', path, "must be 'U', 'D', 'L', 'R', 'W', { action: 'move', dx, dy }, or { action: 'wait' }", action);50+ return null;51+ }52+ if (!record(action)) {53+ add(issues, 'automaton-action', path, "must be { action: 'move', dx, dy } or { action: 'wait' }", action);54+ return null;55+ }56+ for (const key of Object.keys(action)) if (!ACTION_KEYS.has(key)) add(issues, 'unsupported-field', `${path}.${key}`, 'unsupported field', action[key]);57+ if (action.action !== 'move' && action.action !== 'wait') {58+ add(issues, 'automaton-action', `${path}.action`, "must be 'move' or 'wait'", action.action);59+ return null;60+ }61+ if (action.action === 'wait' && (action.dx !== undefined || action.dy !== undefined)) add(issues, 'automaton-action', path, 'wait cannot include dx or dy', action);62+ if (action.action === 'move' && (!integer(action.dx) || !integer(action.dy) || Math.abs(action.dx) + Math.abs(action.dy) !== 1)) {63+ add(issues, 'automaton-vector', path, 'move must use one cardinal step with dx/dy of -1, 0, or 1', action);64+ }65+ if (action.cue !== undefined && (typeof action.cue !== 'string' || action.cue.length === 0 || action.cue.length > 16)) add(issues, 'automaton-cue', `${path}.cue`, 'must be a non-empty display string (16 characters maximum)', action.cue);66+ if (action.sound !== undefined && (typeof action.sound !== 'string' || !AUTOMATON_SOUNDS.has(action.sound))) add(issues, 'automaton-sound', `${path}.sound`, 'must be a supported host sound cue', action.sound);67+ if (issues.length !== startIssueCount) return null;68+ const result = { action: action.action };69+ if (action.action === 'move') { result.dx = action.dx; result.dy = action.dy; }70+ if (action.cue !== undefined) result.cue = action.cue;71+ if (action.sound !== undefined) result.sound = action.sound;72+ return result;73+}74+75+function normalizeObject(object, index, level, path, customProperties, issues) {76+ const startIssueCount = issues.length;77+ if (!record(object)) { add(issues, 'object-shape', path, 'must be an object', object); return null; }78+ for (const key of Object.keys(object)) if (!OBJECT_KEYS.has(key)) add(issues, 'unsupported-field', `${path}.${key}`, 'unsupported field', object[key]);79+ if (!integer(object.x) || !integer(object.y)) add(issues, 'coordinate-type', path, 'x and y must be integers', object);80+ else if (object.x < 0 || object.y < 0 || object.x >= level.width || object.y >= level.height) add(issues, 'coordinate-range', path, `position (${object.x}, ${object.y}) is outside the ${level.width}x${level.height} board`, object);81+ if (object.id !== undefined && (typeof object.id !== 'string' || object.id.length === 0)) add(issues, 'object-id', `${path}.id`, 'must be a non-empty string', object.id);82+ const id = object.id || `o${index}`;83+ const kind = object.kind || (object.text !== undefined ? 'word' : 'object');84+ if (kind !== 'word' && kind !== 'object') add(issues, 'object-kind', `${path}.kind`, "must be 'word' or 'object'", kind);85+ const text = typeof object.text === 'string' ? object.text.toUpperCase() : object.text;86+ const noun = typeof object.noun === 'string' ? object.noun.toUpperCase() : object.noun;87+ if (kind === 'word') {88+ if (!token(text)) add(issues, 'word-token', `${path}.text`, 'must be an uppercase token', object.text);89+ } else if (!token(noun) || RESERVED_TOKENS.has(noun)) {90+ add(issues, 'noun-token', `${path}.noun`, 'must be an uppercase noun token, not a rule token', object.noun);91+ } else if (customProperties.has(noun)) {92+ add(issues, 'reserved-automaton-property', `${path}.noun`, `${noun} is a declared property and cannot be an object noun`, object.noun);93+ }94+ if (issues.length !== startIssueCount) return null;95+ return { ...(object.id !== undefined ? { id: object.id } : {}), kind, ...(kind === 'word' ? { text } : { noun }), x: object.x, y: object.y };96+}97+98+/** Return canonical data and stable structural issues without mutating input. */99+export function inspectLevel(level, options = {}) {100+ const profile = options.profile || 'standalone';101+ const path = options.path || 'level';102+ const issues = [];103+ if (!record(level)) return { value: null, issues: [{ code: 'level-shape', path, message: 'must be an object', value: level }] };104+ for (const key of Object.keys(level)) if (!LEVEL_KEYS.has(key)) add(issues, 'unsupported-field', `${path}.${key}`, 'unsupported field', level[key]);105+ const width = level.width;106+ const height = level.height;107+ if (!integer(width) || width < 1 || (profile === 'pack' && width > MAX_LEVEL_WIDTH)) add(issues, 'level-width', `${path}.width`, profile === 'pack' ? `must be an integer from 1 to ${MAX_LEVEL_WIDTH}` : 'must be positive', width);108+ if (!integer(height) || height < 1 || (profile === 'pack' && height > MAX_LEVEL_HEIGHT)) add(issues, 'level-height', `${path}.height`, profile === 'pack' ? `must be an integer from 1 to ${MAX_LEVEL_HEIGHT}` : 'must be positive', height);109+ if (profile === 'pack' && (typeof level.id !== 'string' || !LEVEL_ID_RE.test(level.id))) add(issues, 'level-id', `${path}.id`, 'must be a stable lowercase id starting with a letter', level.id);110+ if (profile === 'standalone' && level.id !== undefined && typeof level.id !== 'string') add(issues, 'level-id', `${path}.id`, 'must be a string when provided', level.id);111+ if (level.title !== undefined && typeof level.title !== 'string') add(issues, 'title-type', `${path}.title`, 'must be a string', level.title);112+ if (profile === 'pack' && typeof level.title === 'string' && level.title.length > 64) add(issues, 'title-length', `${path}.title`, 'Titles can be at most 64 characters so they fit on phones', level.title);113+ if (level.hint !== undefined && typeof level.hint !== 'string') add(issues, 'hint-type', `${path}.hint`, 'must be a string', level.hint);114+ if (level.category !== undefined && typeof level.category !== 'string') add(issues, 'category-type', `${path}.category`, 'must be a string', level.category);115+ if (level.introduces !== undefined && (!Array.isArray(level.introduces) || level.introduces.some(entry => typeof entry !== 'string'))) add(issues, 'introduces-type', `${path}.introduces`, 'must be an array of strings', level.introduces);116+ if (level.reuses !== undefined && (!Array.isArray(level.reuses) || level.reuses.some(entry => typeof entry !== 'string'))) add(issues, 'reuses-type', `${path}.reuses`, 'must be an array of strings', level.reuses);117+ if (level.allowBypass !== undefined && typeof level.allowBypass !== 'boolean') add(issues, 'allow-bypass-type', `${path}.allowBypass`, 'must be a boolean', level.allowBypass);118+ if (level.allowHandsOff !== undefined && typeof level.allowHandsOff !== 'boolean') add(issues, 'allow-hands-off-type', `${path}.allowHandsOff`, 'must be a boolean', level.allowHandsOff);119+ if (level.solverBudget !== undefined && (!integer(level.solverBudget) || level.solverBudget < 1)) add(issues, 'solver-budget-type', `${path}.solverBudget`, 'must be a positive integer', level.solverBudget);120+ if (level.mustForm !== undefined && (!Array.isArray(level.mustForm) || level.mustForm.some(rule => typeof rule !== 'string' || rule.length === 0))) add(issues, 'must-form-type', `${path}.mustForm`, 'must be an array of non-empty rule strings', level.mustForm);121+ if (!Array.isArray(level.objects)) add(issues, 'objects-shape', `${path}.objects`, 'must be an array', level.objects);122+ else {123+ if (profile === 'pack' && level.objects.length > MAX_LEVEL_OBJECTS) add(issues, 'objects-limit', `${path}.objects`, `must contain at most ${MAX_LEVEL_OBJECTS} entities`, level.objects.length);124+ }125+ const automataInput = level.automata;126+ if (automataInput !== undefined && !Array.isArray(automataInput)) add(issues, 'automata-shape', `${path}.automata`, `must be an array of at most ${MAX_LEVEL_AUTOMATA} declarative automata`, automataInput);127+ if (Array.isArray(automataInput) && automataInput.length > MAX_LEVEL_AUTOMATA) add(issues, 'automata-limit', `${path}.automata`, `must contain at most ${MAX_LEVEL_AUTOMATA} entries`, automataInput.length);128+ const customProperties = new Set((Array.isArray(automataInput) ? automataInput : []).map(system => typeof system?.property === 'string' ? system.property.toUpperCase() : null).filter(Boolean));129+ const objectIds = new Set();130+ const objects = [];131+ if (Array.isArray(level.objects) && integer(width) && integer(height)) level.objects.forEach((object, index) => {132+ const objectPath = `${path}.objects[${index}]`;133+ const canonical = normalizeObject(object, index, { width, height }, objectPath, customProperties, issues);134+ const id = typeof object?.id === 'string' && object.id.length > 0 ? object.id : `o${index}`;135+ if (objectIds.has(id)) add(issues, 'duplicate-object-id', `${objectPath}.id`, `duplicates another object id (${id})`, id);136+ else objectIds.add(id);137+ if (canonical) objects.push(canonical);138+ });139+ const automata = [];140+ const automatonIds = new Set();141+ if (Array.isArray(automataInput)) automataInput.forEach((system, index) => {142+ const automatonPath = `${path}.automata[${index}]`;143+ const startIssueCount = issues.length;144+ if (!record(system)) { add(issues, 'automaton-shape', automatonPath, 'must be an object', system); return; }145+ for (const key of Object.keys(system)) if (!AUTOMATON_KEYS.has(key)) add(issues, 'unsupported-field', `${automatonPath}.${key}`, 'unsupported field', system[key]);146+ if (typeof system.id !== 'string' || !LEVEL_ID_RE.test(system.id)) add(issues, 'automaton-id', `${automatonPath}.id`, 'must be a stable lowercase id starting with a letter', system.id);147+ else if (automatonIds.has(system.id)) add(issues, 'duplicate-automaton-id', `${automatonPath}.id`, `duplicates another automaton id (${system.id})`, system.id);148+ else automatonIds.add(system.id);149+ const subject = typeof system.subject === 'string' ? system.subject.toUpperCase() : system.subject;150+ if (subject !== undefined && (!token(subject) || RESERVED_TOKENS.has(subject))) add(issues, 'automaton-subject', `${automatonPath}.subject`, 'must be an uppercase noun token, not a rule token', system.subject);151+ const property = typeof system.property === 'string' ? system.property.toUpperCase() : system.property;152+ if (!token(property) || CORE_PROPERTIES.has(property)) add(issues, 'automaton-property', `${automatonPath}.property`, 'must be a custom uppercase property token', system.property);153+ if (!Array.isArray(system.pattern) || system.pattern.length < 1 || system.pattern.length > MAX_AUTOMATON_PATTERN) add(issues, 'automaton-pattern', `${automatonPath}.pattern`, `must contain 1 to ${MAX_AUTOMATON_PATTERN} steps`, system.pattern);154+ const pattern = Array.isArray(system.pattern) ? system.pattern.map((action, actionIndex) => canonicalAction(action, `${automatonPath}.pattern[${actionIndex}]`, issues)).filter(Boolean) : [];155+ if (system.phase !== undefined && (!integer(system.phase) || system.phase < 0 || (Array.isArray(system.pattern) && system.phase >= system.pattern.length))) add(issues, 'automaton-phase', `${automatonPath}.phase`, 'must be an integer initial beat index within pattern', system.phase);156+ if (issues.length === startIssueCount) automata.push({ id: system.id, ...(subject === undefined ? {} : { subject }), property, pattern, ...(system.phase === undefined ? {} : { phase: system.phase }) });157+ });158+ if (issues.length) return { value: null, issues };159+ const canonical = {};160+ for (const key of LEVEL_KEYS) if (level[key] !== undefined && key !== 'objects' && key !== 'automata') canonical[key] = clone(level[key]);161+ canonical.width = width;162+ canonical.height = height;163+ canonical.objects = objects;164+ if (automataInput !== undefined) canonical.automata = automata;165+ return { value: canonical, issues: [] };166+}167+168+export function normalizeLevel(level, options = {}) {169+ const result = inspectLevel(level, options);170+ if (result.issues.length) throw new LevelSchemaError(result.issues);171+ return result.value;172+}173+174+export function validateLevel(level, options = {}) {175+ return inspectLevel(level, options).issues;176+}
A src/level-view.js +5 -0
M src/main.js +2 -1
M src/mods.js +17 -138
@@ -1,5 +1,6 @@
1-import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';21 import { parseRules, RuleParseLimitError } from './engine/rules.js';2+import { CORE_PROPERTIES, inspectLevel, LEVEL_ID_RE, normalizeLevel, RESERVED_TOKENS, TOKEN_RE } from './level-schema.js';3+export { MAX_AUTOMATON_PATTERN, MAX_LEVEL_AUTOMATA, MAX_LEVEL_OBJECTS } from './level-schema.js';34 export { collectModWarnings, MAX_MOD_WARNINGS } from './mods/diagnostics.js';45 56 /**
@@ -16,27 +17,20 @@ export const LEGACY_RULES_VERSION = 1;
1617 export const MAX_MOD_SOURCE_LENGTH = 512 * 1024;1718 export const MAX_MOD_LEVELS = 64;1819 export const MAX_LEVEL_TITLE_LENGTH = 64;19-export const MAX_LEVEL_OBJECTS = 4096;20-export const MAX_LEVEL_AUTOMATA = 32;21-export const MAX_AUTOMATON_PATTERN = 32;2220 export const SUPPORTED_MOD_FEATURES = Object.freeze(['automata']);2321 2422 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;25-const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;2623 const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;27-const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;28-const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);29-const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);3024 const FEATURE_RE = /^[a-z][a-z0-9._-]{0,31}$/;31-const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);3225 3326 export class ModValidationError extends Error {34- constructor(errors, source = null) {27+ constructor(errors, source = null, issues = []) {3528 const list = Array.isArray(errors) ? errors : [errors];3629 super(`Invalid mod${list.length === 1 ? '' : ' (multiple errors)'}:\n- ${list.join('\n- ')}`);3730 this.name = 'ModValidationError';3831 this.errors = list;3932 this.source = source;33+ this.issues = issues;4034 }4135 }4236
@@ -82,7 +76,7 @@ function validatePalette(palette, errors) {
8276 return;8377 }8478 for (const [noun, glyph] of Object.entries(palette)) {85- if (!token(noun) || RESERVED.has(noun)) errors.push(issue(`palette.${noun}`, 'key must be an uppercase noun token, not a rule token'));79+ if (!token(noun) || RESERVED_TOKENS.has(noun)) errors.push(issue(`palette.${noun}`, 'key must be an uppercase noun token, not a rule token'));8680 if (typeof glyph === 'string') {8781 if (glyph.length === 0 || glyph.length > 16) errors.push(issue(`palette.${noun}`, 'value must be a non-empty display string (16 characters maximum)'));8882 } else if (isRecord(glyph)) {
@@ -99,119 +93,16 @@ function validatePalette(palette, errors) {
9993 }10094 }10195 102-function validateLevel(level, index, errors) {96+function validateLevel(level, index, errors, schemaIssues) {10397 const path = `levels[${index}]`;104- if (!isRecord(level)) {105- errors.push(issue(path, 'must be an object'));106- return;107- }108- if (typeof level.id !== 'string' || !LEVEL_ID_RE.test(level.id)) {109- errors.push(issue(`${path}.id`, 'must be a stable lowercase id starting with a letter'));110- }111- if (!isInteger(level.width) || level.width < 1 || level.width > 128) errors.push(issue(`${path}.width`, 'must be an integer from 1 to 128'));112- if (!isInteger(level.height) || level.height < 1 || level.height > 128) errors.push(issue(`${path}.height`, 'must be an integer from 1 to 128'));113- if (level.title !== undefined && typeof level.title !== 'string') errors.push(issue(`${path}.title`, 'must be a string'));114- if (typeof level.title === 'string' && level.title.length > MAX_LEVEL_TITLE_LENGTH) {115- errors.push(issue(`${path}.title`, 'Titles can be at most 64 characters so they fit on phones'));116- }117- if (level.hint !== undefined && typeof level.hint !== 'string') errors.push(issue(`${path}.hint`, 'must be a string'));118- const declaredProperties = new Set();119- if (Array.isArray(level.automata)) for (const automaton of level.automata) {120- const property = typeof automaton?.property === 'string' ? automaton.property.toUpperCase() : null;121- if (property && token(property)) declaredProperties.add(property);122- }123- if (!Array.isArray(level.objects)) {124- errors.push(issue(`${path}.objects`, 'must be an array'));125- return;126- }127- if (level.objects.length > MAX_LEVEL_OBJECTS) {128- errors.push(issue(`${path}.objects`, `must contain at most ${MAX_LEVEL_OBJECTS} entities`));129- return;130- }131- const ids = new Set();132- level.objects.forEach((object, objectIndex) => {133- const objectPath = `${path}.objects[${objectIndex}]`;134- if (!isRecord(object)) {135- errors.push(issue(objectPath, 'must be an object'));136- return;137- }138- if (!isInteger(object.x) || !isInteger(object.y)) errors.push(issue(objectPath, 'x and y must be integers'));139- if (isInteger(level.width) && isInteger(level.height) && isInteger(object.x) && isInteger(object.y) &&140- (object.x < 0 || object.x >= level.width || object.y < 0 || object.y >= level.height)) {141- errors.push(issue(objectPath, `position (${object.x}, ${object.y}) is outside the ${level.width}x${level.height} board`));142- }143- if (object.id !== undefined && (typeof object.id !== 'string' || object.id.length === 0)) {144- errors.push(issue(`${objectPath}.id`, 'must be a non-empty string'));145- }146- const effectiveId = typeof object.id === 'string' && object.id.length > 0 ? object.id : `o${objectIndex}`;147- if (ids.has(effectiveId)) errors.push(issue(`${objectPath}.id`, `duplicates another object id (${effectiveId})`));148- else ids.add(effectiveId);149- const kind = object.kind || (object.text !== undefined ? 'word' : 'object');150- if (kind !== 'word' && kind !== 'object') errors.push(issue(`${objectPath}.kind`, "must be 'word' or 'object'"));151- if (kind === 'word') {152- if (!token(typeof object.text === 'string' ? object.text.toUpperCase() : object.text)) {153- errors.push(issue(`${objectPath}.text`, 'must be an uppercase token'));154- }155- } else if (!token(typeof object.noun === 'string' ? object.noun.toUpperCase() : object.noun) || RESERVED.has(String(object.noun).toUpperCase())) {156- errors.push(issue(`${objectPath}.noun`, 'must be an uppercase noun token, not a rule token'));157- } else if (declaredProperties.has(String(object.noun).toUpperCase())) {158- errors.push(issue(`${objectPath}.noun`, `${String(object.noun).toUpperCase()} is a declared property and cannot be an object noun`));159- }160- });161- if (level.automata !== undefined) {162- if (!Array.isArray(level.automata)) {163- errors.push(issue(`${path}.automata`, `must be an array of at most ${MAX_LEVEL_AUTOMATA} declarative automata`));164- } else if (level.automata.length > MAX_LEVEL_AUTOMATA) {165- errors.push(issue(`${path}.automata`, `must contain at most ${MAX_LEVEL_AUTOMATA} entries`));166- } else {167- const automataIds = new Set();168- level.automata.forEach((automaton, automatonIndex) => {169- const automatonPath = `${path}.automata[${automatonIndex}]`;170- if (!isRecord(automaton)) {171- errors.push(issue(automatonPath, 'must be an object'));172- return;173- }174- for (const key of Object.keys(automaton)) {175- if (!['id', 'subject', 'property', 'pattern', 'phase'].includes(key)) {176- errors.push(issue(`${automatonPath}.${key}`, 'unsupported field'));177- }178- }179- if (typeof automaton.id !== 'string' || !LEVEL_ID_RE.test(automaton.id)) {180- errors.push(issue(`${automatonPath}.id`, 'must be a stable lowercase id starting with a letter'));181- } else if (automataIds.has(automaton.id)) {182- errors.push(issue(`${automatonPath}.id`, `duplicates another automaton id (${automaton.id})`));183- } else automataIds.add(automaton.id);184- const subject = typeof automaton.subject === 'string' ? automaton.subject.toUpperCase() : automaton.subject;185- if (automaton.subject !== undefined && (!token(subject) || RESERVED.has(subject))) errors.push(issue(`${automatonPath}.subject`, 'must be an uppercase noun token, not a rule token'));186- const property = typeof automaton.property === 'string' ? automaton.property.toUpperCase() : automaton.property;187- if (!token(property) || CORE_PROPERTIES.has(property)) errors.push(issue(`${automatonPath}.property`, 'must be a custom uppercase property token'));188- if (!Array.isArray(automaton.pattern) || automaton.pattern.length < 1 || automaton.pattern.length > MAX_AUTOMATON_PATTERN) {189- errors.push(issue(`${automatonPath}.pattern`, `must contain 1 to ${MAX_AUTOMATON_PATTERN} steps`));190- } else automaton.pattern.forEach((step, stepIndex) => {191- const stepPath = `${automatonPath}.pattern[${stepIndex}]`;192- if (!isRecord(step)) {193- errors.push(issue(stepPath, "must be { action: 'move', dx, dy } or { action: 'wait' }"));194- return;195- }196- for (const key of Object.keys(step)) if (!['action', 'dx', 'dy', 'cue', 'sound'].includes(key)) errors.push(issue(`${stepPath}.${key}`, 'unsupported field'));197- if (step.action !== 'move' && step.action !== 'wait') errors.push(issue(`${stepPath}.action`, "must be 'move' or 'wait'"));198- if (step.action === 'move' && (!isInteger(step.dx) || !isInteger(step.dy) || Math.abs(step.dx) + Math.abs(step.dy) !== 1)) {199- errors.push(issue(stepPath, 'move must use one cardinal step with dx/dy of -1, 0, or 1'));200- }201- if (step.action === 'wait' && (step.dx !== undefined || step.dy !== undefined)) errors.push(issue(stepPath, 'wait cannot include dx or dy'));202- 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)'));203- 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'));204- });205- if (automaton.phase !== undefined && (!isInteger(automaton.phase) || automaton.phase < 0 || (Array.isArray(automaton.pattern) && automaton.phase >= automaton.pattern.length))) {206- errors.push(issue(`${automatonPath}.phase`, 'must be an integer initial beat index within pattern'));207- }208- });209- }210- }98+ const result = inspectLevel(level, { profile: 'pack', path });99+ for (const entry of result.issues) errors.push(issue(entry.path, entry.message));100+ schemaIssues?.push(...result.issues);101+ return result.value;211102 }212103 213104 /** Return all validation messages without throwing. */214-export function validateMod(mod) {105+export function validateMod(mod, schemaIssues = null) {215106 const errors = [];216107 if (!isRecord(mod)) return [issue('module.exports', 'must be an object')];217108 const manifest = mod.manifest;
@@ -246,7 +137,7 @@ export function validateMod(mod) {
246137 else {247138 const levelIds = new Set();248139 mod.levels.forEach((level, index) => {249- validateLevel(level, index, errors);140+ validateLevel(level, index, errors, schemaIssues);250141 if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {251142 if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));252143 else levelIds.add(level.id);
@@ -258,7 +149,7 @@ export function validateMod(mod) {
258149 if (mod.vocabulary !== undefined) {259150 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));260151 else mod.vocabulary.forEach((word, index) => {261- if (!token(word) || RESERVED.has(word)) errors.push(issue(`vocabulary[${index}]`, 'must be an uppercase noun token, not a rule token'));152+ if (!token(word) || RESERVED_TOKENS.has(word)) errors.push(issue(`vocabulary[${index}]`, 'must be an uppercase noun token, not a rule token'));262153 });263154 }264155 return errors;
@@ -268,20 +159,7 @@ function normalizeMod(mod) {
268159 const normalized = copy(mod);269160 normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;270161 normalized.manifest.rules = normalized.manifest.rules ?? LEGACY_RULES_VERSION;271- normalized.levels = normalized.levels.map(level => ({272- ...level,273- ...(Array.isArray(level.automata) ? { automata: level.automata.map(automaton => ({274- ...automaton,275- ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),276- ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),277- })) } : {}),278- objects: level.objects.map(object => ({279- ...object,280- kind: object.kind || (object.text !== undefined ? 'word' : 'object'),281- ...(object.text !== undefined ? { text: object.text.toUpperCase() } : {}),282- ...(object.noun !== undefined ? { noun: object.noun.toUpperCase() } : {}),283- })),284- }));162+ normalized.levels = normalized.levels.map((level, index) => normalizeLevel(level, { profile: 'pack', path: `levels[${index}]` }));285163 normalized.vocabulary = normalized.vocabulary || [];286164 return normalized;287165 }
@@ -316,7 +194,8 @@ export function loadMod(source, options = {}) {
316194 } catch (error) {317195 throw new Error(`Could not load ${filename}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });318196 }319- const errors = validateMod(module.exports);197+ const schemaIssues = [];198+ const errors = validateMod(module.exports, schemaIssues);320199 if (errors.length) {321200 // Keep only the fields needed to translate validation paths into player322201 // copy. This avoids handing the evaluator's complete export to UI code.
@@ -334,7 +213,7 @@ export function loadMod(source, options = {}) {
334213 })) : [],335214 })) }336215 : null;337- throw new ModValidationError(errors, context);216+ throw new ModValidationError(errors, context, schemaIssues);338217 }339218 return normalizeMod(module.exports);340219 }
M src/pack-service.js +2 -4
M src/scenes/gift.js +2 -1
M src/share.js +19 -14
@@ -7,6 +7,7 @@
77 */88 import { validateMod, LEGACY_RULES_VERSION, MOD_SCHEMA_VERSION } from './mods.js';99 import { cloneJsonData } from './data/clone.js';10+import { LevelSchemaError, normalizeLevel } from './level-schema.js';1011 1112 export const SHARE_SCHEMA_VERSION = 1;1213 export const MAX_SHARED_JSON_BYTES = 48 * 1024;
@@ -37,20 +38,24 @@ function normalizePack(pack) {
3738 engine: copy.manifest?.engine ?? MOD_SCHEMA_VERSION,3839 rules: copy.manifest?.rules ?? LEGACY_RULES_VERSION,3940 };40- copy.levels = Array.isArray(copy.levels) ? copy.levels.map(level => ({41- ...level,42- ...(Array.isArray(level.automata) ? { automata: level.automata.map(automaton => ({43- ...automaton,44- ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),45- ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),46- })) } : {}),47- objects: Array.isArray(level.objects) ? level.objects.map(object => ({48- ...object,49- kind: object.kind || (object.text !== undefined ? 'word' : 'object'),50- ...(object.text !== undefined ? { text: String(object.text).toUpperCase() } : {}),51- ...(object.noun !== undefined ? { noun: String(object.noun).toUpperCase() } : {}),52- })) : level.objects,53- })) : copy.levels;41+ if (Array.isArray(copy.levels)) {42+ const levelIssues = [];43+ copy.levels = copy.levels.map((level, index) => {44+ try {45+ return normalizeLevel(level, { profile: 'pack', path: `levels[${index}]` });46+ } catch (error) {47+ if (error instanceof LevelSchemaError) levelIssues.push(...error.issues);48+ return level;49+ }50+ });51+ if (levelIssues.length) {52+ const errors = levelIssues.map(entry => `${entry.path}: ${entry.message}`);53+ const failure = new TypeError(`Invalid shared pack:\n- ${errors.join('\n- ')}`);54+ failure.errors = errors;55+ failure.issues = levelIssues;56+ throw failure;57+ }58+ }5459 if (Array.isArray(copy.vocabulary)) copy.vocabulary = copy.vocabulary.map(word => String(word).toUpperCase());5560 if (isObject(copy.palette)) {5661 const palette = {};
A tests/level-schema.test.js +128 -0
@@ -0,0 +1,128 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { LevelSchemaError, normalizeLevel, validateLevel } from '../src/level-schema.js';4+import { createGame } from '../src/engine.js';5+import { playableLevelFromView } from '../src/level-view.js';6+import { loadMod } from '../src/mods.js';7+import { decodeSharedPack, encodeSharedPack, exportCommonJS } from '../src/share.js';8+9+test('normalizes legacy level shorthand into cloned canonical data', () => {10+ const source = {11+ width: 4,12+ height: 1,13+ objects: [{ text: 'miau', x: 0, y: 0 }, { noun: 'cat', x: 1, y: 0 }],14+ automata: [{ id: 'patrol', property: 'tick', pattern: ['L', 'W', { action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }] }],15+ };16+ const canonical = normalizeLevel(source);17+ assert.deepEqual(canonical.objects, [18+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },19+ { kind: 'object', noun: 'CAT', x: 1, y: 0 },20+ ]);21+ assert.deepEqual(canonical.automata[0].pattern, [22+ { action: 'move', dx: -1, dy: 0 },23+ { action: 'wait' },24+ { action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' },25+ ]);26+ source.objects[0].text = 'ROCK';27+ source.automata[0].pattern[0] = 'R';28+ assert.equal(canonical.objects[0].text, 'MIAU');29+ assert.deepEqual(canonical.automata[0].pattern[0], { action: 'move', dx: -1, dy: 0 });30+});31+32+test('pack profile owns identity and size policy', () => {33+ const level = { width: 1, height: 1, objects: [] };34+ assert.deepEqual(validateLevel(level), []);35+ const issues = validateLevel(level, { profile: 'pack', path: 'levels[0]' });36+ assert.equal(issues[0].code, 'level-id');37+ assert.equal(issues[0].path, 'levels[0].id');38+});39+40+test('invalid vectors and unsupported fields expose stable issue codes', () => {41+ const result = validateLevel({42+ width: 2,43+ height: 1,44+ objects: [],45+ automata: [{ id: 'clock', property: 'tick', pattern: [{ action: 'move', dx: 1, dy: 1, extra: true }] }],46+ });47+ assert.deepEqual(result.map(issue => issue.code), ['unsupported-field', 'automaton-vector']);48+ assert.throws(() => normalizeLevel({ width: 2, height: 1, objects: [], automata: [{ id: 'clock', property: 'tick', pattern: ['Q'] }] }), error => {49+ assert.ok(error instanceof LevelSchemaError);50+ assert.equal(error.issues[0].code, 'automaton-action');51+ return true;52+ });53+});54+55+test('rejects unknown level and object fields instead of dropping authored data', () => {56+ const issues = validateLevel({ width: 1, height: 1, channel: 'gate', objects: [{ noun: 'CAT', x: 0, y: 0, leaves: true }] });57+ assert.deepEqual(issues.map(issue => issue.path), ['level.channel', 'level.objects[0].leaves']);58+ assert.deepEqual(issues.map(issue => issue.code), ['unsupported-field', 'unsupported-field']);59+});60+61+test('engine diagnostics retain the second automaton identity and canonical issue', () => {62+ assert.throws(() => createGame({63+ width: 1,64+ height: 1,65+ objects: [],66+ automata: [67+ { id: 'first', property: 'TICK', pattern: [{ action: 'wait' }] },68+ { id: 'second', property: 'MOVE', pattern: [{ action: 'move', dx: 1, dy: 1 }] },69+ ],70+ }), error => {71+ assert.equal(error.issueCode, 'automaton-vector');72+ assert.equal(error.issuePath, 'level.automata[1].pattern[0]');73+ assert.match(error.message, /automata second pattern\[0\]/);74+ return true;75+ });76+});77+78+test('catalog and Gift view metadata stays outside the playable level boundary', () => {79+ const authored = { id: 'core:proof', width: 1, height: 1, objects: [] };80+ const view = {81+ ...authored,82+ globalId: 'core:proof', localId: 'proof', packId: 'core',83+ packName: 'Miau Core', origin: 'bundled', palette: { MIAU: '🐈' },84+ };85+ assert.deepEqual(createGame(playableLevelFromView(view)).getState(), createGame(authored).getState());86+ assert.equal(view.localId, 'proof');87+ assert.deepEqual(validateLevel(playableLevelFromView({ ...view, unsupported: true })).map(issue => issue.path), ['level.unsupported']);88+});89+90+test('direct, CommonJS, and shared levels agree on malformed coordinates', () => {91+ const level = { id: 'broken', width: 1, height: 1, objects: [{ noun: 'MIAU', x: '0', y: 0 }] };92+ const pack = { manifest: { id: 'schema-parity', name: 'Schema Parity', version: '1.0.0' }, levels: [level] };93+ assert.throws(() => createGame(level), error => error.issueCode === 'coordinate-type' && error.issuePath === 'level.objects[0]');94+ assert.throws(() => loadMod(`module.exports = ${JSON.stringify(pack)};`), error => {95+ assert.equal(error.issues[0].code, 'coordinate-type');96+ assert.equal(error.issues[0].path, 'levels[0].objects[0]');97+ return true;98+ });99+ assert.throws(() => encodeSharedPack(pack), error => {100+ assert.equal(error.issues[0].code, 'coordinate-type');101+ assert.equal(error.issues[0].path, 'levels[0].objects[0]');102+ return true;103+ });104+});105+106+test('legacy automaton shorthand round trips as canonical gameplay data', () => {107+ const word = (text, x, y) => ({ text, x, y });108+ const level = {109+ id: 'patrol', title: 'Patrol', width: 7, height: 3,110+ objects: [111+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),112+ word('FENN', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),113+ { noun: 'MIAU', x: 4, y: 0 }, { noun: 'FENN', x: 4, y: 1 },114+ ],115+ automata: [{ id: 'fox', subject: 'FENN', property: 'tick', pattern: ['R', 'W', 'L'] }],116+ };117+ const pack = { manifest: { id: 'schema-patrol', name: 'Schema Patrol', version: '1.0.0' }, levels: [level] };118+ const local = loadMod(`module.exports = ${JSON.stringify(pack)};`);119+ const exported = loadMod(exportCommonJS(pack));120+ const shared = decodeSharedPack(encodeSharedPack(pack));121+ for (const candidate of [local, exported, shared]) {122+ assert.deepEqual(candidate.levels[0].automata[0].pattern, [123+ { action: 'move', dx: 1, dy: 0 }, { action: 'wait' }, { action: 'move', dx: -1, dy: 0 },124+ ]);125+ assert.deepEqual(createGame(candidate.levels[0]).getState(), createGame(level).getState());126+ assert.deepEqual(createGame(candidate.levels[0]).wait().state, createGame(level).wait().state);127+ }128+});