Files
station-master/test/step-queue.test.ts
T
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00

355 lines
15 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');
});
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');
});
});