1/** Predicates implemented by the MVP engine. */2import { buildIndexes } from './engine/indexes.js';3import { parseRules, RuleParseLimitError } from './engine/rules.js';4import { CORE_PROPERTY_TOKENS } from './engine/tokens.js';5import { LevelSchemaError, TOKEN_RE, normalizeLevel } from './level-schema.js';67export const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);8const clone = value => structuredClone(value);9function validateLevel(level) {10 const levelLabel = level && typeof level === 'object'11 ? (level.title ? `${level.title}${level.id ? ` (${level.id})` : ''}` : (level.id || 'Untitled puzzle'))12 : 'Untitled puzzle';13 const context = `level "${levelLabel}"`;14 try {15 return normalizeLevel(level, { profile: 'standalone', path: 'level' });16 } catch (error) {17 if (!(error instanceof LevelSchemaError)) throw error;18 const issue = error.issues[0];19 const index = issue.path.match(/objects\[(\d+)\]/)?.[1];20 const id = level?.objects?.[Number(index)]?.id || `o${index ?? '?'}`;21 const automatonIndex = issue.path.match(/automata\[(\d+)\]/)?.[1];22 const automaton = level?.automata?.[Number(automatonIndex)];23 const automatonId = automaton?.id || `automaton[${automatonIndex ?? '?'}]`;24 const adapt = errorToThrow => {25 errorToThrow.issue = issue;26 errorToThrow.issueCode = issue.code;27 errorToThrow.issuePath = issue.path;28 return errorToThrow;29 };30 if (issue.code === 'level-shape') throw adapt(new TypeError('level must be an object'));31 if (issue.code === 'level-width') throw adapt(new RangeError(`${context}: width must be positive`));32 if (issue.code === 'level-height') throw adapt(new RangeError(`${context}: height must be positive`));33 if (issue.code === 'objects-shape') throw adapt(new TypeError(`${context}: objects must be an array`));34 if (issue.code === 'coordinate-range') throw adapt(new RangeError(`${context}: objects[${index}] is outside level`));35 if (issue.code === 'coordinate-type') throw adapt(new TypeError(`${context}: objects[${index}] must have integer coordinates`));36 if (issue.code === 'object-shape') throw adapt(new TypeError(`${context}: objects[${index}] must be an object`));37 if (issue.code === 'object-id') throw adapt(new TypeError(`${context}: objects[${index}] id must be a non-empty string`));38 if (issue.code === 'duplicate-object-id') throw adapt(new Error(`${context}: duplicate object id: ${id}`));39 if (issue.code === 'object-kind') throw adapt(new TypeError(`${context}: object ${id} kind must be word or object`));40 if (issue.code === 'word-token') throw adapt(new TypeError(`${context}: word ${id} needs a non-empty uppercase token`));41 if (issue.code === 'noun-token') throw adapt(new TypeError(`${context}: object ${id} needs a valid noun token`));42 if (issue.code === 'reserved-automaton-property') throw adapt(new TypeError(`${context}: object ${id} noun ${String(issue.value).toUpperCase()} is reserved as an automaton property`));43 if (issue.code === 'automata-shape' || issue.code === 'automata-limit') throw adapt(new TypeError(`${context}: automata must be an array of at most 32 entries`));44 if (issue.code === 'automaton-id') throw adapt(new TypeError(`${context}: automata[${automatonIndex ?? '?'}] needs a valid id`));45 if (issue.code === 'duplicate-automaton-id') throw adapt(new Error(`${context}: duplicate automaton id: ${issue.value}`));46 if (issue.code === 'automaton-subject') throw adapt(new TypeError(`${context}: automata ${automatonId} needs a valid subject when provided`));47 if (issue.code === 'automaton-property') throw adapt(new TypeError(`${context}: automata ${automatonId} needs a custom property`));48 if (issue.code === 'automaton-pattern') throw adapt(new TypeError(`${context}: automata ${automatonId} needs 1 to 32 pattern steps`));49 if (issue.code === 'automaton-vector') throw adapt(new TypeError(`${context}: automata ${automatonId} pattern[${issue.path.match(/pattern\[(\d+)\]/)?.[1] ?? '?'}] move must be cardinal`));50 if (issue.code === 'automaton-action') throw adapt(new TypeError(`${context}: automata ${automatonId} pattern[${issue.path.match(/pattern\[(\d+)\]/)?.[1] ?? '?'}] needs move or wait`));51 throw adapt(new TypeError(`${context}: ${issue.path}: ${issue.message}`));52 }53}5455export function createGame(level) {56 const original = validateLevel(level);57 const automataDefs = (original.automata || []).map(system => ({ ...system }));58 // Restore runs once per solver transition. Keep the authored identity and59 // automaton lookup tables immutable so each restore only validates the60 // supplied snapshot rather than rebuilding level metadata.61 const authoredById = new Map(original.objects.map((object, index) => [62 object.id || `o${index}`,63 { ...object, kind: object.kind || (object.text != null ? 'word' : 'object') },64 ]));65 const automataById = new Map(automataDefs.map(system => [system.id, system]));66 const dynamicProperties = new Set(automataDefs.map(system => system.property));67 const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);68 let state;69 let history = [];70 let transformSources = new Map();71 let indexes = { objectsById: new Map(), objectsByCell: new Map(), objectsByProperty: new Map(), rulesBySubject: new Map(), propertiesByObjectId: new Map() };72 const inBounds = (x, y) => x >= 0 && y >= 0 && x < state.width && y < state.height;73 const getState = () => clone(state);7475 function refreshAutomata() {76 state.automata = automataDefs.map(system => {77 const prior = state.automata?.find(candidate => candidate.id === system.id);78 const active = state.rules.some(rule => rule.predicate === system.property && (!system.subject || rule.subject === system.subject));79 const carriersByNoun = new Map();80 for (const object of state.objects) {81 if (object.kind !== 'object' || (system.subject && object.noun !== system.subject) || !properties(object).has(system.property)) continue;82 if (!carriersByNoun.has(object.noun)) carriersByNoun.set(object.noun, []);83 carriersByNoun.get(object.noun).push(object.id);84 }85 const carriers = [...carriersByNoun].sort(([a], [b]) => a.localeCompare(b)).map(([noun, instanceIds]) => ({ noun, instanceIds: [...instanceIds].sort() }));86 const phase = prior?.phase ?? (system.phase || 0) % system.pattern.length;87 return {88 id: system.id,89 subject: system.subject,90 property: system.property,91 pattern: clone(system.pattern),92 phase,93 active,94 carriers,95 instanceIds: carriers.flatMap(carrier => carrier.instanceIds),96 next: clone(system.pattern[phase]),97 };98 });99 }100 function refreshRules() {101 const parsed = parseRules(state.objects, semanticProperties);102 state.rules = parsed.rules;103 state.sentences = parsed.sentences;104 indexes = buildIndexes(state, state.rules, semanticProperties);105 refreshAutomata();106 }107 function has(nounName, property) { return state.rules.some(r => r.subject === String(nounName).toUpperCase() && r.predicate === String(property).toUpperCase()); }108 function properties(o) {109 return indexes.propertiesByObjectId.get(o.id) || new Set();110 }111 function transform(transformedIds = new Set()) {112 const positions = new Map(state.objects.map(object => [object.id, object]));113 const orderedRules = state.rules.map((rule, index) => ({ rule, index })).sort((a, b) => {114 const aSource = positions.get(a.rule.sourceIds?.[0]);115 const bSource = positions.get(b.rule.sourceIds?.[0]);116 return (aSource?.y ?? 0) - (bSource?.y ?? 0)117 || (aSource?.x ?? 0) - (bSource?.x ?? 0)118 || (a.rule.axis === 'h' ? 0 : 1) - (b.rule.axis === 'h' ? 0 : 1)119 || a.index - b.index;120 }).map(entry => entry.rule);121 const selfTransforms = new Set(orderedRules122 .filter(rule => !semanticProperties.has(rule.predicate) && rule.subject === rule.predicate)123 .map(rule => rule.subject));124 const changes = new Map();125 for (const rule of orderedRules) {126 if (semanticProperties.has(rule.predicate) || rule.subject === rule.predicate || selfTransforms.has(rule.subject) || changes.has(rule.subject)) continue;127 changes.set(rule.subject, { to: rule.predicate, rule });128 }129 for (const object of state.objects) {130 if (object.kind !== 'object' || transformedIds.has(object.id) || !changes.has(object.noun)) continue;131 const change = changes.get(object.noun);132 object.noun = change.to;133 transformSources.set(object.id, change.rule);134 transformedIds.add(object.id);135 }136 }137 function resolveContacts(evaluateWin = true) {138 const cells = new Map();139 for (const [cell, occupants] of indexes.objectsByCell) cells.set(cell, occupants);140 const removed = new Set();141 const causes = new Map();142 for (const occupants of cells.values()) {143 // A single entity can carry both YOU and WIN after a rule changes. It144 // still loses if it also carries DEFEAT.145 if (occupants.length === 1) {146 const only = occupants[0];147 if (properties(only).has('YOU') && properties(only).has('DEFEAT')) {148 removed.add(only.id);149 causes.set(only.id, { cause: 'defeat', at: { x: only.x, y: only.y }, noun: only.noun, text: only.text });150 }151 continue;152 }153 if (occupants.length < 2) continue;154 if (occupants.some(o => properties(o).has('SINK'))) { occupants.forEach(o => { removed.add(o.id); causes.set(o.id, { cause: 'sink', at: { x: o.x, y: o.y }, noun: o.noun, text: o.text }); }); continue; }155 const survivors = occupants.filter(o => !removed.has(o.id));156 const you = survivors.filter(o => properties(o).has('YOU'));157 if (survivors.some(o => properties(o).has('DEFEAT'))) you.forEach(o => { removed.add(o.id); causes.set(o.id, { cause: 'defeat', at: { x: o.x, y: o.y }, noun: o.noun, text: o.text }); });158 }159 if (removed.size) {160 state.objects = state.objects.filter(o => !removed.has(o.id));161 // Contact effects use the rules that were active at the start of this162 // pass. Once destruction is complete, however, the settled state must163 // expose the remaining words immediately. Reparse here so callers do164 // not get one turn of phantom STOP/YOU/etc. rules (and so indexes used165 // by the next move agree with state.rules).166 refreshRules();167 }168 // Re-evaluate victory against the post-contact rule/index set. This is169 // deliberately a predicate check only: it does not run another contact170 // pass, so a word removed by SINK/DEFEAT cannot leave a phantom WIN (or171 // trigger a second destruction cascade) in this turn.172 if (evaluateWin) {173 state.won = [...indexes.objectsByCell.values()].some(occupants =>174 occupants.some(o => properties(o).has('YOU')) && occupants.some(o => properties(o).has('WIN')),175 );176 }177 return causes;178 }179180 function planAutomatonMove(o, dx, dy, planned, plannedPushers, pusher = null, order = []) {181 if (planned.has(o.id)) return { ok: true };182 const tx = o.x + dx, ty = o.y + dy;183 if (!inBounds(tx, ty)) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: null, reason: 'bounds' } };184 // Objects move while a push chain is planned. The settled cell index is185 // therefore stale within this function; scan the live array so a later186 // actor observes positions moved by an earlier actor in this turn.187 const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));188 for (const other of collisions) {189 const traits = properties(other);190 if (traits.has('PUSH')) {191 const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);192 if (!result.ok) return result;193 } else if (traits.has('STOP')) {194 return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };195 }196 }197 planned.add(o.id);198 if (pusher) plannedPushers.set(o.id, pusher);199 order.push(o.id);200 return { ok: true };201 }202203 function advanceAutomata(movedIds, fast = false, beforeObjects = null) {204 const automated = fast ? null : [];205 const automatonWordIds = fast ? [] : null;206 const automata = [...automataDefs].sort((a, b) => String(a.id).localeCompare(String(b.id)));207 for (const system of automata) {208 const runtime = state.automata.find(candidate => candidate.id === system.id);209 const phase = runtime?.phase ?? ((system.phase || 0) % system.pattern.length);210 const action = system.pattern[phase];211 const active = state.rules.some(rule => rule.predicate === system.property && (!system.subject || rule.subject === system.subject));212 if (!active) {213 if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active });214 continue;215 }216 const projection = object => object.x * action.dx + object.y * action.dy;217 const targets = (indexes.objectsByProperty.get(system.property) || [])218 .filter(object => object.kind === 'object' && (!system.subject || object.noun === system.subject))219 .sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));220 if (!targets.length) {221 if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active, cue: action.cue, sound: action.sound });222 continue;223 }224 if (runtime) runtime.phase = (phase + 1) % system.pattern.length;225 if (action.action === 'wait') {226 if (!fast) for (const actor of targets) automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, from: { x: actor.x, y: actor.y }, to: { x: actor.x, y: actor.y }, cue: action.cue, sound: action.sound });227 continue;228 }229 for (const actor of targets) {230 const planned = new Set();231 const plannedPushers = new Map();232 const order = [];233 const result = planAutomatonMove(actor, action.dx, action.dy, planned, plannedPushers, null, order);234 if (!result.ok) {235 if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, blocked: result.blocked, cue: action.cue, sound: action.sound });236 continue;237 }238 for (const id of order) {239 const object = indexes.objectsById.get(id);240 object.x += action.dx; object.y += action.dy; movedIds.add(id);241 if (fast && beforeObjects?.get(id)?.kind === 'word' && !automatonWordIds.includes(id)) automatonWordIds.push(id);242 }243 if (!fast) {244 const from = new Map(order.map(id => { const object = indexes.objectsById.get(id); return [id, { x: object.x - action.dx, y: object.y - action.dy }]; }));245 for (const id of order) {246 const object = indexes.objectsById.get(id);247 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 });248 }249 }250 }251 }252 refreshAutomata();253 return fast ? automatonWordIds : automated;254 }255256 function settled(kind, accepted, before, blocked = null, destroyedCauses = new Map(), restored = null, pushedBy = new Map(), movedOverride = null, transformedOverride = null, automated = []) {257 const beforeObjects = new Map((before?.objects || []).map(o => [o.id, o]));258 const afterObjects = new Map(state.objects.map(o => [o.id, o]));259 const moved = movedOverride ? movedOverride.map(entry => clone(entry)) : [];260 const transformed = transformedOverride ? transformedOverride.map(entry => clone(entry)) : [];261 for (const [id, after] of afterObjects) {262 const prior = beforeObjects.get(id);263 if (!prior) continue;264 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 });265 if (!transformedOverride && prior.kind === 'object' && after.kind === 'object' && prior.noun !== after.noun) {266 const source = transformSources.get(id);267 transformed.push({ id, from: prior.noun, to: after.noun, ...(source ? { sourceIds: [...source.sourceIds], axis: source.axis } : {}) });268 }269 }270 const destroyed = [];271 for (const [id, detail] of destroyedCauses) {272 const prior = beforeObjects.get(id);273 if (!prior) continue;274 if (!afterObjects.has(id) && detail) destroyed.push({ id, [prior.kind === 'word' ? 'text' : 'noun']: prior.kind === 'word' ? detail.text ?? prior.text : detail.noun ?? prior.noun, at: detail.at, cause: detail.cause });275 }276 const beforeRules = new Map((before?.rules || []).map(r => [r.text, r]));277 const afterRules = new Map(state.rules.map(r => [r.text, r]));278 const rulesAdded = [...afterRules].filter(([text]) => !beforeRules.has(text)).map(([, rule]) => clone(rule));279 const rulesRemoved = [...beforeRules].filter(([text]) => !afterRules.has(text)).map(([, rule]) => clone(rule));280 return { accepted, kind, moved, blocked, rulesAdded, rulesRemoved, transformed, destroyed, automated: clone(automated), automata: clone(state.automata || []), controlLost: !state.objects.some(o => properties(o).has('YOU')), restored, won: state.won };281 }282 function reset() {283 const before = state ? clone(state) : null;284 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: [] };285 history = [];286 transformSources = new Map();287 const transformedIds = new Set();288 refreshRules(); transform(transformedIds); refreshRules();289 resolveContacts();290 return settled('reset', true, before, null, new Map(), true);291 }292 /**293 * Restore a settled snapshot from this authored level. This is intended for294 * deterministic replay and solver frontiers: it rebuilds rules from the295 * restored objects, accepts no foreign IDs or board shape, and starts a new296 * undo baseline rather than recording a synthetic turn.297 */298 function restore(snapshot) {299 if (!snapshot || typeof snapshot !== 'object') throw new TypeError('snapshot must be an object');300 if (snapshot.width !== original.width || snapshot.height !== original.height) throw new RangeError('snapshot board shape does not match this level');301 if (snapshot.levelId !== undefined && snapshot.levelId !== original.id) throw new RangeError('snapshot level does not match this level');302 if (!Array.isArray(snapshot.objects)) throw new TypeError('snapshot.objects must be an array');303 const ids = new Set();304 const objects = snapshot.objects.map((object, index) => {305 if (!object || typeof object !== 'object' || typeof object.id !== 'string') throw new TypeError(`snapshot.objects[${index}] needs an id`);306 if (ids.has(object.id)) throw new Error(`snapshot has duplicate object id: ${object.id}`);307 ids.add(object.id);308 const source = authoredById.get(object.id);309 if (!source) throw new Error(`snapshot object id is not authored by this level: ${object.id}`);310 const kind = object.kind || (object.text != null ? 'word' : 'object');311 if (kind !== source.kind) throw new TypeError(`snapshot object kind changed for ${object.id}`);312 if (![object.x, object.y].every(Number.isInteger) || !inBounds(object.x, object.y)) throw new RangeError(`snapshot object ${object.id} is outside level`);313 if (kind === 'word') {314 if (String(object.text).toUpperCase() !== String(source.text).toUpperCase()) throw new TypeError(`snapshot word text changed for ${object.id}`);315 return { id: object.id, kind, noun: undefined, text: String(object.text).toUpperCase(), x: object.x, y: object.y };316 }317 if (typeof object.noun !== 'string' || !TOKEN_RE.test(object.noun.toUpperCase()) || CORE_PROPERTIES.has(object.noun.toUpperCase())) throw new TypeError(`snapshot object ${object.id} needs a valid noun`);318 return { id: object.id, kind, noun: object.noun.toUpperCase(), text: undefined, x: object.x, y: object.y };319 });320 const phaseById = new Map();321 if (snapshot.automata !== undefined) {322 if (!Array.isArray(snapshot.automata)) throw new TypeError('snapshot.automata must be an array');323 for (const runtime of snapshot.automata) {324 const system = automataById.get(runtime?.id);325 if (!system || phaseById.has(runtime.id) || !Number.isInteger(runtime.phase) || runtime.phase < 0 || runtime.phase >= system.pattern.length) throw new TypeError(`snapshot has an invalid automaton phase: ${runtime?.id || '?'}`);326 phaseById.set(runtime.id, runtime.phase);327 }328 if (phaseById.size !== automataDefs.length) throw new TypeError('snapshot.automata does not match this level');329 }330 const previousState = state;331 const previousIndexes = indexes;332 const previousHistory = history;333 const previousTransformSources = transformSources;334 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) })) };335 history = [];336 transformSources = new Map();337 try {338 refreshRules();339 return getState();340 } catch (error) {341 if (!(error instanceof RuleParseLimitError)) throw error;342 state = previousState;343 indexes = previousIndexes;344 history = previousHistory;345 transformSources = previousTransformSources;346 throw error;347 }348 }349 function fastResult(accepted, stateSnapshot, blocked, ruleTrace, wordMotion) {350 return { accepted, state: stateSnapshot || getState(), blocked: blocked || null, ruleTrace: ruleTrace || [], wordMotion: wordMotion || { player: [], automaton: [] }, won: state.won };351 }352 function takeTurn(dx, dy, wait = false, fast = false) {353 const before = clone(state);354 const ruleTrace = fast ? [{ phase: 'initial', rules: state.rules.map(rule => rule.text) }] : null;355 const traceRules = phase => { if (fast) ruleTrace.push({ phase, rules: state.rules.map(rule => rule.text) }); };356 if (state.won) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);357 if (!wait && Math.abs(dx) + Math.abs(dy) !== 1) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);358 const you = state.objects.filter(o => properties(o).has('YOU'));359 if (!you.length) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);360 const moved = new Set();361 const pushedBy = new Map();362 const resolutionOrder = [];363 let blocked = null;364 if (!wait) {365 const projection = o => o.x * dx + o.y * dy;366 you.sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));367 for (const o of you) {368 if (moved.has(o.id)) continue;369 const planned = new Set();370 const plannedPushers = new Map();371 const plannedOrder = [];372 const result = planAutomatonMove(o, dx, dy, planned, plannedPushers, null, plannedOrder);373 if (!result.ok) { blocked ||= result.blocked; continue; }374 for (const [id, pusher] of plannedPushers) pushedBy.set(id, pusher);375 for (const id of plannedOrder) {376 const target = indexes.objectsById.get(id);377 target.x += dx; target.y += dy; moved.add(target.id);378 }379 resolutionOrder.push(...plannedOrder);380 }381 }382 if (!wait && !moved.size) return fast ? fastResult(false, null, blocked, ruleTrace, null) : settled('step', false, before, blocked);383 const beforeObjects = new Map(before.objects.map(o => [o.id, o]));384 const playerWordIds = resolutionOrder.filter(id => beforeObjects.get(id)?.kind === 'word');385 const movementEntries = fast ? [] : resolutionOrder.map(id => {386 const prior = beforeObjects.get(id);387 const current = indexes.objectsById.get(id);388 return { id, from: { x: prior.x, y: prior.y }, to: { x: current.x, y: current.y }, pushedBy: pushedBy.get(id) || null };389 });390 if (!fast) history.push(before);391 state.moves++;392 try {393 transformSources = new Map();394 const transformedIds = new Set();395 refreshRules(); traceRules('player-before-transform'); transform(transformedIds); refreshRules(); traceRules('player-settled');396 const automated = advanceAutomata(moved, fast, beforeObjects);397 const automatonWordIds = fast ? automated : [];398 if (!fast) for (const entry of automated) if (entry.id && beforeObjects.get(entry.id)?.kind === 'word' && entry.from && entry.to && (entry.from.x !== entry.to.x || entry.from.y !== entry.to.y) && !automatonWordIds.includes(entry.id)) automatonWordIds.push(entry.id);399 const automatedMoves = fast ? [] : 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 } }) }));400 let allMovementEntries = [];401 if (!fast) {402 const movementById = new Map();403 const movementOrder = [];404 for (const entry of [...movementEntries, ...automatedMoves]) {405 if (!movementById.has(entry.id)) {406 movementById.set(entry.id, { ...entry });407 movementOrder.push(entry.id);408 } else {409 const merged = movementById.get(entry.id);410 merged.to = { ...entry.to };411 if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;412 if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };413 }414 }415 allMovementEntries = movementOrder.map(id => movementById.get(id));416 }417 refreshRules(); traceRules('automata-before-transform'); transform(transformedIds); refreshRules(); traceRules('automata-settled');418 const destroyedCauses = new Map();419 for (const [id, detail] of resolveContacts(true)) destroyedCauses.set(id, detail);420 traceRules('settled');421 if (fast) return fastResult(true, null, blocked, ruleTrace, { player: [...new Set(playerWordIds)], automaton: automatonWordIds });422 const transformationEntries = state.objects.flatMap(object => {423 const prior = beforeObjects.get(object.id);424 return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun425 ? [{ 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 } : {}) }]426 : [];427 });428 return settled(wait ? 'wait' : 'step', true, before, blocked, destroyedCauses, null, pushedBy, allMovementEntries, transformationEntries, automated);429 } catch (error) {430 if (!(error instanceof RuleParseLimitError)) throw error;431 // Parsing is part of the turn transaction. Restore the complete settled432 // snapshot, including automaton phases, and remove the speculative undo433 // entry so a complexity rejection is indistinguishable from any other434 // rejected input to history and the solver.435 state = before;436 if (!fast) history.pop();437 transformSources = new Map();438 indexes = buildIndexes(state, state.rules, semanticProperties);439 const complexityBlock = {440 id: null,441 dir: [dx, dy],442 blockerId: null,443 reason: 'rule-complexity',444 details: error.message,445 };446 return fast ? fastResult(false, null, complexityBlock, ruleTrace, null) : settled(wait ? 'wait' : 'step', false, before, complexityBlock);447 }448 }449 function step(dx, dy) { return takeTurn(dx, dy, false); }450 function wait() { return takeTurn(0, 0, true); }451 function stepFast(dx, dy) { return takeTurn(dx, dy, false, true); }452 function waitFast() { return takeTurn(0, 0, true, true); }453 function undo() {454 const before = clone(state);455 if (!history.length) return settled('undo', false, before);456 state = history.pop();457 transformSources = new Map();458 indexes = buildIndexes(state, state.rules, semanticProperties);459 return settled('undo', true, before, null, new Map(), true);460 }461 reset();462 return { getState, step, wait, stepFast, waitFast, undo, reset, restore, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };463}