/** * THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6. * * Holds the public board the screen is currently showing, which is not always the board the game is * actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in * ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a * burst into something watchable. TODO #13. * * TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from * the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are * never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here * imports the DOM either, so it is testable without one. * * NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has * — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need * starting, stopping and cleaning up on every game replacement, and would be untestable without * faking timers. */ import type { PublicFrame } from '../sim/view.ts'; import type { DisplayStep } from '../sim/display-step.ts'; import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts'; import type { PileKey } from '../sim/public-delta.ts'; import { dwellForStep } from '../sim/pacing.ts'; export type StepQueue = { /** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */ reset(frame: PublicFrame): void; /** Queue steps to be shown in order. */ push(steps: readonly DisplayStep[]): void; /** * Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip * a redraw when nothing did. */ advance(now: number): boolean; /** Show everything immediately. Returns true if anything was skipped. */ skip(): boolean; /** The board to draw, or null before any reset has arrived. */ current(): PublicFrame | null; /** * How many queued steps the player is still going to WATCH — the number the "N behind" counter * shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`. */ behind(): number; /** The last step actually shown, for the caption line (#15). Null before anything has been shown. */ showing(): DisplayStep | null; /** * The piles the step now on screen moved, for the display to light. * * Here because this is the only place that holds both the frame before a step and the frame after * it — deriving it anywhere else would mean keeping a second copy of the board in step. */ lit(): readonly PileKey[]; /** True while there is anything left to show. */ busy(): boolean; /** * How many narrated lines belong to steps NOT yet shown. * * The log and the board are two different moments while the queue is behind: a push carries its * narration and its steps together, so every line of a bot's turn is in the history panel before the * board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it * stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last — * so the caller holds back exactly this many and reveals each as its step goes up. */ pendingLines(): number; /** Division nodes whose card changed in the step now on screen, for the map to flash. */ flashing(): readonly number[]; /** * HOLD THE PLAYBACK ON THE STEP NOW SHOWING — Jesse, playtest 2026-09-16, asking for a pause * beside Skip. * * Skip is the only control the row has had, and it is one-way and total: the way to look harder at * a move that just went past was to not be too slow about it. Pause is the opposite lever — the * board stops where it is and nothing is consumed, so a player can read the caption, look at the * district and then carry on from exactly that step. * * TAKES `now` BECAUSE THE QUEUE OWNS NO CLOCK (see the note at the top of this file). The dwell * still owing is preserved across the hold rather than being spent while nobody was watching: * `resume` pushes the deadline out by however long the pause lasted, so a step paused with 200ms * left resumes with 200ms left instead of vanishing on the next frame. * * Returns false when there is nothing to hold, or nothing being held. */ pause(now: number): boolean; resume(now: number): boolean; paused(): boolean; }; /** * WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25). * * The game and the board on screen are two different moments. The server plays every bot move the * instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human — * while this queue is still replaying the bots, step by step. The turn chart and the Division map's * move marker read the live actor, so a table of one person and three bots said "waiting on" that * person throughout, against a playback row naming the bot actually moving. * * While the queue is behind or still showing a step, the answer is that step's player — `null` for an * automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and * `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on, * off a screen that has not caught up with it yet. */ export function actorOnScreen( queue: Pick, live: number | null, ): { actor: number | null; replaying: boolean } { if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false }; const shown = queue.showing(); return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true }; } /** * `pace` is read on every step rather than captured, so changing the setting takes effect at once. * * `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on * screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue, * so holding their click for a dwell shows them nothing and delays the thing they actually want to * watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed * at them. The step is still APPLIED, because the delta chain runs through it. * * Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in * solitaire where every intent is the viewer's own. */ export function createStepQueue( pace: () => number = () => 1, viewer: () => number | null = () => null, ): StepQueue { let shown: PublicFrame | null = null; let last: DisplayStep | null = null; let litPiles: readonly PileKey[] = []; let flashedCards: readonly number[] = []; let pending: DisplayStep[] = []; /** When the step now on screen is due to give way. Null when nothing is waiting. */ let dueAt: number | null = null; /** When the player pressed Pause, so `resume` can give the current step back the time it had. */ let pausedAt: number | null = null; /** How long this step holds the screen — zero for the viewer's own moves; see above. */ const dwell = (step: DisplayStep): number => step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace()); /** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */ const show = (step: DisplayStep): void => { const before = shown; shown = applyPublicDelta(shown, step.frame); last = step; /** * NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule * that gives your own steps no dwell — the display is for watching everybody else. */ litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown); // A Realignment changes the Division under everyone, so it is flashed for the player who did it // too — unlike a pile, which only tells the drawer what they already know. flashedCards = changedDivisionCards(before, shown); }; return { reset(frame) { shown = frame; pending = []; dueAt = null; // A reconnect, an undo or a fresh deal replaces the board outright; a hold on the playback that // no longer exists would leave the row stuck reading "paused" with nothing behind it. pausedAt = null; // Nothing was watched arriving at this board, so nothing on it is lit. litPiles = []; flashedCards = []; // `last` deliberately survives: a reconnect should not blank the caption line, and the // sentence describing the most recent action is still true. }, push(steps) { pending.push(...steps); }, advance(now) { // Held. Nothing is shown and, crucially, nothing is CONSUMED — `dueAt` is left where it was // and `resume` moves it, so the hold costs the current step none of its dwell. if (pausedAt !== null) return false; if (pending.length === 0) { // The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue // idle the instant that step was shown, which snapped the district panel home before anyone // could look at it — see `busy()`. if (dueAt !== null && now >= dueAt) dueAt = null; return false; } // First step of a burst: show it immediately rather than waiting out a dwell for a board the // player has not been shown yet. if (dueAt === null) { const first = pending.shift()!; show(first); dueAt = now + dwell(first); return true; } let drew = false; /** * A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on * this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must * collapse within one call instead of costing a frame each. The board still passes through * every state in order; nobody is shown a state that never existed. */ while (pending.length > 0 && now >= dueAt) { const next = pending.shift()!; show(next); dueAt = dueAt + dwell(next); drew = true; } if (pending.length === 0 && now >= dueAt) dueAt = null; return drew; }, skip() { // Skipping while held is a decision to stop watching, so it also lifts the hold — otherwise the // board would jump to the game and then sit there paused, with a Resume button that does nothing. pausedAt = null; if (pending.length === 0) return false; for (const step of pending) show(step); pending = []; dueAt = null; return true; }, pause(now) { if (pausedAt !== null) return false; // Nothing on screen owes any time and nothing is queued: there is no playback to hold. if (pending.length === 0 && dueAt === null) return false; pausedAt = now; return true; }, resume(now) { if (pausedAt === null) return false; // Give the step on screen back exactly the dwell it was holding when the player pressed Pause. if (dueAt !== null) dueAt += now - pausedAt; pausedAt = null; return true; }, paused: () => pausedAt !== null, current: () => shown, behind: () => pending.filter((s) => dwell(s) > 0).length, showing: () => last, lit: () => litPiles, /** * STILL SHOWING SOMETHING, not just still holding something back. * * This was `pending.length > 0`, which went false the moment the last step of a burst was * shown — so the animation loop stopped and the district panel snapped back to the viewer's own * board without that step ever being visible. Reported from real play: "I briefly saw that it was * the bot's office area, then their turn was done and it pointed back to my office area." * * `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * "there is more to come, or what is up has not had its moment yet". */ pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0), flashing: () => flashedCards, busy: () => pending.length > 0 || dueAt !== null, }; }