Files
station-master/test/step-queue.test.ts
T
Jesse.MarkowitzandClaude Opus 5 3befc420da v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, three were decisions. Every save on the test server was replayed
against this build BEFORE release, which is how the cost of each rule was known
before it was chosen rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a
Passenger Facility, so the opening of every game was spent unable to work a
passenger and one arrival away from a collision. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. The deck follows the choice — starting on
Depots the four Depot upgrade cards are left out, because an upgrade must be to
the next tier and a Depot card at a table of Depots is a dead draw. How much
easier it is showed up as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one collision.

NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that
changes how a game is DEALT rather than how it plays, so replaying a save under
the wrong opening is a different railroad from intent one — silently, with no
error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the
resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
Custom, which is how the distinction was found.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was moved into
the free slot, and the arriving train was pushed in after it without anyone asking
again whether there was room — so the collision §8.3 calls for never happened. The
held train keeps priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the
pick-up restrictions are enforced afterwards in `check`, so a square the rails
reached and the card forbade was reachable, un-offered, and absent from the block
list with nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:07:21 -04:00

414 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,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
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');
});
});