/** * THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6. * * Driven against REAL steps from a real game rather than hand-built fixtures, because the properties * that matter are about what actual play produces: a bot's whole switching turn arriving in one * burst, and a backlog that is mostly bookkeeping. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { legalActions } from '../src/engine/legal.ts'; import type { GameConfig } from '../src/engine/state.ts'; import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts'; import { publicSnapshot } from '../src/sim/view.ts'; import { takeSteps } from '../src/sim/display-step.ts'; import type { DisplayStep } from '../src/sim/display-step.ts'; import { createStepQueue } from '../src/web/step-queue.ts'; import { DWELL } from '../src/sim/pacing.ts'; const config: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false, }, }; /** * Plays a real game and returns its steps, preferring switch moves so a burst actually occurs. * * 400 moves, not 120: switching is not legal until there is track laid and a train in the district, * and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no * switching in it at all, which would make the pacing assertions here vacuous. */ function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType } { const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']); takeSteps(game.display); const steps: DisplayStep[] = []; for (let i = 0; i < moves; i++) { const actor = currentActor(game); if (actor === null) break; const options = legalActions(game.state, actor); if (options.length === 0) break; const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end'); if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break; steps.push(...takeSteps(game.display)); } return { steps, final: publicSnapshot(game.state) }; } /** The baseline a queue starts from, matching what a connect push carries. */ function baseline(seed: number): ReturnType { const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']); return publicSnapshot(game.state); } describe('the step queue', () => { it('shows the whole burst in order and lands on the real board', () => { const { steps, final } = realSteps(1917398, 400); assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps); // Run a clock forward until it settles, in 50ms ticks like a render loop would. let now = 0; for (let i = 0; i < 20_000 && q.busy(); i++) { q.advance(now); now += 50; } assert.equal(q.busy(), false, 'the queue never drained'); assert.deepEqual(q.current(), final, 'the animated board did not land on the real one'); assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step'); }); it('a burst of switching takes real time, and bookkeeping takes none', () => { const { steps } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); // Only the bookkeeping: it must all collapse into a single advance. // `.end` only: `localOps.choose` became an announcement worth watching after the first real play. const bookkeeping = steps.filter((s) => s.cause.endsWith('.end')); assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse'); q.push(bookkeeping); q.advance(0); q.advance(0); assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`); // And switching: each one must hold the screen. const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end'); assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`); const q2 = createStepQueue(); q2.reset(baseline(1917398)); q2.push(switching.slice(0, 6)); q2.advance(0); assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch'); q2.advance(DWELL.switching - 1); assert.equal(q2.behind(), 5, 'a switching move must not be replaced early'); q2.advance(DWELL.switching); assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up'); }); it('counts only what will be watched, so the countdown is steady', () => { // The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number // that collapses the instant it starts. const { steps } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps); const behind = q.behind(); assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`); q.advance(0); let ticks = 0; let previous = q.behind(); let now = 0; while (q.busy() && ticks++ < 20_000) { now += 50; q.advance(now); const nowBehind = q.behind(); assert.ok(nowBehind <= previous, 'the counter must never go up while draining'); previous = nowBehind; } assert.equal(q.behind(), 0); }); it('skip jumps to the real board without losing a single state on the way', () => { const { steps, final } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps); q.advance(0); assert.equal(q.skip(), true, 'there was a backlog to skip'); assert.equal(q.busy(), false); assert.equal(q.behind(), 0); // Skip applies every delta rather than jumping the chain, so the board is exact. assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in'); assert.equal(q.skip(), false, 'skipping an empty queue changes nothing'); }); it('pace 0 turns animation off entirely — TODO #18', () => { const { steps, final } = realSteps(1917398, 400); const q = createStepQueue(() => 0); q.reset(baseline(1917398)); q.push(steps); // One advance at a single instant must consume everything: nothing dwells at all. q.advance(0); q.advance(0); assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting'); assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated'); assert.deepEqual(q.current(), final); }); it('pace scales the wait without changing the order', () => { const { steps } = realSteps(1917398, 400); const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3); assert.equal(switching.length, 3); const half = createStepQueue(() => 0.5); half.reset(baseline(1917398)); half.push(switching); half.advance(0); half.advance(DWELL.switching / 2); assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step'); }); it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => { /** * REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was * shown the queue reported idle: the animation loop stopped and the district panel snapped back * to the viewer's own board without that step ever being looked at. Jesse, from the first real * play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was * done and it pointed back to my office area"*, and the countdown row appeared "very briefly". * * The panel follows `busy()`, so this is the property that keeps somebody else's board on screen * for as long as their move is being shown. */ const { steps } = realSteps(1917398, 400); const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1); assert.equal(one.length, 1); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(one); q.advance(0); assert.equal(q.behind(), 0, 'nothing is queued behind it'); assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle'); q.advance(DWELL.switching - 1); assert.equal(q.busy(), true, 'still inside its dwell'); q.advance(DWELL.switching); assert.equal(q.busy(), false, 'and idle only once its moment has passed'); }); it("does not spend time replaying the viewer's own moves", () => { // A seated player's own board is drawn from their authoritative Frame, so they have already seen // their own click. Holding it delays the thing they wanted to watch — a bot's turn. const { steps } = realSteps(1917398, 400); const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3); assert.equal(mine.length, 3, 'need three of seat 0\'s own moves'); const asSeat0 = createStepQueue(() => 1, () => 0); asSeat0.reset(baseline(1917398)); asSeat0.push(mine); // Twice at the same instant: the first call shows the head of the burst, the second collapses the // zero-dwell run behind it. In the page that is two animation frames, ~16ms apart. asSeat0.advance(0); asSeat0.advance(0); assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all"); assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for'); // The same steps seen by somebody else are worth watching. const asSpectator = createStepQueue(() => 1, () => 1); asSpectator.reset(baseline(1917398)); asSpectator.push(mine); asSpectator.advance(0); assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing"); assert.equal(asSpectator.behind(), 2); }); it('a reset discards the backlog rather than merging it onto a new baseline', () => { /** * A reconnecting client holds steps whose deltas chain off a baseline the server has moved past. * Merging them onto the new one would draw a board that never existed — and `applyPublicDelta` * would throw the moment a "null means unchanged" field had nothing to merge onto. */ const { steps, final } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps.slice(0, 10)); q.advance(0); assert.ok(q.busy()); q.reset(final); assert.equal(q.busy(), false, 'a reset must empty the queue'); assert.equal(q.behind(), 0); assert.deepEqual(q.current(), final); // And the caption survives: a reconnect should not blank the "what just happened" line. assert.ok(q.showing() !== null, 'the caption should survive a reset'); }); it('draws nothing before a reset has arrived', () => { const q = createStepQueue(); assert.equal(q.current(), null); assert.equal(q.advance(0), false); assert.equal(q.behind(), 0); assert.equal(q.showing(), null); }); });