- commit
- a13a8764b72a3e1ea50208d3d2f157cd2be4a8d6
- parent
- a89fe70d39423b2641ba5980209133cc810973b4
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 96 | ++++++++++++++++---------- |
| A | tests/ | 108 | +++++++++++++++++++++++++++++ |
| A | tests/ | 111 | ++++++++++++++++++++++++++++++ |
@@ -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 }@@ -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+});@@ -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+});