diff --git a/CHANGELOG.md b/CHANGELOG.md index 03736bb..b2566ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,60 @@ page as `v0.1.0 · · `, 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 **Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated diff --git a/package.json b/package.json index b2f4583..33f227a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0", + "version": "0.8.0.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/sim/pacing.ts b/src/sim/pacing.ts index 70b8400..7545434 100644 --- a/src/sim/pacing.ts +++ b/src/sim/pacing.ts @@ -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 * (`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 - * 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 * `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 = { /** A train physically moving on the board. The thing worth watching, and protected accordingly. */ 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. * @@ -107,9 +118,17 @@ export function kindOf(cause: StepCause): StepKind { case 'redFlag.play': 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': + 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 'draw.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 { - return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace)); + return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace))); } /** diff --git a/src/web/main.ts b/src/web/main.ts index 73c82ae..440fc10 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 * 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. diff --git a/src/web/step-queue.ts b/src/web/step-queue.ts index b837dbf..0fcefae 100644 --- a/src/web/step-queue.ts +++ b/src/web/step-queue.ts @@ -47,14 +47,32 @@ export type StepQueue = { 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 last: DisplayStep | null = null; let pending: DisplayStep[] = []; /** When the step now on screen is due to give way. Null when nothing is waiting. */ 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. */ const show = (step: DisplayStep): void => { shown = applyPublicDelta(shown, step.frame); @@ -76,7 +94,10 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue { advance(now) { 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; } // 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) { const first = pending.shift()!; show(first); - dueAt = now + dwellForStep(first, pace()); + dueAt = now + dwell(first); return true; } let drew = false; @@ -97,7 +118,7 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue { while (pending.length > 0 && now >= dueAt) { const next = pending.shift()!; show(next); - dueAt = dueAt + dwellForStep(next, pace()); + dueAt = dueAt + dwell(next); drew = true; } if (pending.length === 0 && now >= dueAt) dueAt = null; @@ -113,8 +134,19 @@ export function createStepQueue(pace: () => number = () => 1): StepQueue { }, current: () => shown, - behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length, + behind: () => pending.filter((s) => dwell(s) > 0).length, 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, }; } diff --git a/test/pacing.test.ts b/test/pacing.test.ts index 74ae6d6..2c975cf 100644 --- a/test/pacing.test.ts +++ b/test/pacing.test.ts @@ -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(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'); }); }); diff --git a/test/step-queue.test.ts b/test/step-queue.test.ts index 4d64b2a..18c8695 100644 --- a/test/step-queue.test.ts +++ b/test/step-queue.test.ts @@ -87,7 +87,8 @@ describe('the step queue', () => { q.reset(baseline(1917398)); // 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'); q.push(bookkeeping); 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'); }); + 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', () => { /** * A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.