Files
station-master/test/pacing.test.ts
T
Jesse.MarkowitzandClaude Opus 5 d0e5091824 v0.8.0.7 — the Salvage Yard had nothing to say, and phases too little time to read
The Salvage Yard was face up all along; its tile just read "a card". apply.ts
pushes a synthetic train-<n> id on trainScheduled, nothing in s.cards matches it,
and cardName fell through to its default — and since a train is scheduled several
times a Day that id is on top most of the time. Measured before touching anything:
the tile read "a card" from the opening frame through 60 pushes while its depth
climbed from 2 to 8. cardName resolves it now, in sim/view.ts, because this is a
name.

The engine half is filed as Gitea#23 rather than fixed here. reshuffleIfDepleted
sweeps the Salvage Yard back into the draw deck, so that synthetic id can be
shuffled in and drawn into a hand as an id with no card behind it. Eight games
driven to 4000 moves across eight seeds produced zero reshuffles, so it is latent;
there are two defensible fixes and the choice turns on what the synthetic id is
for, which is not a call to make while fixing a label.

And phases scale with the speed control again, damped to a third of the rate. They
were pinned in v0.8.0.3 because scaling them walled off a player's own turn; pinned
turns out to be too short to read at 10x. Damped satisfies both: 1x unchanged, 10x
lands exactly on the four-times guess. Bounded because phase beats cluster rather
than accumulate — 1.0 per push on average, 4 at worst, so the wait after a move is
~2.4s typical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-10 06:58:09 -04:00

245 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
*
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
* intent union out of the source, so the guard survives someone later adding a `default:` that
* would swallow the very thing the exhaustiveness was protecting.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
import type { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
function declaredIntents(): string[] {
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
}
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
describe('pacing — dwell by kind', () => {
it('classifies every intent the engine declares', () => {
const declared = declaredIntents();
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
for (const intent of declared) {
const kind = kindOf(intent as Intent['type']);
assert.ok(
KINDS.includes(kind),
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
);
}
});
it('protects switching and collapses bookkeeping', () => {
// The two ends of the measured argument: a switching move is the thing worth watching, and
// `*.end` bookkeeping is over half of a real game's intents.
assert.equal(kindOf('switch.move'), 'switching');
assert.equal(kindOf('switch.dropCars'), 'switching');
assert.equal(kindOf('switch.sortConsist'), 'switching');
assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping');
/**
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
* to do.
*/
assert.equal(kindOf('localOps.choose'), 'action');
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
});
it('starts switching at a full second, per the 2026-09-09 decision', () => {
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
// a drift, and so the number in the plan and the number in the code cannot disagree.
assert.equal(DWELL.switching, 1000);
assert.equal(dwellFor('switch.move'), 1000);
});
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
/**
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
* relative weighting is the design and the multiplier is only how fast it runs.
*/
for (const pace of [0.5, 1, 1.5, 2, 3]) {
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
assert.ok(
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
`at ${pace}x a switching move no longer outlasts an ordinary action`,
);
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
}
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
});
it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500);
assert.equal(dwellFor('switch.move', 2), 2000);
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
for (const intent of declaredIntents()) {
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
}
// A negative pace is a corrupt preference, not a request to run time backwards.
assert.equal(dwellFor('switch.move', -3), 0);
});
it('counts only the steps a player will actually watch', () => {
/**
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
* whether to press Skip.
*/
const queue: Intent['type'][] = [
...Array<Intent['type']>(12).fill('draw.end'),
...Array<Intent['type']>(5).fill('switch.move'),
];
assert.equal(queue.length, 17);
assert.equal(watchableCount(queue), 5);
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
});
it('offers speeds a player actually reached for, and none the code would clamp', () => {
/**
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
* this ladder — which must reach the speeds people ask for and must not offer one that
* `dwellFor` would silently clamp.
*/
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
/**
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
* somebody actually reached for and found insufficient.
*/
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
for (const p of PACE_LEVELS) {
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
}
// Strictly increasing, so stepping the control always changes the speed.
for (let i = 1; i < PACE_LEVELS.length; i++) {
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
}
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
// the ladder is a full minute, which is the "watch them struggle" case.
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
});
it('a silent step beats only when the clock turns over — TODO #18', () => {
/**
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
* times per intent — which came to a quarter of an hour a game.
*/
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
// Narration always earns the dwell of whatever caused it, clock or no clock.
assert.equal(
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
DWELL.switching,
);
});
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
/**
* Two complaints, one from each direction, and the answer is between them.
*
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
* player's own turn. So they were pinned at their tabled beat.
*
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
*
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
* the cost stays bounded because phase beats cluster rather than accumulate.
*/
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
const p = dwellForStep(phase, pace);
const t = dwellForStep(theirs, pace);
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
assert.ok(
p < DWELL.phase * pace,
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
);
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
}
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
// follows the multiplier straight, because "faster" should mean everything.
assert.equal(dwellForStep(phase, 0), 0);
assert.equal(dwellForStep(theirs, 0), 0);
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
});
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
// case for one crew: the announcement, six moves, and an end that shows nothing.
const turn: Intent['type'][] = [
'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'),
'switch.end',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, DWELL.action + 6 * DWELL.switching);
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
});
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
/**
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
* was done."* This is the shape that turn actually had — no switching in it at all, because
* switching is not legal until there is track down — and under the original values it came to
* 750ms for the whole thing.
*/
const turn: Intent['type'][] = [
'localOps.choose',
'draw.fromHomeOffice',
'card.play',
'draw.end',
'localOps.choose',
'freightAgent.stockOutbound',
];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
});
});