- commit
- db5a1fe0408d7e61f6e21c74b48ee1c8860a83d8
- parent
- 2dfb250ac86e455a26f248dba4edf37db5fa805e
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 106 | ++++++++---------------------- |
| A | src/ | 47 | +++++++++++++ |
| A | src/ | 87 | +++++++++++++++++++++++++ |
| A | tests/ | 30 | ++++++++ |
@@ -1,10 +1,11 @@
11 /** Predicates implemented by the MVP engine. */2+import { buildIndexes } from './engine/indexes.js';3+import { parseRules } from './engine/rules.js';4+25 export const CORE_PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);36 const MAX_AUTOMATA = 32;47 const MAX_AUTOMATON_PATTERN = 32;58 const clone = value => structuredClone(value);6-const key = (x, y) => `${x},${y}`;7-const directions = [[1, 0], [0, 1]];89 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;910 1011 function validateLevel(level) {@@ -75,74 +76,10 @@ export function createGame(level) {
7576 const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);7677 let state;7778 let history = [];79+ let indexes = { objectsById: new Map(), objectsByCell: new Map(), objectsByProperty: new Map(), rulesBySubject: new Map(), propertiesByObjectId: new Map() };7880 const inBounds = (x, y) => x >= 0 && y >= 0 && x < state.width && y < state.height;7981 const getState = () => clone(state);8082 81- function wordsAt() {82- const result = new Map();83- for (const o of state.objects) if (o.kind === 'word') {84- const k = key(o.x, o.y);85- if (!result.has(k)) result.set(k, []);86- result.get(k).push({ text: o.text, id: o.id });87- }88- return result;89- }90-91- function parseRules() {92- const words = wordsAt();93- const at = (x, y) => words.get(key(x, y)) || [];94- const noun = t => t && t !== 'IS' && t !== 'AND' && !semanticProperties.has(t);95- const predicate = t => noun(t) || semanticProperties.has(t);96- const found = new Map();97- const spans = [];98- for (const [origin, starts] of words) {99- const [ox, oy] = origin.split(',').map(Number);100- for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {101- let x = ox, y = oy;102- let subjectPaths = [{ text: first.text, sourceIds: [first.id] }];103- const has = (px, py, test) => at(px, py).some(entry => test(entry.text));104- while (has(x + dx, y + dy, t => t === 'AND') && has(x + 2 * dx, y + 2 * dy, noun)) {105- const ands = at(x + dx, y + dy).filter(entry => entry.text === 'AND');106- const nouns = at(x + 2 * dx, y + 2 * dy).filter(entry => noun(entry.text));107- x += 2 * dx; y += 2 * dy;108- subjectPaths = subjectPaths.flatMap(path => ands.flatMap(and => nouns.map(next => ({109- text: `${path.text} AND ${next.text}`,110- sourceIds: [...path.sourceIds, and.id, next.id],111- }))));112- }113- const isWords = at(x + dx, y + dy).filter(entry => entry.text === 'IS');114- if (!isWords.length) continue;115- const is = isWords[0];116- x += 2 * dx; y += 2 * dy;117- let predicatePaths = at(x, y).filter(entry => predicate(entry.text)).map(entry => ({ text: entry.text, sourceIds: [entry.id] }));118- if (!predicatePaths.length) continue;119- let endX = x, endY = y;120- while (has(endX + dx, endY + dy, t => t === 'AND') && has(endX + 2 * dx, endY + 2 * dy, predicate)) {121- const ands = at(endX + dx, endY + dy).filter(entry => entry.text === 'AND');122- const predicates = at(endX + 2 * dx, endY + 2 * dy).filter(entry => predicate(entry.text));123- endX += 2 * dx; endY += 2 * dy;124- predicatePaths = predicatePaths.flatMap(path => ands.flatMap(and => predicates.map(next => ({125- text: `${path.text} AND ${next.text}`,126- sourceIds: [...path.sourceIds, and.id, next.id],127- }))));128- }129- // Add expanded subjects and predicates while retaining the first130- // occurrence of each rule for deterministic stacked-word parsing.131- for (const s of subjectPaths) for (const p of predicatePaths) spans.push({ ids: [...s.sourceIds, is.id, ...p.sourceIds], axis: dx ? 'h' : 'v' });132- for (const s of subjectPaths) for (const p of predicatePaths) {133- const subjectNames = s.text.split(' AND ');134- const predicateNames = p.text.split(' AND ');135- for (const subject of subjectNames) for (const pred of predicateNames) {136- const ruleKey = `${subject} IS ${pred}`;137- if (!found.has(ruleKey)) found.set(ruleKey, { subject, predicate: pred, text: ruleKey, sourceIds: [...s.sourceIds, is.id, ...p.sourceIds], axis: dx ? 'h' : 'v' });138- }139- }140- }141- }142- state.sentences = spans.filter(span => !spans.some(other => other !== span && other.axis === span.axis && other.ids.length > span.ids.length && span.ids.every(id => other.ids.includes(id))));143- return [...found.values()];144- }145-14683 function refreshAutomata() {14784 state.automata = automataDefs.map(system => {14885 const prior = state.automata?.find(candidate => candidate.id === system.id);@@ -168,11 +105,16 @@ export function createGame(level) {
168105 };169106 });170107 }171- function refreshRules() { state.rules = parseRules(); refreshAutomata(); }108+ function refreshRules() {109+ const parsed = parseRules(state.objects, semanticProperties);110+ state.rules = parsed.rules;111+ state.sentences = parsed.sentences;112+ indexes = buildIndexes(state, state.rules, semanticProperties);113+ refreshAutomata();114+ }172115 function has(nounName, property) { return state.rules.some(r => r.subject === String(nounName).toUpperCase() && r.predicate === String(property).toUpperCase()); }173116 function properties(o) {174- if (o.kind === 'word') return new Set(['PUSH']);175- return new Set(state.rules.filter(r => r.subject === o.noun && semanticProperties.has(r.predicate)).map(r => r.predicate));117+ return indexes.propertiesByObjectId.get(o.id) || new Set();176118 }177119 function transform() {178120 const changes = new Map();@@ -181,7 +123,7 @@ export function createGame(level) {
181123 }182124 function resolveContacts(evaluateWin = true) {183125 const cells = new Map();184- for (const o of state.objects) { const k = key(o.x, o.y); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(o); }126+ for (const [cell, occupants] of indexes.objectsByCell) cells.set(cell, occupants);185127 const removed = new Set();186128 const causes = new Map();187129 for (const occupants of cells.values()) {@@ -202,7 +144,10 @@ export function createGame(level) {
202144 const win = remaining.filter(o => properties(o).has('WIN'));203145 if (evaluateWin && remainingYou.length && win.length) state.won = true;204146 }205- if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));147+ if (removed.size) {148+ state.objects = state.objects.filter(o => !removed.has(o.id));149+ indexes = buildIndexes(state, state.rules, semanticProperties);150+ }206151 return causes;207152 }208153 @@ -210,6 +155,9 @@ export function createGame(level) {
210155 if (planned.has(o.id)) return { ok: true };211156 const tx = o.x + dx, ty = o.y + dy;212157 if (!inBounds(tx, ty)) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: null, reason: 'bounds' } };158+ // Objects move while a push chain is planned. The settled cell index is159+ // therefore stale within this function; scan the live array so a later160+ // actor observes positions moved by an earlier actor in this turn.213161 const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));214162 for (const other of collisions) {215163 const traits = properties(other);@@ -238,7 +186,9 @@ export function createGame(level) {
238186 continue;239187 }240188 const projection = object => object.x * action.dx + object.y * action.dy;241- const targets = state.objects.filter(object => object.kind === 'object' && (!system.subject || object.noun === system.subject) && properties(object).has(system.property)).sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));189+ const targets = (indexes.objectsByProperty.get(system.property) || [])190+ .filter(object => object.kind === 'object' && (!system.subject || object.noun === system.subject))191+ .sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));242192 if (!targets.length) {243193 automated.push({ systemId: system.id, phase, action: action.action, active, cue: action.cue, sound: action.sound });244194 continue;@@ -257,13 +207,13 @@ export function createGame(level) {
257207 automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, blocked: result.blocked, cue: action.cue, sound: action.sound });258208 continue;259209 }260- const from = new Map(order.map(id => { const object = state.objects.find(candidate => candidate.id === id); return [id, { x: object.x, y: object.y }]; }));210+ const from = new Map(order.map(id => { const object = indexes.objectsById.get(id); return [id, { x: object.x, y: object.y }]; }));261211 for (const id of order) {262- const object = state.objects.find(candidate => candidate.id === id);212+ const object = indexes.objectsById.get(id);263213 object.x += action.dx; object.y += action.dy; movedIds.add(id);264214 }265215 for (const id of order) {266- const object = state.objects.find(candidate => candidate.id === id);216+ const object = indexes.objectsById.get(id);267217 automated.push({ systemId: system.id, phase, action: action.action, active, id, from: from.get(id), to: { x: object.x, y: object.y }, pushedBy: plannedPushers.get(id) || null, cue: action.cue, sound: action.sound });268218 }269219 }@@ -371,7 +321,7 @@ export function createGame(level) {
371321 if (!result.ok) { blocked ||= result.blocked; continue; }372322 for (const [id, pusher] of plannedPushers) pushedBy.set(id, pusher);373323 for (const id of plannedOrder) {374- const target = state.objects.find(candidate => candidate.id === id);324+ const target = indexes.objectsById.get(id);375325 target.x += dx; target.y += dy; moved.add(target.id);376326 }377327 resolutionOrder.push(...plannedOrder);@@ -381,7 +331,7 @@ export function createGame(level) {
381331 const beforeObjects = new Map(before.objects.map(o => [o.id, o]));382332 const movementEntries = resolutionOrder.map(id => {383333 const prior = beforeObjects.get(id);384- const current = state.objects.find(o => o.id === id);334+ const current = indexes.objectsById.get(id);385335 return { id, from: { x: prior.x, y: prior.y }, to: { x: current.x, y: current.y }, pushedBy: pushedBy.get(id) || null };386336 });387337 history.push(before);@@ -0,0 +1,47 @@
1+/**2+ * Turn-local indexes for the rule engine.3+ *4+ * The engine mutates object positions during a turn, so this index is rebuilt5+ * at each settled rule boundary. Consumers must treat the maps as read-only6+ * and rebuild after replacing the object array or changing a noun.7+ */8+export function buildIndexes(state, rules, semanticProperties) {9+ const objectsById = new Map();10+ const objectsByCell = new Map();11+ const rulesBySubject = new Map();12+ const objectsByProperty = new Map();13+14+ for (const object of state.objects) {15+ objectsById.set(object.id, object);16+ const cell = `${object.x},${object.y}`;17+ const occupants = objectsByCell.get(cell);18+ if (occupants) occupants.push(object);19+ else objectsByCell.set(cell, [object]);20+ }21+ for (const rule of rules) {22+ const subjectRules = rulesBySubject.get(rule.subject);23+ if (subjectRules) subjectRules.push(rule);24+ else rulesBySubject.set(rule.subject, [rule]);25+ }26+27+ const propertiesByObjectId = new Map();28+ for (const object of state.objects) {29+ if (object.kind === 'word') {30+ propertiesByObjectId.set(object.id, new Set(['PUSH']));31+ continue;32+ }33+ const properties = new Set(34+ (rulesBySubject.get(object.noun) || [])35+ .filter(rule => semanticProperties.has(rule.predicate))36+ .map(rule => rule.predicate),37+ );38+ propertiesByObjectId.set(object.id, properties);39+ for (const property of properties) {40+ const carriers = objectsByProperty.get(property);41+ if (carriers) carriers.push(object);42+ else objectsByProperty.set(property, [object]);43+ }44+ }45+46+ return { objectsById, objectsByCell, objectsByProperty, rulesBySubject, propertiesByObjectId };47+}@@ -0,0 +1,87 @@
1+const key = (x, y) => `${x},${y}`;2+const directions = [[1, 0], [0, 1]];3+4+/**5+ * Parse physical word objects into dynamic rules and sentence spans.6+ *7+ * This function is deliberately pure: callers provide the settled object8+ * list and semantic properties, and the returned arrays contain new rule and9+ * span records. Object order remains the source of deterministic tie breaks.10+ */11+export function parseRules(objects, semanticProperties) {12+ const words = new Map();13+ for (const object of objects) if (object.kind === 'word') {14+ const cell = key(object.x, object.y);15+ const entries = words.get(cell);16+ const entry = { text: object.text, id: object.id };17+ if (entries) entries.push(entry);18+ else words.set(cell, [entry]);19+ }20+ const at = (x, y) => words.get(key(x, y)) || [];21+ const noun = text => text && text !== 'IS' && text !== 'AND' && !semanticProperties.has(text);22+ const predicate = text => noun(text) || semanticProperties.has(text);23+ const found = new Map();24+ const spans = [];25+26+ for (const [origin, starts] of words) {27+ const [ox, oy] = origin.split(',').map(Number);28+ for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {29+ let x = ox;30+ let y = oy;31+ let subjectPaths = [{ text: first.text, sourceIds: [first.id] }];32+ const has = (px, py, test) => at(px, py).some(entry => test(entry.text));33+ while (has(x + dx, y + dy, text => text === 'AND') && has(x + 2 * dx, y + 2 * dy, noun)) {34+ const ands = at(x + dx, y + dy).filter(entry => entry.text === 'AND');35+ const nouns = at(x + 2 * dx, y + 2 * dy).filter(entry => noun(entry.text));36+ x += 2 * dx;37+ y += 2 * dy;38+ subjectPaths = subjectPaths.flatMap(path => ands.flatMap(and => nouns.map(next => ({39+ text: `${path.text} AND ${next.text}`,40+ sourceIds: [...path.sourceIds, and.id, next.id],41+ }))));42+ }43+ const isWords = at(x + dx, y + dy).filter(entry => entry.text === 'IS');44+ if (!isWords.length) continue;45+ const is = isWords[0];46+ x += 2 * dx;47+ y += 2 * dy;48+ let predicatePaths = at(x, y).filter(entry => predicate(entry.text)).map(entry => ({ text: entry.text, sourceIds: [entry.id] }));49+ if (!predicatePaths.length) continue;50+ let endX = x;51+ let endY = y;52+ while (has(endX + dx, endY + dy, text => text === 'AND') && has(endX + 2 * dx, endY + 2 * dy, predicate)) {53+ const ands = at(endX + dx, endY + dy).filter(entry => entry.text === 'AND');54+ const predicates = at(endX + 2 * dx, endY + 2 * dy).filter(entry => predicate(entry.text));55+ endX += 2 * dx;56+ endY += 2 * dy;57+ predicatePaths = predicatePaths.flatMap(path => ands.flatMap(and => predicates.map(next => ({58+ text: `${path.text} AND ${next.text}`,59+ sourceIds: [...path.sourceIds, and.id, next.id],60+ }))));61+ }62+ // Retain every physical sentence for presentation and choose the first63+ // source for each logical rule for deterministic stacked-word parsing.64+ for (const subjectPath of subjectPaths) for (const predicatePath of predicatePaths) {65+ spans.push({ ids: [...subjectPath.sourceIds, is.id, ...predicatePath.sourceIds], axis: dx ? 'h' : 'v' });66+ const subjectNames = subjectPath.text.split(' AND ');67+ const predicateNames = predicatePath.text.split(' AND ');68+ for (const subject of subjectNames) for (const predicateName of predicateNames) {69+ const ruleKey = `${subject} IS ${predicateName}`;70+ if (!found.has(ruleKey)) found.set(ruleKey, {71+ subject,72+ predicate: predicateName,73+ text: ruleKey,74+ sourceIds: [...subjectPath.sourceIds, is.id, ...predicatePath.sourceIds],75+ axis: dx ? 'h' : 'v',76+ });77+ }78+ }79+ }80+ }81+82+ const sentences = spans.filter(span => !spans.some(other => (83+ other !== span && other.axis === span.axis && other.ids.length > span.ids.length && span.ids.every(id => other.ids.includes(id))84+ )));85+ return { rules: [...found.values()], sentences };86+}87+@@ -0,0 +1,30 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { parseRules } from '../src/engine/rules.js';4+5+const semanticProperties = new Set(['YOU', 'PUSH', 'STOP', 'WIN']);6+const word = (text, x, y, id = `${text}-${x}-${y}`) => ({ kind: 'word', text, x, y, id });7+8+test('pure rule parser preserves stacked sources and maximal physical spans', () => {9+ const objects = [10+ word('ROCK', 0, 0, 'h-rock'), word('IS', 1, 0, 'h-is'), word('STOP', 2, 0, 'h-stop'),11+ word('ROCK', 0, 2, 'v-rock'), word('IS', 0, 3, 'v-is'), word('STOP', 0, 4, 'v-stop'),12+ word('ROCK', 3, 0, 'and-rock'), word('AND', 4, 0, 'and-op'), word('FLAG', 5, 0, 'and-flag'),13+ word('IS', 6, 0, 'and-is'), word('WIN', 7, 0, 'and-win'),14+ ];15+ const parsed = parseRules(objects, semanticProperties);16+ assert.equal(parsed.rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);17+ assert.deepEqual(parsed.sentences, [18+ { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },19+ { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },20+ { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },21+ ]);22+});23+24+test('pure rule parser does not mutate its object input', () => {25+ const objects = [word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0)];26+ const before = structuredClone(objects);27+ const parsed = parseRules(objects, semanticProperties);28+ assert.deepEqual(objects, before);29+ assert.deepEqual(parsed.rules.map(rule => rule.text), ['MIAU IS YOU']);30+});