clone

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

Extract rule parsing and indexed engine lookups

Dami committed db5a1fe
commit
db5a1fe0408d7e61f6e21c74b48ee1c8860a83d8
parent
2dfb250ac86e455a26f248dba4edf37db5fa805e
author
Dami <dami.etoile@gmail.com>
4 files changed, +192 -78
Msrc/engine.js106++++++++----------------------
Asrc/engine/indexes.js47+++++++++++++
Asrc/engine/rules.js87+++++++++++++++++++++++++
Atests/rules.test.js30++++++++
M src/engine.js +28 -78
@@ -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);
A src/engine/indexes.js +47 -0
@@ -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+}
A src/engine/rules.js +87 -0
@@ -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+
A tests/rules.test.js +30 -0
@@ -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+});