Both repositories allow anonymous clone — checked rather than assumed: info/refs for git-upload-pack answers 200 for each, git-receive-pack answers 401. So everything committed here is public, and tracked files are supposed to carry placeholders rather than real hosts. Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware", across CHANGELOG.md, the common-board plan's three deferral banners, sim/pacing.ts, and two test files. Prose and comments only, no behaviour; the quotes are untouched, because what was said about bot pacing is the part worth keeping. Left alone deliberately: nineteen older mentions in entries about v0.7.5, v0.7.6 and v0.7.8 and in TODO.md, since rewriting a changelog after the fact makes the record less true; and scripts/deploy-web.ts, where the host is the functional default for FB_URL rather than prose — turning that into a required variable changes how deploying works and wants deciding on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
221 lines
12 KiB
TypeScript
221 lines
12 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, 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');
|
||
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 other people, not the clock', () => {
|
||
/**
|
||
* Jesse, from a real 5× game: *"after my turn, when I actually execute my turn, I'm still subject
|
||
* to that same delay before it moves on. That makes no sense."* It was not his move being
|
||
* replayed — it was the automatic phases behind it, which were scaling with `pace` along with
|
||
* everything else. Measured over 40 turns, the waiting split almost evenly between other players
|
||
* and phases turning over, so a 5× game spent 105 seconds on the clock alone.
|
||
*/
|
||
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: {} } };
|
||
for (const pace of [1, 3, 5, 7]) {
|
||
assert.equal(dwellForStep(phase, pace), DWELL.phase, `a phase beat grew at ${pace}x`);
|
||
assert.equal(dwellForStep(theirs, pace), DWELL.switching * pace);
|
||
}
|
||
// Off still means off, for the clock as much as for anybody's move.
|
||
assert.equal(dwellForStep(phase, 0), 0);
|
||
assert.equal(dwellForStep(theirs, 0), 0);
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|