clone

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

Validate mod feature words and engine property nouns

Dami committed d2be209
commit
d2be209052904698912c6c0192745b4e873d6866
parent
0d18c4ad62fe99e821eda64cadc715c45cf43d7c
author
Dami <dami.etoile@gmail.com>
4 files changed, +171 -2
Msrc/engine.js12++++
Msrc/mods.js81++++++++++++++++++++++++++++++-
Mtests/engine.test.js10++++
Mtests/mods.test.js70++++++++++++++++++++++++++-
M src/engine.js +12 -0
M src/mods.js +80 -1
@@ -1,3 +1,5 @@
1+import { parseRules } from './engine/rules.js';2+13 /**24 * Browser loader and validator for local CommonJS mods.35 *
@@ -12,12 +14,16 @@ export const MAX_MOD_LEVELS = 64;
1214 export const MAX_LEVEL_OBJECTS = 4096;1315 export const MAX_LEVEL_AUTOMATA = 32;1416 export const MAX_AUTOMATON_PATTERN = 32;17+export const SUPPORTED_MOD_FEATURES = Object.freeze(['automata']);18+export const MAX_MOD_WARNINGS = 64;1519 1620 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;1721 const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;1822 const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;1923 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;2024 const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);25+const CORE_PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);26+const FEATURE_RE = /^[a-z][a-z0-9._-]{0,31}$/;2127 const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);2228 2329 export class ModValidationError extends Error {
@@ -75,6 +81,11 @@ function validateLevel(level, index, errors) {
7581 if (!isInteger(level.height) || level.height < 1 || level.height > 128) errors.push(issue(`${path}.height`, 'must be an integer from 1 to 128'));7682 if (level.title !== undefined && typeof level.title !== 'string') errors.push(issue(`${path}.title`, 'must be a string'));7783 if (level.hint !== undefined && typeof level.hint !== 'string') errors.push(issue(`${path}.hint`, 'must be a string'));84+ const declaredProperties = new Set();85+ if (Array.isArray(level.automata)) for (const automaton of level.automata) {86+ const property = typeof automaton?.property === 'string' ? automaton.property.toUpperCase() : null;87+ if (property && token(property)) declaredProperties.add(property);88+ }7889 if (!Array.isArray(level.objects)) {7990 errors.push(issue(`${path}.objects`, 'must be an array'));8091 return;
@@ -109,6 +120,8 @@ function validateLevel(level, index, errors) {
109120 }110121 } else if (!token(typeof object.noun === 'string' ? object.noun.toUpperCase() : object.noun) || RESERVED.has(String(object.noun).toUpperCase())) {111122 errors.push(issue(`${objectPath}.noun`, 'must be an uppercase noun token, not a rule token'));123+ } else if (declaredProperties.has(String(object.noun).toUpperCase())) {124+ errors.push(issue(`${objectPath}.noun`, `${String(object.noun).toUpperCase()} is a declared property and cannot be an object noun`));112125 }113126 });114127 if (level.automata !== undefined) {
@@ -137,7 +150,7 @@ function validateLevel(level, index, errors) {
137150 const subject = typeof automaton.subject === 'string' ? automaton.subject.toUpperCase() : automaton.subject;138151 if (automaton.subject !== undefined && (!token(subject) || RESERVED.has(subject))) errors.push(issue(`${automatonPath}.subject`, 'must be an uppercase noun token, not a rule token'));139152 const property = typeof automaton.property === 'string' ? automaton.property.toUpperCase() : automaton.property;140- if (!token(property)) errors.push(issue(`${automatonPath}.property`, 'must be an uppercase property token'));153+ if (!token(property) || CORE_PROPERTIES.has(property)) errors.push(issue(`${automatonPath}.property`, 'must be a custom uppercase property token'));141154 if (!Array.isArray(automaton.pattern) || automaton.pattern.length < 1 || automaton.pattern.length > MAX_AUTOMATON_PATTERN) {142155 errors.push(issue(`${automatonPath}.pattern`, `must contain 1 to ${MAX_AUTOMATON_PATTERN} steps`));143156 } else automaton.pattern.forEach((step, stepIndex) => {
@@ -177,6 +190,20 @@ export function validateMod(mod) {
177190 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'));178191 if (manifest.engine !== undefined && manifest.engine !== MOD_SCHEMA_VERSION) errors.push(issue('manifest.engine', `must be ${MOD_SCHEMA_VERSION} for this loader`));179192 }193+ if (manifest && manifest.features !== undefined) {194+ if (!Array.isArray(manifest.features)) errors.push(issue('manifest.features', 'must be an array of unique lowercase feature identifiers'));195+ else {196+ const features = new Set();197+ manifest.features.forEach((feature, index) => {198+ if (typeof feature !== 'string' || !FEATURE_RE.test(feature)) errors.push(issue(`manifest.features[${index}]`, 'must be a lowercase feature identifier'));199+ else if (features.has(feature)) errors.push(issue(`manifest.features[${index}]`, `duplicates feature '${feature}'`));200+ else {201+ features.add(feature);202+ if (!SUPPORTED_MOD_FEATURES.includes(feature)) errors.push(issue(`manifest.features[${index}]`, `unsupported feature '${feature}'`));203+ }204+ });205+ }206+ }180207 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`));181208 else {182209 const levelIds = new Set();
@@ -198,6 +225,58 @@ export function validateMod(mod) {
198225 return errors;199226 }200227 228+/**229+ * Return bounded, nonfatal diagnostics for authored rule sentences. The230+ * engine parser is reused so stacked words and AND expansion stay aligned231+ * with gameplay. Diagnostics are intentionally not attached to normalized232+ * packs or serialized share data.233+ */234+export function collectModWarnings(normalizedMod) {235+ if (!isRecord(normalizedMod)) return [];236+ const knownNouns = new Set();237+ for (const noun of Array.isArray(normalizedMod.vocabulary) ? normalizedMod.vocabulary : []) {238+ if (typeof noun === 'string') knownNouns.add(noun.toUpperCase());239+ }240+ for (const noun of Object.keys(isRecord(normalizedMod.palette) ? normalizedMod.palette : {})) knownNouns.add(noun.toUpperCase());241+ const warnings = [];242+ const seen = new Set();243+ for (const level of Array.isArray(normalizedMod.levels) ? normalizedMod.levels : []) {244+ for (const object of Array.isArray(level?.objects) ? level.objects : []) {245+ if (object?.kind !== 'word' && typeof object?.noun === 'string') knownNouns.add(object.noun.toUpperCase());246+ }247+ }248+ for (const [levelIndex, level] of (Array.isArray(normalizedMod.levels) ? normalizedMod.levels : []).entries()) {249+ const objects = (Array.isArray(level?.objects) ? level.objects : []).map((object, index) => ({ ...object, id: object.id || `o${index}` }));250+ const semantic = new Set(CORE_PROPERTIES);251+ for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {252+ if (typeof automaton?.property === 'string') semantic.add(automaton.property.toUpperCase());253+ }254+ const parsed = parseRules(objects, semantic);255+ const words = new Map(objects.filter(object => object?.kind === 'word').map(object => [object.id, object]));256+ const objectIndexes = new Map(objects.map((object, index) => [object.id, index]));257+ for (const sentence of parsed.sentences) {258+ const connector = sentence.ids.findIndex(id => words.get(id)?.text === 'IS');259+ for (const sourceId of sentence.ids.slice(connector + 1)) {260+ const predicate = words.get(sourceId)?.text;261+ if (!predicate || predicate === 'AND' || semantic.has(predicate) || knownNouns.has(predicate)) continue;262+ const key = `${level?.id || levelIndex}:${sourceId}`;263+ if (seen.has(key)) continue;264+ seen.add(key);265+ const levelId = typeof level?.id === 'string' ? level.id : undefined;266+ const location = `levels[${levelIndex}].objects[${objectIndexes.get(sourceId)}].text`;267+ const proof = `Proof ${levelIndex + 1}${level?.title ? ` (${level.title})` : ''}`;268+ warnings.push({269+ message: `${proof}: ${predicate} is not a declared property or known noun in this pack.`,270+ detail: `${location}: predicate ${predicate} is not a core property, declared property, or pack noun.`,271+ ...(levelId ? { levelId } : {}),272+ });273+ if (warnings.length >= MAX_MOD_WARNINGS) return warnings;274+ }275+ }276+ }277+ return warnings;278+}279+201280 function normalizeMod(mod) {202281 const normalized = copy(mod);203282 normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;
M tests/engine.test.js +10 -0
M tests/mods.test.js +69 -1
@@ -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, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';4+import { collectModWarnings, 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 const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');
@@ -75,6 +75,74 @@ test('accepts word-carried automata without an explicit subject', () => {
7575 });7676 });7777 78+test('warns on an unknown authored predicate while allowing known noun transforms', () => {79+ const mod = loadMod(`module.exports = {80+ manifest: { id: 'predicate-warnings', name: 'Predicate Warnings', version: '1.0.0' },81+ vocabulary: ['ROCK', 'MIAU'],82+ levels: [{ id: 'intro', width: 9, height: 2, objects: [83+ { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'SHUT', x: 2, y: 0 },84+ { kind: 'word', text: 'ROCK', x: 4, y: 0 }, { kind: 'word', text: 'IS', x: 5, y: 0 }, { kind: 'word', text: 'MIAU', x: 6, y: 0 },85+ { kind: 'object', noun: 'DOOR', x: 0, y: 1 },86+ ] }],87+ };`);88+ const warnings = collectModWarnings(mod);89+ assert.equal(warnings.length, 1);90+ assert.equal(warnings[0].levelId, 'intro');91+ assert.match(warnings[0].message, /SHUT/);92+ assert.match(warnings[0].detail, /levels\[0\]\.objects\[2\]\.text/);93+});94+95+test('warning paths identify each unknown predicate in an AND sentence', () => {96+ const mod = loadMod(`module.exports = {97+ manifest: { id: 'two-warnings', name: 'Two Warnings', version: '1.0.0' },98+ levels: [{ id: 'intro', title: 'An open question', width: 5, height: 1, objects: [99+ { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 },100+ { text: 'SHUT', x: 2, y: 0 }, { text: 'AND', x: 3, y: 0 }, { text: 'LOCK', x: 4, y: 0 },101+ ] }],102+ };`);103+ assert.deepEqual(collectModWarnings(mod).map(warning => warning.detail.split(':')[0]), [104+ 'levels[0].objects[2].text', 'levels[0].objects[4].text',105+ ]);106+});107+108+test('duplicate physical sentences report each unknown predicate word once', () => {109+ const mod = loadMod(`module.exports = {110+ manifest: { id: 'duplicate-warnings', name: 'Duplicate Warnings', version: '1.0.0' },111+ levels: [{ id: 'intro', width: 3, height: 2, objects: [112+ { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 }, { text: 'SHUT', x: 2, y: 0 },113+ { text: 'DOOR', x: 0, y: 1 }, { text: 'IS', x: 1, y: 1 }, { text: 'SHUT', x: 2, y: 1 },114+ ] }],115+ };`);116+ assert.deepEqual(collectModWarnings(mod).map(warning => warning.detail.split(':')[0]), [117+ 'levels[0].objects[2].text', 'levels[0].objects[5].text',118+ ]);119+});120+121+test('declared automaton properties cannot also be object nouns', () => {122+ assert.throws(() => loadMod(`module.exports = {123+ manifest: { id: 'property-collision', name: 'Property Collision', version: '1.0.0' },124+ levels: [{ id: 'intro', width: 1, height: 1, objects: [{ kind: 'object', noun: 'TICK', x: 0, y: 0 }],125+ automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }] }] }],126+ };`), /declared property and cannot be an object noun/);127+});128+129+test('rejects unknown manifest features but preserves legacy automata packs', () => {130+ const base = { manifest: { id: 'feature-check', name: 'Feature Check', version: '1.0.0' }, levels: [{ id: 'intro', width: 1, height: 1, objects: [] }] };131+ assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['pairs'] } }).join('; '), /manifest\.features\[0\].*unsupported feature 'pairs'/);132+ assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['automata', 'automata'] } }).join('; '), /manifest\.features\[1\].*duplicates feature 'automata'/);133+ assert.match(validateMod({ ...base, manifest: { ...base.manifest, features: ['Automata'] } }).join('; '), /manifest\.features\[0\].*lowercase feature identifier/);134+ assert.deepEqual(validateMod({ ...base, manifest: { ...base.manifest, features: ['automata'] } }), []);135+ assert.deepEqual(validateMod({ ...base, levels: [{ ...base.levels[0], automata: [{ id: 'clock', property: 'TICK', pattern: [{ action: 'wait' }] }] }] }), []);136+});137+138+test('rejects a core property as a custom automaton property before play', () => {139+ const mod = {140+ manifest: { id: 'bad-core-property', name: 'Bad Core Property', version: '1.0.0' },141+ levels: [{ id: 'intro', width: 1, height: 1, objects: [], automata: [{ id: 'clock', property: 'YOU', pattern: [{ action: 'wait' }] }] }],142+ };143+ assert.match(validateMod(mod).join('; '), /automata\[0\]\.property: must be a custom uppercase property token/);144+});145+78146 test('rejects unsafe or unbounded automata steps', () => {79147 assert.throws(() => loadMod(`module.exports = {80148 manifest: { id: 'bad-automata', name: 'Bad Automata', version: '1.0.0' },