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
177 lines
8.8 KiB
TypeScript
177 lines
8.8 KiB
TypeScript
/**
|
|
* 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, 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 `phoenix.local`. 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('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, 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', lines: ['moved'], frame: { table: {} } }),
|
|
DWELL.switching,
|
|
);
|
|
});
|
|
|
|
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 `phoenix.local`:
|
|
* *"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');
|
|
});
|
|
});
|