From ff629c07082d1a5fd2f975a47b6452ee568a054b Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Wed, 9 Sep 2026 21:06:22 -0400 Subject: [PATCH] =?UTF-8?q?v0.8.0.3=20=E2=80=94=20Skip=20on=20the=20left,?= =?UTF-8?q?=20a=20caption=20that=20says=20who,=20and=20a=20clock=20that=20?= =?UTF-8?q?stops=20stretching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from playing v0.8.0.2, all about the row rather than the mechanism. Skip was on the far right and a player's eye is on the countdown. Moved to the left, in front of the count. The caption said what but never who. 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 introduces itself now: "The Division: Mainline phase". A player's move already carries its name from record(), so it is left alone. The row was also hiding one step early, because it showed only while behind > 0 — which goes false exactly when the last step of a burst goes up, so the step most likely to be read lost its caption. And the speed control was stretching the clock along with the players. It was not his own move being replayed — own moves have cost nothing since v0.8.0.1 — it was the phases behind it, which put 105 seconds of clock-ticking into a 5x game. pace now scales a player's move and leaves a phase at its tabled beat, which is what the control has always claimed to do. Off still means off for both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6 --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/sim/pacing.ts | 24 +++++++++++++++++++----- src/web/main.ts | 41 ++++++++++++++++++++++++++++++++++------- src/web/play.html | 6 +++++- test/pacing.test.ts | 23 +++++++++++++++++++++-- 6 files changed, 123 insertions(+), 16 deletions(-) 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.