clone

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

main
miau-is-true / src / mods.js
mods.js 11.6K
1import { parseRules, RuleParseLimitError } from './engine/rules.js';2import { CORE_PROPERTIES, inspectLevel, LEVEL_ID_RE, normalizeLevel, RESERVED_TOKENS, TOKEN_RE } from './level-schema.js';3export { MAX_AUTOMATON_PATTERN, MAX_LEVEL_AUTOMATA, MAX_LEVEL_OBJECTS } from './level-schema.js';4export { collectModWarnings, MAX_MOD_WARNINGS } from './mods/diagnostics.js';56/**7 * Browser loader and validator for local CommonJS mods.8 *9 * A mod is source text that assigns an object to `module.exports` (or to10 * `exports`). The host is responsible for obtaining that source from a local11 * file or an editor. This loader deliberately has no URL or package resolver.12 */1314export const MOD_SCHEMA_VERSION = 1;15export const CURRENT_RULES_VERSION = 2;16export const LEGACY_RULES_VERSION = 1;17export const MAX_MOD_SOURCE_LENGTH = 512 * 1024;18export const MAX_MOD_LEVELS = 64;19export const MAX_LEVEL_TITLE_LENGTH = 64;20export const SUPPORTED_MOD_FEATURES = Object.freeze(['automata']);2122const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;23const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;24const FEATURE_RE = /^[a-z][a-z0-9._-]{0,31}$/;2526export class ModValidationError extends Error {27 constructor(errors, source = null, issues = []) {28 const list = Array.isArray(errors) ? errors : [errors];29 super(`Invalid mod${list.length === 1 ? '' : ' (multiple errors)'}:\n- ${list.join('\n- ')}`);30 this.name = 'ModValidationError';31 this.errors = list;32 this.source = source;33 this.issues = issues;34 }35}3637function issue(path, message) { return `${path}: ${message}`; }38function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }39function isInteger(value) { return Number.isInteger(value); }40function token(value) { return typeof value === 'string' && TOKEN_RE.test(value); }41function copy(value) {42 return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));43}4445function validateInitialRuleComplexity(level, index, errors) {46 if (!isRecord(level) || !Array.isArray(level.objects) || !isInteger(level.width) || !isInteger(level.height)) return;47 // Structural validation owns the detailed malformed object messages. Only48 // ask the parser about boards that have enough shape to be parsed safely.49 if (level.objects.some(object => !isRecord(object) || !isInteger(object.x) || !isInteger(object.y))) return;50 const semanticProperties = new Set(CORE_PROPERTIES);51 for (const automaton of Array.isArray(level.automata) ? level.automata : []) {52 if (typeof automaton?.property === 'string') semanticProperties.add(automaton.property.toUpperCase());53 }54 const objects = level.objects.map((object, objectIndex) => ({55 ...object,56 id: object.id || `o${objectIndex}`,57 kind: object.kind || (object.text !== undefined ? 'word' : 'object'),58 ...(typeof object.text === 'string' ? { text: object.text.toUpperCase() } : {}),59 ...(typeof object.noun === 'string' ? { noun: object.noun.toUpperCase() } : {}),60 }));61 try {62 parseRules(objects, semanticProperties);63 } catch (error) {64 if (error instanceof RuleParseLimitError) {65 const kind = error.kind || 'complexity';66 const limit = Number.isInteger(error.limit) ? ` (${error.limit})` : '';67 errors.push(issue(`levels[${index}].objects`, `rule parse ${kind} budget exceeded${limit}; reduce stacked rule alternatives`));68 }69 }70}7172function validatePalette(palette, errors) {73 if (palette === undefined) return;74 if (!isRecord(palette)) {75 errors.push(issue('palette', 'must be an object mapping noun tokens to emoji or text'));76 return;77 }78 for (const [noun, glyph] of Object.entries(palette)) {79 if (!token(noun) || RESERVED_TOKENS.has(noun)) errors.push(issue(`palette.${noun}`, 'key must be an uppercase noun token, not a rule token'));80 if (typeof glyph === 'string') {81 if (glyph.length === 0 || glyph.length > 16) errors.push(issue(`palette.${noun}`, 'value must be a non-empty display string (16 characters maximum)'));82 } else if (isRecord(glyph)) {83 for (const key of Object.keys(glyph)) if (key !== 'glyph' && key !== 'faces') {84 errors.push(issue(`palette.${noun}.${key}`, "unsupported field; use only 'glyph' and 'faces'"));85 }86 if (typeof glyph.glyph !== 'string' || glyph.glyph.length === 0 || glyph.glyph.length > 16) {87 errors.push(issue(`palette.${noun}.glyph`, 'must be a non-empty display string (16 characters maximum)'));88 }89 if (glyph.faces !== 'left' && glyph.faces !== 'right') errors.push(issue(`palette.${noun}.faces`, "must be 'left' or 'right'"));90 } else {91 errors.push(issue(`palette.${noun}`, 'value must be a display string or { glyph, faces: \'left\' | \'right\' }'));92 }93 }94}9596function validateLevel(level, index, errors, schemaIssues) {97 const path = `levels[${index}]`;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;102}103104/** Return all validation messages without throwing. */105export function validateMod(mod, schemaIssues = null) {106 const errors = [];107 if (!isRecord(mod)) return [issue('module.exports', 'must be an object')];108 const manifest = mod.manifest;109 if (!isRecord(manifest)) errors.push(issue('manifest', 'is required and must be an object'));110 else {111 if (typeof manifest.id !== 'string' || !ID_RE.test(manifest.id)) errors.push(issue('manifest.id', 'must be 2–64 characters: lowercase letters, numbers, ., _, or -'));112 if (typeof manifest.name !== 'string' || manifest.name.trim() === '') errors.push(issue('manifest.name', 'must be a non-empty string'));113 if (manifest.author !== undefined && (typeof manifest.author !== 'string' || manifest.author.trim() === '' || manifest.author.length > 120)) errors.push(issue('manifest.author', 'must be a non-empty name of at most 120 characters'));114 if (manifest.forkOf !== undefined && (typeof manifest.forkOf !== 'string' || !ID_RE.test(manifest.forkOf))) errors.push(issue('manifest.forkOf', 'must be the original pack id'));115 if (typeof manifest.version !== 'string' || !VERSION_RE.test(manifest.version)) errors.push(issue('manifest.version', 'must be a semantic version such as 1.0.0'));116 if (manifest.engine !== undefined && manifest.engine !== MOD_SCHEMA_VERSION) errors.push(issue('manifest.engine', `must be ${MOD_SCHEMA_VERSION} for this loader`));117 if (manifest.rules !== undefined) {118 if (!isInteger(manifest.rules) || manifest.rules < 1) errors.push(issue('manifest.rules', 'must be a positive integer no greater than 2'));119 else if (manifest.rules > CURRENT_RULES_VERSION) errors.push(issue('manifest.rules', `unsupported future rules revision ${manifest.rules}; host supports ${CURRENT_RULES_VERSION}`));120 }121 }122 if (manifest && manifest.features !== undefined) {123 if (!Array.isArray(manifest.features)) errors.push(issue('manifest.features', 'must be an array of unique lowercase feature identifiers'));124 else {125 const features = new Set();126 manifest.features.forEach((feature, index) => {127 if (typeof feature !== 'string' || !FEATURE_RE.test(feature)) errors.push(issue(`manifest.features[${index}]`, 'must be a lowercase feature identifier'));128 else if (features.has(feature)) errors.push(issue(`manifest.features[${index}]`, `duplicates feature '${feature}'`));129 else {130 features.add(feature);131 if (!SUPPORTED_MOD_FEATURES.includes(feature)) errors.push(issue(`manifest.features[${index}]`, `unsupported feature '${feature}'`));132 }133 });134 }135 }136 if (!Array.isArray(mod.levels) || mod.levels.length === 0 || mod.levels.length > MAX_MOD_LEVELS) errors.push(issue('levels', `must contain 1 to ${MAX_MOD_LEVELS} levels`));137 else {138 const levelIds = new Set();139 mod.levels.forEach((level, index) => {140 validateLevel(level, index, errors, schemaIssues);141 if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {142 if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));143 else levelIds.add(level.id);144 }145 });146 }147 if (Array.isArray(mod.levels)) mod.levels.forEach((level, index) => validateInitialRuleComplexity(level, index, errors));148 validatePalette(mod.palette, errors);149 if (mod.vocabulary !== undefined) {150 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));151 else mod.vocabulary.forEach((word, index) => {152 if (!token(word) || RESERVED_TOKENS.has(word)) errors.push(issue(`vocabulary[${index}]`, 'must be an uppercase noun token, not a rule token'));153 });154 }155 return errors;156}157158function normalizeMod(mod) {159 const normalized = copy(mod);160 normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;161 normalized.manifest.rules = normalized.manifest.rules ?? LEGACY_RULES_VERSION;162 normalized.levels = normalized.levels.map((level, index) => normalizeLevel(level, { profile: 'pack', path: `levels[${index}]` }));163 normalized.vocabulary = normalized.vocabulary || [];164 return normalized;165}166167function engineHelpers() {168 const word = (text, x, y, id) => ({ kind: 'word', text: String(text).toUpperCase(), x, y, ...(id ? { id } : {}) });169 const object = (noun, x, y, id) => ({ kind: 'object', noun: String(noun).toUpperCase(), x, y, ...(id ? { id } : {}) });170 const level = (width, height, objects, metadata = {}) => ({ width, height, objects, ...metadata });171 return Object.freeze({ schemaVersion: MOD_SCHEMA_VERSION, word, object, level });172}173174/**175 * Evaluate trusted, host-provided CommonJS source and return a validated mod.176 * No require other than `@miau/engine` is available, and it is not possible to177 * load a URL or package from this function. Callers should label local files178 * as trusted before passing their text here.179 */180export function loadMod(source, options = {}) {181 if (typeof source !== 'string') throw new TypeError('loadMod(source): source must be a string');182 if (source.length > MAX_MOD_SOURCE_LENGTH) throw new RangeError(`CommonJS mod source exceeds ${MAX_MOD_SOURCE_LENGTH} characters`);183 const filename = String(options.filename || 'mod.cjs').replace(/[\r\n]/g, ' ');184 const module = { exports: {} };185 const exports = module.exports;186 const require = name => {187 if (name === '@miau/engine') return engineHelpers();188 throw new Error(`Cannot require '${name}' in ${filename}; only @miau/engine is available`);189 };190 let execute;191 try {192 execute = new Function('module', 'exports', 'require', `'use strict';\n//# sourceURL=${filename}\n${source}`);193 execute(module, exports, require);194 } catch (error) {195 throw new Error(`Could not load ${filename}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });196 }197 const schemaIssues = [];198 const errors = validateMod(module.exports, schemaIssues);199 if (errors.length) {200 // Keep only the fields needed to translate validation paths into player201 // copy. This avoids handing the evaluator's complete export to UI code.202 const context = Array.isArray(module.exports?.levels)203 ? { levels: module.exports.levels.map(level => ({204 id: level?.id,205 title: level?.title,206 objects: Array.isArray(level?.objects) ? level.objects.map(object => ({207 id: object?.id,208 kind: object?.kind,209 noun: object?.noun,210 text: object?.text,211 x: object?.x,212 y: object?.y,213 })) : [],214 })) }215 : null;216 throw new ModValidationError(errors, context, schemaIssues);217 }218 return normalizeMod(module.exports);219}220221export const isMod = value => validateMod(value).length === 0;