clone

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

feat: resolve dynamic rules and emit settled turn events

Dami committed a3ca476
commit
a3ca476136883d0b424d6a59b4ea91a946ff0ab6
parent
2c90f31cafe4a97b3fda0167a7a8eb4a4887261c
author
Dami <dami.etoile@gmail.com>
2 files changed, +410 -0
Asrc/engine.js233++++++++++++++++++++++++++++++
Atests/engine.test.js177+++++++++++++++++++++++
A src/engine.js +233 -0
@@ -0,0 +1,233 @@
1+/** Predicates implemented by the MVP engine. */2+export const CORE_PROPERTIES = new Set(['YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);3+const clone = value => structuredClone(value);4+const key = (x, y) => `${x},${y}`;5+const directions = [[1, 0], [0, 1]];6+const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;7+8+function validateLevel(level) {9+ if (!level || typeof level !== 'object') throw new TypeError('level must be an object');10+ const levelLabel = level.title ? `${level.title}${level.id ? ` (${level.id})` : ''}` : (level.id || 'Untitled puzzle');11+ const context = `level "${levelLabel}"`;12+ if (!Number.isInteger(level.width) || level.width < 1) throw new RangeError(`${context}: width must be positive`);13+ if (!Number.isInteger(level.height) || level.height < 1) throw new RangeError(`${context}: height must be positive`);14+ if (!Array.isArray(level.objects)) throw new TypeError(`${context}: objects must be an array`);15+ const ids = new Set();16+ level.objects.forEach((o, i) => {17+ if (!o || typeof o !== 'object') throw new TypeError(`${context}: objects[${i}] must be an object`);18+ if (![o.x, o.y].every(Number.isInteger) || o.x < 0 || o.y < 0 || o.x >= level.width || o.y >= level.height) throw new RangeError(`${context}: objects[${i}] is outside level`);19+ if (o.id !== undefined && (typeof o.id !== 'string' || o.id.length === 0)) throw new TypeError(`${context}: objects[${i}] id must be a non-empty string`);20+ const id = o.id || `o${i}`;21+ if (ids.has(id)) throw new Error(`${context}: duplicate object id: ${id}`);22+ ids.add(id);23+ const kind = o.kind || (o.text != null ? 'word' : 'object');24+ if (kind !== 'word' && kind !== 'object') throw new TypeError(`${context}: object ${id} kind must be word or object`);25+ 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`);26+ 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`);27+ });28+ return clone(level);29+}30+31+export function createGame(level) {32+ const original = validateLevel(level);33+ let state;34+ let history = [];35+ const inBounds = (x, y) => x >= 0 && y >= 0 && x < state.width && y < state.height;36+ const getState = () => clone(state);37+38+ function wordsAt() {39+ const result = new Map();40+ for (const o of state.objects) if (o.kind === 'word') {41+ const k = key(o.x, o.y);42+ if (!result.has(k)) result.set(k, []);43+ result.get(k).push({ text: o.text, id: o.id });44+ }45+ return result;46+ }47+48+ function parseRules() {49+ const words = wordsAt();50+ 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);53+ const found = new Map();54+ for (const [origin, starts] of words) {55+ const [ox, oy] = origin.split(',').map(Number);56+ for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {57+ let x = ox, y = oy;58+ let subjectPaths = [{ text: first.text, sourceIds: [first.id] }];59+ const has = (px, py, test) => at(px, py).some(entry => test(entry.text));60+ while (has(x + dx, y + dy, t => t === 'AND') && has(x + 2 * dx, y + 2 * dy, noun)) {61+ const ands = at(x + dx, y + dy).filter(entry => entry.text === 'AND');62+ const nouns = at(x + 2 * dx, y + 2 * dy).filter(entry => noun(entry.text));63+ x += 2 * dx; y += 2 * dy;64+ subjectPaths = subjectPaths.flatMap(path => ands.flatMap(and => nouns.map(next => ({65+ text: `${path.text} AND ${next.text}`,66+ sourceIds: [...path.sourceIds, and.id, next.id],67+ }))));68+ }69+ const isWords = at(x + dx, y + dy).filter(entry => entry.text === 'IS');70+ if (!isWords.length) continue;71+ const is = isWords[0];72+ x += 2 * dx; y += 2 * dy;73+ let predicatePaths = at(x, y).filter(entry => predicate(entry.text)).map(entry => ({ text: entry.text, sourceIds: [entry.id] }));74+ if (!predicatePaths.length) continue;75+ let endX = x, endY = y;76+ while (has(endX + dx, endY + dy, t => t === 'AND') && has(endX + 2 * dx, endY + 2 * dy, predicate)) {77+ const ands = at(endX + dx, endY + dy).filter(entry => entry.text === 'AND');78+ const predicates = at(endX + 2 * dx, endY + 2 * dy).filter(entry => predicate(entry.text));79+ endX += 2 * dx; endY += 2 * dy;80+ predicatePaths = predicatePaths.flatMap(path => ands.flatMap(and => predicates.map(next => ({81+ text: `${path.text} AND ${next.text}`,82+ sourceIds: [...path.sourceIds, and.id, next.id],83+ }))));84+ }85+ // Add expanded subjects and predicates while retaining the first86+ // occurrence of each rule for deterministic stacked-word parsing.87+ for (const s of subjectPaths) for (const p of predicatePaths) {88+ const subjectNames = s.text.split(' AND ');89+ const predicateNames = p.text.split(' AND ');90+ for (const subject of subjectNames) for (const pred of predicateNames) {91+ const ruleKey = `${subject} IS ${pred}`;92+ if (!found.has(ruleKey)) found.set(ruleKey, { subject, predicate: pred, text: ruleKey, sourceIds: [...s.sourceIds, is.id, ...p.sourceIds], axis: dx ? 'h' : 'v' });93+ }94+ }95+ }96+ }97+ return [...found.values()];98+ }99+100+ function refreshRules() { state.rules = parseRules(); }101+ function has(nounName, property) { return state.rules.some(r => r.subject === String(nounName).toUpperCase() && r.predicate === String(property).toUpperCase()); }102+ function properties(o) {103+ if (o.kind === 'word') return new Set(['PUSH']);104+ return new Set(state.rules.filter(r => r.subject === o.noun && CORE_PROPERTIES.has(r.predicate)).map(r => r.predicate));105+ }106+ function transform() {107+ const changes = new Map();108+ 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);109+ for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);110+ }111+ function resolveContacts() {112+ const cells = new Map();113+ 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); }114+ const removed = new Set();115+ const causes = new Map();116+ for (const occupants of cells.values()) {117+ if (occupants.length < 2) continue;118+ 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; }119+ const survivors = occupants.filter(o => !removed.has(o.id));120+ const you = survivors.filter(o => properties(o).has('YOU'));121+ 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 }); });122+ const remaining = survivors.filter(o => !removed.has(o.id));123+ const remainingYou = remaining.filter(o => properties(o).has('YOU'));124+ const win = remaining.filter(o => properties(o).has('WIN'));125+ if (remainingYou.some(a => win.some(b => a.id !== b.id))) state.won = true;126+ }127+ if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));128+ return causes;129+ }130+131+ function settled(kind, accepted, before, blocked = null, destroyedCauses = new Map(), restored = null, pushedBy = new Map(), movedOverride = null, transformedOverride = null) {132+ const beforeObjects = new Map((before?.objects || []).map(o => [o.id, o]));133+ const afterObjects = new Map(state.objects.map(o => [o.id, o]));134+ const moved = movedOverride ? movedOverride.map(entry => clone(entry)) : [];135+ const transformed = transformedOverride ? transformedOverride.map(entry => clone(entry)) : [];136+ for (const [id, after] of afterObjects) {137+ const prior = beforeObjects.get(id);138+ if (!prior) continue;139+ 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 });140+ if (!transformedOverride && prior.kind === 'object' && after.kind === 'object' && prior.noun !== after.noun) transformed.push({ id, from: prior.noun, to: after.noun });141+ }142+ const destroyed = [];143+ for (const [id, detail] of destroyedCauses) {144+ const prior = beforeObjects.get(id);145+ if (!prior) continue;146+ 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 });147+ }148+ const beforeRules = new Map((before?.rules || []).map(r => [r.text, r]));149+ const afterRules = new Map(state.rules.map(r => [r.text, r]));150+ const rulesAdded = [...afterRules].filter(([text]) => !beforeRules.has(text)).map(([, rule]) => clone(rule));151+ const rulesRemoved = [...beforeRules].filter(([text]) => !afterRules.has(text)).map(([, rule]) => clone(rule));152+ return { accepted, kind, moved, blocked, rulesAdded, rulesRemoved, transformed, destroyed, controlLost: !state.objects.some(o => properties(o).has('YOU')), restored, won: state.won };153+ }154+ function reset() {155+ const before = state ? clone(state) : null;156+ 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: [] };157+ history = [];158+ refreshRules(); transform(); refreshRules();159+ resolveContacts();160+ return settled('reset', true, before, null, new Map(), true);161+ }162+ function step(dx, dy) {163+ const before = clone(state);164+ if (state.won) return settled('step', false, before);165+ if (Math.abs(dx) + Math.abs(dy) !== 1) return settled('step', false, before);166+ const you = state.objects.filter(o => properties(o).has('YOU'));167+ if (!you.length) return settled('step', false, before);168+ const projection = o => o.x * dx + o.y * dy;169+ you.sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));170+ const moved = new Set();171+ const pushedBy = new Map();172+ const resolutionOrder = [];173+ function planMove(o, planned, plannedPushers, pusher = null, order = []) {174+ if (planned.has(o.id)) return { ok: true };175+ const tx = o.x + dx, ty = o.y + dy;176+ if (!inBounds(tx, ty)) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: null, reason: 'bounds' } };177+ const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));178+ for (const other of collisions) {179+ const traits = properties(other);180+ if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };181+ if (traits.has('PUSH')) {182+ const result = planMove(other, planned, plannedPushers, o.id, order);183+ if (!result.ok) return result;184+ }185+ }186+ planned.add(o.id);187+ if (pusher) plannedPushers.set(o.id, pusher);188+ order.push(o.id);189+ return { ok: true };190+ }191+ let blocked = null;192+ for (const o of you) {193+ if (moved.has(o.id)) continue;194+ const planned = new Set();195+ const plannedPushers = new Map();196+ const plannedOrder = [];197+ const result = planMove(o, planned, plannedPushers, null, plannedOrder);198+ if (!result.ok) { blocked ||= result.blocked; continue; }199+ for (const [id, pusher] of plannedPushers) pushedBy.set(id, pusher);200+ for (const id of plannedOrder) {201+ const target = state.objects.find(candidate => candidate.id === id);202+ target.x += dx; target.y += dy; moved.add(target.id);203+ }204+ resolutionOrder.push(...plannedOrder);205+ }206+ if (!moved.size) return settled('step', false, before, blocked);207+ const beforeObjects = new Map(before.objects.map(o => [o.id, o]));208+ const movementEntries = resolutionOrder.map(id => {209+ const prior = beforeObjects.get(id);210+ const current = state.objects.find(o => o.id === id);211+ return { id, from: { x: prior.x, y: prior.y }, to: { x: current.x, y: current.y }, pushedBy: pushedBy.get(id) || null };212+ });213+ history.push(before);214+ state.moves++;215+ refreshRules(); transform(); refreshRules();216+ const transformationEntries = state.objects.flatMap(object => {217+ const prior = beforeObjects.get(object.id);218+ return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun219+ ? [{ id: object.id, from: prior.noun, to: object.noun }]220+ : [];221+ });222+ const destroyedCauses = resolveContacts();223+ return settled('step', true, before, blocked, destroyedCauses, null, pushedBy, movementEntries, transformationEntries);224+ }225+ function undo() {226+ const before = clone(state);227+ if (!history.length) return settled('undo', false, before);228+ state = history.pop();229+ return settled('undo', true, before, null, new Map(), true);230+ }231+ reset();232+ return { getState, step, undo, reset, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };233+}
A tests/engine.test.js +177 -0
@@ -0,0 +1,177 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createGame } from '../src/engine.js';4+5+const word = (text, x, y, id = `${text}-${x}-${y}`) => ({ id, kind: 'word', text, x, y });6+const object = (noun, x, y, id = `${noun}-${x}-${y}`) => ({ id, kind: 'object', noun, x, y });7+const level = objects => ({ width: 8, height: 5, objects });8+9+test('parses AND subjects and predicates in both axes', () => {10+ const game = createGame(level([11+ word('BABA', 0, 0), word('AND', 1, 0), word('ROCK', 2, 0), word('IS', 3, 0), word('YOU', 4, 0), word('AND', 5, 0), word('PUSH', 6, 0),12+ object('BABA', 0, 1), object('ROCK', 2, 1),13+ ]));14+ assert.equal(game.has('BABA', 'YOU'), true);15+ assert.equal(game.has('ROCK', 'YOU'), true);16+ assert.equal(game.has('BABA', 'PUSH'), true);17+ for (const rule of game.getState().rules) {18+ assert.ok(Array.isArray(rule.sourceIds) && rule.sourceIds.length > 0);19+ assert.ok(rule.sourceIds.every(id => typeof id === 'string'));20+ assert.ok(rule.axis === 'h' || rule.axis === 'v');21+ }22+});23+24+test('pushes a whole chain and refuses a STOP wall atomically', () => {25+ const game = createGame(level([26+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),27+ word('ROCK', 0, 1), word('IS', 1, 1), word('PUSH', 2, 1),28+ word('WALL', 0, 2), word('IS', 1, 2), word('STOP', 2, 2),29+ object('BABA', 1, 3), object('ROCK', 2, 3), object('ROCK', 3, 3), object('WALL', 4, 3),30+ ]));31+ const event = game.step(1, 0);32+ assert.equal(event.accepted, false);33+ assert.equal(event.kind, 'step');34+ assert.deepEqual(event.blocked, { id: 'ROCK-3-3', dir: [1, 0], blockerId: 'WALL-4-3', reason: 'stop' });35+ assert.deepEqual(event.moved, []);36+ assert.deepEqual(game.getState().objects.filter(o => o.kind === 'object').map(o => [o.noun, o.x, o.y]), [['BABA', 1, 3], ['ROCK', 2, 3], ['ROCK', 3, 3], ['WALL', 4, 3]]);37+});38+39+test('accepted turns report movement and rule deltas', () => {40+ const game = createGame(level([41+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),42+ word('ROCK', 1, 1, 'rock-word'), word('IS', 2, 1, 'rock-is'), word('STOP', 3, 1, 'rock-stop'),43+ object('BABA', 3, 2, 'player'),44+ ]));45+ const event = game.step(0, -1);46+ assert.equal(event.accepted, true);47+ assert.deepEqual(event.moved.map(movement => movement.id), ['rock-stop', 'player']);48+ assert.deepEqual(event.moved.slice().sort((a, b) => a.id.localeCompare(b.id)), [49+ { id: 'player', from: { x: 3, y: 2 }, to: { x: 3, y: 1 }, pushedBy: null },50+ { id: 'rock-stop', from: { x: 3, y: 1 }, to: { x: 3, y: 0 }, pushedBy: 'player' },51+ ].sort((a, b) => a.id.localeCompare(b.id)));52+ assert.deepEqual(event.rulesAdded, []);53+ assert.deepEqual(event.rulesRemoved.map(rule => rule.text), ['ROCK IS STOP']);54+ assert.deepEqual(event.rulesRemoved[0].sourceIds, ['rock-word', 'rock-is', 'rock-stop']);55+ assert.equal(event.rulesRemoved[0].axis, 'h');56+ assert.equal(event.won, false);57+});58+59+test('dynamic rules apply after words move and noun transformation is deterministic', () => {60+ const game = createGame(level([61+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),62+ word('ROCK', 0, 1), word('IS', 1, 1), word('WALL', 2, 1),63+ object('BABA', 0, 3), object('ROCK', 2, 3),64+ ]));65+ assert.equal(game.getState().objects.find(o => o.id === 'BABA-0-3').noun, 'BABA');66+ assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');67+ assert.equal(game.has('BABA', 'YOU'), true);68+ game.step(0, -1);69+ assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');70+ assert.equal(game.has('BABA', 'YOU'), true);71+ assert.equal(game.canUndo, true);72+ game.undo();73+ assert.equal(game.getState().moves, 0);74+ game.reset();75+ assert.equal(game.getState().objects.find(o => o.id === 'ROCK-2-3').noun, 'WALL');76+});77+78+test('WIN, SINK and DEFEAT resolve on contact', () => {79+ const game = createGame(level([80+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),81+ word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),82+ word('WATER', 0, 2), word('IS', 1, 2), word('SINK', 2, 2),83+ word('SKULL', 0, 3), word('IS', 1, 3), word('DEFEAT', 2, 3),84+ object('BABA', 1, 4), object('FLAG', 2, 4),85+ ]));86+ game.step(1, 0);87+ assert.equal(game.getState().won, true);88+});89+90+test('DEFEAT is resolved before WIN in the same overlap', () => {91+ const game = createGame(level([92+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),93+ word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),94+ word('SKULL', 0, 2), word('IS', 1, 2), word('DEFEAT', 2, 2),95+ object('BABA', 1, 3), object('FLAG', 2, 3), object('SKULL', 2, 3),96+ ]));97+ const event = game.step(1, 0);98+ assert.equal(game.getState().won, false);99+ assert.equal(game.getState().objects.some(o => o.noun === 'BABA'), false);100+ assert.deepEqual(event.destroyed, [{ id: 'BABA-1-3', noun: 'BABA', at: { x: 2, y: 3 }, cause: 'defeat' }]);101+ assert.equal(event.controlLost, true);102+ assert.deepEqual(event.moved, [{ id: 'BABA-1-3', from: { x: 1, y: 3 }, to: { x: 2, y: 3 }, pushedBy: null }]);103+});104+105+test('SINK removes a YOU overlap before WIN can trigger', () => {106+ const game = createGame(level([107+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),108+ word('FLAG', 0, 1), word('IS', 1, 1), word('WIN', 2, 1),109+ word('WATER', 0, 2), word('IS', 1, 2), word('SINK', 2, 2),110+ object('BABA', 3, 3), object('FLAG', 3, 3), object('WATER', 3, 3),111+ ]));112+ assert.equal(game.getState().won, false);113+ assert.equal(game.getState().objects.some(o => o.noun === 'BABA'), false);114+});115+116+test('stacked words expose each valid token in stable object order', () => {117+ const game = createGame(level([118+ word('BABA', 0, 0, 'a'), word('ROCK', 0, 0, 'b'), word('IS', 1, 0, 'is'), word('YOU', 2, 0, 'you'),119+ object('BABA', 1, 2), object('ROCK', 2, 2),120+ ]));121+ assert.equal(game.has('BABA', 'YOU'), true);122+ assert.equal(game.has('ROCK', 'YOU'), true);123+ assert.deepEqual(game.getState().rules.map(rule => [rule.text, rule.sourceIds, rule.axis]), [124+ ['BABA IS YOU', ['a', 'is', 'you'], 'h'],125+ ['ROCK IS YOU', ['b', 'is', 'you'], 'h'],126+ ]);127+});128+129+test('duplicate sentences retain a deterministic source', () => {130+ const game = createGame(level([131+ word('BABA', 0, 0, 'first-noun'), word('IS', 1, 0, 'first-is'), word('YOU', 2, 0, 'first-you'),132+ word('BABA', 0, 1, 'second-noun'), word('IS', 1, 1, 'second-is'), word('YOU', 2, 1, 'second-you'),133+ object('BABA', 4, 2),134+ ]));135+ assert.deepEqual(game.getState().rules.map(rule => rule.sourceIds), [['first-noun', 'first-is', 'first-you']]);136+});137+138+test('validates coordinates and duplicate ids', () => {139+ assert.throws(() => createGame(level([object('BABA', 9, 0)])), /outside/);140+ assert.throws(() => createGame(level([object('BABA', 0, 0, 'same'), object('ROCK', 1, 0, 'same')])), /duplicate/);141+ assert.throws(() => createGame(level([object('YOU', 0, 0)])), /valid noun token/);142+});143+144+test('bounds blocks expose a null blocker and lifecycle kinds stay stable', () => {145+ const game = createGame(level([146+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), object('BABA', 0, 1),147+ ]));148+ const blocked = game.step(-1, 0);149+ assert.equal(blocked.kind, 'step');150+ assert.deepEqual(blocked.blocked, { id: 'BABA-0-1', dir: [-1, 0], blockerId: null, reason: 'bounds' });151+ const reset = game.reset();152+ assert.equal(reset.kind, 'reset');153+ const moved = game.step(1, 0);154+ assert.equal(moved.accepted, true);155+ const undo = game.undo();156+ assert.equal(undo.kind, 'undo');157+ assert.equal(undo.restored, true);158+});159+160+test('diagnoses malformed tokens with level and object context', () => {161+ assert.throws(() => createGame({ title: 'Broken Garden', width: 2, height: 2, objects: [word('', 0, 0, 'bad-word')] }), /level "Broken Garden".*word bad-word.*token/);162+ assert.throws(() => createGame({ id: 'garden-7', width: 2, height: 2, objects: [object('bad noun', 0, 0, 'bad-object')] }), /level "garden-7".*object bad-object.*noun token/);163+});164+165+test('undo keeps the complete movement history', () => {166+ const game = createGame(level([167+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),168+ object('BABA', 0, 1),169+ ]));170+ for (let i = 0; i < 101; i++) {171+ assert.equal(game.step(1, 0).accepted, true);172+ assert.equal(game.step(-1, 0).accepted, true);173+ }174+ for (let i = 0; i < 202; i++) assert.equal(game.undo().accepted, true);175+ assert.equal(game.undo().accepted, false);176+ assert.equal(game.getState().moves, 0);177+});