clone

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

Share authoritative turn phases with fast solver transitions

Dami committed a13a876
commit
a13a8764b72a3e1ea50208d3d2f157cd2be4a8d6
parent
a89fe70d39423b2641ba5980209133cc810973b4
author
Dami <dami.etoile@gmail.com>
3 files changed, +277 -38
Msrc/engine.js96++++++++++++++++----------
Atests/engine-fast-parity.test.js108+++++++++++++++++++++++++++++
Atests/engine-fast.test.js111++++++++++++++++++++++++++++++
M src/engine.js +58 -38
@@ -222,8 +222,9 @@ export function createGame(level) {
222222 return { ok: true };223223 }224224 225- function advanceAutomata(movedIds) {226- const automated = [];225+ function advanceAutomata(movedIds, fast = false, beforeObjects = null) {226+ const automated = fast ? null : [];227+ const automatonWordIds = fast ? [] : null;227228 const automata = [...automataDefs].sort((a, b) => String(a.id).localeCompare(String(b.id)));228229 for (const system of automata) {229230 const runtime = state.automata.find(candidate => candidate.id === system.id);
@@ -231,7 +232,7 @@ export function createGame(level) {
231232 const action = system.pattern[phase];232233 const active = state.rules.some(rule => rule.predicate === system.property && (!system.subject || rule.subject === system.subject));233234 if (!active) {234- automated.push({ systemId: system.id, phase, action: action.action, active });235+ if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active });235236 continue;236237 }237238 const projection = object => object.x * action.dx + object.y * action.dy;
@@ -239,12 +240,12 @@ export function createGame(level) {
239240 .filter(object => object.kind === 'object' && (!system.subject || object.noun === system.subject))240241 .sort((a, b) => projection(b) - projection(a) || String(a.id).localeCompare(String(b.id)));241242 if (!targets.length) {242- automated.push({ systemId: system.id, phase, action: action.action, active, cue: action.cue, sound: action.sound });243+ if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active, cue: action.cue, sound: action.sound });243244 continue;244245 }245246 if (runtime) runtime.phase = (phase + 1) % system.pattern.length;246247 if (action.action === 'wait') {247- 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 });248+ 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 });248249 continue;249250 }250251 for (const actor of targets) {
@@ -253,22 +254,25 @@ export function createGame(level) {
253254 const order = [];254255 const result = planAutomatonMove(actor, action.dx, action.dy, planned, plannedPushers, null, order);255256 if (!result.ok) {256- automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, blocked: result.blocked, cue: action.cue, sound: action.sound });257+ if (!fast) automated.push({ systemId: system.id, phase, action: action.action, active, id: actor.id, blocked: result.blocked, cue: action.cue, sound: action.sound });257258 continue;258259 }259- const from = new Map(order.map(id => { const object = indexes.objectsById.get(id); return [id, { x: object.x, y: object.y }]; }));260260 for (const id of order) {261261 const object = indexes.objectsById.get(id);262262 object.x += action.dx; object.y += action.dy; movedIds.add(id);263+ if (fast && beforeObjects?.get(id)?.kind === 'word' && !automatonWordIds.includes(id)) automatonWordIds.push(id);263264 }264- for (const id of order) {265- const object = indexes.objectsById.get(id);266- 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 });265+ if (!fast) {266+ 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 }]; }));267+ for (const id of order) {268+ const object = indexes.objectsById.get(id);269+ 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 });270+ }267271 }268272 }269273 }270274 refreshAutomata();271- return automated;275+ return fast ? automatonWordIds : automated;272276 }273277 274278 function settled(kind, accepted, before, blocked = null, destroyedCauses = new Map(), restored = null, pushedBy = new Map(), movedOverride = null, transformedOverride = null, automated = []) {
@@ -365,12 +369,17 @@ export function createGame(level) {
365369 throw error;366370 }367371 }368- function takeTurn(dx, dy, wait = false) {372+ function fastResult(accepted, stateSnapshot, blocked, ruleTrace, wordMotion) {373+ return { accepted, state: stateSnapshot || getState(), blocked: blocked || null, ruleTrace: ruleTrace || [], wordMotion: wordMotion || { player: [], automaton: [] }, won: state.won };374+ }375+ function takeTurn(dx, dy, wait = false, fast = false) {369376 const before = clone(state);370- if (state.won) return settled('step', false, before);371- if (!wait && Math.abs(dx) + Math.abs(dy) !== 1) return settled('step', false, before);377+ const ruleTrace = fast ? [{ phase: 'initial', rules: state.rules.map(rule => rule.text) }] : null;378+ const traceRules = phase => { if (fast) ruleTrace.push({ phase, rules: state.rules.map(rule => rule.text) }); };379+ if (state.won) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);380+ if (!wait && Math.abs(dx) + Math.abs(dy) !== 1) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);372381 const you = state.objects.filter(o => properties(o).has('YOU'));373- if (!you.length) return settled('step', false, before);382+ if (!you.length) return fast ? fastResult(false, null, null, ruleTrace, null) : settled('step', false, before);374383 const moved = new Set();375384 const pushedBy = new Map();376385 const resolutionOrder = [];
@@ -393,38 +402,46 @@ export function createGame(level) {
393402 resolutionOrder.push(...plannedOrder);394403 }395404 }396- if (!wait && !moved.size) return settled('step', false, before, blocked);405+ if (!wait && !moved.size) return fast ? fastResult(false, null, blocked, ruleTrace, null) : settled('step', false, before, blocked);397406 const beforeObjects = new Map(before.objects.map(o => [o.id, o]));398- const movementEntries = resolutionOrder.map(id => {407+ const playerWordIds = resolutionOrder.filter(id => beforeObjects.get(id)?.kind === 'word');408+ const movementEntries = fast ? [] : resolutionOrder.map(id => {399409 const prior = beforeObjects.get(id);400410 const current = indexes.objectsById.get(id);401411 return { id, from: { x: prior.x, y: prior.y }, to: { x: current.x, y: current.y }, pushedBy: pushedBy.get(id) || null };402412 });403- history.push(before);413+ if (!fast) history.push(before);404414 state.moves++;405415 try {406416 transformSources = new Map();407417 const transformedIds = new Set();408- refreshRules(); transform(transformedIds); refreshRules();409- const automated = advanceAutomata(moved);410- 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 } }) }));411- const movementById = new Map();412- const movementOrder = [];413- for (const entry of [...movementEntries, ...automatedMoves]) {414- if (!movementById.has(entry.id)) {415- movementById.set(entry.id, { ...entry });416- movementOrder.push(entry.id);417- } else {418- const merged = movementById.get(entry.id);419- merged.to = { ...entry.to };420- if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;421- if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };418+ refreshRules(); traceRules('player-before-transform'); transform(transformedIds); refreshRules(); traceRules('player-settled');419+ const automated = advanceAutomata(moved, fast, beforeObjects);420+ const automatonWordIds = fast ? automated : [];421+ 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);422+ 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 } }) }));423+ let allMovementEntries = [];424+ if (!fast) {425+ const movementById = new Map();426+ const movementOrder = [];427+ for (const entry of [...movementEntries, ...automatedMoves]) {428+ if (!movementById.has(entry.id)) {429+ movementById.set(entry.id, { ...entry });430+ movementOrder.push(entry.id);431+ } else {432+ const merged = movementById.get(entry.id);433+ merged.to = { ...entry.to };434+ if (!merged.pushedBy && entry.pushedBy) merged.pushedBy = entry.pushedBy;435+ if (entry.selfDirection) merged.selfDirection = { ...entry.selfDirection };436+ }422437 }438+ allMovementEntries = movementOrder.map(id => movementById.get(id));423439 }424- const allMovementEntries = movementOrder.map(id => movementById.get(id));425- refreshRules(); transform(transformedIds); refreshRules();440+ refreshRules(); traceRules('automata-before-transform'); transform(transformedIds); refreshRules(); traceRules('automata-settled');426441 const destroyedCauses = new Map();427442 for (const [id, detail] of resolveContacts(true)) destroyedCauses.set(id, detail);443+ traceRules('settled');444+ if (fast) return fastResult(true, null, blocked, ruleTrace, { player: [...new Set(playerWordIds)], automaton: automatonWordIds });428445 const transformationEntries = state.objects.flatMap(object => {429446 const prior = beforeObjects.get(object.id);430447 return prior?.kind === 'object' && object.kind === 'object' && prior.noun !== object.noun
@@ -439,20 +456,23 @@ export function createGame(level) {
439456 // entry so a complexity rejection is indistinguishable from any other440457 // rejected input to history and the solver.441458 state = before;442- history.pop();459+ if (!fast) history.pop();443460 transformSources = new Map();444461 indexes = buildIndexes(state, state.rules, semanticProperties);445- return settled(wait ? 'wait' : 'step', false, before, {462+ const complexityBlock = {446463 id: null,447464 dir: [dx, dy],448465 blockerId: null,449466 reason: 'rule-complexity',450467 details: error.message,451- });468+ };469+ return fast ? fastResult(false, null, complexityBlock, ruleTrace, null) : settled(wait ? 'wait' : 'step', false, before, complexityBlock);452470 }453471 }454472 function step(dx, dy) { return takeTurn(dx, dy, false); }455473 function wait() { return takeTurn(0, 0, true); }474+ function stepFast(dx, dy) { return takeTurn(dx, dy, false, true); }475+ function waitFast() { return takeTurn(0, 0, true, true); }456476 function undo() {457477 const before = clone(state);458478 if (!history.length) return settled('undo', false, before);
@@ -462,5 +482,5 @@ export function createGame(level) {
462482 return settled('undo', true, before, null, new Map(), true);463483 }464484 reset();465- return { getState, step, wait, undo, reset, restore, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };485+ return { getState, step, wait, stepFast, waitFast, undo, reset, restore, properties: o => [...properties(o)], has, get canUndo() { return history.length > 0; } };466486 }
A tests/engine-fast-parity.test.js +108 -0
@@ -0,0 +1,108 @@
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, extra = {}) => ({ width: 8, height: 5, objects, ...extra });8+9+function settledState(state) {10+ return {11+ width: state.width,12+ height: state.height,13+ objects: state.objects.slice().sort((a, b) => a.id.localeCompare(b.id)),14+ rules: state.rules,15+ sentences: state.sentences,16+ automata: state.automata,17+ won: state.won,18+ };19+}20+21+function assertParity(board, action) {22+ const publicGame = createGame(board);23+ const fastGame = createGame(board);24+ const publicResult = action(publicGame, false);25+ const fastResult = action(fastGame, true);26+ assert.equal(fastResult.accepted, publicResult.accepted);27+ assert.deepEqual(settledState(fastResult.state), settledState(publicGame.getState()));28+ assert.deepEqual(settledState(fastGame.getState()), settledState(publicGame.getState()));29+ assert.equal(fastGame.canUndo, false, 'fast transitions must not create undo history');30+ return { publicGame, fastGame, publicResult, fastResult };31+}32+33+test('fast step matches W6 stacked rule parsing and movement', () => {34+ const board = level([35+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),36+ word('ROCK', 0, 1), word('AND', 1, 1), word('IS', 1, 1), word('FLAG', 2, 1),37+ object('MIAU', 0, 2, 'player'),38+ ]);39+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));40+ assert.ok(fastResult.ruleTrace.some(entry => entry.rules.includes('ROCK IS FLAG')));41+});42+43+test('fast blocked transition matches PUSH and STOP semantics', () => {44+ const board = level([45+ word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),46+ word('ROCK', 0, 1), word('IS', 1, 1), word('PUSH', 2, 1),47+ word('WALL', 0, 2), word('IS', 1, 2), word('STOP', 2, 2),48+ object('BABA', 1, 3, 'player'), object('ROCK', 2, 3, 'rock'), object('ROCK', 3, 3, 'rock-2'), object('WALL', 4, 3, 'wall'),49+ ]);50+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));51+ assert.deepEqual(fastResult.blocked, { id: 'rock-2', dir: [1, 0], blockerId: 'wall', reason: 'stop' });52+});53+54+test('fast step matches singleton DEFEAT and WIN contact ordering', () => {55+ const board = level([56+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),57+ word('ROCK', 0, 1), word('IS', 1, 1), word('DEFEAT', 2, 1),58+ word('FLAG', 0, 2), word('IS', 1, 2), word('WIN', 2, 2),59+ object('MIAU', 0, 3, 'player'), object('ROCK', 1, 3, 'defeat'), object('FLAG', 1, 3, 'goal'),60+ ]);61+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));62+ assert.equal(fastResult.won, false);63+ assert.equal(fastResult.state.objects.some(entry => entry.id === 'player'), false);64+});65+66+test('fast step matches noun-chain transformation', () => {67+ const board = level([68+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),69+ word('ROCK', 0, 1), word('IS', 1, 1), word('FLAG', 2, 1),70+ word('FLAG', 0, 2), word('IS', 1, 2), word('WIN', 2, 2),71+ object('MIAU', 0, 3, 'player'), object('ROCK', 3, 3, 'rock'),72+ ]);73+ assertParity(board, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));74+});75+76+test('fast step matches SINK rule-word removal and reparse', () => {77+ const board = level([78+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),79+ word('WATER', 3, 1), word('IS', 4, 1), word('SINK', 5, 1),80+ word('ROCK', 1, 3, 'rock-word'), word('IS', 2, 3, 'rock-is'), word('STOP', 3, 3, 'stop-word'),81+ object('MIAU', 0, 3, 'player'), object('WATER', 4, 3, 'water'),82+ ]);83+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));84+ assert.equal(fastResult.state.rules.some(rule => rule.text === 'ROCK IS STOP'), false);85+ assert.ok(fastResult.ruleTrace.some(entry => entry.rules.includes('ROCK IS STOP')));86+});87+88+test('fast wait matches Clockwork automaton phase advancement', () => {89+ const board = level([90+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),91+ word('COG', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),92+ object('MIAU', 0, 3, 'player'), object('COG', 3, 3, 'cog'),93+ ], { automata: [{ id: 'clock', subject: 'COG', property: 'TICK', phase: 0, pattern: [{ action: 'wait' }, { action: 'move', dx: 1, dy: 0 }] }] });94+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.waitFast() : game.wait());95+ assert.equal(fastResult.state.automata[0].phase, 1);96+});97+98+test('fast wait reports automaton word movement separately from player work', () => {99+ const board = level([100+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),101+ word('COG', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),102+ word('COG', 0, 2), word('IS', 1, 2), word('PUSH', 2, 2),103+ object('MIAU', 0, 4, 'player'), object('COG', 2, 3, 'cog'), word('WIN', 3, 3, 'moving-word'),104+ ], { automata: [{ id: 'clock', subject: 'COG', property: 'TICK', phase: 0, pattern: [{ action: 'move', dx: 1, dy: 0 }] }] });105+ const { fastResult } = assertParity(board, (game, fast) => fast ? game.waitFast() : game.wait());106+ assert.deepEqual(fastResult.wordMotion.player, []);107+ assert.deepEqual(fastResult.wordMotion.automaton, ['moving-word']);108+});
A tests/engine-fast.test.js +111 -0
@@ -0,0 +1,111 @@
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) => ({ id, kind: 'object', noun, x, y });7+8+function compareStep(level, action, expectedWordMotion = { player: [], automaton: [] }) {9+ const publicGame = createGame(level);10+ const fastGame = createGame(level);11+ const publicResult = action(publicGame, false);12+ const fastResult = action(fastGame, true);13+ assert.equal(fastResult.accepted, publicResult.accepted);14+ assert.deepEqual(fastResult.state, publicGame.getState());15+ assert.deepEqual(fastResult.wordMotion, expectedWordMotion);16+ assert.equal(fastGame.canUndo, false);17+ assert.equal(publicGame.canUndo, publicResult.accepted);18+ return fastResult;19+}20+21+test('stepFast matches public movement and keeps a compact rule trace', () => {22+ const level = {23+ id: 'fast-move', width: 6, height: 3,24+ objects: [25+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),26+ object('MIAU', 1, 2, 'player'),27+ ],28+ };29+ const result = compareStep(level, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0));30+ assert.equal(result.accepted, true);31+ assert.equal(result.ruleTrace[0].phase, 'initial');32+ assert.ok(result.ruleTrace.some(entry => entry.rules.includes('MIAU IS YOU')));33+});34+35+test('waitFast matches public automaton phase advancement', () => {36+ const level = {37+ id: 'fast-clock', width: 6, height: 3,38+ objects: [39+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),40+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),41+ object('MIAU', 0, 2, 'player'), object('CLOCK', 3, 2, 'clock'),42+ ],43+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'wait' }, { action: 'move', dx: 1, dy: 0 }] }],44+ };45+ const result = compareStep(level, (game, fast) => fast ? game.waitFast() : game.wait());46+ assert.equal(result.state.automata[0].phase, 1);47+ assert.deepEqual(result.wordMotion, { player: [], automaton: [] });48+});49+50+test('fast provenance separates player and automaton word motion', () => {51+ const level = {52+ id: 'fast-provenance', width: 7, height: 4,53+ objects: [54+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),55+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1), word('AND', 3, 1), word('PUSH', 4, 1),56+ object('MIAU', 0, 2, 'player'), object('CLOCK', 1, 3, 'clock'), word('ROCK', 2, 3, 'loose-word'),57+ ],58+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: 1, dy: 0 }] }],59+ };60+ const result = compareStep(level, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0), { player: [], automaton: ['loose-word'] });61+ assert.deepEqual(result.wordMotion.automaton, ['loose-word']);62+});63+64+test('fast provenance retains automaton motion for a word already moved by the player', () => {65+ const level = {66+ id: 'fast-provenance-merge', width: 6, height: 4,67+ objects: [68+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),69+ word('CLOCK', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),70+ word('ROCK', 0, 2), word('IS', 1, 2), word('PUSH', 2, 2),71+ object('MIAU', 0, 3, 'player'), word('ROCK', 1, 3, 'rock-word'), object('CLOCK', 3, 3, 'clock'),72+ ],73+ automata: [{ id: 'clockwork', subject: 'CLOCK', property: 'TICK', pattern: [{ action: 'move', dx: -1, dy: 0 }] }],74+ };75+ const result = compareStep(level, (game, fast) => fast ? game.stepFast(1, 0) : game.step(1, 0), { player: ['rock-word'], automaton: ['rock-word'] });76+ assert.deepEqual(result.wordMotion, { player: ['rock-word'], automaton: ['rock-word'] });77+});78+79+test('blocked fast input does not create history or mutate settled state', () => {80+ const level = {81+ id: 'fast-blocked', width: 3, height: 2,82+ objects: [word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0), object('MIAU', 0, 1, 'player')],83+ };84+ const game = createGame(level);85+ const before = game.getState();86+ const result = game.stepFast(-1, 0);87+ assert.equal(result.accepted, false);88+ assert.equal(result.blocked.reason, 'bounds');89+ assert.deepEqual(result.state, before);90+ assert.equal(game.canUndo, false);91+});92+93+test('fast parser-limit rejection rolls back without creating history', () => {94+ const objects = [95+ word('BABA', 0, 0, 'subject'), word('IS', 1, 1, 'loose-is'),96+ word('MIAU', 0, 3, 'miau-subject'), word('IS', 1, 3, 'miau-is'), word('YOU', 2, 3, 'miau-you'),97+ object('MIAU', 1, 2, 'player'),98+ ];99+ for (let index = 0; index < 15; index += 1) {100+ const x = 2 + index * 2;101+ objects.push(word('WIN', x, 0, `win-${index}`), word('PUSH', x, 0, `push-${index}`));102+ if (index < 14) objects.push(word('AND', x + 1, 0, `and-${index}`));103+ }104+ const game = createGame({ id: 'fast-limit', width: 31, height: 4, objects });105+ const before = game.getState();106+ const result = game.stepFast(0, -1);107+ assert.equal(result.accepted, false);108+ assert.equal(result.blocked.reason, 'rule-complexity');109+ assert.deepEqual(result.state, before);110+ assert.equal(game.canUndo, false);111+});