Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f09f05cb |
@@ -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
|
||||
|
||||
### Solitaire asks first, the same way multiplayer already does
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<a class="door" href="./play.html?solitaire">
|
||||
<h2>Play solitaire</h2>
|
||||
<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 — if you close the tab and reopen this site
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
+45
-3
@@ -2622,7 +2622,9 @@ describe('the static build', () => {
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/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<string, string> = {}) => {
|
||||
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<string, string>();
|
||||
const store = new Map<string, string>(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
|
||||
|
||||
Reference in New Issue
Block a user