From b4f09f05cbf24bef1b416625b421bf5ba37bf83b Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Sat, 29 Aug 2026 19:59:37 -0400 Subject: [PATCH] =?UTF-8?q?v0.7.6=20=E2=80=94=20the=20solitaire=20door=20c?= =?UTF-8?q?ould=20not=20reach=20solitaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by Jesse playing v0.7.5 on phoenix.local: a browser that had ever held a multiplayer seat could not reach the new solitaire setup screen at all. start() checked a browser-remembered multiplayer session before ever looking at solitaire's own state, and a bare ./play.html load could not tell "clicked Play solitaire" apart from "reloaded mid multiplayer game" — the same problem ?lobby already solved for the door on the other side, never applied to this one. The door now links to ./play.html?solitaire, and start() treats that, an explicit ?seed=, or the setup screen's own ?hand= (written by every Deal) as proof this navigation means solitaire — checked ahead of the remembered-session lookup rather than only below it. 862 tests pass, three new. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX --- CHANGELOG.md | 26 +++++++++++++++++++++++++ TODO.md | 15 ++++++++++++--- package.json | 2 +- src/web/index.html | 2 +- src/web/main.ts | 21 +++++++++++++++++++- test/web.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 105 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b8891..7c7ac40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,32 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.6 — 2026-08-29 + +### The solitaire door could not reach solitaire + +Found by Jesse verifying v0.7.5 on `phoenix.local`: from a browser that had ever held a multiplayer +seat, clicking **Play solitaire** on the splash landed straight in a Co-op, four-seat lobby left over +from unrelated earlier testing — not the new setup screen v0.7.5 just shipped. + +`start()` checks a browser-remembered multiplayer session (`station-master.remote.v1`) before it ever +looks at solitaire's own state, and there was nothing distinguishing "clicked Play solitaire" from +"reloaded mid multiplayer game" — a bare `./play.html` load means both. `?lobby` already solved the +identical problem for the door on the other side (D11); the solitaire door had no equivalent marker. + +The door now links to `./play.html?solitaire`, and `start()` treats that — along with an explicit +`?seed=` or a `?hand=` the setup screen's own Deal button just wrote — as unambiguous proof this +navigation means solitaire, checked ahead of the remembered-session lookup rather than only below it. +The `hand` check matters on its own: without it, pressing Deal would work once and then bounce the +very next load into the remembered game, since `commitNewGame`'s URL carries `hand=` but not +`solitaire=`. + +862 tests pass, three of them new: the door reaching solitaire past a remembered game, a bare reload +still correctly resuming one (unchanged behaviour, pinned so the fix does not overreach), and Deal's +own URL surviving the same bounce. + +--- + ## 0.7.5 — 2026-08-29 ### Solitaire asks first, the same way multiplayer already does diff --git a/TODO.md b/TODO.md index 793d6b7..c38e38c 100644 --- a/TODO.md +++ b/TODO.md @@ -315,9 +315,18 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main rules — before a genuinely fresh visit deals anything; a saved game, an explicit `?seed=`, or a URL a Deal already wrote all skip past it. The in-game dialog, the lobby and this screen now share one `wireGameTypeBlock()`/`commitNewGame()` pair instead of the dialog carrying its own - copy. Reasoning in `CHANGELOG.md`. **Committed but not yet played in a browser** — verified by - `tsc --noEmit` and the full suite (859 pass), not by loading the page and clicking through it. - Worth being an early item in the next play session, alongside #39's four unplayed v0.7.4 features. + copy. Reasoning in `CHANGELOG.md`. + + **Played in a browser on `phoenix.local` 2026-08-29, and it found a real bug — fixed same day in + v0.7.6.** The splash's "Play solitaire" door landed straight in a leftover Co-op four-seat lobby + instead of the new setup screen: `start()` checked a browser-remembered multiplayer session + before ever looking at solitaire's own state, and a bare `./play.html` load could not tell "I + clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem + `?lobby` already solved for the door on the other side (D11), just never applied to this one. The + door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup. Still + not verified past that: nobody has clicked all the way through the setup screen's own fields and + confirmed the dealt game matches what was chosen. Worth being an early item in the next play + session, alongside #39's four unplayed v0.7.4 features. --- diff --git a/package.json b/package.json index 3ef440b..689c8f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.5", + "version": "0.7.6", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/web/index.html b/src/web/index.html index 3fdfcbe..274b66f 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -79,7 +79,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle Set up a game → - +

Play solitaire

Play by yourself and run the entire division for five full days. Clear the Revenue floor of 15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site diff --git a/src/web/main.ts b/src/web/main.ts index 2c361a3..5cc3c91 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -718,10 +718,29 @@ function start(): void { return; } + /** + * ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby` + * above, for the door on the other side. A browser that has ever held a multiplayer seat carries + * `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell + * "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door + * always lost to whatever multiplayer game or lobby this browser last touched, and could never + * actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed + * back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new + * setup screen. The door now marks its intent explicitly, the same way `?lobby` already does — + * and so does everything else that already means "this is a solitaire navigation": an explicit + * `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes + * it on every commit, so landing back here with it set is that navigation, not a bare reload). + * Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would + * work once and then bounce the very next load into whatever multiplayer game this browser last + * touched, since its URL carries `hand=` but not `solitaire=`. + */ + const wantsSolitaire = + params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand'); + // Entered without checking it still exists — deliberately. Verifying up front would mean an // await before anything renders on the common path, where the game IS still there; instead the // session reports a dead game through `abandonRemote`, which lands in the lobby. - const remembered = loadRemote(); + const remembered = wantsSolitaire ? null : loadRemote(); if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) { beginRemote({ ...remembered, seat: remembered.seat }); return; diff --git a/test/web.test.ts b/test/web.test.ts index f9937fd..f3c97cd 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -2622,7 +2622,9 @@ describe('the static build', () => { assert.ok(!/https?:\/\//.test(html.replace(//g, '')), `${name} fetches something external`); } const splash = readFileSync(join(dist, 'index.html'), 'utf8'); - assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game'); + // `?solitaire` marks the door's intent explicitly (2026-08-29) so a browser that remembers a + // multiplayer session cannot swallow it — see `start()`'s own comment on `wantsSolitaire`. + assert.match(splash, /href="\.\/play\.html\?solitaire"/, 'the splash does not link to the game'); assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays'); }); @@ -3875,7 +3877,7 @@ describe('the New Game dialog', () => { * questions through `settings-form.ts`, which addresses its radio groups by NAME through the * DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog. */ - const load = async (search: string) => { + const load = async (search: string, stored: Record = {}) => { execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' }); const served = new Set( @@ -3966,7 +3968,7 @@ describe('the New Game dialog', () => { origin: 'http://box.local', pathname: '/play.html', }; - const store = new Map(); + const store = new Map(Object.entries(stored)); g['localStorage'] = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), @@ -4160,6 +4162,46 @@ describe('the New Game dialog', () => { assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything'); }); + it('the solitaire door reaches solitaire even when this browser remembers a multiplayer game', async () => { + // Found 2026-08-29 verifying v0.7.5 on phoenix.local: a browser with ANY remembered multiplayer + // seat (`station-master.remote.v1`) could never reach solitaire's setup screen at all — a bare + // `./play.html` load and the splash's "Play solitaire" door were indistinguishable from a reload + // mid-multiplayer-game, and `start()` checked the remembered session first. The door now marks + // its intent with `?solitaire`, the same way `?lobby` already does for the door on the other side. + const remembered = JSON.stringify({ + games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } }, + last: 'g1', + }); + const { els } = await load('?solitaire', { 'station-master.remote.v1': remembered }); + assert.equal(els.get('solitairesetup')!['hidden'], false, 'the door lost to the remembered game'); + assert.equal(els.get('gameui')!['hidden'], true, 'the remembered multiplayer game was resumed instead'); + }); + + it('a bare reload still resumes a remembered multiplayer game, unlike the solitaire door', async () => { + // The other half of the fix above: `?solitaire` must be what changed, not remembered-session + // resume itself, which is the correct behaviour for an actual reload mid-game (D11/D14). + const remembered = JSON.stringify({ + games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } }, + last: 'g1', + }); + const { els } = await load('', { 'station-master.remote.v1': remembered }); + assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the remembered game'); + assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen wrongly took priority'); + }); + + it("the setup screen's own Deal does not bounce into a remembered multiplayer game", async () => { + // The same bug one level deeper: `commitNewGame` writes `?hand=...`, not `?solitaire=...`, so the + // very next load after pressing Deal has to be recognised as a solitaire navigation too — checked + // via `hand`, the same signal `start()` already uses to skip the setup screen a second time. + const remembered = JSON.stringify({ + games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } }, + last: 'g1', + }); + const { els } = await load('?hand=sixRandom', { 'station-master.remote.v1': remembered }); + assert.equal(els.get('gameui')!['hidden'], false, "the Deal button's own URL was not honoured"); + assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen re-asked its own answer'); + }); + it('deals six cards by default now, matching what the lobby calls Solitaire', async () => { // Jesse, 2026-08-23: every game type opens with six. `SOLO_CONFIG` — the ENGINE's fallback, which // every sim measurement is taken against — deliberately did not move; this is what the setup