clone

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

main
miau-is-true / tests / mods.test.js
mods.test.js 24.3K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { readFile } from 'node:fs/promises';4import { collectModWarnings, CURRENT_RULES_VERSION, loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';56const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');7const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');89function pathologicalRuleLevel({ kind = 'spans' } = {}) {10 const objects = [];11 if (kind === 'paths') {12 let x = 0;13 for (let depth = 0; depth < 8; depth += 1) {14 for (let alternative = 0; alternative < 2; alternative += 1) {15 objects.push({ kind: 'word', text: depth === 0 ? 'MIAU' : `N${depth}`, x, y: 0, id: `n${depth}-${alternative}` });16 }17 x += 1;18 for (let alternative = 0; alternative < 2; alternative += 1) {19 objects.push({ kind: 'word', text: 'AND', x, y: 0, id: `a${depth}-${alternative}` });20 }21 x += 1;22 }23 for (let alternative = 0; alternative < 2; alternative += 1) {24 objects.push({ kind: 'word', text: 'IS', x, y: 0, id: `is-${alternative}` });25 objects.push({ kind: 'word', text: 'YOU', x: x + 1, y: 0, id: `you-${alternative}` });26 }27 return { id: 'path-budget', width: x + 2, height: 1, objects };28 }29 for (let index = 0; index < 257; index += 1) {30 objects.push({ kind: 'word', text: 'MIAU', x: 0, y: 0, id: `subject-${index}` });31 objects.push({ kind: 'word', text: 'IS', x: 1, y: 0, id: `is-${index}` });32 objects.push({ kind: 'word', text: 'YOU', x: 2, y: 0, id: `predicate-${index}` });33 }34 return { id: 'span-budget', width: 3, height: 1, objects };35}3637test('loads the complete example CommonJS mod', () => {38 const mod = loadMod(exampleSource, { filename: 'example.cjs' });39 assert.equal(mod.manifest.id, 'moon-cat');40 assert.equal(mod.levels.length, 1);41 assert.equal(mod.levels[0].objects.length, 18);42 assert.equal(mod.palette.CAT, '๐Ÿˆ');43 assert.deepEqual(validateMod(mod), []);44});4546test('rejects a pathological initial board before a CommonJS pack can install', () => {47 const level = pathologicalRuleLevel();48 const source = `module.exports = ${JSON.stringify({49 manifest: { id: 'rule-budget', name: 'Rule Budget', version: '1.0.0' },50 levels: [level],51 })};`;52 assert.throws(() => loadMod(source, { filename: 'rule-budget.cjs' }), error => {53 assert.ok(error instanceof ModValidationError);54 assert.match(error.message, /levels\[0\]\.objects: rule parse spans budget exceeded/);55 assert.match(error.message, /65536/);56 return true;57 });58});5960test('rejects path expansion before previewing a CommonJS pack', () => {61 const source = `module.exports = ${JSON.stringify({62 manifest: { id: 'path-budget', name: 'Path Budget', version: '1.0.0' },63 levels: [pathologicalRuleLevel({ kind: 'paths' })],64 })};`;65 assert.throws(() => loadMod(source, { filename: 'path-budget.cjs' }), error => {66 assert.ok(error instanceof ModValidationError);67 assert.match(error.message, /levels\[0\]\.objects: rule parse paths budget exceeded/);68 assert.match(error.message, /16384/);69 return true;70 });71});7273test('reports parser budget exhaustion as a bounded diagnostics warning', () => {74 const mod = {75 manifest: { id: 'rule-warning', name: 'Rule Warning', version: '1.0.0' },76 levels: [pathologicalRuleLevel()],77 };78 const warnings = collectModWarnings(mod);79 assert.equal(warnings.length, 1);80 assert.equal(warnings[0].word, 'RULES');81 assert.equal(warnings[0].levelId, 'span-budget');82 assert.match(warnings[0].message, /too complex/);83 assert.match(warnings[0].detail, /spans budget exceeded/);84});8586test('warns on long proof titles without changing authored data', () => {87 const shortTitle = 's'.repeat(32);88 const longTitle = 'A proof title that is deliberately too long';89 const mod = loadMod(`module.exports = ${JSON.stringify({90 manifest: { id: 'title-warning', name: 'Title Warning', version: '1.0.0', rules: 2 },91 levels: [92 { id: 'short', title: shortTitle, width: 1, height: 1, objects: [] },93 { id: 'long', title: longTitle, width: 1, height: 1, objects: [] },94 ],95 })};`, { filename: 'title-warning.cjs' });96 const warnings = collectModWarnings(mod);97 assert.equal(mod.levels[1].title, longTitle);98 assert.equal(warnings.length, 1);99 assert.deepEqual(warnings[0], {100 word: 'TITLE',101 levelId: 'long',102 levelTitle: longTitle,103 sentence: null,104 message: 'Proof titles should use 24 characters or fewer for easier reading.',105 detail: `levels[1].title: proof title is ${longTitle.length} characters; recommended maximum is 24.`,106 });107});108109test('rejects authored proof titles over the phone fitting ceiling', () => {110 const title = 'x'.repeat(65);111 assert.throws(() => loadMod(`module.exports = ${JSON.stringify({112 manifest: { id: 'title-too-long', name: 'Title Too Long', version: '1.0.0' },113 levels: [{ id: 'too-long', title, width: 1, height: 1, objects: [] }],114 })};`), error => {115 assert.ok(error instanceof ModValidationError);116 assert.match(error.message, /levels\[0\]\.title: Titles can be at most 64 characters so they fit on phones/);117 return true;118 });119});120121test('loads the minimal playable fox automata proof', () => {122 const mod = loadMod(automataExampleSource, { filename: 'automata-example.cjs' });123 assert.equal(mod.manifest.id, 'automata-example');124 assert.equal(mod.levels[0].id, 'fox-keeps-time');125 assert.equal(mod.levels[0].automata[0].property, 'TICK');126 assert.equal(mod.levels[0].automata[0].subject, undefined);127 assert.deepEqual(mod.levels[0].automata[0].pattern[0], { action: 'move', dx: -1, dy: 0, cue: '๐ŸฆŠ', sound: 'tick' });128 assert.equal(mod.palette.FENN, '๐ŸฆŠ');129 assert.deepEqual(validateMod(mod), []);130});131132test('normalizes plain CommonJS noun and word values to uppercase', () => {133 const mod = loadMod(`module.exports = {134 manifest: { id: 'case-demo', name: 'Case Demo', version: '1.0.0' },135 levels: [{ id: 'intro', width: 3, height: 1, objects: [136 { kind: 'word', text: 'miau', x: 0, y: 0 },137 { kind: 'word', text: 'is', x: 1, y: 0 },138 { kind: 'object', noun: 'miau', x: 2, y: 0 },139 ] }],140 };`);141 assert.equal(mod.levels[0].objects[0].text, 'MIAU');142 assert.equal(mod.levels[0].objects[1].text, 'IS');143 assert.equal(mod.levels[0].objects[2].noun, 'MIAU');144});145146test('accepts grapheme-safe world identity metadata including tick accent', () => {147 const mod = loadMod(`module.exports = {148 manifest: { id: 'world-identity', name: 'World Identity', version: '1.0.0', rules: 2, world: {149 glyph: 'โš™๏ธ', tagline: 'A clockwork world.', accent: 'tick',150 } },151 palette: { MIAU: '๐Ÿˆ' },152 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],153 };`);154 assert.deepEqual(validateMod(mod), []);155 assert.deepEqual(collectModWarnings(mod), []);156 assert.equal(mod.manifest.world.glyph, 'โš™๏ธ');157});158159test('warns when one grapheme exceeds the world glyph UTF-16 cap', () => {160 const glyph = '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆโ€๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ';161 assert.equal([...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(glyph)].length, 1);162 assert.ok(glyph.length > 16);163 const mod = loadMod(`module.exports = {164 manifest: { id: 'long-glyph', name: 'Long Glyph', version: '1.0.0', rules: 2, world: { glyph: ${JSON.stringify(glyph)} } },165 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],166 };`);167 assert.match(collectModWarnings(mod)[0].detail, /manifest\.world\.glyph/);168});169170test('world identity notes stay bounded when optional metadata has many unknown fields', () => {171 const world = Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`extra${index}`, index]));172 const mod = loadMod(`module.exports = {173 manifest: { id: 'many-world-fields', name: 'Many World Fields', version: '1.0.0', rules: 2, world: ${JSON.stringify(world)} },174 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],175 };`);176 const worldNotes = collectModWarnings(mod).filter(warning => warning.word === 'WORLD');177 assert.equal(worldNotes.length, 2);178 assert.match(worldNotes[0].detail, /extra0: unsupported field/);179});180181test('accepts directional palette faces and preserves plain strings', () => {182 const mod = loadMod(`module.exports = {183 manifest: { id: 'facing-demo', name: 'Facing Demo', version: '1.0.0' },184 palette: { CAT: { glyph: '๐Ÿˆ', faces: 'left' }, FLAG: '๐Ÿšฉ' },185 levels: [{ id: 'intro', width: 1, height: 1, objects: [{ kind: 'object', noun: 'CAT', x: 0, y: 0 }] }],186 };`);187 assert.deepEqual(mod.palette, { CAT: { glyph: '๐Ÿˆ', faces: 'left' }, FLAG: '๐Ÿšฉ' });188});189190test('loads bounded declarative level automata and normalizes rule tokens', () => {191 const mod = loadMod(`module.exports = {192 manifest: { id: 'automata-demo', name: 'Automata Demo', version: '1.0.0' },193 levels: [{ id: 'intro', width: 2, height: 1, objects: [], automata: [{194 id: 'patrol', subject: 'cat', property: 'move', phase: 1,195 pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait', cue: 'pause' }],196 }] }],197 };`);198 assert.deepEqual(mod.levels[0].automata[0], {199 id: 'patrol', subject: 'CAT', property: 'MOVE', phase: 1,200 pattern: [{ action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }, { action: 'wait', cue: 'pause' }],201 });202});203204test('accepts word-carried automata without an explicit subject', () => {205 const mod = loadMod(`module.exports = {206 manifest: { id: 'word-carried-clock', name: 'Word Carried Clock', version: '1.0.0' },207 levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{208 id: 'clock', property: 'tick', pattern: [{ action: 'move', dx: -1, dy: 0 }],209 }] }],210 };`);211 assert.deepEqual(mod.levels[0].automata[0], {212 id: 'clock', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }],213 });214});215216test('warns on an unknown authored predicate while allowing known noun transforms', () => {217 const mod = loadMod(`module.exports = {218 manifest: { id: 'predicate-warnings', name: 'Predicate Warnings', version: '1.0.0', rules: 2 },219 vocabulary: ['ROCK', 'MIAU'],220 levels: [{ id: 'intro', width: 9, height: 2, objects: [221 { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'SHUT', x: 2, y: 0 },222 { kind: 'word', text: 'ROCK', x: 4, y: 0 }, { kind: 'word', text: 'IS', x: 5, y: 0 }, { kind: 'word', text: 'MIAU', x: 6, y: 0 },223 { kind: 'object', noun: 'DOOR', x: 0, y: 1 },224 { kind: 'object', noun: 'MIAU', x: 1, y: 1 },225 { kind: 'object', noun: 'ROCK', x: 2, y: 1 },226 ] }],227 };`);228 const warnings = collectModWarnings(mod);229 assert.equal(warnings.length, 1);230 assert.equal(warnings[0].levelId, 'intro');231 assert.match(warnings[0].message, /SHUT/);232 assert.match(warnings[0].detail, /levels\[0\]\.objects\[2\]\.text/);233});234235test('warning paths identify each unknown predicate in an AND sentence', () => {236 const mod = loadMod(`module.exports = {237 manifest: { id: 'two-warnings', name: 'Two Warnings', version: '1.0.0', rules: 2 },238 levels: [{ id: 'intro', title: 'An open question', width: 5, height: 1, objects: [239 { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 },240 { text: 'SHUT', x: 2, y: 0 }, { text: 'AND', x: 3, y: 0 }, { text: 'LOCK', x: 4, y: 0 },241 { kind: 'object', noun: 'DOOR', x: 0, y: 0 },242 ] }],243 };`);244 assert.deepEqual(collectModWarnings(mod).map(warning => warning.detail.split(':')[0]), [245 'levels[0].objects[2].text', 'levels[0].objects[4].text',246 ]);247});248249test('duplicate physical sentences report each unknown predicate word once', () => {250 const mod = loadMod(`module.exports = {251 manifest: { id: 'duplicate-warnings', name: 'Duplicate Warnings', version: '1.0.0', rules: 2 },252 levels: [{ id: 'intro', width: 3, height: 2, objects: [253 { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 }, { text: 'SHUT', x: 2, y: 0 },254 { text: 'DOOR', x: 0, y: 1 }, { text: 'IS', x: 1, y: 1 }, { text: 'SHUT', x: 2, y: 1 },255 { kind: 'object', noun: 'DOOR', x: 1, y: 1 },256 ] }],257 };`);258 const warnings = collectModWarnings(mod);259 assert.equal(warnings.length, 1);260 assert.equal(warnings[0].word, 'SHUT');261});262263test('warns on a declared property word left loose or unsupported in another proof', () => {264 const mod = loadMod(`module.exports = {265 manifest: { id: 'level-local-properties', name: 'Level Local Properties', version: '1.0.0', rules: 2 },266 levels: [267 { id: 'clock', width: 3, height: 1, objects: [268 { kind: 'word', text: 'MOUSE', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'TICK', x: 2, y: 0 },269 { kind: 'object', noun: 'MOUSE', x: 0, y: 0 },270 ], automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }] }] },271 { id: 'plain', width: 1, height: 1, objects: [{ kind: 'word', text: 'TICK', x: 0, y: 0 }] },272 ],273 };`);274 const warnings = collectModWarnings(mod).filter(warning => warning.levelId === 'plain');275 assert.equal(warnings.length, 1);276 assert.equal(warnings[0].word, 'TICK');277 assert.equal(warnings[0].sentence, null);278 assert.match(warnings[0].message, /another proof/);279});280281test('normalizes rules compatibility revisions and rejects malformed futures', () => {282 assert.equal(CURRENT_RULES_VERSION, 2);283 const legacy = loadMod(`module.exports = {284 manifest: { id: 'legacy-rules', name: 'Legacy Rules', version: '1.0.0' },285 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],286 };`);287 assert.equal(legacy.manifest.rules, 1);288 assert.deepEqual(collectModWarnings(legacy), [{289 word: 'RULES', levelId: null, levelTitle: null, sentence: null,290 message: 'This pack was made for older rules; some proofs may play differently.',291 detail: 'manifest.rules: effective revision 1; current host revision 2.',292 }]);293 const current = loadMod(`module.exports = {294 manifest: { id: 'current-rules', name: 'Current Rules', version: '1.0.0', rules: 2 },295 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],296 };`);297 assert.equal(current.manifest.rules, 2);298 assert.deepEqual(collectModWarnings(current), []);299 for (const value of [0, -1, 1.5, '2']) {300 assert.match(validateMod({301 manifest: { id: 'bad-rules', name: 'Bad Rules', version: '1.0.0', rules: value },302 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],303 }).join('; '), /manifest\.rules: must be a positive integer/);304 }305 assert.match(validateMod({306 manifest: { id: 'future-rules', name: 'Future Rules', version: '1.0.0', rules: 3 },307 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],308 }).join('; '), /manifest\.rules: unsupported future rules revision 3/);309});310311test('pack palette and vocabulary nouns suppress intentional transformations', () => {312 const mod = loadMod(`module.exports = {313 manifest: { id: 'known-target', name: 'Known Target', version: '1.0.0', rules: 2 },314 palette: { GEM: '๐Ÿ’Ž' },315 levels: [{ id: 'intro', width: 4, height: 2, objects: [316 { kind: 'word', text: 'ROCK', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'GEM', x: 2, y: 0 },317 { kind: 'object', noun: 'ROCK', x: 0, y: 1 },318 ] }],319 };`);320 assert.deepEqual(collectModWarnings(mod), []);321});322323test('flags a noun/property ambiguity across proofs', () => {324 const mod = loadMod(`module.exports = {325 manifest: { id: 'cross-proof-ambiguity', name: 'Cross Proof Ambiguity', version: '1.0.0', rules: 2 },326 palette: { SHUT: '๐Ÿšช' },327 levels: [328 { id: 'property', width: 1, height: 1, objects: [], automata: [{ id: 'shut-property', property: 'SHUT', pattern: [{ action: 'wait' }] }] },329 { id: 'thing', width: 1, height: 1, objects: [{ kind: 'object', noun: 'SHUT', x: 0, y: 0 }] },330 { id: 'rule', width: 4, height: 2, objects: [331 { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'SHUT', x: 2, y: 0 },332 { kind: 'object', noun: 'DOOR', x: 0, y: 1 }, { kind: 'object', noun: 'SHUT', x: 1, y: 1 },333 ] },334 ],335 };`);336 const warning = collectModWarnings(mod).find(entry => entry.levelId === 'rule');337 assert.equal(warning.word, 'SHUT');338 assert.match(warning.message, /declared as a property and used as a thing/);339 assert.equal(warning.sentence, 'DOOR IS SHUT');340});341342test('keeps an object-only cross-proof noun as an ordinary unknown note', () => {343 const mod = loadMod(`module.exports = {344 manifest: { id: 'cross-proof-noun', name: 'Cross Proof Noun', version: '1.0.0', rules: 2 },345 levels: [346 { id: 'thing', width: 1, height: 1, objects: [{ kind: 'object', noun: 'LOCK', x: 0, y: 0 }] },347 { id: 'rule', width: 4, height: 2, objects: [348 { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'LOCK', x: 2, y: 0 },349 { kind: 'object', noun: 'DOOR', x: 0, y: 1 },350 ] },351 ],352 };`);353 const warning = collectModWarnings(mod).find(entry => entry.levelId === 'rule');354 assert.equal(warning.word, 'LOCK');355 assert.match(warning.message, /used as a thing in another proof/);356 assert.doesNotMatch(warning.message, /declared as a property/);357});358359test('declared automaton properties cannot also be object nouns', () => {360 assert.throws(() => loadMod(`module.exports = {361 manifest: { id: 'property-collision', name: 'Property Collision', version: '1.0.0' },362 levels: [{ id: 'intro', width: 1, height: 1, objects: [{ kind: 'object', noun: 'TICK', x: 0, y: 0 }],363 automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }] }] }],364 };`), /declared property and cannot be an object noun/);365});366367test('rejects unknown manifest features but preserves legacy automata packs', () => {368 const base = { manifest: { id: 'feature-check', name: 'Feature Check', version: '1.0.0' }, levels: [{ id: 'intro', width: 1, height: 1, objects: [] }] };369 assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['pairs'] } }).join('; '), /manifest\.features\[0\].*unsupported feature 'pairs'/);370 assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['automata', 'automata'] } }).join('; '), /manifest\.features\[1\].*duplicates feature 'automata'/);371 assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['Automata'] } }).join('; '), /manifest\.features\[0\].*lowercase feature identifier/);372 assert.deepEqual(validateMod({ ...base, manifest: { ...base.manifest, features: ['automata'] } }), []);373 assert.deepEqual(validateMod({ ...base, levels: [{ ...base.levels[0], automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }] }] }] }), []);374});375376test('rejects a core property as a custom automaton property before play', () => {377 const mod = {378 manifest: { id: 'bad-core-property', name: 'Bad Core Property', version: '1.0.0' },379 levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{ id: 'clock', property: 'YOU', pattern: [{ action: 'wait' }] }] }],380 };381 assert.match(validateMod(mod).join('; '), /automata\[0\]\.property: must be a custom uppercase property token/);382});383384test('rejects unsafe or unbounded automata steps', () => {385 assert.throws(() => loadMod(`module.exports = {386 manifest: { id: 'bad-automata', name: 'Bad Automata', version: '1.0.0' },387 levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{388 id: 'bad', subject: 'YOU', property: 'MOVE', pattern: [389 { action: 'move', dx: 2, dy: 0, cue: 'this cue is much too long', sound: 'custom-url', extra: true },390 ],391 }] }],392 };`), error => {393 assert.ok(error instanceof ModValidationError);394 assert.match(error.message, /subject/);395 assert.match(error.message, /move/);396 assert.match(error.message, /cue/);397 assert.match(error.message, /sound/);398 assert.match(error.message, /extra/);399 return true;400 });401});402403test('rejects unbounded directional palette shapes', () => {404 assert.throws(() => loadMod(`module.exports = {405 manifest: { id: 'bad-facing', name: 'Bad Facing', version: '1.0.0' },406 palette: { CAT: { glyph: '๐Ÿˆ', faces: 'up', extra: true }, FLAG: { glyph: '' } },407 levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],408 };`), error => {409 assert.ok(error instanceof ModValidationError);410 assert.match(error.message, /palette\.CAT\.faces/);411 assert.match(error.message, /palette\.CAT\.extra/);412 assert.match(error.message, /palette\.FLAG\.glyph/);413 return true;414 });415});416417test('reports malformed manifest, out-of-bounds position, and duplicate IDs together', () => {418 assert.throws(() => loadMod(`module.exports = {419 manifest: { id: 'Bad ID', name: '', version: 'tomorrow' },420 levels: [{ id: 'intro', width: 2, height: 2, objects: [421 { kind: 'object', noun: 'MIAU', x: 9, y: 9, id: 'same' },422 { kind: 'object', noun: 'FLAG', x: 0, y: 0, id: 'same' },423 ] }],424 };`), error => {425 assert.ok(error instanceof ModValidationError);426 assert.match(error.message, /manifest\.id/);427 assert.match(error.message, /manifest\.name/);428 assert.match(error.message, /manifest\.version/);429 assert.match(error.message, /outside/);430 assert.match(error.message, /duplicate/);431 return true;432 });433});434435test('rejects core properties as nouns, palette keys, and vocabulary entries', () => {436 assert.throws(() => loadMod(`module.exports = {437 manifest: { id: 'reserved-nouns', name: 'Reserved Nouns', version: '1.0.0' },438 vocabulary: ['YOU'],439 palette: { STOP: '๐Ÿ›‘' },440 levels: [{ id: 'intro', width: 1, height: 1, objects: [441 { kind: 'object', noun: 'WIN', x: 0, y: 0 },442 ] }],443 };`), error => {444 assert.ok(error instanceof ModValidationError);445 assert.match(error.message, /vocabulary\[0\]/);446 assert.match(error.message, /palette\.STOP/);447 assert.match(error.message, /levels\[0\]\.objects\[0\]\.noun/);448 return true;449 });450});451452test('rejects IDs that collide with generated engine IDs before play', () => {453 const source = `module.exports = {454 manifest: { id: 'id-collision', name: 'ID Collision', version: '1.0.0' },455 levels: [{ id: 'intro', width: 2, height: 1, objects: [456 { kind: 'object', noun: 'MIAU', x: 0, y: 0 },457 { kind: 'object', noun: 'ROCK', x: 1, y: 0, id: 'o0' },458 ] }],459 };`;460 assert.throws(() => loadMod(source), /duplicates another object id \(o0\)/);461});462463test('requires authored stable level IDs and rejects duplicates', () => {464 const base = { manifest: { id: 'level-identities', name: 'Level Identities', version: '1.0.0' } };465 const level = { width: 1, height: 1, objects: [] };466 assert.match(validateMod({ ...base, levels: [level] }).join('; '), /levels\[0\]\.id/);467 assert.match(validateMod({ ...base, levels: [{ ...level, id: 'same' }, { ...level, id: 'same' }] }).join('; '), /duplicates another level id/);468 assert.match(validateMod({ ...base, levels: [{ ...level, id: 'Uppercase' }] }).join('; '), /stable lowercase id/);469});470471test('rejects oversized local module source before evaluating it', () => {472 assert.throws(() => loadMod(' '.repeat(MAX_MOD_SOURCE_LENGTH + 1)), /source exceeds/);473});474475test('rejects oversized level and entity arrays during validation', () => {476 const level = { id: 'intro', width: 1, height: 1, objects: [] };477 assert.match(validateMod({478 manifest: { id: 'too-many-levels', name: 'Too Many Levels', version: '1.0.0' },479 levels: Array(MAX_MOD_LEVELS + 1).fill(level),480 }).join('; '), /levels: must contain/);481 assert.match(validateMod({482 manifest: { id: 'too-many-objects', name: 'Too Many Objects', version: '1.0.0' },483 levels: [{ ...level, objects: Array(MAX_LEVEL_OBJECTS + 1).fill({ kind: 'object', noun: 'MIAU', x: 0, y: 0 }) }],484 }).join('; '), /entities/);485});486487test('rejects unsupported require targets before returning a mod', () => {488 assert.throws(489 () => loadMod("module.exports = require('fs');", { filename: 'unsafe.cjs' }),490 /Cannot require 'fs' in unsafe\.cjs/,491 );492});493494test('wraps a module exception with its filename', () => {495 assert.throws(496 () => loadMod("throw new Error('broken mod');", { filename: 'broken.cjs' }),497 /Could not load broken\.cjs: broken mod/,498 );499});500501test('returns no partial result when validation fails', () => {502 let result = 'unchanged';503 try {504 result = loadMod(`module.exports = {505 manifest: { id: 'partial', name: 'Partial', version: '1.0.0' },506 levels: [{ id: 'intro', width: 1, height: 1, objects: [{ kind: 'object', noun: 'MIAU', x: 2, y: 0 }] }],507 };`);508 } catch (error) {509 assert.ok(error instanceof ModValidationError);510 }511 assert.equal(result, 'unchanged');512});