- commit
- 2950532bd6347894934f5d91bb1b270d8ccd56d0
- parent
- 006d6caf41dde8f4aee048359f6e45fa0617cad8
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 116 | +++++++------------ |
| A | src/ | 176 | ++++++++++++++++++++++++++++++ |
| A | src/ | 5 | + |
| M | src/ | 3 | +- |
| M | src/ | 155 | +++------------------------ |
| M | src/ | 6 | +- |
| M | src/ | 3 | +- |
| M | src/ | 33 | +++-- |
| A | tests/ | 128 | ++++++++++++++++++++++ |
@@ -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.@@ -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+}@@ -19,6 +19,7 @@ import { createDevView } from './app/dev-view.js';
1919 import { createIntentRouter } from './app/intent-router.js';2020 import { createPresentationClock, parseSlowmo } from './presentation-clock.js';2121 import { winHandoffIsCurrent } from './app/level-identity.js';22+import { playableLevelFromView } from './level-view.js';2223 import './style.css';2324 import './overrides.css';2425 import './font.css';@@ -138,7 +139,7 @@ function loadLevel(index) {
138139 feedback = '';139140 catalog.setActiveLevel(selected.id);140141 activeLevelId = catalog.activeLevelId;141- levelNo = selectedIndex; game = createGame(selected); lastState = game.getState();142+ levelNo = selectedIndex; game = createGame(playableLevelFromView(selected)); lastState = game.getState();142143 levelTitle.textContent = lastState.title; levelIndex.textContent = `${String(levelNo + 1).padStart(2,'0')} / ${String(list.length).padStart(2,'0')}`;143144 hintCopy.textContent = lastState.hint || 'Find the sentence that changes the proof.';144145 hintPrepared = prepare(hintCopy.textContent, '12px Space Grotesk');@@ -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 }@@ -4,6 +4,7 @@ import { createCatalog } from './catalog.js';
44 import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';55 import { cloneData } from './data/clone.js';66 import { resolveWorldMetadata } from './mods/world.js';7+import { playableLevelFromView } from './level-view.js';78 89 function errorDetails(error) {910 return Array.isArray(error?.errors) ? error.errors.map(String)@@ -84,10 +85,7 @@ function sourceFromView(view) {
8485 manifest: manifestFromView(view),8586 vocabulary: cloneData(view.vocabulary || []),8687 palette: cloneData(view.palette || {}),87- levels: view.levels.map(level => {88- const { globalId, localId, packId, packName, origin, palette, ...authored } = level;89- return authored;90- }),88+ levels: view.levels.map(playableLevelFromView),9189 };9290 }9391 @@ -1,5 +1,6 @@
11 import { drawBoard } from '../render/board.js';22 import { createGame } from '../engine.js';3+import { playableLevelFromView } from '../level-view.js';34 import { cloneData } from '../data/clone.js';45 import { warningLines } from './warning-lines.js';56 import { actionMatches, keycapPressFrame } from '../render/motion.js';@@ -225,7 +226,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
225226 // The raw pack level has no derived rules. Use the engine snapshot so226227 // active words, sentence bars, YOU markers, and the lost-control overlay227228 // all match how the proof will actually play.228- const previewGame = createGame(first);229+ const previewGame = createGame(playableLevelFromView(first));229230 const previewState = previewGame.getState();230231 drawBoard(ctx, previewState, { x: boardX, y: boardY, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {}, game: previewGame });231232 } else text(ctx, 'NO PREVIEW LEVEL', previewX, previewY + 65, { size: 13, weight: 800, color: COLORS.muted });@@ -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 = {};@@ -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+});