- commit
- 981a78442a20d63a4a903cd30311326cdab2ab4f
- parent
- a3be2e07cb37e166b760c6d9936e0c0071faef2a
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 282 | +++++++++++++++++++++++++----- |
| M | tests/ | 147 | ++++++++++++++++ |
@@ -1,5 +1,7 @@
11 /** Predicates implemented by the MVP engine. */22 export const CORE_PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);3+const MAX_AUTOMATA = 32;4+const MAX_AUTOMATON_PATTERN = 32;35 const clone = value => structuredClone(value);46 const key = (x, y) => `${x},${y}`;57 const directions = [[1, 0], [0, 1]];@@ -25,11 +27,52 @@ function validateLevel(level) {
2527 if (kind === 'word' && (typeof o.text !== 'string' || !TOKEN_RE.test(o.text.toUpperCase()))) throw new TypeError(`${context}: word ${id} needs a non-empty uppercase token`);2628 if (kind !== 'word' && (typeof o.noun !== 'string' || !TOKEN_RE.test(o.noun.toUpperCase()) || ['IS', 'AND', ...CORE_PROPERTIES].includes(o.noun.toUpperCase()))) throw new TypeError(`${context}: object ${id} needs a valid noun token`);2729 });30+ if (level.automata !== undefined) {31+ if (!Array.isArray(level.automata) || level.automata.length > MAX_AUTOMATA) throw new TypeError(`${context}: automata must be an array of at most ${MAX_AUTOMATA} entries`);32+ const automataIds = new Set();33+ level.automata.forEach((system, i) => {34+ if (!system || typeof system !== 'object') throw new TypeError(`${context}: automata[${i}] must be an object`);35+ if (typeof system.id !== 'string' || !/^[a-z][a-z0-9_-]*$/i.test(system.id)) throw new TypeError(`${context}: automata[${i}] needs a valid id`);36+ if (automataIds.has(system.id)) throw new Error(`${context}: duplicate automaton id: ${system.id}`);37+ automataIds.add(system.id);38+ if (system.subject !== undefined && (typeof system.subject !== 'string' || !TOKEN_RE.test(system.subject.toUpperCase()))) throw new TypeError(`${context}: automata ${system.id} needs a valid subject when provided`);39+ if (typeof system.property !== 'string' || !TOKEN_RE.test(system.property.toUpperCase()) || CORE_PROPERTIES.has(system.property.toUpperCase())) throw new TypeError(`${context}: automata ${system.id} needs a custom property`);40+ if (!Array.isArray(system.pattern) || !system.pattern.length || system.pattern.length > MAX_AUTOMATON_PATTERN) throw new TypeError(`${context}: automata ${system.id} needs 1 to ${MAX_AUTOMATON_PATTERN} pattern steps`);41+ system.pattern.forEach((action, actionIndex) => {42+ const move = typeof action === 'string' ? ({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] }[action.toUpperCase()]) : null;43+ const isWait = action === 'W' || (action && typeof action === 'object' && action.action === 'wait');44+ const isMove = Boolean(move) || (action && typeof action === 'object' && action.action === 'move');45+ if (!isMove && !isWait) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] needs move or wait`);46+ if (isMove && !move && (!Number.isInteger(action.dx) || !Number.isInteger(action.dy) || Math.abs(action.dx) + Math.abs(action.dy) !== 1)) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] move must be cardinal`);47+ if (typeof action === 'object' && action.cue !== undefined && (typeof action.cue !== 'string' || action.cue.length > 16)) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] cue must be at most 16 characters`);48+ if (typeof action === 'object' && action.sound !== undefined && (typeof action.sound !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(action.sound))) throw new TypeError(`${context}: automata ${system.id} pattern[${actionIndex}] sound must be a cue name`);49+ });50+ if (system.phase !== undefined && (!Number.isInteger(system.phase) || system.phase < 0)) throw new TypeError(`${context}: automata ${system.id} phase must be a non-negative integer`);51+ });52+ }2853 return clone(level);2954 }3055 3156 export function createGame(level) {3257 const original = validateLevel(level);58+ const automataDefs = (original.automata || []).map(system => ({59+ ...system,60+ subject: system.subject?.toUpperCase(),61+ property: system.property.toUpperCase(),62+ // Accept the Clockwork proposal's U/D/L/R/W shorthand at the engine63+ // boundary; validated mod data uses the explicit action object shape.64+ pattern: system.pattern.map(action => {65+ if (typeof action === 'string') {66+ const code = action.toUpperCase();67+ if (code === 'W') return { action: 'wait' };68+ const [dx, dy] = ({ U: [0, -1], D: [0, 1], L: [-1, 0], R: [1, 0] })[code];69+ return { action: 'move', dx, dy };70+ }71+ return { ...action };72+ }),73+ }));74+ const dynamicProperties = new Set(automataDefs.map(system => system.property));75+ const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);3376 let state;3477 let history = [];3578 const inBounds = (x, y) => x >= 0 && y >= 0 && x < state.width && y < state.height;@@ -48,8 +91,8 @@ export function createGame(level) {
4891 function parseRules() {4992 const words = wordsAt();5093 const at = (x, y) => words.get(key(x, y)) || [];51- const noun = t => t && t !== 'IS' && t !== 'AND' && !CORE_PROPERTIES.has(t);52- const predicate = t => noun(t) || CORE_PROPERTIES.has(t);94+ const noun = t => t && t !== 'IS' && t !== 'AND' && !semanticProperties.has(t);95+ const predicate = t => noun(t) || semanticProperties.has(t);5396 const found = new Map();5497 const spans = [];5598 for (const [origin, starts] of words) {@@ -100,18 +143,43 @@ export function createGame(level) {
100143 return [...found.values()];101144 }102145 103- function refreshRules() { state.rules = parseRules(); }146+ function refreshAutomata() {147+ state.automata = automataDefs.map(system => {148+ const prior = state.automata?.find(candidate => candidate.id === system.id);149+ const active = state.rules.some(rule => rule.predicate === system.property && (!system.subject || rule.subject === system.subject));150+ const carriersByNoun = new Map();151+ for (const object of state.objects) {152+ if (object.kind !== 'object' || (system.subject && object.noun !== system.subject) || !properties(object).has(system.property)) continue;153+ if (!carriersByNoun.has(object.noun)) carriersByNoun.set(object.noun, []);154+ carriersByNoun.get(object.noun).push(object.id);155+ }156+ const carriers = [...carriersByNoun].sort(([a], [b]) => a.localeCompare(b)).map(([noun, instanceIds]) => ({ noun, instanceIds: [...instanceIds].sort() }));157+ const phase = prior?.phase ?? (system.phase || 0) % system.pattern.length;158+ return {159+ id: system.id,160+ subject: system.subject,161+ property: system.property,162+ pattern: clone(system.pattern),163+ phase,164+ active,165+ carriers,166+ instanceIds: carriers.flatMap(carrier => carrier.instanceIds),167+ next: clone(system.pattern[phase]),168+ };169+ });170+ }171+ function refreshRules() { state.rules = parseRules(); refreshAutomata(); }104172 function has(nounName, property) { return state.rules.some(r => r.subject === String(nounName).toUpperCase() && r.predicate === String(property).toUpperCase()); }105173 function properties(o) {106174 if (o.kind === 'word') return new Set(['PUSH']);107- return new Set(state.rules.filter(r => r.subject === o.noun && CORE_PROPERTIES.has(r.predicate)).map(r => r.predicate));175+ return new Set(state.rules.filter(r => r.subject === o.noun && semanticProperties.has(r.predicate)).map(r => r.predicate));108176 }109177 function transform() {110178 const changes = new Map();111- for (const r of state.rules) if (!CORE_PROPERTIES.has(r.predicate) && r.subject !== r.predicate && !changes.has(r.subject)) changes.set(r.subject, r.predicate);179+ 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);112180 for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);113181 }114- function resolveContacts() {182+ function resolveContacts(evaluateWin = true) {115183 const cells = new Map();116184 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); }117185 const removed = new Set();@@ -121,7 +189,7 @@ export function createGame(level) {
121189 // wins without needing a second occupant in its cell.122190 if (occupants.length === 1) {123191 const only = occupants[0];124- if (properties(only).has('YOU') && properties(only).has('WIN')) state.won = true;192+ if (evaluateWin && properties(only).has('YOU') && properties(only).has('WIN')) state.won = true;125193 continue;126194 }127195 if (occupants.length < 2) continue;@@ -132,13 +200,79 @@ export function createGame(level) {
132200 const remaining = survivors.filter(o => !removed.has(o.id));133201 const remainingYou = remaining.filter(o => properties(o).has('YOU'));134202 const win = remaining.filter(o => properties(o).has('WIN'));135- if (remainingYou.length && win.length) state.won = true;203+ if (evaluateWin && remainingYou.length && win.length) state.won = true;136204 }137205 if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));138206 return causes;139207 }140208 141- function settled(kind, accepted, before, blocked = null, destroyedCauses = new Map(), restored = null, pushedBy = new Map(), movedOverride = null, transformedOverride = null) {209+ function planAutomatonMove(o, dx, dy, planned, plannedPushers, pusher = null, order = []) {210+ if (planned.has(o.id)) return { ok: true };211+ const tx = o.x + dx, ty = o.y + dy;212+ if (!inBounds(tx, ty)) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: null, reason: 'bounds' } };213+ const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));214+ for (const other of collisions) {215+ const traits = properties(other);216+ if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };217+ if (traits.has('PUSH')) {218+ const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);219+ if (!result.ok) return result;220+ }221+ }222+ planned.add(o.id);223+ if (pusher) plannedPushers.set(o.id, pusher);224+ order.push(o.id);225+ return { ok: true };226+ }227+228+ function advanceAutomata(movedIds) {229+ const automated = [];230+ const automata = [...automataDefs].sort((a, b) => String(a.id).localeCompare(String(b.id)));231+ for (const system of automata) {232+ const runtime = state.automata.find(candidate => candidate.id === system.id);233+ const phase = runtime?.phase ?? ((system.phase || 0) % system.pattern.length);234+ const action = system.pattern[phase];235+ const active = state.rules.some(rule => rule.predicate === system.property && (!system.subject || rule.subject === system.subject));236+ if (!active) {237+ automated.push({ systemId: system.id, phase, action: action.action, active });238+ continue;239+ }240+ 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)));242+ if (!targets.length) {243+ automated.push({ systemId: system.id, phase, action: action.action, active, cue: action.cue, sound: action.sound });244+ continue;245+ }246+ if (runtime) runtime.phase = (phase + 1) % system.pattern.length;247+ if (action.action === 'wait') {248+ 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 });249+ continue;250+ }251+ for (const actor of targets) {252+ const planned = new Set();253+ const plannedPushers = new Map();254+ const order = [];255+ const result = planAutomatonMove(actor, action.dx, action.dy, planned, plannedPushers, null, order);256+ if (!result.ok) {257+ automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, blocked: result.blocked, cue: action.cue, sound: action.sound });258+ continue;259+ }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 }]; }));261+ for (const id of order) {262+ const object = state.objects.find(candidate => candidate.id === id);263+ object.x += action.dx; object.y += action.dy; movedIds.add(id);264+ }265+ for (const id of order) {266+ const object = state.objects.find(candidate => candidate.id === id);267+ 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 });268+ }269+ }270+ }271+ refreshAutomata();272+ return automated;273+ }274+275+ function settled(kind, accepted, before, blocked = null, destroyedCauses = new Map(), restored = null, pushedBy = new Map(), movedOverride = null, transformedOverride = null, automated = []) {142276 const beforeObjects = new Map((before?.objects || []).map(o => [o.id, o]));143277 const afterObjects = new Map(state.objects.map(o => [o.id, o]));144278 const moved = movedOverride ? movedOverride.map(entry => clone(entry)) : [];@@ -159,61 +293,91 @@ export function createGame(level) {
159293 const afterRules = new Map(state.rules.map(r => [r.text, r]));160294 const rulesAdded = [...afterRules].filter(([text]) => !beforeRules.has(text)).map(([, rule]) => clone(rule));161295 const rulesRemoved = [...beforeRules].filter(([text]) => !afterRules.has(text)).map(([, rule]) => clone(rule));162- return { accepted, kind, moved, blocked, rulesAdded, rulesRemoved, transformed, destroyed, controlLost: !state.objects.some(o => properties(o).has('YOU')), restored, won: state.won };296+ 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 };163297 }164298 function reset() {165299 const before = state ? clone(state) : null;166- 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: [] };300+ 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: [] };167301 history = [];168302 refreshRules(); transform(); refreshRules();169303 resolveContacts();170304 return settled('reset', true, before, null, new Map(), true);171305 }172- function step(dx, dy) {306+ /**307+ * Restore a settled snapshot from this authored level. This is intended for308+ * deterministic replay and solver frontiers: it rebuilds rules from the309+ * restored objects, accepts no foreign IDs or board shape, and starts a new310+ * undo baseline rather than recording a synthetic turn.311+ */312+ function restore(snapshot) {313+ if (!snapshot || typeof snapshot !== 'object') throw new TypeError('snapshot must be an object');314+ if (snapshot.width !== original.width || snapshot.height !== original.height) throw new RangeError('snapshot board shape does not match this level');315+ if (snapshot.levelId !== undefined && snapshot.levelId !== original.id) throw new RangeError('snapshot level does not match this level');316+ if (!Array.isArray(snapshot.objects)) throw new TypeError('snapshot.objects must be an array');317+ const authored = new Map(original.objects.map((object, index) => [object.id || `o${index}`, { ...object, kind: object.kind || (object.text != null ? 'word' : 'object') }]));318+ const ids = new Set();319+ const objects = snapshot.objects.map((object, index) => {320+ if (!object || typeof object !== 'object' || typeof object.id !== 'string') throw new TypeError(`snapshot.objects[${index}] needs an id`);321+ if (ids.has(object.id)) throw new Error(`snapshot has duplicate object id: ${object.id}`);322+ ids.add(object.id);323+ const source = authored.get(object.id);324+ if (!source) throw new Error(`snapshot object id is not authored by this level: ${object.id}`);325+ const kind = object.kind || (object.text != null ? 'word' : 'object');326+ if (kind !== source.kind) throw new TypeError(`snapshot object kind changed for ${object.id}`);327+ if (![object.x, object.y].every(Number.isInteger) || !inBounds(object.x, object.y)) throw new RangeError(`snapshot object ${object.id} is outside level`);328+ if (kind === 'word') {329+ if (String(object.text).toUpperCase() !== String(source.text).toUpperCase()) throw new TypeError(`snapshot word text changed for ${object.id}`);330+ return { id: object.id, kind, noun: undefined, text: String(object.text).toUpperCase(), x: object.x, y: object.y };331+ }332+ 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`);333+ return { id: object.id, kind, noun: object.noun.toUpperCase(), text: undefined, x: object.x, y: object.y };334+ });335+ const phaseById = new Map();336+ if (snapshot.automata !== undefined) {337+ if (!Array.isArray(snapshot.automata)) throw new TypeError('snapshot.automata must be an array');338+ for (const runtime of snapshot.automata) {339+ const system = automataDefs.find(candidate => candidate.id === runtime?.id);340+ 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 || '?'}`);341+ phaseById.set(runtime.id, runtime.phase);342+ }343+ if (phaseById.size !== automataDefs.length) throw new TypeError('snapshot.automata does not match this level');344+ }345+ 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) })) };346+ history = [];347+ refreshRules();348+ transform();349+ refreshRules();350+ return getState();351+ }352+ function takeTurn(dx, dy, wait = false) {173353 const before = clone(state);174354 if (state.won) return settled('step', false, before);175- if (Math.abs(dx) + Math.abs(dy) !== 1) return settled('step', false, before);355+ if (!wait && Math.abs(dx) + Math.abs(dy) !== 1) return settled('step', false, before);176356 const you = state.objects.filter(o => properties(o).has('YOU'));177357 if (!you.length) return settled('step', false, before);178- const projection = o => o.x * dx + o.y * dy;179- you.sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));180358 const moved = new Set();181359 const pushedBy = new Map();182360 const resolutionOrder = [];183- function planMove(o, planned, plannedPushers, pusher = null, order = []) {184- if (planned.has(o.id)) return { ok: true };185- const tx = o.x + dx, ty = o.y + dy;186- if (!inBounds(tx, ty)) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: null, reason: 'bounds' } };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('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };191- if (traits.has('PUSH')) {192- const result = planMove(other, planned, plannedPushers, o.id, order);193- if (!result.ok) return result;194- }195- }196- planned.add(o.id);197- if (pusher) plannedPushers.set(o.id, pusher);198- order.push(o.id);199- return { ok: true };200- }201361 let blocked = null;202- for (const o of you) {203- if (moved.has(o.id)) continue;204- const planned = new Set();205- const plannedPushers = new Map();206- const plannedOrder = [];207- const result = planMove(o, planned, plannedPushers, null, plannedOrder);208- if (!result.ok) { blocked ||= result.blocked; continue; }209- for (const [id, pusher] of plannedPushers) pushedBy.set(id, pusher);210- for (const id of plannedOrder) {211- const target = state.objects.find(candidate => candidate.id === id);212- target.x += dx; target.y += dy; moved.add(target.id);362+ if (!wait) {363+ const projection = o => o.x * dx + o.y * dy;364+ you.sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));365+ for (const o of you) {366+ if (moved.has(o.id)) continue;367+ const planned = new Set();368+ const plannedPushers = new Map();369+ const plannedOrder = [];370+ const result = planAutomatonMove(o, dx, dy, planned, plannedPushers, null, plannedOrder);371+ if (!result.ok) { blocked ||= result.blocked; continue; }372+ for (const [id, pusher] of plannedPushers) pushedBy.set(id, pusher);373+ for (const id of plannedOrder) {374+ const target = state.objects.find(candidate => candidate.id === id);375+ target.x += dx; target.y += dy; moved.add(target.id);376+ }377+ resolutionOrder.push(...plannedOrder);213378 }214- resolutionOrder.push(...plannedOrder);215379 }216- if (!moved.size) return settled('step', false, before, blocked);380+ if (!wait && !moved.size) return settled('step', false, before, blocked);217381 const beforeObjects = new Map(before.objects.map(o => [o.id, o]));218382 const movementEntries = resolutionOrder.map(id => {219383 const prior = beforeObjects.get(id);@@ -223,15 +387,35 @@ export function createGame(level) {
223387 history.push(before);224388 state.moves++;225389 refreshRules(); transform(); refreshRules();390+ const automated = advanceAutomata(moved);391+ 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 } }) }));392+ const movementById = new Map();393+ const movementOrder = [];394+ for (const entry of [...movementEntries, ...automatedMoves]) {395+ if (!movementById.has(entry.id)) {396+ movementById.set(entry.id, { ...entry });397+ movementOrder.push(entry.id);398+ } else {399+ const merged = movementById.get(entry.id);400+ merged.to = { ...entry.to };401+ if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;402+ if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };403+ }404+ }405+ const allMovementEntries = movementOrder.map(id => movementById.get(id));406+ refreshRules(); transform(); refreshRules();407+ const destroyedCauses = new Map();408+ for (const [id, detail] of resolveContacts(true)) destroyedCauses.set(id, detail);226409 const transformationEntries = state.objects.flatMap(object => {227410 const prior = beforeObjects.get(object.id);228411 return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun229412 ? [{ id: object.id, from: prior.noun, to: object.noun }]230413 : [];231414 });232- const destroyedCauses = resolveContacts();233- return settled('step', true, before, blocked, destroyedCauses, null, pushedBy, movementEntries, transformationEntries);415+ return settled(wait ? 'wait' : 'step', true, before, blocked, destroyedCauses, null, pushedBy, allMovementEntries, transformationEntries, automated);234416 }417+ function step(dx, dy) { return takeTurn(dx, dy, false); }418+ function wait() { return takeTurn(0, 0, true); }235419 function undo() {236420 const before = clone(state);237421 if (!history.length) return settled('undo', false, before);@@ -239,5 +423,5 @@ export function createGame(level) {
239423 return settled('undo', true, before, null, new Map(), true);240424 }241425 reset();242- return { getState, step, undo, reset, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };426+ return { getState, step, wait, undo, reset, restore, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };243427 }@@ -125,6 +125,153 @@ test('a singleton object with YOU and WIN is won on load and reset', () => {
125125 assert.equal(game.getState().won, true);126126 });127127 128+test('turn-clock automata expose active phases and repeat through wait turns', () => {129+ const game = createGame({130+ ...level([131+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),132+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),133+ object('MIAU', 0, 4, 'player'), object('CLOCK', 2, 2, 'clock'),134+ ]),135+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [136+ { action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' },137+ { action: 'wait' },138+ { action: 'move', dx: -1, dy: 0 },139+ ] }],140+ });141+ assert.deepEqual(game.getState().automata, [{142+ id: 'clockwork', subject: 'CLOCK', property: 'TICK', phase: 0, active: true,143+ pattern: [{ action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' }, { action: 'wait' }, { action: 'move', dx: -1, dy: 0 }],144+ carriers: [{ noun: 'CLOCK', instanceIds: ['clock'] }], instanceIds: ['clock'], next: { action: 'move', dx: 1, dy: 0, cue: '→', sound: 'tick' },145+ }]);146+ const first = game.wait();147+ assert.equal(first.accepted, true);148+ assert.equal(first.kind, 'wait');149+ assert.deepEqual(first.automated[0], { systemId: 'clockwork', phase: 0, action: 'move', active: true, id: 'clock', from: { x: 2, y: 2 }, to: { x: 3, y: 2 }, pushedBy: null, cue: '→', sound: 'tick' });150+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);151+ assert.equal(game.getState().automata[0].phase, 1);152+ game.wait();153+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);154+ game.wait();155+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 2);156+ assert.equal(game.getState().automata[0].phase, 0);157+});158+159+test('blocked player input does not advance automata, while undo and reset restore phases', () => {160+ const game = createGame({161+ ...level([162+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),163+ word('WALL', 0, 1), word('IS', 1, 1), word('STOP', 2, 1),164+ word('CLOCK', 0, 2), word('IS', 1, 2), word('TICK', 2, 2),165+ object('MIAU', 0, 3, 'player'), object('WALL', 1, 3, 'wall'), object('CLOCK', 4, 3, 'clock'),166+ ]),167+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }] }],168+ });169+ const blocked = game.step(1, 0);170+ assert.equal(blocked.accepted, false);171+ assert.equal(game.getState().automata[0].phase, 0);172+ const moved = game.wait();173+ assert.equal(moved.accepted, true);174+ assert.equal(game.getState().automata[0].phase, 0);175+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);176+ const undo = game.undo();177+ assert.equal(undo.accepted, true);178+ assert.equal(game.getState().automata[0].phase, 0);179+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 4);180+ game.wait();181+ const reset = game.reset();182+ assert.equal(reset.kind, 'reset');183+ assert.equal(game.getState().automata[0].phase, 0);184+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 4);185+});186+187+test('automata use deterministic STOP and PUSH collision behavior', () => {188+ const game = createGame({189+ ...level([190+ word('MIAU', 0, 2), word('IS', 1, 2), word('YOU', 2, 2),191+ word('CLOCK', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),192+ word('ROCK', 0, 1), word('IS', 1, 1), word('PUSH', 2, 1),193+ object('MIAU', 0, 4, 'player'), object('CLOCK', 1, 3, 'clock'), object('ROCK', 2, 3, 'rock'),194+ ]),195+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: 1, dy: 0 }] }],196+ });197+ const event = game.wait();198+ assert.deepEqual(event.moved.map(entry => entry.id), ['rock', 'clock']);199+ assert.deepEqual(game.getState().objects.filter(o => o.kind === 'object').map(o => [o.id, o.x, o.y]), [['player', 0, 4], ['clock', 2, 3], ['rock', 3, 3]]);200+});201+202+test('merged player push and automaton self-move expose the actor direction', () => {203+ const game = createGame({204+ ...level([205+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),206+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1), word('AND', 3, 1), word('PUSH', 4, 1),207+ object('MIAU', 0, 3, 'player'), object('CLOCK', 1, 3, 'clock'), object('ROCK', 2, 3, 'rock'),208+ ]),209+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: 1, dy: 0 }] }],210+ });211+ const event = game.step(1, 0);212+ const clockMove = event.moved.find(entry => entry.id === 'clock');213+ assert.equal(clockMove.pushedBy, 'player');214+ assert.deepEqual(clockMove.selfDirection, { dx: 1, dy: 0 });215+});216+217+test('property-carried automata follow whichever noun currently has the property', () => {218+ const game = createGame({219+ ...level([220+ word('MOUSE', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),221+ word('MIAU', 0, 1), word('IS', 1, 1), word('YOU', 2, 1),222+ object('MOUSE', 4, 3, 'mouse'), object('MIAU', 0, 4, 'player'),223+ ]),224+ automata: [{ id: 'shared-clock', property: 'TICK', pattern: ['L', 'W'] }],225+ });226+ const first = game.wait();227+ assert.equal(first.accepted, true);228+ assert.equal(game.getState().objects.find(o => o.id === 'mouse').x, 3);229+ assert.equal(game.getState().automata[0].phase, 1);230+ assert.equal(game.getState().automata[0].subject, undefined);231+ game.wait();232+ assert.equal(game.getState().objects.find(o => o.id === 'mouse').x, 3);233+ assert.equal(game.getState().automata[0].phase, 0);234+});235+236+test('automata state reports carrier nouns and IDs after a property transfer', () => {237+ const game = createGame({238+ ...level([239+ word('MOUSE', 0, 0), word('IS', 1, 0), word('TICK', 2, 0),240+ word('BELL', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),241+ object('MOUSE', 4, 3, 'carrier'), object('MIAU', 0, 4, 'player'),242+ word('MIAU', 4, 0), word('IS', 5, 0), word('YOU', 6, 0),243+ ]),244+ automata: [{ id: 'shared-clock', property: 'TICK', pattern: [{ action: 'wait' }] }],245+ });246+ assert.deepEqual(game.getState().automata[0].carriers, [{ noun: 'MOUSE', instanceIds: ['carrier'] }]);247+ const snapshot = game.getState();248+ snapshot.objects.find(object => object.id === 'carrier').noun = 'BELL';249+ game.restore(snapshot);250+ assert.deepEqual(game.getState().automata[0].carriers, [{ noun: 'BELL', instanceIds: ['carrier'] }]);251+ assert.deepEqual(game.getState().automata[0].instanceIds, ['carrier']);252+});253+254+test('restore replays a same-level snapshot with automata phases and no undo entry', () => {255+ const game = createGame({256+ ...level([257+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),258+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),259+ object('MIAU', 0, 3, 'player'), object('CLOCK', 3, 3, 'clock'),260+ ]),261+ id: 'restore-fixture',262+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }, { action: 'wait' }] }],263+ });264+ const initial = game.getState();265+ game.wait();266+ assert.equal(game.getState().automata[0].phase, 1);267+ const restored = game.restore(initial);268+ assert.deepEqual(restored, initial);269+ assert.equal(game.canUndo, false);270+ assert.equal(game.getState().objects.find(o => o.id === 'clock').x, 3);271+ assert.throws(() => game.restore({ ...initial, width: 7 }), /board shape/);272+ assert.throws(() => game.restore({ ...initial, objects: [{ ...initial.objects[0], id: 'foreign' }] }), /not authored/);273+});274+128275 test('WIN, SINK and DEFEAT resolve on contact', () => {129276 const game = createGame(level([130277 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),