v0.8.0.1 — bot play was way too fast, and the last step never got its moment

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
This commit is contained in:
Jesse.Markowitz
2026-09-09 17:20:18 -04:00
co-authored by Claude Opus 5
parent 02289e94b8
commit 0cfeb4c496
7 changed files with 258 additions and 22 deletions
+57 -5
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DWELL, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
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';
@@ -50,7 +50,13 @@ describe('pacing — dwell by kind', () => {
assert.equal(kindOf('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.end'), 'bookkeeping');
assert.equal(kindOf('localOps.choose'), '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');
@@ -63,6 +69,30 @@ describe('pacing — dwell by kind', () => {
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);
@@ -111,14 +141,36 @@ describe('pacing — dwell by kind', () => {
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 and the arithmetic the design promised: ~6s to watch a whole exercise.
// 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, 6000);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch');
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');
});
});