clone

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

feat(packs): validate stable IDs and register multiple packs

Dami committed 4f54a79
commit
4f54a7999382de08463c1eec5bff2a9420cc6237
parent
86124f98c4a52b0b44245daa431db6b1a64d5913
author
Dami <dami.etoile@gmail.com>
6 files changed, +385 -11
Mmods/example.cjs1+
Asrc/catalog.js179++++++++++++++++++++++++++++++
Msrc/levels.js3+
Msrc/mods.js36+++++-
Atests/catalog.test.js132++++++++++++++++++++++
Mtests/mods.test.js45+++++++-
M mods/example.cjs +1 -0
A src/catalog.js +179 -0
@@ -0,0 +1,179 @@
1+import { levels as builtInLevels } from './levels.js';2+import { MOD_SCHEMA_VERSION, validateMod } from './mods.js';3+4+export const CORE_PACK_ID = 'core';5+const LOCAL_LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;6+const ORIGINS = new Set(['local', 'url', 'bundled']);7+8+function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }9+function clone(value) {10+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));11+}12+function fail(errors) {13+ const list = Array.isArray(errors) ? errors : [errors];14+ const error = new Error(`Cannot register pack:\n- ${list.join('\n- ')}`);15+ error.name = 'CatalogValidationError';16+ error.errors = list;17+ return error;18+}19+20+/**21+ * Return the built-in pack in the same shape as a loaded mod. Built-in levels22+ * are copied so a caller cannot mutate the source registry through a game.23+ */24+export function createCorePack(sourceLevels = builtInLevels) {25+ return {26+ manifest: { id: CORE_PACK_ID, name: 'Miau Core', version: '1.0.0', engine: MOD_SCHEMA_VERSION },27+ levels: clone(sourceLevels),28+ palette: {},29+ vocabulary: [],30+ };31+}32+33+function validatePack(pack) {34+ if (!isRecord(pack)) throw fail('pack must be an object');35+ const errors = validateMod(pack);36+ const seen = new Set();37+ if (Array.isArray(pack.levels)) {38+ pack.levels.forEach((level, index) => {39+ const localId = level?.id;40+ if (typeof localId !== 'string' || !LOCAL_LEVEL_ID_RE.test(localId)) {41+ errors.push(`levels[${index}].id: must be an authored lowercase id starting with a letter`);42+ } else if (seen.has(localId)) {43+ errors.push(`levels[${index}].id: duplicates another level id (${localId})`);44+ } else {45+ seen.add(localId);46+ }47+ });48+ }49+ if (errors.length) throw fail(errors);50+}51+52+function makePackRecord(pack) {53+ validatePack(pack);54+ const normalized = clone(pack);55+ const packId = normalized.manifest.id;56+ const entries = normalized.levels.map(level => {57+ const localId = level.id;58+ const id = `${packId}:${localId}`;59+ return {60+ id,61+ localId,62+ packId,63+ packName: normalized.manifest.name,64+ level: { ...level, id },65+ palette: normalized.palette || {},66+ };67+ });68+ return {69+ id: packId,70+ source: normalized,71+ manifest: normalized.manifest,72+ palette: normalized.palette || {},73+ levels: entries,74+ };75+}76+77+/**78+ * Create an additive, deterministic pack catalog.79+ *80+ * The core pack is registered first. Imported packs append in registration81+ * order. Pack IDs must be unique. Level IDs are unique inside a pack and are82+ * exposed globally as `${packId}:${localId}`, so two packs may use the same83+ * local level name without overwriting each other.84+ */85+export function createCatalog(options = {}) {86+ const coreLevels = Array.isArray(options) ? options : options.coreLevels;87+ const packs = new Map();88+ const order = [];89+ let activeId = null;90+ const core = makePackRecord(createCorePack(coreLevels || builtInLevels));91+ core.origin = 'bundled';92+ packs.set(core.id, core);93+ order.push(core.id);94+ activeId = core.levels[0]?.id || null;95+96+ function register(pack, options = {}) {97+ const origin = options.origin || 'local';98+ const replace = options.replace === true;99+ if (!ORIGINS.has(origin)) throw fail(`origin must be one of: ${[...ORIGINS].join(', ')}`);100+ const record = makePackRecord(pack);101+ record.origin = origin;102+ const previous = packs.get(record.id);103+ if (previous) {104+ if (record.id === CORE_PACK_ID) throw fail('core pack is bundled and cannot be replaced');105+ if (!replace) throw fail(`pack id collision: ${record.id}`);106+ packs.set(record.id, record);107+ if (activeId?.startsWith(`${record.id}:`) && !record.levels.some(level => level.id === activeId)) {108+ activeId = record.levels[0]?.id || null;109+ }110+ if (!activeId) activeId = getLevels().find(level => level.id)?.id || null;111+ return record.id;112+ }113+ packs.set(record.id, record);114+ order.push(record.id);115+ if (!activeId) activeId = record.levels[0]?.id || null;116+ return record.id;117+ }118+119+ /**120+ * Remove an imported pack. Returns false when the ID is unknown. Bundled121+ * packs, including core, are immutable and throw instead of being removed.122+ */123+ function removePack(id) {124+ const record = packs.get(id);125+ if (!record) return false;126+ if (record.origin === 'bundled' || id === CORE_PACK_ID) throw fail(`bundled pack cannot be removed: ${id}`);127+ const wasActive = activeId?.startsWith(`${id}:`);128+ packs.delete(id);129+ order.splice(order.indexOf(id), 1);130+ if (wasActive) activeId = getLevels().find(level => level.id)?.id || null;131+ return true;132+ }133+134+ function getPacks() {135+ return order.map(id => {136+ const pack = packs.get(id);137+ return { id: pack.id, origin: pack.origin, manifest: clone(pack.manifest), levelCount: pack.levels.length };138+ });139+ }140+ /** Return a validated deep clone of the original pack, or undefined. */141+ function getPack(id) {142+ const pack = packs.get(id);143+ return pack ? clone(pack.source) : undefined;144+ }145+ function getLevels() {146+ return order.flatMap(packId => packs.get(packId).levels.map(entry => ({147+ ...clone(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,148+ packName: entry.packName, origin: packs.get(entry.packId).origin, palette: clone(entry.palette),149+ })));150+ }151+ function getLevel(identifier) {152+ const all = getLevels();153+ const found = typeof identifier === 'number' ? all[identifier] : all.find(level => level.id === identifier);154+ return found ? clone(found) : undefined;155+ }156+ function setActiveLevel(identifier) {157+ const level = getLevel(identifier);158+ if (!level) return false;159+ activeId = level.id;160+ return true;161+ }162+ function getActiveLevel() { return getLevel(activeId); }163+164+ return Object.freeze({165+ register,166+ importMod: register,167+ removePack,168+ getPacks,169+ getPack,170+ getLevels,171+ getLevel,172+ setActiveLevel,173+ getActiveLevel,174+ get activeLevelId() { return activeId; },175+ get size() { return getLevels().length; },176+ });177+}178+179+export const createPackCatalog = createCatalog;
M src/levels.js +3 -0
M src/mods.js +30 -6
@@ -7,8 +7,12 @@
77 */88 99 export const MOD_SCHEMA_VERSION = 1;10+export const MAX_MOD_SOURCE_LENGTH = 512 * 1024;11+export const MAX_MOD_LEVELS = 64;12+export const MAX_LEVEL_OBJECTS = 4096;1013 1114 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;15+const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;1216 const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;1317 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;1418 const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);
@@ -50,6 +54,9 @@ function validateLevel(level, index, errors) {
5054 errors.push(issue(path, 'must be an object'));5155 return;5256 }57+ if (typeof level.id !== 'string' || !LEVEL_ID_RE.test(level.id)) {58+ errors.push(issue(`${path}.id`, 'must be a stable lowercase id starting with a letter'));59+ }5360 if (!isInteger(level.width) || level.width < 1 || level.width > 128) errors.push(issue(`${path}.width`, 'must be an integer from 1 to 128'));5461 if (!isInteger(level.height) || level.height < 1 || level.height > 128) errors.push(issue(`${path}.height`, 'must be an integer from 1 to 128'));5562 if (level.title !== undefined && typeof level.title !== 'string') errors.push(issue(`${path}.title`, 'must be a string'));
@@ -58,6 +65,10 @@ function validateLevel(level, index, errors) {
5865 errors.push(issue(`${path}.objects`, 'must be an array'));5966 return;6067 }68+ if (level.objects.length > MAX_LEVEL_OBJECTS) {69+ errors.push(issue(`${path}.objects`, `must contain at most ${MAX_LEVEL_OBJECTS} entities`));70+ return;71+ }6172 const ids = new Set();6273 level.objects.forEach((object, objectIndex) => {6374 const objectPath = `${path}.objects[${objectIndex}]`;
@@ -70,11 +81,12 @@ function validateLevel(level, index, errors) {
7081 (object.x < 0 || object.x >= level.width || object.y < 0 || object.y >= level.height)) {7182 errors.push(issue(objectPath, `position (${object.x}, ${object.y}) is outside the ${level.width}x${level.height} board`));7283 }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);84+ if (object.id !== undefined && (typeof object.id !== 'string' || object.id.length === 0)) {85+ errors.push(issue(`${objectPath}.id`, 'must be a non-empty string'));7786 }87+ const effectiveId = typeof object.id === 'string' && object.id.length > 0 ? object.id : `o${objectIndex}`;88+ if (ids.has(effectiveId)) errors.push(issue(`${objectPath}.id`, `duplicates another object id (${effectiveId})`));89+ else ids.add(effectiveId);7890 const kind = object.kind || (object.text !== undefined ? 'word' : 'object');7991 if (kind !== 'word' && kind !== 'object') errors.push(issue(`${objectPath}.kind`, "must be 'word' or 'object'"));8092 if (kind === 'word') {
@@ -96,11 +108,22 @@ export function validateMod(mod) {
96108 else {97109 if (typeof manifest.id !== 'string' || !ID_RE.test(manifest.id)) errors.push(issue('manifest.id', 'must be 2–64 characters: lowercase letters, numbers, ., _, or -'));98110 if (typeof manifest.name !== 'string' || manifest.name.trim() === '') errors.push(issue('manifest.name', 'must be a non-empty string'));111+ 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'));112+ if (manifest.forkOf !== undefined && (typeof manifest.forkOf !== 'string' || !ID_RE.test(manifest.forkOf))) errors.push(issue('manifest.forkOf', 'must be the original pack id'));99113 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'));100114 if (manifest.engine !== undefined && manifest.engine !== MOD_SCHEMA_VERSION) errors.push(issue('manifest.engine', `must be ${MOD_SCHEMA_VERSION} for this loader`));101115 }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));116+ 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`));117+ else {118+ const levelIds = new Set();119+ mod.levels.forEach((level, index) => {120+ validateLevel(level, index, errors);121+ if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {122+ if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));123+ else levelIds.add(level.id);124+ }125+ });126+ }104127 validatePalette(mod.palette, errors);105128 if (mod.vocabulary !== undefined) {106129 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));
@@ -142,6 +165,7 @@ function engineHelpers() {
142165 */143166 export function loadMod(source, options = {}) {144167 if (typeof source !== 'string') throw new TypeError('loadMod(source): source must be a string');168+ if (source.length > MAX_MOD_SOURCE_LENGTH) throw new RangeError(`CommonJS mod source exceeds ${MAX_MOD_SOURCE_LENGTH} characters`);145169 const filename = String(options.filename || 'mod.cjs').replace(/[\r\n]/g, ' ');146170 const module = { exports: {} };147171 const exports = module.exports;
A tests/catalog.test.js +132 -0
@@ -0,0 +1,132 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createCatalog } from '../src/catalog.js';4+5+const pack = (id, title, levels) => ({6+ manifest: { id, name: title, version: '1.0.0' },7+ palette: { MIAU: '🐈' },8+ levels,9+});10+const level = (title, id) => ({ id, title, width: 2, height: 1, objects: [11+ { kind: 'word', text: 'MIAU', x: 0, y: 0 },12+ { kind: 'word', text: 'IS', x: 1, y: 0 },13+] });14+15+test('starts with stable core pack and preserves additive import order', () => {16+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });17+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core']);18+ catalog.importMod(pack('alpha', 'Alpha', [level('A one', 'start'), level('A two', 'next')]));19+ catalog.importMod(pack('beta', 'Beta', [level('B one', 'start')]));20+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core', 'alpha', 'beta']);21+ assert.equal(catalog.getPacks().find(pack => pack.id === 'alpha').origin, 'local');22+ assert.deepEqual(catalog.getLevels().map(level => level.id), ['core:intro', 'alpha:start', 'alpha:next', 'beta:start']);23+ assert.equal(catalog.getLevel('alpha:start').origin, 'local');24+});25+26+test('supports ordered and ID based level lookup plus active selection', () => {27+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });28+ catalog.register(pack('alpha', 'Alpha', [level('A one', 'start')]));29+ assert.equal(catalog.getLevel(1).id, 'alpha:start');30+ assert.equal(catalog.getLevel('alpha:start').title, 'A one');31+ assert.equal(catalog.getActiveLevel().id, 'core:intro');32+ assert.equal(catalog.setActiveLevel('alpha:start'), true);33+ assert.equal(catalog.activeLevelId, 'alpha:start');34+ assert.equal(catalog.getActiveLevel().packId, 'alpha');35+ assert.equal(catalog.setActiveLevel('missing'), false);36+ assert.equal(catalog.getLevel('missing'), undefined);37+});38+39+test('returns the validated original pack for export without exposing registry state', () => {40+ const source = pack('alpha', 'Alpha', [level('A one', 'start')]);41+ source.vocabulary = ['MIAU'];42+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });43+ catalog.register(source);44+ const exported = catalog.getPack('alpha');45+ assert.equal(exported.manifest.id, 'alpha');46+ assert.equal(exported.levels[0].id, 'start');47+ assert.deepEqual(exported.palette, source.palette);48+ assert.equal(exported.origin, undefined);49+ exported.manifest.name = 'Changed';50+ exported.levels[0].title = 'Changed';51+ assert.equal(catalog.getPack('alpha').manifest.name, 'Alpha');52+ assert.equal(catalog.getPack('alpha').levels[0].title, 'A one');53+ assert.equal(catalog.getPack('missing'), undefined);54+});55+56+test('rejects duplicate pack IDs and leaves the earlier pack intact', () => {57+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });58+ catalog.register(pack('alpha', 'Original', [level('Original', 'one')]));59+ assert.throws(() => catalog.register(pack('alpha', 'Replacement', [level('Replacement', 'two')])), /pack id collision: alpha/);60+ assert.equal(catalog.getPacks().length, 2);61+ assert.equal(catalog.getLevel('alpha:one').title, 'Original');62+});63+64+test('replaces an imported pack atomically, preserving order and repairing active selection', () => {65+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });66+ catalog.register(pack('alpha', 'Original', [level('Original one', 'one'), level('Original two', 'two')]), { origin: 'url' });67+ catalog.setActiveLevel('alpha:two');68+ catalog.register(pack('alpha', 'Replacement', [level('Replacement', 'new')]), { origin: 'url', replace: true });69+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core', 'alpha']);70+ assert.equal(catalog.getPacks()[1].origin, 'url');71+ assert.equal(catalog.getLevel('alpha:new').origin, 'url');72+ assert.equal(catalog.activeLevelId, 'alpha:new');73+ assert.equal(catalog.getLevel('alpha:two'), undefined);74+});75+76+test('protects the bundled core pack from replacement', () => {77+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });78+ assert.throws(() => catalog.register(pack('core', 'Fake Core', [level('Fake', 'fake')]), { replace: true }), /bundled and cannot be replaced/);79+ assert.equal(catalog.getPacks()[0].origin, 'bundled');80+ assert.equal(catalog.getLevel('core:intro').title, 'Core');81+});82+83+test('removes imported packs, preserves order, and repairs active selection', () => {84+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });85+ catalog.register(pack('alpha', 'Alpha', [level('Alpha', 'alpha')]));86+ catalog.register(pack('beta', 'Beta', [level('Beta', 'beta')]));87+ catalog.setActiveLevel('beta:beta');88+ assert.equal(catalog.removePack('beta'), true);89+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core', 'alpha']);90+ assert.equal(catalog.getLevel('beta:beta'), undefined);91+ assert.equal(catalog.activeLevelId, 'core:intro');92+ assert.equal(catalog.removePack('missing'), false);93+});94+95+test('protects core and bundled packs from removal', () => {96+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });97+ assert.throws(() => catalog.removePack('core'), /bundled pack cannot be removed/);98+ catalog.register(pack('bundled-pack', 'Bundled', [level('Bundled', 'only')]), { origin: 'bundled' });99+ assert.throws(() => catalog.removePack('bundled-pack'), /bundled pack cannot be removed/);100+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core', 'bundled-pack']);101+});102+103+test('rejects duplicate local level IDs before registration', () => {104+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });105+ assert.throws(() => catalog.register(pack('alpha', 'Alpha', [level('One', 'same'), level('Two', 'same')])), /duplicates another level id/);106+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core']);107+});108+109+test('requires authored level IDs and a known registration origin', () => {110+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });111+ const missingId = pack('missing-id', 'Missing ID', [{ ...level('No ID', 'temporary'), id: undefined }]);112+ assert.throws(() => catalog.register(missingId), /levels\[0\]\.id/);113+ assert.throws(() => catalog.register(pack('origin', 'Origin', [level('Origin', 'origin')]), { origin: 'remote' }), /origin must be one of/);114+ assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core']);115+});116+117+test('validates a module before registering any of its levels', () => {118+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });119+ const invalid = pack('broken', 'Broken', [{ ...level('Bad', 'bad'), objects: [{ kind: 'object', noun: 'YOU', x: 0, y: 0 }] }]);120+ assert.throws(() => catalog.importMod(invalid), /levels\[0\]\.objects\[0\]\.noun/);121+ assert.equal(catalog.size, 1);122+ assert.equal(catalog.getLevel('broken:bad'), undefined);123+});124+125+test('returns cloned levels so callers cannot mutate registry state', () => {126+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });127+ const first = catalog.getLevel('core:intro');128+ first.title = 'Changed';129+ first.objects[0].text = 'BROKEN';130+ assert.equal(catalog.getLevel('core:intro').title, 'Core');131+ assert.equal(catalog.getLevel('core:intro').objects[0].text, 'MIAU');132+});
M tests/mods.test.js +40 -5
@@ -1,7 +1,7 @@
11 import test from 'node:test';22 import assert from 'node:assert/strict';33 import { readFile } from 'node:fs/promises';4-import { loadMod, ModValidationError, validateMod } from '../src/mods.js';4+import { loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';55 66 const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');77
@@ -17,7 +17,7 @@ test('loads the complete example CommonJS mod', () => {
1717 test('normalizes plain CommonJS noun and word values to uppercase', () => {1818 const mod = loadMod(`module.exports = {1919 manifest: { id: 'case-demo', name: 'Case Demo', version: '1.0.0' },20- levels: [{ width: 3, height: 1, objects: [20+ levels: [{ id: 'intro', width: 3, height: 1, objects: [2121 { kind: 'word', text: 'miau', x: 0, y: 0 },2222 { kind: 'word', text: 'is', x: 1, y: 0 },2323 { kind: 'object', noun: 'miau', x: 2, y: 0 },
@@ -31,7 +31,7 @@ test('normalizes plain CommonJS noun and word values to uppercase', () => {
3131 test('reports malformed manifest, out-of-bounds position, and duplicate IDs together', () => {3232 assert.throws(() => loadMod(`module.exports = {3333 manifest: { id: 'Bad ID', name: '', version: 'tomorrow' },34- levels: [{ width: 2, height: 2, objects: [34+ levels: [{ id: 'intro', width: 2, height: 2, objects: [3535 { kind: 'object', noun: 'MIAU', x: 9, y: 9, id: 'same' },3636 { kind: 'object', noun: 'FLAG', x: 0, y: 0, id: 'same' },3737 ] }],
@@ -51,7 +51,7 @@ test('rejects core properties as nouns, palette keys, and vocabulary entries', (
5151 manifest: { id: 'reserved-nouns', name: 'Reserved Nouns', version: '1.0.0' },5252 vocabulary: ['YOU'],5353 palette: { STOP: '🛑' },54- levels: [{ width: 1, height: 1, objects: [54+ levels: [{ id: 'intro', width: 1, height: 1, objects: [5555 { kind: 'object', noun: 'WIN', x: 0, y: 0 },5656 ] }],5757 };`), error => {
@@ -63,6 +63,41 @@ test('rejects core properties as nouns, palette keys, and vocabulary entries', (
6363 });6464 });6565 66+test('rejects IDs that collide with generated engine IDs before play', () => {67+ const source = `module.exports = {68+ manifest: { id: 'id-collision', name: 'ID Collision', version: '1.0.0' },69+ levels: [{ id: 'intro', width: 2, height: 1, objects: [70+ { kind: 'object', noun: 'MIAU', x: 0, y: 0 },71+ { kind: 'object', noun: 'ROCK', x: 1, y: 0, id: 'o0' },72+ ] }],73+ };`;74+ assert.throws(() => loadMod(source), /duplicates another object id \(o0\)/);75+});76+77+test('requires authored stable level IDs and rejects duplicates', () => {78+ const base = { manifest: { id: 'level-identities', name: 'Level Identities', version: '1.0.0' } };79+ const level = { width: 1, height: 1, objects: [] };80+ assert.match(validateMod({ ...base, levels: [level] }).join('; '), /levels\[0\]\.id/);81+ assert.match(validateMod({ ...base, levels: [{ ...level, id: 'same' }, { ...level, id: 'same' }] }).join('; '), /duplicates another level id/);82+ assert.match(validateMod({ ...base, levels: [{ ...level, id: 'Uppercase' }] }).join('; '), /stable lowercase id/);83+});84+85+test('rejects oversized local module source before evaluating it', () => {86+ assert.throws(() => loadMod(' '.repeat(MAX_MOD_SOURCE_LENGTH + 1)), /source exceeds/);87+});88+89+test('rejects oversized level and entity arrays during validation', () => {90+ const level = { id: 'intro', width: 1, height: 1, objects: [] };91+ assert.match(validateMod({92+ manifest: { id: 'too-many-levels', name: 'Too Many Levels', version: '1.0.0' },93+ levels: Array(MAX_MOD_LEVELS + 1).fill(level),94+ }).join('; '), /levels: must contain/);95+ assert.match(validateMod({96+ manifest: { id: 'too-many-objects', name: 'Too Many Objects', version: '1.0.0' },97+ levels: [{ ...level, objects: Array(MAX_LEVEL_OBJECTS + 1).fill({ kind: 'object', noun: 'MIAU', x: 0, y: 0 }) }],98+ }).join('; '), /entities/);99+});100+66101 test('rejects unsupported require targets before returning a mod', () => {67102 assert.throws(68103 () => loadMod("module.exports = require('fs');", { filename: 'unsafe.cjs' }),
@@ -82,7 +117,7 @@ test('returns no partial result when validation fails', () => {
82117 try {83118 result = loadMod(`module.exports = {84119 manifest: { id: 'partial', name: 'Partial', version: '1.0.0' },85- levels: [{ width: 1, height: 1, objects: [{ kind: 'object', noun: 'MIAU', x: 2, y: 0 }] }],120+ levels: [{ id: 'intro', width: 1, height: 1, objects: [{ kind: 'object', noun: 'MIAU', x: 2, y: 0 }] }],86121 };`);87122 } catch (error) {88123 assert.ok(error instanceof ModValidationError);