clone

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

main
miau-is-true / tests / juice.test.js
juice.test.js 21.0K
1import test from 'node:test';2import assert from 'node:assert/strict';3import { createGame } from '../src/engine.js';4import { createJuice, JUICE_TIMINGS } from '../src/juice.js';5import { projectEntityVisual } from '../src/juice/visuals.js';6import { destructionEffect, blockedPresentation } from '../src/juice/effects.js';78const state = objects => ({ objects });9const object = (id, x, y) => ({ id, kind: 'object', noun: 'MIAU', x, y });1011test('projectEntityVisual keeps presentation math pure and isolated from juice state', () => {12 const visual = projectEntityVisual({13 id: 'miau', fromX: 0, fromY: 1, x: 1, y: 1, start: 0, duration: 100,14 pushed: false, dx: 1, dy: 0, facing: 'right', faceFlipStart: 0,15 popDuration: 0, bounceDuration: 0,16 }, { at: 50, faceFlipDuration: 70 });17 assert.equal(visual.id, 'miau');18 assert.equal(visual.moving, true);19 assert.equal(visual.facing, 'right');20 assert.ok(visual.x > 0.8);21 assert.ok(visual.glyphScaleX < 0.6);22});2324test('pushed word visuals skid only during horizontal travel', () => {25 const pushed = projectEntityVisual({26 id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,27 pushed: true, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,28 }, { at: 45 });29 assert.ok(Math.abs(pushed.skid - 0.09) < 1e-9);30 assert.equal(projectEntityVisual({31 id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,32 pushed: false, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,33 }, { at: 45 }).skid, 0);34 assert.equal(projectEntityVisual({35 id: 'word-you', fromX: 0, fromY: 0, x: 0, y: 1, start: 0, duration: 90,36 pushed: true, dx: 0, dy: 1, facing: 'down', popDuration: 0, bounceDuration: 0,37 }, { at: 45 }).skid, 0);38 assert.equal(projectEntityVisual({39 id: 'word-you', fromX: 0, fromY: 0, x: 1, y: 0, start: 0, duration: 90,40 pushed: true, dx: 1, dy: 0, facing: 'right', popDuration: 0, bounceDuration: 0,41 }, { at: 90 }).skid, 0);42});4344test('juice effect builders preserve settled coordinates and reduced motion timing', () => {45 const death = destructionEffect({ cause: 'sink', noun: 'MIAU', at: { x: 2, y: 3 } }, 10, 200);46 assert.deepEqual(death.cell, { x: 2, y: 3 });47 assert.equal(death.role, 'SINK');48 const blocked = blockedPresentation({ id: 'miau', reason: 'bounds' }, 10, 0, true, 16);49 assert.equal(blocked.shake, null);50 assert.equal(blocked.blocked.outlineUntil, 26);51});5253test('move and push tweens expose grid coordinates and push scale', () => {54 const juice = createJuice({ now: () => 0 });55 juice.syncState(state([object('miau', 0, 0), object('rock', 1, 0)]), 0);56 juice.consume({57 kind: 'step', accepted: true,58 moved: [59 { id: 'miau', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null },60 { id: 'rock', from: { x: 1, y: 0 }, to: { x: 2, y: 0 }, pushedBy: 'miau' },61 ],62 rulesAdded: [], rulesRemoved: [], destroyed: [],63 }, 0);6465 const half = juice.frame(45);66 assert.equal(half.positions.get('miau').moving, true);67 assert.ok(half.positions.get('miau').x > 0.8);68 assert.equal(half.positions.get('rock').pushed, true);69 assert.ok(half.positions.get('rock').scaleX < half.positions.get('miau').scaleX);7071 juice.beginInput(50);72 assert.equal(juice.frame(50).positions.get('miau').x, 1);73 assert.equal(juice.frame(50).positions.get('rock').x, 2);74 assert.equal(juice.frame(50).positions.get('miau').facing, 'right');75 assert.equal(juice.frame(50).positions.get('rock').facing, 'right');76});7778test('snapping before a level transition prevents reused ids from drawing at old cells', () => {79 const juice = createJuice({ now: () => 0 });80 juice.syncState(state([object('player', 0, 0)]), 0);81 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);82 juice.snap(10);83 juice.syncState(state([object('player', 0, 1)]), 10);84 const player = juice.frame(10).positions.get('player');85 assert.equal(player.facing, 'right');86 assert.ok(Math.abs(player.glyphScaleX - (1 - 0.8 * Math.sin(Math.PI * 10 / 70))) < 1e-9);87});8889test('resetLevel clears old sentences, effects, and win beats before seeding the next proof', () => {90 const juice = createJuice({ now: () => 0 });91 const oldState = { objects: [object('a', 0, 0), object('b', 1, 0)], sentences: [{ ids: ['a', 'b'], axis: 'h' }] };92 juice.syncState(oldState, 0);93 juice.consume({ kind: 'step', accepted: true, moved: [], rulesAdded: [{ text: 'MIAU IS WIN', subject: 'MIAU', predicate: 'WIN', sourceIds: ['a', 'b'], axis: 'h' }], rulesRemoved: [], destroyed: [{ id: 'a', noun: 'MIAU', at: { x: 0, y: 0 }, cause: 'sink' }], won: true }, 0, oldState);94 const nextState = { objects: [object('a', 3, 1), object('b', 4, 1)], sentences: [{ ids: ['a', 'b'], axis: 'v' }] };95 const frame = juice.resetLevel(nextState, 20);96 assert.equal(frame.win, null);97 assert.deepEqual(frame.effects, []);98 assert.deepEqual(frame.rules, []);99 assert.deepEqual(frame.sentences.map(sentence => ({ ids: sentence.ids, axis: sentence.axis })), [{ ids: ['a', 'b'], axis: 'v' }]);100 assert.equal(frame.positions.get('a').facing, null);101 assert.ok(Math.abs(frame.positions.get('a').glyphScaleX - 1) < 1e-9);102});103104test('facing follows horizontal movement and preserves through vertical moves and level changes', () => {105 const juice = createJuice({ now: () => 0 });106 juice.syncState(state([object('player', 1, 1), object('rock', 2, 1)]), 0);107 juice.consume({108 kind: 'step', accepted: true,109 moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }],110 rulesAdded: [], rulesRemoved: [], destroyed: [],111 }, 0);112 assert.equal(juice.frame(45).positions.get('player').facing, 'right');113 juice.consume({114 kind: 'step', accepted: true,115 moved: [116 { id: 'player', from: { x: 2, y: 1 }, to: { x: 2, y: 0 }, pushedBy: null },117 { id: 'rock', from: { x: 2, y: 1 }, to: { x: 3, y: 1 }, pushedBy: 'player' },118 ],119 rulesAdded: [], rulesRemoved: [], destroyed: [],120 }, 50);121 assert.equal(juice.frame(95).positions.get('player').facing, 'right');122 assert.equal(juice.frame(95).positions.get('rock').facing, 'right');123 juice.snap(100);124 assert.equal(juice.frame(100).positions.get('player').facing, 'right');125 juice.resetLevel(state([object('player', 3, 3)]), 120);126 assert.equal(juice.frame(120).positions.get('player').facing, null);127});128129test('horizontal facing changes use an independent 70ms glyph flip', () => {130 const juice = createJuice({ now: () => 0 });131 juice.syncState(state([object('player', 0, 0)]), 0);132 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);133 assert.ok(Math.abs(juice.frame(35).positions.get('player').glyphScaleX - 0.2) < 1e-9);134 assert.ok(Math.abs(juice.frame(70).positions.get('player').glyphScaleX - 1) < 1e-9);135 const reduced = createJuice({ reducedMotion: true, now: () => 0 });136 reduced.syncState(state([object('player', 0, 0)]), 0);137 reduced.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);138 assert.equal(reduced.frame(35).positions.get('player').glyphScaleX, 1);139});140141test('undo restores each entity previous facing', () => {142 const juice = createJuice({ now: () => 0 });143 juice.syncState(state([object('player', 0, 0)]), 0);144 const move = (from, to, at) => juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from, to, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, at);145 move({ x: 0, y: 0 }, { x: 1, y: 0 }, 0);146 move({ x: 1, y: 0 }, { x: 0, y: 0 }, 100);147 assert.equal(juice.frame(100).positions.get('player').facing, 'left');148 juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200);149 assert.equal(juice.frame(200).positions.get('player').facing, 'right');150});151152test('a pushed MIAU faces its actual horizontal travel and Undo restores its prior pose', () => {153 const juice = createJuice({ now: () => 0 });154 juice.syncState(state([object('miau', 1, 1)]), 0);155 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'miau', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: 'rock' }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);156 assert.equal(juice.frame(90).positions.get('miau').facing, 'right');157 juice.consume({ kind: 'blocked', accepted: false, blocked: { id: 'miau', reason: 'bounds' }, moved: [] }, 100);158 assert.equal(juice.frame(100).positions.get('miau').facing, 'right');159 juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'miau', from: { x: 2, y: 1 }, to: { x: 1, y: 1 } }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 200);160 assert.equal(juice.frame(200).positions.get('miau').facing, null);161});162163test('reset clears facing immediately and undo restores nonmoving entities', () => {164 const juice = createJuice({ now: () => 0 });165 juice.syncState(state([object('player', 0, 0), object('rock', 2, 0)]), 0);166 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'rock', from: { x: 2, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);167 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [{ id: 'rock', noun: 'MIAU', at: { x: 1, y: 0 }, cause: 'sink' }] }, 100);168 juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'player', from: { x: 1, y: 0 }, to: { x: 0, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200);169 assert.equal(juice.frame(200).positions.get('rock').facing, 'left');170 juice.consume({ kind: 'reset', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 300);171 assert.equal(juice.frame(300).positions.get('player').facing, null);172 assert.equal(juice.frame(300).positions.get('rock').facing, null);173});174175test('undo reseeds an actor removed by death so its visual returns', () => {176 const juice = createJuice({ now: () => 0 });177 const alive = state([object('player', 1, 1)]);178 const dead = state([]);179 juice.syncState(alive, 0);180 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [{ id: 'player', noun: 'MIAU', at: { x: 2, y: 1 }, cause: 'defeat' }] }, 0, dead);181 juice.syncState(dead, 10);182 assert.equal(juice.frame(10).positions.has('player'), true);183 juice.consume({ kind: 'undo', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 20, alive);184 assert.deepEqual(185 { x: juice.frame(20).positions.get('player').x, y: juice.frame(20).positions.get('player').y },186 { x: 1, y: 1 },187 );188 assert.equal(juice.frame(20).effects.some(effect => effect.type === 'defeat' || effect.type === 'sink'), false);189 assert.equal(juice.frame(20).shake.amplitude, 0);190});191192test('reset clears death effects immediately after a fatal turn', () => {193 const juice = createJuice({ now: () => 0 });194 const alive = state([object('player', 1, 1)]);195 juice.syncState(alive, 0);196 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'player', from: { x: 1, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [{ id: 'player', noun: 'MIAU', at: { x: 2, y: 1 }, cause: 'sink' }] }, 0, state([]));197 juice.consume({ kind: 'reset', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 20, alive);198 const frame = juice.frame(20);199 assert.deepEqual(frame.effects, []);200 assert.equal(frame.shake.amplitude, 0);201 assert.equal(frame.positions.get('player').facing, null);202});203204test('rule formation waits 45ms after movement lands', () => {205 const juice = createJuice({ now: () => 0 });206 const rule = { text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: ['a', 'b', 'c'], axis: 'h' };207 juice.consume({208 kind: 'step', accepted: true,209 moved: [{ id: 'a', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }],210 rulesAdded: [rule], rulesRemoved: [], destroyed: [],211 }, 0);212 assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold - 1).rules[0].phase, 'holding');213 assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold).rules[0].progress, 0);214 assert.equal(juice.frame(JUICE_TIMINGS.move + JUICE_TIMINGS.ruleHold + JUICE_TIMINGS.ruleForm).rules[0].phase, 'active');215});216217test('blocked movement produces a nudge and reduced motion outline', () => {218 const normal = createJuice({ now: () => 0 });219 normal.consume({ kind: 'step', accepted: false, blocked: { id: 'miau', dir: [1, 0], blockerId: 'wall', reason: 'stop' } }, 0);220 normal.syncState(state([object('miau', 0, 0)]), 0);221 assert.ok(normal.frame(65).positions.get('miau').x > 0);222 assert.equal(normal.frame(200).blocked, null);223224 const reduced = createJuice({ reducedMotion: true, now: () => 0 });225 reduced.syncState(state([object('miau', 0, 0)]), 0);226 reduced.consume({ kind: 'step', accepted: false, blocked: { id: 'miau', dir: [1, 0], blockerId: null, reason: 'bounds' } }, 0);227 assert.equal(reduced.frame(0).positions.get('miau').x, 0);228 assert.equal(reduced.frame(0).blocked.reason, 'bounds');229 assert.equal(reduced.frame(20).blocked, null);230});231232test('reduced motion makes accepted movement and rule changes immediate', () => {233 const juice = createJuice({ reducedMotion: true, now: () => 0 });234 juice.syncState(state([object('miau', 0, 0)]), 0);235 juice.consume({236 kind: 'step', accepted: true,237 moved: [{ id: 'miau', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }],238 rulesAdded: [{ text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: [], axis: 'h' }],239 rulesRemoved: [], destroyed: [],240 }, 0);241 const frame = juice.frame(0);242 assert.equal(frame.positions.get('miau').x, 1);243 assert.equal(frame.rules[0].phase, 'active');244 assert.equal(frame.rules[0].progress, 1);245});246247test('physical sentence bars use ids and freeze removed pre-move cells', () => {248 const juice = createJuice({ now: () => 0 });249 const oldState = { objects: [object('a', 0, 0), object('b', 1, 0), object('c', 2, 0)], sentences: [{ ids: ['a', 'b', 'c'], axis: 'h' }] };250 juice.syncState(oldState, 0);251 juice.beginInput(10);252 const nextState = { objects: [object('a', 0, 0), object('b', 1, 1), object('c', 2, 0)], sentences: [] };253 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'b', from: { x: 1, y: 0 }, to: { x: 1, y: 1 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 10, nextState);254 const bar = juice.frame(10).sentences[0];255 assert.equal(bar.key, 'a>b>c');256 assert.equal(bar.phase, 'removing');257 assert.deepEqual(bar.frozenCells.map(cell => [cell.x, cell.y]), [[0, 0], [1, 0], [2, 0]]);258});259260test('physical ledger sentences stamp by tile and strike before exiting', () => {261 const juice = createJuice({ now: () => 0 });262 const empty = { objects: [object('a', 0, 0), object('b', 1, 0)], sentences: [] };263 const formed = { ...empty, sentences: [{ ids: ['a', 'b'], axis: 'h' }] };264 juice.syncState(empty, 0);265 juice.consume({266 kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],267 }, 0, formed);268 assert.deepEqual(juice.frame(90).ledger[0].tokens, ['MIAU', 'MIAU']);269 assert.deepEqual(juice.frame(90).ledger[0].stampProgress, [0, 0]);270 assert.ok(juice.frame(180).ledger[0].stampProgress[0] > 0);271 assert.equal(juice.frame(250).ledger[0].stampProgress[0], 1);272 assert.ok(juice.frame(250).ledger[0].stampProgress[1] > 0);273274 juice.syncState(formed, 300);275 assert.deepEqual(juice.frame(900).ledger[0].stampProgress, [1, 1]);276 juice.consume({277 kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],278 }, 300, empty);279 assert.equal(juice.frame(300).ledger[0].phase, 'removing');280 assert.equal(juice.frame(440).ledger[0].strikeProgress, 1);281 assert.ok(juice.frame(440).ledger[0].exitProgress < 1);282 assert.equal(juice.frame(600).ledger.length, 0);283});284285test('engine push breaking a sentence freezes its pre-push cells', () => {286 const game = createGame({287 width: 5,288 height: 3,289 objects: [290 { id: 'miau', kind: 'object', noun: 'MIAU', x: 1, y: 2 },291 { id: 'word-miau', kind: 'word', text: 'MIAU', x: 1, y: 1 },292 { id: 'word-is', kind: 'word', text: 'IS', x: 2, y: 1 },293 { id: 'word-you', kind: 'word', text: 'YOU', x: 3, y: 1 },294 ],295 });296 const before = game.getState();297 assert.deepEqual(before.sentences.map(sentence => sentence.ids), [['word-miau', 'word-is', 'word-you']]);298 const juice = createJuice({ now: () => 0 });299 juice.syncState(before, 0);300 juice.beginInput(0);301 const result = game.step(0, -1);302 const after = game.getState();303 assert.equal(result.accepted, true);304 assert.deepEqual(after.sentences, []);305 juice.consume(result, 0, after);306 const removed = juice.frame(0).sentences.find(sentence => sentence.phase === 'removing');307 assert.ok(removed);308 assert.deepEqual(removed.frozenCells.map(cell => [cell.x, cell.y]), [[1, 1], [2, 1], [3, 1]]);309});310311test('snap preserves the rule hold while it finishes movement', () => {312 const juice = createJuice({ now: () => 0 });313 const rule = { text: 'MIAU IS YOU', subject: 'MIAU', predicate: 'YOU', sourceIds: ['a', 'b', 'c'], axis: 'h' };314 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'a', from: { x: 0, y: 0 }, to: { x: 1, y: 0 }, pushedBy: null }], rulesAdded: [rule], rulesRemoved: [], destroyed: [] }, 0);315 juice.beginInput(10);316 assert.equal(juice.frame(10).rules[0].phase, 'holding');317});318319test('undo, reset, destruction, and win expose presentation timelines', () => {320 const juice = createJuice({ now: () => 0 });321 juice.syncState(state([object('miau', 0, 0)]), 0);322 juice.consume({ kind: 'undo', accepted: true, restored: true, moved: [{ id: 'miau', from: { x: 1, y: 0 }, to: { x: 0, y: 0 }, pushedBy: null }], rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0);323 assert.equal(juice.frame(0).tint.color, 'cool');324 assert.ok(juice.frame(100).positions.get('miau').scaleX > 0.55);325 juice.consume({ kind: 'step', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], transformed: [], destroyed: [{ id: 'miau', noun: 'MIAU', at: { x: 0, y: 0 }, cause: 'sink' }], won: true }, 20);326 assert.equal(juice.frame(20).effects[0].role, 'SINK');327 assert.equal(juice.frame(20).shake.amplitude, 4);328 assert.equal(juice.frame(20).win.cardVisible, false);329 assert.equal(juice.frame(20).win.beatStart, 110);330 assert.equal(juice.frame(370).win.cardVisible, true);331 juice.consume({ kind: 'reset', accepted: true }, 30);332 assert.equal(juice.frame(30).wipe.progress, 0);333});334335test('automaton wait pulses its actor and undo clears the pulse', () => {336 const juice = createJuice({ now: () => 0 });337 const board = state([object('fox', 2, 1)]);338 juice.syncState(board, 0);339 juice.consume({340 kind: 'wait', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [],341 automated: [{ systemId: 'fox-clock', phase: 0, action: 'wait', active: true, id: 'fox' }],342 }, 0, board);343 assert.ok(juice.frame(190).positions.get('fox').scaleX > 1);344 juice.consume({ kind: 'undo', accepted: true, moved: [], rulesAdded: [], rulesRemoved: [], destroyed: [], restored: true }, 200, board);345 assert.equal(juice.frame(200).positions.get('fox').scaleX, 1);346});347348test('an actor pushed and then self moved faces its autonomous direction', () => {349 const juice = createJuice({ now: () => 0 });350 juice.syncState(state([object('fox', 2, 1)]), 0);351 juice.consume({352 kind: 'step', accepted: true, rulesAdded: [], rulesRemoved: [], destroyed: [],353 moved: [{ id: 'fox', from: { x: 2, y: 1 }, to: { x: 2, y: 1 }, pushedBy: 'player', selfDirection: { dx: -1, dy: 0 } }],354 }, 0);355 assert.equal(juice.frame(0).positions.get('fox').facing, 'left');356});357358test('automaton movement follows the player beat and phase slides, then rewinds on undo', () => {359 const juice = createJuice({ now: () => 0 });360 const before = { ...state([object('fox', 2, 1)]), automata: [{ id: 'clock', phase: 0, pattern: [0, 1, 2] }] };361 const after = { ...state([object('fox', 3, 1)]), automata: [{ id: 'clock', phase: 1, pattern: [0, 1, 2] }] };362 juice.resetLevel(before, 0);363 juice.consume({ kind: 'step', accepted: true, moved: [{ id: 'fox', from: { x: 2, y: 1 }, to: { x: 3, y: 1 }, pushedBy: null, cause: 'automaton' }], automata: after.automata, rulesAdded: [], rulesRemoved: [], destroyed: [] }, 0, after);364 assert.equal(juice.frame(90).positions.get('fox').x, 2);365 assert.equal(juice.frame(90).automata[0].progress, 0);366 assert.ok(juice.frame(180).positions.get('fox').x > 2);367 assert.ok(juice.frame(180).automata[0].progress > 0);368 juice.consume({ kind: 'undo', accepted: true, moved: [{ id: 'fox', from: { x: 3, y: 1 }, to: { x: 2, y: 1 }, pushedBy: null }], automata: before.automata, rulesAdded: [], rulesRemoved: [], destroyed: [] }, 300, before);369 assert.equal(juice.frame(300).automata[0].fromPhase, 1);370 assert.equal(juice.frame(300).automata[0].toPhase, 0);371});