Files
station-master/test/step-queue.test.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

410 lines
18 KiB
TypeScript

/**
* 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<typeof publicSnapshot> } {
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<typeof publicSnapshot> {
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');
});
});