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
245 lines
13 KiB
TypeScript
245 lines
13 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');
|
||
/**
|
||
* 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');
|
||
});
|
||
});
|