diff --git a/CHANGELOG.md b/CHANGELOG.md index b2566ce..867e6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,78 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 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. + +### `?pace=` never worked, and a whole game was played at the wrong speed + +Jesse: *"I'm playing at pace = 7, and the bots are still moving too fast for me to follow."* At 7× +a switching move holds for seven seconds, so that could not be calibration — and it was not. **He was +at 1× the entire time.** + +`index.html`'s two doors are `./play.html?lobby` and `./play.html?solitaire`. Arriving through the +splash therefore **replaces** the query string, and `location.search` on the play page is `?lobby` — +so `PACE_OVERRIDE` was null and it fell back to the stored setting of 1. v0.8.0 shipped `?pace=` as +the only way to change speed and the game's own front door destroyed it. Verified rather than +assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so +the mechanism was right and the value never arrived. + +Fixed twice over, because one of them is the durable answer: + +- **A speed control on the play screen**, beside zoom — `− 1× +`, persisted per viewer, reading + through to the queue on the very next move. `PACE_LEVELS` is `0, 0.5, 1, 2, 3, 5, 7, 10`: off is + the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the + ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a + full minute to watch. +- **The doors now carry `pace` through**, so the URL lever is honest for handing two playtesters + different speeds — the only thing it was ever for. When one is present the control says + `7× (URL)` and disables itself rather than showing buttons that do nothing. + +`PACE_LEVELS` lives in `sim/pacing.ts` with `DWELL` and `MAX_PACE`, not in `main.ts` — the whole +tuning surface in one file, and testable, which a constant inside the page entry point is not. + +**The committed default is unchanged at 1×.** What it should be is a question for a game played at a +speed that actually took effect. + +### "0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with +two collisions in it: *"It shows a total of two collisions, but zero today. Since we just finished day +one, that does seem to be a contradiction."* Unrelated to v0.8.0 — this has been wrong since the +dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the +summary. + +#### One line of ordering + +`advance.ts`, at the rollover: + +```ts +s.clock.day += 1; +s.collisionsToday = 0; +``` + +And `noteDayEnd()` fires when `f.day` goes UP — so the dialog reporting the Day that just finished is +drawn from the very frame in which that Day's count was zeroed. It printed the *new* Day's zero beside +a running total that could not possibly agree with it. Reproduced on four of five seeds before +touching anything: Day 1 ended with `today=3 total=3`, and the dialog read `today=0 total=3`. + +**Not derivable on the client, which is why the fix is in the engine.** A Day turns over inside the +phases that run themselves, so in multiplayer the push announcing the new Day is the same push that +carries the reset — a client may never see the ended Day's final count to remember it. So +`collisionsPrevDay` is captured in state at the rollover, immediately before the reset, and rides on +the frame like the other two counts. + +#### And "today" was the wrong word anyway + +Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then +"today" is Day 2. It now names the Day: **"Collisions: 2 on Day 1, 2 in all."** The end-of-game +results screen passes no Day and keeps "today", where the Day has not turned over and the word is +accurate. + +`test/redaction.test.ts`'s allow-list did its job on the way through: adding a public property failed +the suite until it was declared out loud. + +--- + ## 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; diff --git a/docs/plans/jitsi-common-board.md b/docs/plans/jitsi-common-board.md index cda8721..5a38fe0 100644 --- a/docs/plans/jitsi-common-board.md +++ b/docs/plans/jitsi-common-board.md @@ -361,14 +361,15 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit **Read those two, not this**, when building steps 2-7. The differences that matter: > > - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts` -> object. Their contents sit at the top level. **All 37 properties, which is the same list as +> object. Their contents sit at the top level. **All 38 properties, which is the same list as > `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`, > `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`, > `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`, > `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`, -> `collisionsToday`, `collisionsTotal`, `status`, `outcome`, `extraDays`, `extensionVotes`, +> `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`, +> `extensionVotes`, > `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`, -> `districts`. The first 35 come from `projectSharedTable`; `division` and `districts` are added +> `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added > by `PublicFrame` itself. > - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it > in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second, diff --git a/package.json b/package.json index 33f227a..d1b0e99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.1", + "version": "0.8.0.2", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index dfa3125..8a79f27 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -1638,6 +1638,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { s.clock.day += 1; s.clock.stage = 1; + // Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is + // drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`. + s.collisionsPrevDay = s.collisionsToday; s.collisionsToday = 0; events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); rotateSeats(s, events); diff --git a/src/engine/setup.ts b/src/engine/setup.ts index d08e628..9512aa4 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState { turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), movedThisPhase: new Set(), collisionsToday: 0, + collisionsPrevDay: 0, collisionsTotal: 0, status: 'active', outcome: null, diff --git a/src/engine/state.ts b/src/engine/state.ts index ed2c873..2ad304e 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -1119,6 +1119,20 @@ export type GameState = { movedThisPhase: Set; /** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */ collisionsToday: number; + /** + * What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately + * before the reset. + * + * The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER + * the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no + * matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it — + * "it shows a total of two collisions, but zero today ... that does seem to be a contradiction". + * + * NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in + * multiplayer the push that reports the new Day is the same push that reports the reset — a client + * may never see the ended Day's final count to remember it. + */ + collisionsPrevDay: number; /** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */ collisionsTotal: number; /** diff --git a/src/sim/pacing.ts b/src/sim/pacing.ts index 7545434..9766575 100644 --- a/src/sim/pacing.ts +++ b/src/sim/pacing.ts @@ -148,6 +148,18 @@ export function kindOf(cause: StepCause): StepKind { */ export const MAX_PACE = 10; +/** + * The speeds the on-screen control offers, slowest last. + * + * `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has + * seen it a hundred times will want it off". The ladder runs well past 1 because that is what the + * first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took + * effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish + * was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it + * takes. + */ +export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10] as const; + /** * How long to show one step, in ms, at a given speed. * diff --git a/src/sim/view.ts b/src/sim/view.ts index 2eeb239..d378716 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -437,6 +437,8 @@ export type Frame = { maxCollisionsPerDay: number; maxCollisionsTotal: number; collisionsToday: number; + /** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */ + collisionsPrevDay: number; collisionsTotal: number; status: GameState['status']; outcome: GameState['outcome']; @@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) { maxCollisionsPerDay: s.config.maxCollisionsPerDay, maxCollisionsTotal: s.config.maxCollisionsTotal, collisionsToday: s.collisionsToday, + collisionsPrevDay: s.collisionsPrevDay, collisionsTotal: s.collisionsTotal, status: s.status, outcome: s.outcome, diff --git a/src/web/main.ts b/src/web/main.ts index 440fc10..a86ccb9 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -26,6 +26,7 @@ import { createLocalSession, createRemoteSession } from './session.ts'; import type { PlayerIndex } from '../engine/state.ts'; import type { PublicDistrict } from '../sim/view.ts'; import { createStepQueue } from './step-queue.ts'; +import { PACE_LEVELS } from '../sim/pacing.ts'; import { notice, prefillCode, runLobby } from './lobby.ts'; import type { LobbyReady } from './lobby.ts'; import { @@ -53,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1'; /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; + /** * Small persisted preferences, kept in a `localStorage` key of their own — separate from * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs @@ -2603,6 +2605,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); } +/** + * PLAYBACK SPEED — v0.8.0.3, TODO #13. + * + * Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The + * queue reads `settings.pace` through a closure on every step, so a change here takes effect on the + * very next move rather than the next game. + */ +const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null; +const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null; +const paceLabel = document.getElementById('pacelabel'); +if (paceSlowerBtn && paceFasterBtn && paceLabel) { + const nearestPace = (): number => { + // A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both + // legitimate — so the buttons step from whichever preset is closest rather than refusing to move. + const want = PACE_OVERRIDE ?? settings.pace; + return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]); + }; + const paintPace = (): void => { + const p = PACE_OVERRIDE ?? settings.pace; + paceLabel.textContent = p === 0 ? 'off' : `${p}×`; + const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]); + paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1; + paceFasterBtn.disabled = i <= 0; + // A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons. + if (PACE_OVERRIDE !== null) { + paceSlowerBtn.disabled = true; + paceFasterBtn.disabled = true; + paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`; + } + }; + const stepPace = (by: number): void => { + const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]); + const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))]; + if (next === undefined) return; + saveSettings({ pace: next }); + paintPace(); + // The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once. + renderWatching(); + }; + // Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game, + // not to the number: a player pressing "slower" wants to watch for longer. + paceSlowerBtn.onclick = () => stepPace(1); + paceFasterBtn.onclick = () => stepPace(-1); + paintPace(); +} + const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null; const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null; const zoomLabel = document.getElementById('zoomlabel'); diff --git a/src/web/panels.ts b/src/web/panels.ts index 6afb839..ad7ec75 100644 --- a/src/web/panels.ts +++ b/src/web/panels.ts @@ -180,7 +180,7 @@ export function dayEndHtml(f: Frame): string { ahead + standingsHtml(f) + targetHtml(f) + - collisionsHtml(f) + collisionsHtml(f, ended) ); } @@ -238,13 +238,28 @@ function targetHtml(f: Frame): string { * its config and enforces neither, so reporting a collision budget there would put a rule on * screen that this game does not have. */ -function collisionsHtml(f: Frame): string { +function collisionsHtml(f: Frame, endedDay?: number): string { const scoredOnCollisions = (f.mode === 'competitive' || f.mode === 'coop') && (f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0); - return scoredOnCollisions - ? `

Collisions: ${f.collisionsToday} today, ${f.collisionsTotal} in all.

` - : ''; + if (!scoredOnCollisions) return ''; + /** + * "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction. + * + * That dialog is drawn from the frame whose `day` went UP — which is the same frame in which + * `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09, + * at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero + * today ... that does seem to be a contradiction." + * + * So when the caller knows which Day just ended it says so by name, and reads the count captured at + * the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has + * not turned over and the word is accurate. + */ + const [count, when] = + endedDay === undefined + ? [f.collisionsToday, 'today'] + : [f.collisionsPrevDay, `on Day ${endedDay}`]; + return `

Collisions: ${count} ${when}, ${f.collisionsTotal} in all.

`; } /** diff --git a/src/web/play.html b/src/web/play.html index 7fc765c..5469e14 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -872,6 +872,14 @@ ul.blocked li{padding:2px 0} 100% + + + 1× + diff --git a/src/web/splash.ts b/src/web/splash.ts index 30bb4f5..aac63fd 100644 --- a/src/web/splash.ts +++ b/src/web/splash.ts @@ -40,6 +40,29 @@ if (heroImage && lightbox) { * other way costs a click and a lobby that says it cannot reach a server — which is legible, and * recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. */ +/** + * CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3. + * + * Both doors are static hrefs that REPLACE the query string (`play.html?lobby`, + * `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse + * played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The + * durable answer is the speed control on the play screen, which persists per viewer — this keeps the + * URL lever honest for handing two playtesters different speeds, which is the only thing it was ever + * for. + */ +try { + const pace = new URLSearchParams(location.search).get('pace'); + if (pace !== null) { + for (const door of Array.from(document.querySelectorAll('a.door'))) { + const href = door.getAttribute('href'); + // Only the doors into the game, and only ones that have not been disabled above. + if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`); + } + } +} catch { + // A door that keeps its own href is the status quo, not a broken page. +} + const mpDoor = document.getElementById('door-multiplayer'); if (mpDoor) { const close = (): void => { diff --git a/test/pacing.test.ts b/test/pacing.test.ts index 2c975cf..f1caaec 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, MAX_PACE, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts'; +import { DWELL, MAX_PACE, PACE_LEVELS, 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'; @@ -120,6 +120,31 @@ describe('pacing — dwell by kind', () => { assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind'); }); + it('offers speeds a player actually reached for, and none the code would clamp', () => { + /** + * Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the + * only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving + * from the splash REPLACES the query string. Hence a real control on the play screen, and hence + * this ladder — which must reach the speeds people ask for and must not offer one that + * `dwellFor` would silently clamp. + */ + assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off'); + assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder'); + assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name'); + for (const p of PACE_LEVELS) { + assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`); + assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p)); + } + // Strictly increasing, so stepping the control always changes the speed. + for (let i = 1; i < PACE_LEVELS.length; i++) { + assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing'); + } + // The slowest rung has to be slow enough to be worth having: six switching moves at the top of + // the ladder is a full minute, which is the "watch them struggle" case. + const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6; + assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`); + }); + it('a silent step beats only when the clock turns over — TODO #18', () => { /** * Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 700e80d..65e5124 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -524,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => { // The rules the game was dealt under, and the score. 'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal', + // What the Day that just ended finished on. Public for the same reason the running counts are: + // a collision happens on the Mainline in front of everybody. + 'collisionsPrevDay', 'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally', // Names, seats, revenue and HAND SIZE — never hand contents. 'players', diff --git a/test/web.test.ts b/test/web.test.ts index b838c55..2d631e3 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -3614,6 +3614,46 @@ describe('the Day rolling over says so (Gitea#10)', () => { assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`); }); + it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => { + /** + * Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two + * collisions, but zero today. Since we just finished day one, that does seem to be a + * contradiction."* + * + * The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes + * `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the + * fresh Day's zero and printed it beside a running total that could not agree with it. The count + * is captured at the rollover now, and the dialog names the Day rather than saying "today". + */ + const s = createEngineGame({ + id: 'collide', + seed: 5, + config: { + mode: 'competitive', + days: 5, + minCombinedRevenue: 60, + maxCollisionsPerDay: 3, + maxCollisionsTotal: 10, + pvpCardsAllowed: false, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['Joe', 'Bot 1'], + }); + // The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset. + s.clock.day = 2; + s.collisionsPrevDay = 2; + s.collisionsToday = 0; + s.collisionsTotal = 2; + + const html = dayEndHtml(snapshot(s, [], null)); + assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`); + assert.ok(html.includes('2 on Day 1'), `the ended Day's collisions are wrong:\n${html}`); + assert.ok(html.includes('2 in all'), `the running total is wrong:\n${html}`); + assert.doesNotMatch(html, /0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`); + // The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day. + assert.doesNotMatch(html, /0<\/b> on Day 1/, 'reported no collisions on a Day that had two'); + }); + it('counts the last Day as the last Day rather than promising more', () => { const html = dayEndHtml(frameAt(6)); assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');