v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
co-authored by
Claude Opus 5
parent
312e0301e0
commit
02289e94b8
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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.
|
||||
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose');
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user