diff --git a/CHANGELOG.md b/CHANGELOG.md index 867e6e1..e0b2b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,49 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.8.0.3 — 2026-09-09 + +Three things from playing v0.8.0.2, all of them about the row rather than the mechanism. + +### Skip was at the wrong end of the row + +Jesse: *"the skip button should be on the far left, in front of where it says [the count], so it's +always close to where people are looking."* It was on the far right, and a player's eye is on the +countdown. Moved. + +### The caption said what, but never who + +*"I saw 2 behind, 1 behind, and then it was caught up, but it didn't tell me what the actual action +was, like who I was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I +was supposed to be looking for."* + +The caption was there. It was the wrong half of the sentence. **Measured over 40 turns of a real +3-seat game, half the waiting is automatic phases** — 21.0s of phases against 21.7s of other players — +and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on". +A phase now introduces itself: **"The Division: ▸ Mainline phase"**. A player's move already carries +its name from `record()`, so it is left alone rather than stuttering it twice. + +**And the row was hiding a step early.** It was shown only while `behind > 0` — which goes false the +moment the LAST step of a burst goes up, so the one step a player was most likely to be reading about +lost its caption. It now stays up while the queue is still showing something, and reads "catching up" +once nothing is queued behind. + +### The speed control was stretching the clock, not just the other players + +*"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. Since I've just done my turn, I don't need to wait after it."* + +He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It +was the automatic phases behind it, which were scaling with `pace` along with everything else. At 5× +that put **105 seconds of clock-ticking** into the game, all of it after a player's own move and none +of it anything to watch. + +**`pace` now scales a player's move and leaves a phase at its tabled beat.** The control is labelled +as how long another player's move is held, and that is now what it does. A phase still gets its beat +(TODO #18) and still vanishes entirely at `pace = 0`, because off has to mean off. + +--- + ## 0.8.0.2 — 2026-09-09 Two things found by playing v0.8.0.1 on `phoenix.local`, neither of them in the mechanism itself. diff --git a/package.json b/package.json index d1b0e99..2fe1050 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.2", + "version": "0.8.0.3", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/sim/pacing.ts b/src/sim/pacing.ts index 9766575..5e53307 100644 --- a/src/sim/pacing.ts +++ b/src/sim/pacing.ts @@ -182,10 +182,25 @@ export function dwellFor(cause: StepCause, pace = 1): number { * have to import `DisplayStep` back from the module that imports `StepCause` from it. */ export function dwellForStep( - step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } }, + step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } }, pace = 1, ): number { - if (step.lines.length > 0) return dwellFor(step.cause, pace); + // Off means off, for the clock as much as for anybody's move. + if (pace <= 0) return 0; + /** + * THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK. + * + * A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the + * waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so + * scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own + * move and none of it anything to watch. Jesse, from that 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."* + * + * The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted + * to watch a bot shunt cars. + */ + const speed = step.player === null ? 1 : pace; + if (step.lines.length > 0) return dwellFor(step.cause, speed); /** * A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give * every phase a visible beat", for New Train, the Mainline and the shift change. @@ -194,12 +209,11 @@ export function dwellForStep( * silently killed #18: a phase can move trains without saying anything, and those steps were being * flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps * `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on - * all of them would cost a quarter of an hour a game. The phase turning over is the thing a player - * is being shown, and there are about 180 of those in a full game. + * all of them would cost a quarter of an hour a game. */ const table = step.frame.table as Record; const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table; - return turned ? dwellFor(step.cause, pace) : 0; + return turned ? dwellFor(step.cause, speed) : 0; } /** diff --git a/src/web/main.ts b/src/web/main.ts index a86ccb9..7760a46 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -201,17 +201,23 @@ function drainIntoQueue(): void { * drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and * has never needed a private viewer. */ -function renderWatching(): void { +function renderWatching(f?: Frame): void { const behind = stepQueue.behind(); const row = $('watching'); - // Collapsed whenever the board is level with the game — which in solitaire is nearly always, and - // between turns in multiplayer too. A row that is always there would be a row nobody reads. - if (behind === 0) { + /** + * VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING. + * + * It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes + * up, so the one step a player was most likely to be reading about lost its caption. Collapsed + * otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row + * that is always there is a row nobody reads. + */ + if (behind === 0 && !stepQueue.busy()) { row.hidden = true; return; } row.hidden = false; - $('watching-behind').textContent = `${behind} behind`; + $('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`; /** * THE CAPTION IS #15, and this is where that item lands rather than as a line of its own. * @@ -220,8 +226,29 @@ function renderWatching(): void { * The queue IS that, so the caption simply names the step being shown, and the counter beside it * says how much of the wait is left. */ + /** + * WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I + * was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was + * supposed to be looking for."* + * + * The caption was there; it was the wrong half of the sentence. Half the waiting is automatic + * phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting + * on". So the name goes first, and a phase says so in as many words rather than leaving the reader + * to infer that nobody is acting. + * + * The narrated line is used as it stands otherwise, because `record()` already prefixes it with the + * player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter. + */ const showing = stepQueue.showing(); - $('watching-what').textContent = showing?.lines[0]?.text ?? ''; + const said = showing?.lines[0]?.text ?? ''; + const who = + showing === null || showing === undefined + ? '' + : showing.player === null + ? 'The Division' + : (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`); + // A player action already names its actor; a phase does not, so it is introduced. + $('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said; /** * SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION. * @@ -1264,7 +1291,7 @@ function render(): void { renderTurnChart(f); renderPresence(f); - renderWatching(); + renderWatching(f); $('revenue').textContent = String(f.revenue); /** * THE OBJECTIVE, WITHOUT THE COMMENTARY. diff --git a/src/web/play.html b/src/web/play.html index 5469e14..f47f848 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -125,6 +125,7 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px; .wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0; background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap} #watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none} +#watching-who{color:#c9cee0;font-weight:700} #presence:empty{display:none} /* division strip */ #division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px} @@ -914,9 +915,12 @@ ul.blocked li{padding:2px 0} and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is almost always. -->
diff --git a/test/pacing.test.ts b/test/pacing.test.ts index f1caaec..8e03386 100644 --- a/test/pacing.test.ts +++ b/test/pacing.test.ts @@ -152,18 +152,37 @@ describe('pacing — dwell by kind', () => { * 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, lines: [] as string[] }; + 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', lines: ['moved'], frame: { table: {} } }), + 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.