/** * 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(12).fill('draw.end'), ...Array(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(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'); }); });