clone

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

Separate mod diagnostics and share engine tokens

Dami committed d99d0b0
commit
d99d0b0d494bf2d1f3c3a47d134d87fdf482c3c3
parent
af8582ad2a8ee2788a8337fcfa470842f6d02231
author
Dami <dami.etoile@gmail.com>
4 files changed, +83 -79
Msrc/engine.js3+-
Asrc/engine/tokens.js2+
Msrc/mods.js81+-----------------------------
Asrc/mods/diagnostics.js76++++++++++++++++++++++++++++
M src/engine.js +2 -1
A src/engine/tokens.js +2 -0
M src/mods.js +3 -78
@@ -1,4 +1,5 @@
1-import { parseRules } from './engine/rules.js';1+import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';2+export { collectModWarnings, MAX_MOD_WARNINGS } from './mods/diagnostics.js';23 34 /**45 * Browser loader and validator for local CommonJS mods.
@@ -15,14 +16,13 @@ export const MAX_LEVEL_OBJECTS = 4096;
1516 export const MAX_LEVEL_AUTOMATA = 32;1617 export const MAX_AUTOMATON_PATTERN = 32;1718 export const SUPPORTED_MOD_FEATURES = Object.freeze(['automata']);18-export const MAX_MOD_WARNINGS = 64;1919 2020 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;2121 const LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;2222 const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;2323 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;2424 const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);25-const CORE_PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);25+const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);2626 const FEATURE_RE = /^[a-z][a-z0-9._-]{0,31}$/;2727 const AUTOMATON_SOUNDS = new Set(['tick', 'move', 'push', 'blockedStop', 'blockedBounds', 'ruleFormed', 'ruleBroken', 'transform', 'sink', 'defeat', 'win', 'undo', 'restart', 'select', 'confirm', 'back', 'scene']);2828
@@ -225,81 +225,6 @@ export function validateMod(mod) {
225225 return errors;226226 }227227 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 warnings = [];237- const levels = Array.isArray(normalizedMod.levels) ? normalizedMod.levels : [];238- const allProperties = new Set(CORE_PROPERTIES);239- for (const level of levels) for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {240- if (typeof automaton?.property === 'string') allProperties.add(automaton.property.toUpperCase());241- }242- for (const [levelIndex, level] of levels.entries()) {243- const objects = (Array.isArray(level?.objects) ? level.objects : []).map((object, index) => ({ ...object, id: object.id || `o${index}` }));244- const semantic = new Set(CORE_PROPERTIES);245- for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {246- if (typeof automaton?.property === 'string') semantic.add(automaton.property.toUpperCase());247- }248- const parsed = parseRules(objects, semantic);249- const words = new Map(objects.filter(object => object?.kind === 'word').map(object => [object.id, object]));250- const objectIndexes = new Map(objects.map((object, index) => [object.id, index]));251- const knownNouns = new Set(objects.filter(object => object?.kind !== 'word' && typeof object?.noun === 'string').map(object => object.noun.toUpperCase()));252- for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {253- if (typeof automaton?.subject === 'string') knownNouns.add(automaton.subject.toUpperCase());254- }255- const levelId = typeof level?.id === 'string' ? level.id : undefined;256- const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;257- const seenWords = new Set();258- const sourceLocation = sourceId => `levels[${levelIndex}].objects[${objectIndexes.get(sourceId)}].text`;259- const formed = new Map();260- for (const rule of parsed.rules) {261- const ids = rule.sourceIds || [];262- const isIndex = ids.findIndex(id => words.get(id)?.text === 'IS');263- const predicateId = ids.slice(isIndex + 1).find(id => words.get(id)?.text === rule.predicate);264- if (predicateId && !formed.has(predicateId)) formed.set(predicateId, { sentence: rule.text, role: 'predicate', subject: rule.subject });265- for (const id of ids.slice(0, isIndex < 0 ? 0 : isIndex)) {266- const word = words.get(id)?.text;267- if (word && word !== 'AND' && !formed.has(id)) formed.set(id, { sentence: rule.text, role: 'subject' });268- }269- }270- const addWarning = ({ word, sourceId, sentence = null, message, detail }) => {271- const upper = String(word).toUpperCase();272- if (seenWords.has(upper)) return;273- seenWords.add(upper);274- warnings.push({ word: upper, ...(levelId ? { levelId } : {}), levelTitle, sentence, message, detail: detail || sourceLocation(sourceId) });275- };276- for (const object of objects) {277- if (object.kind !== 'word' || typeof object.text !== 'string') continue;278- const word = object.text.toUpperCase();279- if (word === 'IS' || word === 'AND' || semantic.has(word) || knownNouns.has(word)) continue;280- const context = formed.get(object.id);281- const otherProperty = allProperties.has(word) && !semantic.has(word);282- const sentence = context?.sentence || null;283- const message = context?.role === 'predicate'284- ? (otherProperty285- ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} will transform ${context.subject} into ${word}.`286- : `${word} is not a declared property here; ${sentence} will transform ${context.subject} into ${word}.`)287- : context?.role === 'subject'288- ? (otherProperty289- ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} uses it as a noun.`290- : `${word} is not a known noun in this proof; ${sentence} uses it as a noun.`)291- : otherProperty292- ? `${word} is declared as a property in another proof but has no behavior here.`293- : `${word} is not a known noun or declared property in this proof.`;294- addWarning({ word, sourceId: object.id, sentence, message, detail: context295- ? `${sourceLocation(object.id)}: ${word} is not an implemented property or known noun in this proof.`296- : `${sourceLocation(object.id)}: word ${word} is not a placed noun, automaton subject, core property, or property implemented in this proof.` });297- if (warnings.length >= MAX_MOD_WARNINGS) return warnings;298- }299- }300- return warnings;301-}302-303228 function normalizeMod(mod) {304229 const normalized = copy(mod);305230 normalized.manifest.engine = normalized.manifest.engine ?? MOD_SCHEMA_VERSION;
A src/mods/diagnostics.js +76 -0
@@ -0,0 +1,76 @@
1+import { parseRules } from '../engine/rules.js';2+import { CORE_PROPERTY_TOKENS } from '../engine/tokens.js';3+4+export const MAX_MOD_WARNINGS = 64;5+const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);6+const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);7+8+/** Return bounded author warnings without mutating or annotating pack data. */9+export function collectModWarnings(normalizedMod) {10+ if (!isRecord(normalizedMod)) return [];11+ const warnings = [];12+ const levels = Array.isArray(normalizedMod.levels) ? normalizedMod.levels : [];13+ const allProperties = new Set(CORE_PROPERTIES);14+ for (const level of levels) for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {15+ if (typeof automaton?.property === 'string') allProperties.add(automaton.property.toUpperCase());16+ }17+ for (const [levelIndex, level] of levels.entries()) {18+ const objects = (Array.isArray(level?.objects) ? level.objects : []).map((object, index) => ({ ...object, id: object.id || `o${index}` }));19+ const semantic = new Set(CORE_PROPERTIES);20+ for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {21+ if (typeof automaton?.property === 'string') semantic.add(automaton.property.toUpperCase());22+ }23+ const parsed = parseRules(objects, semantic);24+ const words = new Map(objects.filter(object => object?.kind === 'word').map(object => [object.id, object]));25+ const objectIndexes = new Map(objects.map((object, index) => [object.id, index]));26+ const knownNouns = new Set(objects.filter(object => object?.kind !== 'word' && typeof object?.noun === 'string').map(object => object.noun.toUpperCase()));27+ for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {28+ if (typeof automaton?.subject === 'string') knownNouns.add(automaton.subject.toUpperCase());29+ }30+ const levelId = typeof level?.id === 'string' ? level.id : undefined;31+ const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;32+ const seenWords = new Set();33+ const sourceLocation = sourceId => `levels[${levelIndex}].objects[${objectIndexes.get(sourceId)}].text`;34+ const formed = new Map();35+ for (const rule of parsed.rules) {36+ const ids = rule.sourceIds || [];37+ const isIndex = ids.findIndex(id => words.get(id)?.text === 'IS');38+ const predicateId = ids.slice(isIndex + 1).find(id => words.get(id)?.text === rule.predicate);39+ if (predicateId && !formed.has(predicateId)) formed.set(predicateId, { sentence: rule.text, role: 'predicate', subject: rule.subject });40+ for (const id of ids.slice(0, isIndex < 0 ? 0 : isIndex)) {41+ const word = words.get(id)?.text;42+ if (word && word !== 'AND' && !formed.has(id)) formed.set(id, { sentence: rule.text, role: 'subject' });43+ }44+ }45+ const addWarning = ({ word, sourceId, sentence = null, message, detail }) => {46+ const upper = String(word).toUpperCase();47+ if (seenWords.has(upper)) return;48+ seenWords.add(upper);49+ warnings.push({ word: upper, ...(levelId ? { levelId } : {}), levelTitle, sentence, message, detail: detail || sourceLocation(sourceId) });50+ };51+ for (const object of objects) {52+ if (object.kind !== 'word' || typeof object.text !== 'string') continue;53+ const word = object.text.toUpperCase();54+ if (word === 'IS' || word === 'AND' || semantic.has(word) || knownNouns.has(word)) continue;55+ const context = formed.get(object.id);56+ const otherProperty = allProperties.has(word) && !semantic.has(word);57+ const sentence = context?.sentence || null;58+ const message = context?.role === 'predicate'59+ ? (otherProperty60+ ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} will transform ${context.subject} into ${word}.`61+ : `${word} is not a declared property here; ${sentence} will transform ${context.subject} into ${word}.`)62+ : context?.role === 'subject'63+ ? (otherProperty64+ ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} uses it as a noun.`65+ : `${word} is not a known noun in this proof; ${sentence} uses it as a noun.`)66+ : otherProperty67+ ? `${word} is declared as a property in another proof but has no behavior here.`68+ : `${word} is not a known noun or declared property in this proof.`;69+ addWarning({ word, sourceId: object.id, sentence, message, detail: context70+ ? `${sourceLocation(object.id)}: ${word} is not an implemented property or known noun in this proof.`71+ : `${sourceLocation(object.id)}: word ${word} is not a placed noun, automaton subject, core property, or property implemented in this proof.` });72+ if (warnings.length >= MAX_MOD_WARNINGS) return warnings;73+ }74+ }75+ return warnings;76+}