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
262 lines
11 KiB
TypeScript
262 lines
11 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 { 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 `phoenix.local`: *"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('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);
|
|
});
|
|
});
|