- commit
- d2be209052904698912c6c0192745b4e873d6866
- parent
- 0d18c4ad62fe99e821eda64cadc715c45cf43d7c
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 12 | ++++ |
| M | src/ | 81 | ++++++++++++++++++++++++++++++- |
| M | tests/ | 10 | ++++ |
| M | tests/ | 70 | ++++++++++++++++++++++++++- |
@@ -51,6 +51,18 @@ function validateLevel(level) {
5151 if (system.phase !== undefined && (!Number.isInteger(system.phase) || system.phase < 0)) throw new TypeError(`${context}: automata ${system.id} phase must be a non-negative integer`);5252 });5353 }54+ const customProperties = new Set(55+ (Array.isArray(level.automata) ? level.automata : [])56+ .map(system => typeof system?.property === 'string' ? system.property.toUpperCase() : null)57+ .filter(Boolean),58+ );59+ for (const object of level.objects) {60+ const kind = object.kind || (object.text != null ? 'word' : 'object');61+ const noun = typeof object.noun === 'string' ? object.noun.toUpperCase() : '';62+ if (kind === 'object' && customProperties.has(noun)) {63+ throw new TypeError(`${context}: object ${object.id || '?'} noun ${noun} is reserved as an automaton property`);64+ }65+ }5466 return clone(level);5567 }5668 @@ -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;@@ -410,6 +410,16 @@ test('validates coordinates and duplicate ids', () => {
410410 assert.throws(() => createGame(level([object('YOU', 0, 0)])), /valid noun token/);411411 });412412 413+test('reserves custom automaton properties as rule words', () => {414+ assert.throws(() => createGame({415+ ...level([416+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),417+ object('TICK', 3, 1, 'tick-thing'),418+ ]),419+ automata: [{ id: 'clockwork', property: 'TICK', pattern: [{ action: 'wait' }] }],420+ }), /object tick-thing noun TICK is reserved as an automaton property/);421+});422+413423 test('bounds blocks expose a null blocker and lifecycle kinds stay stable', () => {414424 const game = createGame(level([415425 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), object('BABA', 0, 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' },