v0.7.6 — the solitaire door could not reach solitaire

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-29 19:59:37 -04:00
co-authored by Claude Sonnet 5
parent 3e961496b0
commit 7bbd8111a1
6 changed files with 105 additions and 9 deletions
+26
View File
@@ -19,6 +19,32 @@ page as `v0.1.0 · <sha> · <date>`, 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 ## 0.7.5 — 2026-08-29
### Solitaire asks first, the same way multiplayer already does ### Solitaire asks first, the same way multiplayer already does
+12 -3
View File
@@ -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 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 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 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 copy. Reasoning in `CHANGELOG.md`.
`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. **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.
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.7.5", "version": "0.7.6",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+1 -1
View File
@@ -79,7 +79,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<span class="go" id="door-multiplayer-go">Set up a game &rarr;</span> <span class="go" id="door-multiplayer-go">Set up a game &rarr;</span>
</a> </a>
<a class="door" href="./play.html"> <a class="door" href="./play.html?solitaire">
<h2>Play solitaire</h2> <h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of <p>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 &mdash; if you close the tab and reopen this site 15 by the end or the game is a loss. Your game data is saved in your browser &mdash; if you close the tab and reopen this site
+20 -1
View File
@@ -718,10 +718,29 @@ function start(): void {
return; 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 // 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 // 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. // 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) { if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
beginRemote({ ...remembered, seat: remembered.seat }); beginRemote({ ...remembered, seat: remembered.seat });
return; return;
+45 -3
View File
@@ -2622,7 +2622,9 @@ describe('the static build', () => {
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`); assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
} }
const splash = readFileSync(join(dist, 'index.html'), 'utf8'); 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'); 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 * 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. * 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<string, string> = {}) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' }); execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set( const served = new Set(
@@ -3966,7 +3968,7 @@ describe('the New Game dialog', () => {
origin: 'http://box.local', origin: 'http://box.local',
pathname: '/play.html', pathname: '/play.html',
}; };
const store = new Map<string, string>(); const store = new Map<string, string>(Object.entries(stored));
g['localStorage'] = { g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null, getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v), 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'); 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 () => { 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 // 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 // every sim measurement is taken against — deliberately did not move; this is what the setup