/** * 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 { actorOnScreen, 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'); }); /** * PAUSE — Jesse, playtest 2026-09-16, asking for one beside Skip. * * The property that matters is not "it stops", which any flag gives you. It is that a hold COSTS * THE STEP NOTHING: a move paused half-way through its dwell has to resume with half a dwell left, * or pausing to look at something would punish you by throwing the rest of it away the moment you * let go. That is the whole assertion below, measured against `DWELL.switching` rather than a * hand-picked number so it follows the tuning table. */ it('pause holds the board where it is, and resume gives the step back the dwell it had left', () => { const { steps } = realSteps(1917398, 400); 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 q = createStepQueue(); q.reset(baseline(1917398)); q.push(switching.slice(0, 6)); q.advance(0); assert.equal(q.behind(), 5, 'the first is shown at once; five are still to watch'); // Held half-way through the first move's dwell, and left held for ten times that long. const half = DWELL.switching / 2; assert.equal(q.pause(half), true); assert.equal(q.paused(), true); const held = q.showing()?.seq; q.advance(half + 10_000); assert.equal(q.behind(), 5, 'a held queue consumed a step'); assert.equal(q.showing()?.seq, held, 'the board moved while it was supposed to be held'); assert.equal(q.busy(), true, 'a held queue must still read busy, or the render loop stops'); // Resumed, the move still owes exactly the half-dwell it had left — no more, and no less. const at = half + 10_000; assert.equal(q.resume(at), true); assert.equal(q.paused(), false); q.advance(at + half - 1); assert.equal(q.behind(), 5, 'the step was robbed of time it was owed while held'); q.advance(at + half); assert.equal(q.behind(), 4, 'and it never gave way once the rest of its dwell was up'); }); it('refuses to hold an idle queue, and Skip lifts a hold rather than leaving it stuck', () => { const q = createStepQueue(); q.reset(baseline(1917398)); assert.equal(q.pause(0), false, 'an idle queue has no playback to hold'); assert.equal(q.paused(), false); const { steps } = realSteps(1917398, 400); q.push(steps); q.advance(0); assert.equal(q.pause(10), true); assert.equal(q.skip(), true, 'Skip must still work while held'); assert.equal(q.paused(), false, 'Skip left the queue held, with a Resume that does nothing'); assert.equal(q.busy(), false); }); 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 the test server: *"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('can always be emptied, so a player is never stranded behind it', () => { /** * "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the * thing standing between a player and their own turn. So the ways it can be cleared matter more * than they did: `skip()` must always work, from any state, including one where the clock has * never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in, * and how this was found. */ const { steps, final } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps); // Never advanced at all: no frame has been shown, and the queue is full. assert.equal(q.busy(), true); assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable'); assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out'); assert.deepEqual(q.current(), final); }); 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); }); }); describe('whose move the screen is showing (Gitea#25)', () => { const step = (player: number | null) => ({ player }) as DisplayStep; const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({ behind: () => behind, busy: () => busy, showing: () => showing, }); it('names the live actor once the board has caught up', () => { assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false }); }); it('names the player of the step on screen while the board is behind — not the live actor', () => { // One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay. assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true }); }); it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => { assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true }); }); it('names nobody for an automatic phase being shown', () => { assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true }); }); it('falls back to the live actor before any step has been shown', () => { assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false }); }); }); describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => { it('owes exactly the lines of the steps not yet shown', () => { const { steps } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back'); q.push(steps); const owed = steps.reduce((n, s) => n + s.lines.length, 0); assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines'); // Drive the clock as a render loop would; the debt falls monotonically and ends at nothing. let now = 0; let last = owed; for (let i = 0; i < 20_000 && q.busy(); i++) { q.advance(now); const left = q.pendingLines(); assert.ok(left <= last, 'the held-back count grew while the board caught up'); last = left; now += 50; } assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld'); }); it('skipping reveals the whole log at once', () => { const { steps } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps); q.skip(); assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short'); }); it('flashes nothing on an ordinary step', () => { // Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random. const { steps } = realSteps(1917398, 400); const q = createStepQueue(); q.reset(baseline(1917398)); q.push(steps.slice(0, 5)); q.advance(0); assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one'); }); });