Files
station-master/src/web/step-queue.ts
T
Jesse.MarkowitzandClaude Opus 5 9a9e50b3c6 v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can
work the train. The turn chart follows the animation queue, so being five behind
looks five behind across the whole screen rather than half of it. Pause sits
beside Skip and preserves the dwell a held step still owed. A one-render look at
another player's Office Area. The district summary counts the board being shown.
LIMITS is printed beneath its card instead of through its border. The Mainline
region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only
card that reads it. A passenger Modifier on a Whistle Post reports itself dormant
rather than claiming the facility "only receives". An automatic phase says what
the Division is doing instead of answering by negation. The version appears once
in the header rather than twice on every .s9pk. Save files carry the join code,
the Stage and the date.

The New Train phase, reviewed before being changed: the make-up panel now says
what the train STILL needs rather than only what its card calls for, explains
that a player adds one car before the round passes on, marks the train being
loaded on the Division map, and gives an addable car in the yard the same amber
every other clickable thing on the page wears.

Reasoning, measurements and the reports behind each are in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 16:06:01 -04:00

261 lines
12 KiB
TypeScript

/**
* 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<StepQueue, 'behind' | 'busy' | 'showing'>,
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,
};
}