- commit
- 364e659b5c46226ebda4975fc1128245fd084d65
- parent
- 9a32b1d5b1c3bce0b6f03d7300612f9c3dd0e66c
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 144 | ++++++++++++++++++++++-------- |
| M | tests/ | 125 | ++++++++++++++++++++++++++- |
| M | tests/ | 12 | ++- |
@@ -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 }@@ -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),@@ -51,15 +51,19 @@ test('noun transformation changes ROCK into the controllable GEM', () => {
5151 assert.equal(game.getState().won, true);5252 });5353 54-test('editing a stacked predicate removes STOP while preserving PUSH', () => {54+test('PUSH takes precedence over STOP in Rules are cargo', () => {5555 const game = createGame(pack.levels[2]);5656 assert.equal(game.has('ROCK', 'PUSH'), true);5757 assert.equal(game.has('ROCK', 'STOP'), true);5858 59- // Before the edit, ROCK is STOP and cannot be pushed right.59+ move(game, 'right', 7);60+ assert.equal(game.getState().won, true);61+ assert.equal(game.has('ROCK', 'STOP'), true);62+});63+64+test('editing a stacked predicate removes STOP while preserving PUSH', () => {65+ const game = createGame(pack.levels[2]);6066 move(game, 'right', 4);61- assert.equal(game.step(1, 0).accepted, false);62- assert.equal(game.getState().objects.find(object => object.id === 'cargo-rock-object').x, 6);6367 6468 // Reach the STOP word from below and push it out of the sentence.6569 move(game, 'up', 2);