Compare commits

...
1 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 0cfeb4c496 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
2026-09-09 17:20:18 -04:00
7 changed files with 258 additions and 22 deletions
+54
View File
@@ -19,6 +19,60 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.8.0.1 — 2026-09-09
**Bot play was way too fast.** v0.8.0 was installed on `phoenix.local` and played within the hour;
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
### The bug: the last step of a burst never got its moment
`busy()` was `pending.length > 0`. So the instant the FINAL step of a burst was shown, the queue
reported itself idle — the animation loop stopped and, because the district panel follows `busy()`,
it snapped back to the viewer's own board without that step ever being looked at. The countdown row
went with it. `busy()` is now `pending.length > 0 || dueAt !== null`: there is more to come, **or**
what is on screen has not had its moment yet.
### The calibration: 250ms was invented, and it was wrong
Jesse's instruction had been "start at 1s and tune down". That was applied to switching and then a
250ms `action` tier was made up beside it, which held for the case the design was measured against —
a switching burst — and failed the common one. **Switching is not legal until there is track down**,
so an early-game bot turn contains none of it. Measured from a real 3-seat game, one bot turn was:
```
localOps.choose 0ms · draw.fromHomeOffice 250ms · card.play 250ms
draw.end 0ms · localOps.choose 0ms · freightAgent.stockOutbound 250ms
```
**750ms for a whole turn.** `action` is now 700ms, which puts that same turn at 4.7s.
**And `localOps.choose` was the worst of it.** It was classed as bookkeeping, at zero — but it is the
line reading *"Player Bot 1 chose to SWITCH — six Moves to shunt cars around the yard"*: the heading
for everything that follows. A bot's turn began with no indication of what it was about to do. It is
an announcement, and it is now in `action`.
### The viewer's own moves cost nothing
Raising `action` exposed a waste: your own click was being held for 700ms before the bots' turn
started animating. A seated player's own board is drawn from their authoritative `Frame`, never from
the queue, so replaying their own move shows them nothing and delays the thing they wanted to watch.
Own steps are still applied — the delta chain runs through them — but at zero dwell. Automatic phases
have no player and are unaffected, which is what keeps #18 working in solitaire, where every intent
is the viewer's own.
### Faster and slower, without a rebuild
`pace` multipliers above 1 are supported and expected — Jesse asked for 2 and 3 — bounded by a new
`MAX_PACE` of 10 so that `?pace=300` from somebody meaning 3.00 cannot look like a frozen board.
Every tier scales by the same factor, so **a switching move outlasts an ordinary action at 0.5× and
at 3× alike**: the relative weighting is the design, and the multiplier is only how fast it runs.
Whole-game animation is now ~5.7 minutes across a 6-day game.
---
## 0.8.0 — 2026-09-09 ## 0.8.0 — 2026-09-09
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated **Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0", "version": "0.8.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+43 -7
View File
@@ -37,7 +37,9 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier * it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and * (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a * `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
* hundred times will want it off". * hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
* their relative weighting survives.
* *
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and * NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this * `config` rides along in saves and replays. If it ever moves there, the config field supplies this
@@ -46,8 +48,17 @@ export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
export const DWELL: Record<StepKind, number> = { export const DWELL: Record<StepKind, number> = {
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */ /** A train physically moving on the board. The thing worth watching, and protected accordingly. */
switching: 1000, switching: 1000,
/** A card, a car or a load changing hands somewhere visible. */ /**
action: 250, * A card, a car or a load changing hands somewhere visible — and the announcement of what a
* player is about to do.
*
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
* Jesse, from the first real play 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."* His instruction had been "start at
* 1s and tune down", and that was applied only to switching while this number was invented.
*/
action: 700,
/** /**
* An automatic phase that DID something — TODO #18. * An automatic phase that DID something — TODO #18.
* *
@@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind {
case 'redFlag.play': case 'redFlag.play':
return 'action'; return 'action';
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching; /**
// the declaration itself is not, and there are more of these than of anything else. * `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
* it, so a bot's turn began with no indication of what it was about to do.
*/
case 'localOps.choose': case 'localOps.choose':
return 'action';
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
// there are more of these than of anything else.
case 'loadUnload.end': case 'loadUnload.end':
case 'draw.end': case 'draw.end':
case 'switch.end': case 'switch.end':
@@ -119,9 +138,26 @@ export function kindOf(cause: StepCause): StepKind {
} }
} }
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */ /**
* The widest multiplier that is a speed rather than a mistake.
*
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
* dwell and look exactly like a frozen board. Ten is far beyond any speed anyone would choose (2 and
* 3 are the ones actually asked for) and well short of unusable.
*/
export const MAX_PACE = 10;
/**
* How long to show one step, in ms, at a given speed.
*
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
* relative weighting is the design (a train moving is worth more attention than a card changing
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
*/
export function dwellFor(cause: StepCause, pace = 1): number { export function dwellFor(cause: StepCause, pace = 1): number {
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace)); return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
} }
/** /**
+6 -1
View File
@@ -165,7 +165,12 @@ let session: Session;
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session * on the next step instead of the next game. `?pace=` wins over the saved setting for this session
* only. * only.
*/ */
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace); const stepQueue = createStepQueue(
() => PACE_OVERRIDE ?? settings.pace,
// Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when
// a game starts, long after this queue is built.
() => (session ? session.seat() : null),
);
/** /**
* Pulls whatever the session has for us into the queue. Called on every push, before rendering. * Pulls whatever the session has for us into the queue. Called on every push, before rendering.
+39 -7
View File
@@ -47,14 +47,32 @@ export type StepQueue = {
busy(): boolean; busy(): boolean;
}; };
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */ /**
export function createStepQueue(pace: () => number = () => 1): StepQueue { * `pace` is read on every step rather than captured, so changing the setting takes effect at once.
*
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
* at them. The step is still APPLIED, because the delta chain runs through it.
*
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
* solitaire where every intent is the viewer's own.
*/
export function createStepQueue(
pace: () => number = () => 1,
viewer: () => number | null = () => null,
): StepQueue {
let shown: PublicFrame | null = null; let shown: PublicFrame | null = null;
let last: DisplayStep | null = null; let last: DisplayStep | null = null;
let pending: DisplayStep[] = []; let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */ /** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null; let dueAt: number | null = null;
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
const dwell = (step: DisplayStep): number =>
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */ /** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => { const show = (step: DisplayStep): void => {
shown = applyPublicDelta(shown, step.frame); shown = applyPublicDelta(shown, step.frame);
@@ -76,7 +94,10 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
advance(now) { advance(now) {
if (pending.length === 0) { if (pending.length === 0) {
dueAt = null; // The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
// idle the instant that step was shown, which snapped the district panel home before anyone
// could look at it — see `busy()`.
if (dueAt !== null && now >= dueAt) dueAt = null;
return false; return false;
} }
// First step of a burst: show it immediately rather than waiting out a dwell for a board the // First step of a burst: show it immediately rather than waiting out a dwell for a board the
@@ -84,7 +105,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
if (dueAt === null) { if (dueAt === null) {
const first = pending.shift()!; const first = pending.shift()!;
show(first); show(first);
dueAt = now + dwellForStep(first, pace()); dueAt = now + dwell(first);
return true; return true;
} }
let drew = false; let drew = false;
@@ -97,7 +118,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
while (pending.length > 0 && now >= dueAt) { while (pending.length > 0 && now >= dueAt) {
const next = pending.shift()!; const next = pending.shift()!;
show(next); show(next);
dueAt = dueAt + dwellForStep(next, pace()); dueAt = dueAt + dwell(next);
drew = true; drew = true;
} }
if (pending.length === 0 && now >= dueAt) dueAt = null; if (pending.length === 0 && now >= dueAt) dueAt = null;
@@ -113,8 +134,19 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue {
}, },
current: () => shown, current: () => shown,
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length, behind: () => pending.filter((s) => dwell(s) > 0).length,
showing: () => last, showing: () => last,
busy: () => pending.length > 0, /**
* STILL SHOWING SOMETHING, not just still holding something back.
*
* This was `pending.length > 0`, which went false the moment the last step of a burst was
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
* the bot's office area, then their turn was done and it pointed back to my office area."
*
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
* "there is more to come, or what is up has not had its moment yet".
*/
busy: () => pending.length > 0 || dueAt !== null,
}; };
} }
+57 -5
View File
@@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { StepKind } from '../src/sim/pacing.ts';
import type { Intent } from '../src/engine/intents.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('draw.end'), 'bookkeeping');
assert.equal(kindOf('loadUnload.end'), 'bookkeeping'); assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
assert.equal(kindOf('switch.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.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'); 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); 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', () => { it('scales with the viewer\'s pace, and 0 turns it off', () => {
assert.equal(dwellFor('switch.move', 1), 1000); assert.equal(dwellFor('switch.move', 1), 1000);
assert.equal(dwellFor('switch.move', 0.5), 500); 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', () => { 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 // 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'][] = [ const turn: Intent['type'][] = [
'localOps.choose', 'localOps.choose',
...Array<Intent['type']>(6).fill('switch.move'), ...Array<Intent['type']>(6).fill('switch.move'),
'switch.end', 'switch.end',
]; ];
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0); const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
assert.equal(total, 6000); assert.equal(total, DWELL.action + 6 * DWELL.switching);
assert.equal(watchableCount(turn), 6, 'the choose and the end are not things to watch'); 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');
}); });
}); });
+58 -1
View File
@@ -87,7 +87,8 @@ describe('the step queue', () => {
q.reset(baseline(1917398)); q.reset(baseline(1917398));
// Only the bookkeeping: it must all collapse into a single advance. // Only the bookkeeping: it must all collapse into a single advance.
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end') || s.cause === 'localOps.choose'); // `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse'); assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
q.push(bookkeeping); q.push(bookkeeping);
q.advance(0); q.advance(0);
@@ -173,6 +174,62 @@ describe('the step queue', () => {
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step'); assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
}); });
it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
/**
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
* play on `phoenix.local`: *"I briefly saw that it was the bot's office area then their turn was
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
*
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
* for as long as their move is being shown.
*/
const { steps } = realSteps(1917398, 400);
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
assert.equal(one.length, 1);
const q = createStepQueue();
q.reset(baseline(1917398));
q.push(one);
q.advance(0);
assert.equal(q.behind(), 0, 'nothing is queued behind it');
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
q.advance(DWELL.switching - 1);
assert.equal(q.busy(), true, 'still inside its dwell');
q.advance(DWELL.switching);
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
});
it("does not spend time replaying the viewer's own moves", () => {
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
const { steps } = realSteps(1917398, 400);
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
const asSeat0 = createStepQueue(() => 1, () => 0);
asSeat0.reset(baseline(1917398));
asSeat0.push(mine);
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
asSeat0.advance(0);
asSeat0.advance(0);
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
// The same steps seen by somebody else are worth watching.
const asSpectator = createStepQueue(() => 1, () => 1);
asSpectator.reset(baseline(1917398));
asSpectator.push(mine);
asSpectator.advance(0);
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
assert.equal(asSpectator.behind(), 2);
});
it('a reset discards the backlog rather than merging it onto a new baseline', () => { it('a reset discards the backlog rather than merging it onto a new baseline', () => {
/** /**
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past. * A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.