clone

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

Reject packs with excessive initial rule stacks

Dami committed 9a32b1d
commit
9a32b1d5b1c3bce0b6f03d7300612f9c3dd0e66c
parent
07c3d4603e913a147696716a6e999985334539ab
author
Dami <dami.etoile@gmail.com>
3 files changed, +117 -4
Msrc/mods.js29+++++++++++++
Msrc/mods/diagnostics.js24+++++++++--
Mtests/mods.test.js68++++++++++++++++++++++++++++++
M src/mods.js +29 -0
@@ -1,4 +1,5 @@
11 import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';2+import { parseRules, RuleParseLimitError } from './engine/rules.js';23 export { collectModWarnings, MAX_MOD_WARNINGS } from './mods/diagnostics.js';34 45 /**
@@ -44,6 +45,33 @@ function copy(value) {
4445 return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));4546 }4647 48+function validateInitialRuleComplexity(level, index, errors) {49+ if (!isRecord(level) || !Array.isArray(level.objects) || !isInteger(level.width) || !isInteger(level.height)) return;50+ // Structural validation owns the detailed malformed object messages. Only51+ // ask the parser about boards that have enough shape to be parsed safely.52+ if (level.objects.some(object => !isRecord(object) || !isInteger(object.x) || !isInteger(object.y))) return;53+ const semanticProperties = new Set(CORE_PROPERTIES);54+ for (const automaton of Array.isArray(level.automata) ? level.automata : []) {55+ if (typeof automaton?.property === 'string') semanticProperties.add(automaton.property.toUpperCase());56+ }57+ const objects = level.objects.map((object, objectIndex) => ({58+ ...object,59+ id: object.id || `o${objectIndex}`,60+ kind: object.kind || (object.text !== undefined ? 'word' : 'object'),61+ ...(typeof object.text === 'string' ? { text: object.text.toUpperCase() } : {}),62+ ...(typeof object.noun === 'string' ? { noun: object.noun.toUpperCase() } : {}),63+ }));64+ try {65+ parseRules(objects, semanticProperties);66+ } catch (error) {67+ if (error instanceof RuleParseLimitError) {68+ const kind = error.kind || 'complexity';69+ const limit = Number.isInteger(error.limit) ? ` (${error.limit})` : '';70+ errors.push(issue(`levels[${index}].objects`, `rule parse ${kind} budget exceeded${limit}; reduce stacked rule alternatives`));71+ }72+ }73+}74+4775 function validatePalette(palette, errors) {4876 if (palette === undefined) return;4977 if (!isRecord(palette)) {
@@ -215,6 +243,7 @@ export function validateMod(mod) {
215243 }216244 });217245 }246+ if (Array.isArray(mod.levels)) mod.levels.forEach((level, index) => validateInitialRuleComplexity(level, index, errors));218247 validatePalette(mod.palette, errors);219248 if (mod.vocabulary !== undefined) {220249 if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));
M src/mods/diagnostics.js +20 -4
@@ -1,4 +1,4 @@
1-import { parseRules } from '../engine/rules.js';1+import { parseRules, RuleParseLimitError } from '../engine/rules.js';22 import { CORE_PROPERTY_TOKENS } from '../engine/tokens.js';33 44 export const MAX_MOD_WARNINGS = 64;
@@ -15,20 +15,36 @@ export function collectModWarnings(normalizedMod) {
1515 if (typeof automaton?.property === 'string') allProperties.add(automaton.property.toUpperCase());1616 }1717 for (const [levelIndex, level] of levels.entries()) {18+ const levelId = typeof level?.id === 'string' ? level.id : undefined;19+ const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;1820 const objects = (Array.isArray(level?.objects) ? level.objects : []).map((object, index) => ({ ...object, id: object.id || `o${index}` }));1921 const semantic = new Set(CORE_PROPERTIES);2022 for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {2123 if (typeof automaton?.property === 'string') semantic.add(automaton.property.toUpperCase());2224 }23- const parsed = parseRules(objects, semantic);25+ let parsed;26+ try {27+ parsed = parseRules(objects, semantic);28+ } catch (error) {29+ if (!(error instanceof RuleParseLimitError)) throw error;30+ const limit = Number.isInteger(error.limit) ? ` (${error.limit})` : '';31+ warnings.push({32+ word: 'RULES',33+ ...(levelId ? { levelId } : {}),34+ levelTitle,35+ sentence: null,36+ message: `Rule text is too complex to analyze safely; reduce stacked rule alternatives.`,37+ detail: `levels[${levelIndex}].objects: rule parse ${error.kind || 'complexity'} budget exceeded${limit}.`,38+ });39+ if (warnings.length >= MAX_MOD_WARNINGS) return warnings;40+ continue;41+ }2442 const words = new Map(objects.filter(object => object?.kind === 'word').map(object => [object.id, object]));2543 const objectIndexes = new Map(objects.map((object, index) => [object.id, index]));2644 const knownNouns = new Set(objects.filter(object => object?.kind !== 'word' && typeof object?.noun === 'string').map(object => object.noun.toUpperCase()));2745 for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {2846 if (typeof automaton?.subject === 'string') knownNouns.add(automaton.subject.toUpperCase());2947 }30- const levelId = typeof level?.id === 'string' ? level.id : undefined;31- const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;3248 const seenWords = new Set();3349 const sourceLocation = sourceId => `levels[${levelIndex}].objects[${objectIndexes.get(sourceId)}].text`;3450 const formed = new Map();
M tests/mods.test.js +68 -0
@@ -6,6 +6,34 @@ import { collectModWarnings, loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD
66 const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');77 const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');88 9+function 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+}36+937 test('loads the complete example CommonJS mod', () => {1038 const mod = loadMod(exampleSource, { filename: 'example.cjs' });1139 assert.equal(mod.manifest.id, 'moon-cat');
@@ -15,6 +43,46 @@ test('loads the complete example CommonJS mod', () => {
1543 assert.deepEqual(validateMod(mod), []);1644 });1745 46+test('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+});59+60+test('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+});72+73+test('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+});85+1886 test('loads the minimal playable fox automata proof', () => {1987 const mod = loadMod(automataExampleSource, { filename: 'automata-example.cjs' });2088 assert.equal(mod.manifest.id, 'automata-example');