Two things from the first real play on phoenix.local. One bug: busy() was pending.length > 0, so the final step of a burst reported the queue idle the instant it was shown — the district panel snapped back to the viewer's own board and the countdown row vanished before either could be read. And calibration. "Start at 1s and tune down" was applied to switching, while a 250ms action tier was invented beside it — fine for a switching burst, wrong for the common case, since switching is not legal until there is track down. A real early-game bot turn measured 750ms end to end. Actions are 700ms now, and localOps.choose moved out of bookkeeping: it is the line announcing what a bot is about to do, and at zero dwell nobody ever saw it. The viewer's own moves now cost nothing — their board comes from their own Frame, so holding their click only delayed the thing they wanted to watch. And pace supports 2 and 3 as asked, bounded by MAX_PACE so a typo cannot look like a frozen board; every tier scales together, so the weighting survives any speed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
153 lines
6.5 KiB
TypeScript
153 lines
6.5 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 } 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;
|
|
/** True while there is anything left to show. */
|
|
busy(): boolean;
|
|
};
|
|
|
|
/**
|
|
* `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 pending: DisplayStep[] = [];
|
|
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
|
let dueAt: 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 => {
|
|
shown = applyPublicDelta(shown, step.frame);
|
|
last = step;
|
|
};
|
|
|
|
return {
|
|
reset(frame) {
|
|
shown = frame;
|
|
pending = [];
|
|
dueAt = null;
|
|
// `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) {
|
|
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() {
|
|
if (pending.length === 0) return false;
|
|
for (const step of pending) show(step);
|
|
pending = [];
|
|
dueAt = null;
|
|
return true;
|
|
},
|
|
|
|
current: () => shown,
|
|
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
|
showing: () => last,
|
|
/**
|
|
* 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".
|
|
*/
|
|
busy: () => pending.length > 0 || dueAt !== null,
|
|
};
|
|
}
|