/** * 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(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('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(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'); }); });