- commit
- 0d006837d17e4c5be5af9ccf96af05ea1b44cba6
- parent
- 16e1a2d6fd33b4474c6d6b3fac9445eb2325892c
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 94 | +++++++++++++++++ |
| A | mods/ | 40 | +++++++ |
| A | src/ | 164 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 91 | +++++++++++++++++ |
@@ -0,0 +1,94 @@
1+# Creating a mod2+3+Mods are local CommonJS source files. The browser host reads a `.cjs` file as4+text and passes it to `loadMod` from `src/mods.js`. A mod is trusted code: it is5+executed with `new Function`, so only load files the player chose or files that6+you shipped with the game. The loader never fetches a URL and does not provide7+Node's `require` or filesystem APIs. Since this is browser JavaScript, a trusted8+mod can still see the normal global browser environment; use a separate worker9+or iframe sandbox if you need to run untrusted code.10+11+## Smallest useful mod12+13+```js14+module.exports = {15+ manifest: { id: 'hello-miau', name: 'Hello Miau', version: '1.0.0' },16+ palette: { MIAU: '🐈', FLAG: '🚩' },17+ levels: [{18+ width: 5,19+ height: 3,20+ objects: [21+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },22+ { kind: 'word', text: 'IS', x: 1, y: 0 },23+ { kind: 'word', text: 'YOU', x: 2, y: 0 },24+ { kind: 'object', noun: 'MIAU', x: 0, y: 1 },25+ ],26+ }],27+};28+```29+30+The engine recognizes `word` records as rule text and `object` records as31+things in the puzzle. Word and noun values are normalized to uppercase. A32+level's `width` and `height` are cell counts, and every object position must be33+inside that rectangle. Multiple objects may share a cell.34+35+## Module contract36+37+The exported object has these fields:38+39+| Field | Required | Meaning |40+| --- | --- | --- |41+| `manifest` | yes | `id`, `name`, and semantic `version`; optional `engine` must be `1` |42+| `levels` | yes | One or more level objects |43+| `palette` | no | Uppercase noun to display string, usually an emoji |44+| `vocabulary` | no | Uppercase noun tokens used by the mod |45+46+Manifest IDs use lowercase letters, numbers, `.`, `_`, and `-`, and must start47+with a letter. Tokens use uppercase letters, numbers, `_`, and `-`. `IS` and48+`AND`, `YOU`, `PUSH`, `STOP`, `WIN`, `SINK`, and `DEFEAT` are reserved rule49+tokens. They may appear as word text, but cannot be object nouns, palette keys,50+or vocabulary entries. A level may also include `title` and `hint`.51+52+## The `@miau/engine` helper53+54+For less repetitive level declarations, the loader provides one deliberately55+small built-in module:56+57+```js58+const { word, object, level } = require('@miau/engine');59+60+module.exports = {61+ manifest: { id: 'helper-demo', name: 'Helper Demo', version: '1.0.0' },62+ levels: [level(4, 2, [63+ word('CAT', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),64+ object('CAT', 0, 1),65+ ], { title: 'First step' })],66+};67+```68+69+`word(text, x, y, id?)` and `object(noun, x, y, id?)` return ordinary level70+records. `level(width, height, objects, metadata?)` returns an ordinary level.71+No other `require` target is available. An attempt to require a package or72+remote module produces an error naming the unsupported import.73+74+## Loading and errors75+76+```js77+import { loadMod, ModValidationError } from './mods.js';78+79+try {80+ const mod = loadMod(sourceText, { filename: file.name });81+ // Add mod.levels to the level browser and use mod.palette for rendering.82+} catch (error) {83+ if (error instanceof ModValidationError) {84+ showMessage(error.errors.join('\\n'));85+ } else {86+ showMessage(error.message);87+ }88+}89+```90+91+Validation reports every problem in one pass: malformed manifest fields,92+missing levels, out-of-bounds positions, duplicate IDs, unsupported object93+kinds, invalid rule tokens, and invalid palette entries. This lets an editor94+show actionable errors without starting a partially loaded mod.@@ -0,0 +1,40 @@
1+// A complete, local CommonJS mod. The host passes this file's text to loadMod.2+const { word, object, level } = require('@miau/engine');3+4+module.exports = {5+ manifest: {6+ id: 'moon-cat',7+ name: 'Moon Cat',8+ version: '1.0.0',9+ engine: 1,10+ author: 'Miau examples',11+ description: 'A tiny first puzzle showing a custom noun and emoji palette.',12+ },13+ vocabulary: ['CAT', 'ROCK', 'FLAG', 'WALL'],14+ palette: {15+ CAT: '🐈',16+ ROCK: '🪨',17+ FLAG: '🚩',18+ WALL: '🧱',19+ },20+ levels: [level(9, 7, [21+ // CAT IS YOU22+ word('CAT', 1, 1), word('IS', 2, 1), word('YOU', 3, 1),23+ // FLAG IS WIN24+ word('FLAG', 1, 5), word('IS', 2, 5), word('WIN', 3, 5),25+ // ROCK IS PUSH26+ word('ROCK', 5, 1), word('IS', 6, 1), word('PUSH', 7, 1),27+ // WALL IS STOP28+ word('WALL', 1, 3), word('IS', 2, 3), word('STOP', 3, 3),29+30+ object('CAT', 4, 3, 'player'),31+ object('ROCK', 5, 3, 'rock'),32+ object('FLAG', 7, 5, 'flag'),33+ object('WALL', 4, 0, 'north-wall'),34+ object('WALL', 5, 0, 'north-wall-2'),35+ object('WALL', 6, 0, 'north-wall-3'),36+ ], {37+ title: 'Moon Cat',38+ hint: 'Push the rock out of your way, then touch the flag.',39+ })],40+};@@ -0,0 +1,164 @@
1+/**2+ * Browser loader and validator for local CommonJS mods.3+ *4+ * A mod is source text that assigns an object to `module.exports` (or to5+ * `exports`). The host is responsible for obtaining that source from a local6+ * file or an editor. This loader deliberately has no URL or package resolver.7+ */8+9+export const MOD_SCHEMA_VERSION = 1;10+11+const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;12+const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;13+const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;14+const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);15+16+export class ModValidationError extends Error {17+ constructor(errors) {18+ const list = Array.isArray(errors) ? errors : [errors];19+ super(`Invalid mod${list.length === 1 ? '' : ' (multiple errors)'}:\n- ${list.join('\n- ')}`);20+ this.name = 'ModValidationError';21+ this.errors = list;22+ }23+}24+25+function issue(path, message) { return `${path}: ${message}`; }26+function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }27+function isInteger(value) { return Number.isInteger(value); }28+function token(value) { return typeof value === 'string' && TOKEN_RE.test(value); }29+function copy(value) {30+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));31+}32+33+function validatePalette(palette, errors) {34+ if (palette === undefined) return;35+ if (!isRecord(palette)) {36+ errors.push(issue('palette', 'must be an object mapping noun tokens to emoji or text'));37+ return;38+ }39+ for (const [noun, glyph] of Object.entries(palette)) {40+ if (!token(noun) || RESERVED.has(noun)) errors.push(issue(`palette.${noun}`, 'key must be an uppercase noun token, not a rule token'));41+ if (typeof glyph !== 'string' || glyph.length === 0 || glyph.length > 16) {42+ errors.push(issue(`palette.${noun}`, 'value must be a non-empty display string (16 characters maximum)'));43+ }44+ }45+}46+47+function validateLevel(level, index, errors) {48+ const path = `levels[${index}]`;49+ if (!isRecord(level)) {50+ errors.push(issue(path, 'must be an object'));51+ return;52+ }53+ if (!isInteger(level.width) || level.width < 1 || level.width > 128) errors.push(issue(`${path}.width`, 'must be an integer from 1 to 128'));54+ if (!isInteger(level.height) || level.height < 1 || level.height > 128) errors.push(issue(`${path}.height`, 'must be an integer from 1 to 128'));55+ if (level.title !== undefined && typeof level.title !== 'string') errors.push(issue(`${path}.title`, 'must be a string'));56+ if (level.hint !== undefined && typeof level.hint !== 'string') errors.push(issue(`${path}.hint`, 'must be a string'));57+ if (!Array.isArray(level.objects)) {58+ errors.push(issue(`${path}.objects`, 'must be an array'));59+ return;60+ }61+ const ids = new Set();62+ level.objects.forEach((object, objectIndex) => {63+ const objectPath = `${path}.objects[${objectIndex}]`;64+ if (!isRecord(object)) {65+ errors.push(issue(objectPath, 'must be an object'));66+ return;67+ }68+ if (!isInteger(object.x) || !isInteger(object.y)) errors.push(issue(objectPath, 'x and y must be integers'));69+ if (isInteger(level.width) && isInteger(level.height) && isInteger(object.x) && isInteger(object.y) &&70+ (object.x < 0 || object.x >= level.width || object.y < 0 || object.y >= level.height)) {71+ errors.push(issue(objectPath, `position (${object.x}, ${object.y}) is outside the ${level.width}x${level.height} board`));72+ }73+ if (object.id !== undefined) {74+ if (typeof object.id !== 'string' || object.id.length === 0) errors.push(issue(`${objectPath}.id`, 'must be a non-empty string'));75+ else if (ids.has(object.id)) errors.push(issue(`${objectPath}.id`, `duplicates another object id (${object.id})`));76+ else ids.add(object.id);77+ }78+ const kind = object.kind || (object.text !== undefined ? 'word' : 'object');79+ if (kind !== 'word' && kind !== 'object') errors.push(issue(`${objectPath}.kind`, "must be 'word' or 'object'"));80+ if (kind === 'word') {81+ if (!token(typeof object.text === 'string' ? object.text.toUpperCase() : object.text)) {82+ errors.push(issue(`${objectPath}.text`, 'must be an uppercase token'));83+ }84+ } else if (!token(typeof object.noun === 'string' ? object.noun.toUpperCase() : object.noun) || RESERVED.has(String(object.noun).toUpperCase())) {85+ errors.push(issue(`${objectPath}.noun`, 'must be an uppercase noun token, not a rule token'));86+ }87+ });88+}89+90+/** Return all validation messages without throwing. */91+export function validateMod(mod) {92+ const errors = [];93+ if (!isRecord(mod)) return [issue('module.exports', 'must be an object')];94+ const manifest = mod.manifest;95+ if (!isRecord(manifest)) errors.push(issue('manifest', 'is required and must be an object'));96+ else {97+ if (typeof manifest.id !== 'string' || !ID_RE.test(manifest.id)) errors.push(issue('manifest.id', 'must be 2–64 characters: lowercase letters, numbers, ., _, or -'));98+ if (typeof manifest.name !== 'string' || manifest.name.trim() === '') errors.push(issue('manifest.name', 'must be a non-empty string'));99+ 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'));100+ if (manifest.engine !== undefined && manifest.engine !== MOD_SCHEMA_VERSION) errors.push(issue('manifest.engine', `must be ${MOD_SCHEMA_VERSION} for this loader`));101+ }102+ if (!Array.isArray(mod.levels) || mod.levels.length === 0) errors.push(issue('levels', 'must contain at least one level'));103+ else mod.levels.forEach((level, index) => validateLevel(level, index, errors));104+ validatePalette(mod.palette, errors);105+ if (mod.vocabulary !== undefined) {106+ if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));107+ else mod.vocabulary.forEach((word, index) => {108+ if (!token(word) || RESERVED.has(word)) errors.push(issue(`vocabulary[${index}]`, 'must be an uppercase noun token, not a rule token'));109+ });110+ }111+ return errors;112+}113+114+function normalizeMod(mod) {115+ const normalized = copy(mod);116+ normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;117+ normalized.levels = normalized.levels.map(level => ({118+ ...level,119+ objects: level.objects.map(object => ({120+ ...object,121+ kind: object.kind || (object.text !== undefined ? 'word' : 'object'),122+ ...(object.text !== undefined ? { text: object.text.toUpperCase() } : {}),123+ ...(object.noun !== undefined ? { noun: object.noun.toUpperCase() } : {}),124+ })),125+ }));126+ normalized.vocabulary = normalized.vocabulary || [];127+ return normalized;128+}129+130+function engineHelpers() {131+ const word = (text, x, y, id) => ({ kind: 'word', text: String(text).toUpperCase(), x, y, ...(id ? { id } : {}) });132+ const object = (noun, x, y, id) => ({ kind: 'object', noun: String(noun).toUpperCase(), x, y, ...(id ? { id } : {}) });133+ const level = (width, height, objects, metadata = {}) => ({ width, height, objects, ...metadata });134+ return Object.freeze({ schemaVersion: MOD_SCHEMA_VERSION, word, object, level });135+}136+137+/**138+ * Evaluate trusted, host-provided CommonJS source and return a validated mod.139+ * No require other than `@miau/engine` is available, and it is not possible to140+ * load a URL or package from this function. Callers should label local files141+ * as trusted before passing their text here.142+ */143+export function loadMod(source, options = {}) {144+ if (typeof source !== 'string') throw new TypeError('loadMod(source): source must be a string');145+ const filename = String(options.filename || 'mod.cjs').replace(/[\r\n]/g, ' ');146+ const module = { exports: {} };147+ const exports = module.exports;148+ const require = name => {149+ if (name === '@miau/engine') return engineHelpers();150+ throw new Error(`Cannot require '${name}' in ${filename}; only @miau/engine is available`);151+ };152+ let execute;153+ try {154+ execute = new Function('module', 'exports', 'require', `'use strict';\n//# sourceURL=${filename}\n${source}`);155+ execute(module, exports, require);156+ } catch (error) {157+ throw new Error(`Could not load ${filename}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });158+ }159+ const errors = validateMod(module.exports);160+ if (errors.length) throw new ModValidationError(errors);161+ return normalizeMod(module.exports);162+}163+164+export const isMod = value => validateMod(value).length === 0;@@ -0,0 +1,91 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { readFile } from 'node:fs/promises';4+import { loadMod, ModValidationError, validateMod } from '../src/mods.js';5+6+const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');7+8+test('loads the complete example CommonJS mod', () => {9+ const mod = loadMod(exampleSource, { filename: 'example.cjs' });10+ assert.equal(mod.manifest.id, 'moon-cat');11+ assert.equal(mod.levels.length, 1);12+ assert.equal(mod.levels[0].objects.length, 18);13+ assert.equal(mod.palette.CAT, '🐈');14+ assert.deepEqual(validateMod(mod), []);15+});16+17+test('normalizes plain CommonJS noun and word values to uppercase', () => {18+ const mod = loadMod(`module.exports = {19+ manifest: { id: 'case-demo', name: 'Case Demo', version: '1.0.0' },20+ levels: [{ width: 3, height: 1, objects: [21+ { kind: 'word', text: 'miau', x: 0, y: 0 },22+ { kind: 'word', text: 'is', x: 1, y: 0 },23+ { kind: 'object', noun: 'miau', x: 2, y: 0 },24+ ] }],25+ };`);26+ assert.equal(mod.levels[0].objects[0].text, 'MIAU');27+ assert.equal(mod.levels[0].objects[1].text, 'IS');28+ assert.equal(mod.levels[0].objects[2].noun, 'MIAU');29+});30+31+test('reports malformed manifest, out-of-bounds position, and duplicate IDs together', () => {32+ assert.throws(() => loadMod(`module.exports = {33+ manifest: { id: 'Bad ID', name: '', version: 'tomorrow' },34+ levels: [{ width: 2, height: 2, objects: [35+ { kind: 'object', noun: 'MIAU', x: 9, y: 9, id: 'same' },36+ { kind: 'object', noun: 'FLAG', x: 0, y: 0, id: 'same' },37+ ] }],38+ };`), error => {39+ assert.ok(error instanceof ModValidationError);40+ assert.match(error.message, /manifest\.id/);41+ assert.match(error.message, /manifest\.name/);42+ assert.match(error.message, /manifest\.version/);43+ assert.match(error.message, /outside/);44+ assert.match(error.message, /duplicate/);45+ return true;46+ });47+});48+49+test('rejects core properties as nouns, palette keys, and vocabulary entries', () => {50+ assert.throws(() => loadMod(`module.exports = {51+ manifest: { id: 'reserved-nouns', name: 'Reserved Nouns', version: '1.0.0' },52+ vocabulary: ['YOU'],53+ palette: { STOP: '🛑' },54+ levels: [{ width: 1, height: 1, objects: [55+ { kind: 'object', noun: 'WIN', x: 0, y: 0 },56+ ] }],57+ };`), error => {58+ assert.ok(error instanceof ModValidationError);59+ assert.match(error.message, /vocabulary\[0\]/);60+ assert.match(error.message, /palette\.STOP/);61+ assert.match(error.message, /levels\[0\]\.objects\[0\]\.noun/);62+ return true;63+ });64+});65+66+test('rejects unsupported require targets before returning a mod', () => {67+ assert.throws(68+ () => loadMod("module.exports = require('fs');", { filename: 'unsafe.cjs' }),69+ /Cannot require 'fs' in unsafe\.cjs/,70+ );71+});72+73+test('wraps a module exception with its filename', () => {74+ assert.throws(75+ () => loadMod("throw new Error('broken mod');", { filename: 'broken.cjs' }),76+ /Could not load broken\.cjs: broken mod/,77+ );78+});79+80+test('returns no partial result when validation fails', () => {81+ let result = 'unchanged';82+ try {83+ result = loadMod(`module.exports = {84+ manifest: { id: 'partial', name: 'Partial', version: '1.0.0' },85+ levels: [{ width: 1, height: 1, objects: [{ kind: 'object', noun: 'MIAU', x: 2, y: 0 }] }],86+ };`);87+ } catch (error) {88+ assert.ok(error instanceof ModValidationError);89+ }90+ assert.equal(result, 'unchanged');91+});