clone

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

main
miau-is-true / src / level-schema.js
level-schema.js 14.4K
1import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';23/** Shared, data-only level boundary used by the engine and pack adapters. */4export const MAX_LEVEL_WIDTH = 128;5export const MAX_LEVEL_HEIGHT = 128;6export const MAX_LEVEL_OBJECTS = 4096;7export const MAX_LEVEL_AUTOMATA = 32;8export const MAX_AUTOMATON_PATTERN = 32;9export const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;10export const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;11export const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);12export const RESERVED_TOKENS = new Set(['IS', 'AND', ...CORE_PROPERTY_TOKENS]);13export const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);1415const ACTION_KEYS = new Set(['action', 'dx', 'dy', 'cue', 'sound']);16const AUTOMATON_KEYS = new Set(['id', 'subject', 'property', 'pattern', 'phase']);17const LEVEL_KEYS = new Set(['id', 'title', 'hint', 'category', 'introduces', 'reuses', 'width', 'height', 'objects', 'automata', 'allowBypass', 'allowHandsOff', 'mustForm', 'solverBudget']);18const OBJECT_KEYS = new Set(['id', 'kind', 'text', 'noun', 'x', 'y']);19const DIRECTIONS = Object.freeze({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] });2021const clone = value => typeof structuredClone === 'function'22 ? structuredClone(value)23 : JSON.parse(JSON.stringify(value));2425export 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}3334function add(issues, code, path, message, value) {35 issues.push({ code, path, message, ...(value === undefined ? {} : { value }) });36}3738function token(value) { return typeof value === 'string' && TOKEN_RE.test(value); }39function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }40function integer(value) { return Number.isInteger(value); }4142function 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}7475function 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 === undefined ? (object.text !== undefined ? 'word' : 'object') : object.kind;84 if (kind !== 'word' && kind !== 'object') add(issues, 'object-kind', `${path}.kind`, "must be 'word' or 'object'", kind);85 if (kind === 'word' && object.noun !== undefined) add(issues, 'object-field', `${path}.noun`, 'word cannot include noun', object.noun);86 if (kind === 'object' && object.text !== undefined) add(issues, 'object-field', `${path}.text`, 'object cannot include text', object.text);87 const text = typeof object.text === 'string' ? object.text.toUpperCase() : object.text;88 const noun = typeof object.noun === 'string' ? object.noun.toUpperCase() : object.noun;89 if (kind === 'word') {90 if (!token(text)) add(issues, 'word-token', `${path}.text`, 'must be an uppercase token', object.text);91 } else if (!token(noun) || RESERVED_TOKENS.has(noun)) {92 add(issues, 'noun-token', `${path}.noun`, 'must be an uppercase noun token, not a rule token', object.noun);93 } else if (customProperties.has(noun)) {94 add(issues, 'reserved-automaton-property', `${path}.noun`, `${noun} is a declared property and cannot be an object noun`, object.noun);95 }96 if (issues.length !== startIssueCount) return null;97 return { ...(object.id !== undefined ? { id: object.id } : {}), kind, ...(kind === 'word' ? { text } : { noun }), x: object.x, y: object.y };98}99100/** Return canonical data and stable structural issues without mutating input. */101export function inspectLevel(level, options = {}) {102 const profile = options.profile || 'standalone';103 const path = options.path || 'level';104 const issues = [];105 if (!record(level)) return { value: null, issues: [{ code: 'level-shape', path, message: 'must be an object', value: level }] };106 for (const key of Object.keys(level)) if (!LEVEL_KEYS.has(key)) add(issues, 'unsupported-field', `${path}.${key}`, 'unsupported field', level[key]);107 const width = level.width;108 const height = level.height;109 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);110 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);111 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);112 if (profile === 'standalone' && level.id !== undefined && typeof level.id !== 'string') add(issues, 'level-id', `${path}.id`, 'must be a string when provided', level.id);113 if (level.title !== undefined && typeof level.title !== 'string') add(issues, 'title-type', `${path}.title`, 'must be a string', level.title);114 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);115 if (level.hint !== undefined && typeof level.hint !== 'string') add(issues, 'hint-type', `${path}.hint`, 'must be a string', level.hint);116 if (level.category !== undefined && typeof level.category !== 'string') add(issues, 'category-type', `${path}.category`, 'must be a string', level.category);117 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);118 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);119 if (level.allowBypass !== undefined && typeof level.allowBypass !== 'boolean') add(issues, 'allow-bypass-type', `${path}.allowBypass`, 'must be a boolean', level.allowBypass);120 if (level.allowHandsOff !== undefined && typeof level.allowHandsOff !== 'boolean') add(issues, 'allow-hands-off-type', `${path}.allowHandsOff`, 'must be a boolean', level.allowHandsOff);121 if (level.solverBudget !== undefined && (!integer(level.solverBudget) || level.solverBudget < 1)) add(issues, 'solver-budget-type', `${path}.solverBudget`, 'must be a positive integer', level.solverBudget);122 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);123 if (!Array.isArray(level.objects)) add(issues, 'objects-shape', `${path}.objects`, 'must be an array', level.objects);124 else {125 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);126 }127 const automataInput = level.automata;128 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);129 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);130 const customProperties = new Set((Array.isArray(automataInput) ? automataInput : []).map(system => typeof system?.property === 'string' ? system.property.toUpperCase() : null).filter(Boolean));131 const objectIds = new Set();132 const objects = [];133 if (Array.isArray(level.objects) && integer(width) && integer(height)) level.objects.forEach((object, index) => {134 const objectPath = `${path}.objects[${index}]`;135 const canonical = normalizeObject(object, index, { width, height }, objectPath, customProperties, issues);136 const id = typeof object?.id === 'string' && object.id.length > 0 ? object.id : `o${index}`;137 if (objectIds.has(id)) add(issues, 'duplicate-object-id', `${objectPath}.id`, `duplicates another object id (${id})`, id);138 else objectIds.add(id);139 if (canonical) objects.push(canonical);140 });141 const automata = [];142 const automatonIds = new Set();143 if (Array.isArray(automataInput)) automataInput.forEach((system, index) => {144 const automatonPath = `${path}.automata[${index}]`;145 const startIssueCount = issues.length;146 if (!record(system)) { add(issues, 'automaton-shape', automatonPath, 'must be an object', system); return; }147 for (const key of Object.keys(system)) if (!AUTOMATON_KEYS.has(key)) add(issues, 'unsupported-field', `${automatonPath}.${key}`, 'unsupported field', system[key]);148 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);149 else if (automatonIds.has(system.id)) add(issues, 'duplicate-automaton-id', `${automatonPath}.id`, `duplicates another automaton id (${system.id})`, system.id);150 else automatonIds.add(system.id);151 const subject = typeof system.subject === 'string' ? system.subject.toUpperCase() : system.subject;152 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);153 const property = typeof system.property === 'string' ? system.property.toUpperCase() : system.property;154 if (!token(property) || CORE_PROPERTIES.has(property)) add(issues, 'automaton-property', `${automatonPath}.property`, 'must be a custom uppercase property token', system.property);155 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);156 const pattern = Array.isArray(system.pattern) ? system.pattern.map((action, actionIndex) => canonicalAction(action, `${automatonPath}.pattern[${actionIndex}]`, issues)).filter(Boolean) : [];157 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);158 if (issues.length === startIssueCount) automata.push({ id: system.id, ...(subject === undefined ? {} : { subject }), property, pattern, ...(system.phase === undefined ? {} : { phase: system.phase }) });159 });160 if (issues.length) return { value: null, issues };161 const canonical = {};162 for (const key of LEVEL_KEYS) if (level[key] !== undefined && key !== 'objects' && key !== 'automata') canonical[key] = clone(level[key]);163 canonical.width = width;164 canonical.height = height;165 canonical.objects = objects;166 if (automataInput !== undefined) canonical.automata = automata;167 return { value: canonical, issues: [] };168}169170export function normalizeLevel(level, options = {}) {171 const result = inspectLevel(level, options);172 if (result.issues.length) throw new LevelSchemaError(result.issues);173 return result.value;174}175176export function validateLevel(level, options = {}) {177 return inspectLevel(level, options).issues;178}