clone

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

Apply rule precedence with atomic turn rollback

Dami committed 364e659
commit
364e659b5c46226ebda4975fc1128245fd084d65
parent
9a32b1d5b1c3bce0b6f03d7300612f9c3dd0e66c
author
Dami <dami.etoile@gmail.com>
3 files changed, +239 -42
Msrc/engine.js144++++++++++++++++++++++--------
Mtests/engine.test.js125++++++++++++++++++++++++++-
Mtests/word-lab.test.js12++-
M src/engine.js +107 -37
@@ -1,6 +1,6 @@
11 /** Predicates implemented by the MVP engine. */22 import { buildIndexes } from './engine/indexes.js';3-import { parseRules } from './engine/rules.js';3+import { parseRules, RuleParseLimitError } from './engine/rules.js';44 import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';55 66 export const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);
@@ -89,6 +89,7 @@ export function createGame(level) {
8989 const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);9090 let state;9191 let history = [];92+ let transformSources = new Map();9293 let indexes = { objectsById: new Map(), objectsByCell: new Map(), objectsByProperty: new Map(), rulesBySubject: new Map(), propertiesByObjectId: new Map() };9394 const inBounds = (x, y) => x >= 0 && y >= 0 && x < state.width && y < state.height;9495 const getState = () => clone(state);
@@ -129,10 +130,31 @@ export function createGame(level) {
129130 function properties(o) {130131 return indexes.propertiesByObjectId.get(o.id) || new Set();131132 }132- function transform() {133+ function transform(transformedIds = new Set()) {134+ const positions = new Map(state.objects.map(object => [object.id, object]));135+ const orderedRules = state.rules.map((rule, index) => ({ rule, index })).sort((a, b) => {136+ const aSource = positions.get(a.rule.sourceIds?.[0]);137+ const bSource = positions.get(b.rule.sourceIds?.[0]);138+ return (aSource?.y ?? 0) - (bSource?.y ?? 0)139+ || (aSource?.x ?? 0) - (bSource?.x ?? 0)140+ || (a.rule.axis === 'h' ? 0 : 1) - (b.rule.axis === 'h' ? 0 : 1)141+ || a.index - b.index;142+ }).map(entry => entry.rule);143+ const selfTransforms = new Set(orderedRules144+ .filter(rule => !semanticProperties.has(rule.predicate) && rule.subject === rule.predicate)145+ .map(rule => rule.subject));133146 const changes = new Map();134- for (const r of state.rules) if (!semanticProperties.has(r.predicate) && r.subject !== r.predicate && !changes.has(r.subject)) changes.set(r.subject, r.predicate);135- for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);147+ for (const rule of orderedRules) {148+ if (semanticProperties.has(rule.predicate) || rule.subject === rule.predicate || selfTransforms.has(rule.subject) || changes.has(rule.subject)) continue;149+ changes.set(rule.subject, { to: rule.predicate, rule });150+ }151+ for (const object of state.objects) {152+ if (object.kind !== 'object' || transformedIds.has(object.id) || !changes.has(object.noun)) continue;153+ const change = changes.get(object.noun);154+ object.noun = change.to;155+ transformSources.set(object.id, change.rule);156+ transformedIds.add(object.id);157+ }136158 }137159 function resolveContacts(evaluateWin = true) {138160 const cells = new Map();
@@ -141,8 +163,13 @@ export function createGame(level) {
141163 const causes = new Map();142164 for (const occupants of cells.values()) {143165 // A single entity can carry both YOU and WIN after a rule changes. It144- // wins without needing a second occupant in its cell.166+ // still loses if it also carries DEFEAT.145167 if (occupants.length === 1) {168+ const only = occupants[0];169+ if (properties(only).has('YOU') && properties(only).has('DEFEAT')) {170+ removed.add(only.id);171+ causes.set(only.id, { cause: 'defeat', at: { x: only.x, y: only.y }, noun: only.noun, text: only.text });172+ }146173 continue;147174 }148175 if (occupants.length < 2) continue;
@@ -182,10 +209,11 @@ export function createGame(level) {
182209 const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));183210 for (const other of collisions) {184211 const traits = properties(other);185- if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };186212 if (traits.has('PUSH')) {187213 const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);188214 if (!result.ok) return result;215+ } else if (traits.has('STOP')) {216+ return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };189217 }190218 }191219 planned.add(o.id);
@@ -252,7 +280,10 @@ export function createGame(level) {
252280 const prior = beforeObjects.get(id);253281 if (!prior) continue;254282 if (!movedOverride && (prior.x !== after.x || prior.y !== after.y)) moved.push({ id, from: { x: prior.x, y: prior.y }, to: { x: after.x, y: after.y }, pushedBy: pushedBy.get(id) || null });255- if (!transformedOverride && prior.kind === 'object' && after.kind === 'object' && prior.noun !== after.noun) transformed.push({ id, from: prior.noun, to: after.noun });283+ if (!transformedOverride && prior.kind === 'object' && after.kind === 'object' && prior.noun !== after.noun) {284+ const source = transformSources.get(id);285+ transformed.push({ id, from: prior.noun, to: after.noun, ...(source ? { sourceIds: [...source.sourceIds], axis: source.axis } : {}) });286+ }256287 }257288 const destroyed = [];258289 for (const [id, detail] of destroyedCauses) {
@@ -270,7 +301,9 @@ export function createGame(level) {
270301 const before = state ? clone(state) : null;271302 state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects: original.objects.map((o, i) => ({ id: o.id || `o${i}`, kind: o.kind || (o.text != null ? 'word' : 'object'), noun: o.noun?.toUpperCase(), text: o.text?.toUpperCase(), x: o.x, y: o.y })), moves: 0, won: false, rules: [], sentences: [], automata: [] };272303 history = [];273- refreshRules(); transform(); refreshRules();304+ transformSources = new Map();305+ const transformedIds = new Set();306+ refreshRules(); transform(transformedIds); refreshRules();274307 resolveContacts();275308 return settled('reset', true, before, null, new Map(), true);276309 }
@@ -313,12 +346,27 @@ export function createGame(level) {
313346 }314347 if (phaseById.size !== automataDefs.length) throw new TypeError('snapshot.automata does not match this level');315348 }349+ const previousState = state;350+ const previousIndexes = indexes;351+ const previousHistory = history;352+ const previousTransformSources = transformSources;316353 state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects, moves: Number.isInteger(snapshot.moves) && snapshot.moves >= 0 ? snapshot.moves : 0, won: Boolean(snapshot.won), rules: [], sentences: [], automata: automataDefs.map(system => ({ ...system, phase: phaseById.get(system.id) ?? ((system.phase || 0) % system.pattern.length) })) };317354 history = [];318- refreshRules();319- transform();320- refreshRules();321- return getState();355+ transformSources = new Map();356+ try {357+ const transformedIds = new Set();358+ refreshRules();359+ transform(transformedIds);360+ refreshRules();361+ return getState();362+ } catch (error) {363+ if (!(error instanceof RuleParseLimitError)) throw error;364+ state = previousState;365+ indexes = previousIndexes;366+ history = previousHistory;367+ transformSources = previousTransformSources;368+ throw error;369+ }322370 }323371 function takeTurn(dx, dy, wait = false) {324372 const before = clone(state);
@@ -357,33 +405,54 @@ export function createGame(level) {
357405 });358406 history.push(before);359407 state.moves++;360- refreshRules(); transform(); refreshRules();361- const automated = advanceAutomata(moved);362- const automatedMoves = automated.filter(entry => entry.id && entry.from && entry.to && (entry.from.x !== entry.to.x || entry.from.y !== entry.to.y)).map(entry => ({ id: entry.id, from: entry.from, to: entry.to, pushedBy: entry.pushedBy || null, cause: 'automaton', systemId: entry.systemId, ...(entry.pushedBy ? {} : { selfDirection: { dx: entry.to.x - entry.from.x, dy: entry.to.y - entry.from.y } }) }));363- const movementById = new Map();364- const movementOrder = [];365- for (const entry of [...movementEntries, ...automatedMoves]) {366- if (!movementById.has(entry.id)) {367- movementById.set(entry.id, { ...entry });368- movementOrder.push(entry.id);369- } else {370- const merged = movementById.get(entry.id);371- merged.to = { ...entry.to };372- if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;373- if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };408+ try {409+ transformSources = new Map();410+ const transformedIds = new Set();411+ refreshRules(); transform(transformedIds); refreshRules();412+ const automated = advanceAutomata(moved);413+ const automatedMoves = automated.filter(entry => entry.id && entry.from && entry.to && (entry.from.x !== entry.to.x || entry.from.y !== entry.to.y)).map(entry => ({ id: entry.id, from: entry.from, to: entry.to, pushedBy: entry.pushedBy || null, cause: 'automaton', systemId: entry.systemId, ...(entry.pushedBy ? {} : { selfDirection: { dx: entry.to.x - entry.from.x, dy: entry.to.y - entry.from.y } }) }));414+ const movementById = new Map();415+ const movementOrder = [];416+ for (const entry of [...movementEntries, ...automatedMoves]) {417+ if (!movementById.has(entry.id)) {418+ movementById.set(entry.id, { ...entry });419+ movementOrder.push(entry.id);420+ } else {421+ const merged = movementById.get(entry.id);422+ merged.to = { ...entry.to };423+ if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;424+ if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };425+ }374426 }427+ const allMovementEntries = movementOrder.map(id => movementById.get(id));428+ refreshRules(); transform(transformedIds); refreshRules();429+ const destroyedCauses = new Map();430+ for (const [id, detail] of resolveContacts(true)) destroyedCauses.set(id, detail);431+ const transformationEntries = state.objects.flatMap(object => {432+ const prior = beforeObjects.get(object.id);433+ return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun434+ ? [{ id: object.id, from: prior.noun, to: object.noun, ...(transformSources.has(object.id) ? { sourceIds: [...transformSources.get(object.id).sourceIds], axis: transformSources.get(object.id).axis } : {}) }]435+ : [];436+ });437+ return settled(wait ? 'wait' : 'step', true, before, blocked, destroyedCauses, null, pushedBy, allMovementEntries, transformationEntries, automated);438+ } catch (error) {439+ if (!(error instanceof RuleParseLimitError)) throw error;440+ // Parsing is part of the turn transaction. Restore the complete settled441+ // snapshot, including automaton phases, and remove the speculative undo442+ // entry so a complexity rejection is indistinguishable from any other443+ // rejected input to history and the solver.444+ state = before;445+ history.pop();446+ transformSources = new Map();447+ indexes = buildIndexes(state, state.rules, semanticProperties);448+ return settled(wait ? 'wait' : 'step', false, before, {449+ id: null,450+ dir: [dx, dy],451+ blockerId: null,452+ reason: 'rule-complexity',453+ details: error.message,454+ });375455 }376- const allMovementEntries = movementOrder.map(id => movementById.get(id));377- refreshRules(); transform(); refreshRules();378- const destroyedCauses = new Map();379- for (const [id, detail] of resolveContacts(true)) destroyedCauses.set(id, detail);380- const transformationEntries = state.objects.flatMap(object => {381- const prior = beforeObjects.get(object.id);382- return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun383- ? [{ id: object.id, from: prior.noun, to: object.noun }]384- : [];385- });386- return settled(wait ? 'wait' : 'step', true, before, blocked, destroyedCauses, null, pushedBy, allMovementEntries, transformationEntries, automated);387456 }388457 function step(dx, dy) { return takeTurn(dx, dy, false); }389458 function wait() { return takeTurn(0, 0, true); }
@@ -391,6 +460,7 @@ export function createGame(level) {
391460 const before = clone(state);392461 if (!history.length) return settled('undo', false, before);393462 state = history.pop();463+ transformSources = new Map();394464 indexes = buildIndexes(state, state.rules, semanticProperties);395465 return settled('undo', true, before, null, new Map(), true);396466 }
M tests/engine.test.js +124 -1
@@ -30,8 +30,8 @@ test('sentences expose every physical duplicate and preserve maximal AND spans',
3030 assert.equal(game.getState().rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);3131 assert.deepEqual(game.getState().sentences, [3232 { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },33- { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },3433 { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },34+ { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },3535 ]);3636 });3737
@@ -150,6 +150,129 @@ test('dynamic rules apply after words move and noun transformation is determinis
150150 assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');151151 });152152 153+test('PUSH takes precedence over STOP on the same object', () => {154+ const game = createGame(level([155+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),156+ word('ROCK', 0, 1), word('IS', 1, 1), word('STOP', 2, 1), word('AND', 3, 1), word('PUSH', 4, 1),157+ object('MIAU', 0, 3, 'player'), object('ROCK', 1, 3, 'rock'),158+ ]));159+ const event = game.step(1, 0);160+ assert.equal(event.accepted, true);161+ assert.deepEqual(event.moved.map(entry => entry.id), ['rock', 'player']);162+ assert.deepEqual(game.getState().objects.filter(object => object.kind === 'object').map(object => [object.id, object.x]), [['player', 1], ['rock', 2]]);163+164+ const blockedGame = createGame(level([165+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),166+ word('ROCK', 0, 1), word('IS', 1, 1), word('STOP', 2, 1),167+ object('MIAU', 0, 3, 'blocked-player'), object('ROCK', 1, 3, 'blocked-rock'),168+ ]));169+ const blocked = blockedGame.step(1, 0);170+ assert.equal(blocked.accepted, false);171+ assert.equal(blocked.blocked.reason, 'stop');172+ assert.equal(blockedGame.getState().objects.find(object => object.id === 'blocked-rock').x, 1);173+});174+175+test('a singleton YOU and DEFEAT entity is destroyed before victory', () => {176+ const game = createGame(level([177+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0), word('WIN', 4, 0), word('AND', 5, 0), word('DEFEAT', 6, 0),178+ object('MIAU', 3, 2, 'player'),179+ ]));180+ assert.equal(game.getState().objects.some(object => object.id === 'player'), false);181+ assert.equal(game.getState().won, false);182+ const blocked = game.step(1, 0);183+ assert.equal(blocked.accepted, false);184+ assert.equal(blocked.controlLost, true);185+});186+187+test('X IS X suppresses every competing noun transformation', () => {188+ const game = createGame(level([189+ word('ROCK', 0, 0), word('IS', 1, 0), word('FLAG', 2, 0),190+ word('ROCK', 0, 1), word('IS', 1, 1), word('ROCK', 2, 1),191+ word('MIAU', 0, 2), word('IS', 1, 2), word('YOU', 2, 2),192+ object('ROCK', 4, 3, 'rock'), object('MIAU', 0, 3, 'player'),193+ ]));194+ assert.equal(game.getState().objects.find(object => object.id === 'rock').noun, 'ROCK');195+ const event = game.step(1, 0);196+ assert.deepEqual(event.transformed, []);197+ assert.equal(game.getState().objects.find(object => object.id === 'rock').noun, 'ROCK');198+});199+200+test('competing noun transformations use spatial rule order and one link per turn', () => {201+ const game = createGame(level([202+ word('BABA', 0, 0), word('IS', 1, 0), word('ROCK', 2, 0),203+ word('ROCK', 0, 1), word('IS', 1, 1), word('FLAG', 2, 1),204+ word('ROCK', 0, 2), word('IS', 1, 2), word('WALL', 2, 2),205+ word('MIAU', 0, 3), word('IS', 1, 3), word('YOU', 2, 3),206+ object('BABA', 4, 4, 'baba'), object('MIAU', 0, 4, 'player'),207+ ]));208+ assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');209+ game.step(1, 0);210+ assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'FLAG');211+});212+213+test('transformation events retain the selected source rule per object', () => {214+ const game = createGame(level([215+ word('BABA', 0, 0, 'baba-subject'), word('IS', 1, 0, 'baba-is'), word('ROCK', 2, 0, 'baba-rock'),216+ word('ROCK', 0, 1, 'rock-subject'), word('IS', 1, 1, 'rock-is'), word('FLAG', 2, 1, 'rock-flag'),217+ word('ROCK', 0, 2, 'rock-subject-2'), word('IS', 1, 2, 'rock-is-2'), word('WALL', 2, 2, 'rock-wall'),218+ word('FLAG', 0, 3, 'flag-subject'), word('IS', 1, 3, 'flag-is'), word('GEM', 2, 3, 'flag-gem'),219+ word('MIAU', 0, 4), word('IS', 1, 4), word('YOU', 2, 4),220+ object('BABA', 4, 4, 'baba'), object('MIAU', 0, 4, 'player'),221+ ]));222+ assert.equal(game.getState().objects.find(object => object.id === 'baba').noun, 'ROCK');223+ const event = game.step(1, 0);224+ assert.deepEqual(event.transformed, [{225+ id: 'baba', from: 'ROCK', to: 'FLAG', sourceIds: ['rock-subject', 'rock-is', 'rock-flag'], axis: 'h',226+ }]);227+});228+229+test('rule parse complexity rejects a word move atomically', () => {230+ const objects = [231+ word('BABA', 0, 0, 'subject'),232+ word('IS', 1, 1, 'loose-is'),233+ word('MIAU', 0, 4, 'miau-subject'), word('IS', 1, 4, 'miau-is'), word('YOU', 2, 4, 'miau-you'),234+ word('CLOCK', 4, 5, 'clock-subject'), word('IS', 5, 5, 'clock-is'), word('TICK', 6, 5, 'clock-tick'),235+ object('MIAU', 1, 2, 'player'),236+ object('CLOCK', 8, 5, 'clock'),237+ ];238+ for (let index = 0; index < 15; index += 1) {239+ const x = 2 + index * 2;240+ objects.push(word('WIN', x, 0, `predicate-win-${index}`));241+ objects.push(word('PUSH', x, 0, `predicate-push-${index}`));242+ if (index < 14) objects.push(word('AND', x + 1, 0, `predicate-and-${index}`));243+ }244+ const authored = {245+ width: 31,246+ height: 6,247+ objects,248+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', phase: 1, pattern: [{ action: 'wait' }, { action: 'wait' }] }],249+ };250+ const overloaded = structuredClone(authored);251+ overloaded.objects.find(object => object.id === 'loose-is').y = 0;252+ assert.throws(() => createGame(overloaded), error => error.name === 'RuleParseLimitError');253+ const game = createGame(authored);254+ const before = game.getState();255+ const rejected = game.step(0, -1);256+ assert.equal(rejected.accepted, false);257+ assert.deepEqual(rejected.blocked, {258+ id: null,259+ dir: [0, -1],260+ blockerId: null,261+ reason: 'rule-complexity',262+ details: 'rule parse paths exceeded the limit of 16384',263+ });264+ assert.equal(game.canUndo, false);265+ assert.deepEqual(game.getState(), before);266+ assert.equal(game.getState().automata[0].phase, before.automata[0].phase);267+ const restoreAttempt = structuredClone(before);268+ restoreAttempt.objects.find(object => object.id === 'loose-is').y = 0;269+ assert.throws(() => game.restore(restoreAttempt), error => error.name === 'RuleParseLimitError');270+ assert.deepEqual(game.getState(), before);271+ const playable = game.step(1, 0);272+ assert.equal(playable.accepted, true);273+ assert.equal(game.getState().objects.find(object => object.id === 'player').x, 2);274+});275+153276 test('a YOU object wins when a formed rule also gives it WIN', () => {154277 const game = createGame(level([155278 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), word('AND', 3, 0),
M tests/word-lab.test.js +8 -4