Compare commits

...
3 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Sonnet 5 193800a649 v0.7.8 — the setup screen was unreachable for anyone who had ever played
Third report of the same symptom, this time with the build confirmed
current on screen, which ruled out v0.7.7's caching fault and left the
real cause exposed.

v0.7.5 skipped the setup screen whenever load() found a save, reasoned
as "a saved game is a game to resume". A browser that has ever played
solitaire always has one, so the door could never reach the screen
again — and the fresh private window that appeared to vindicate v0.7.7
simply had no save. Two real faults were stacked; the caching one is
fixed and had been masking this.

The door outranks a saved game now: ?solitaire is a request to set one
up, while a bare reload still resumes (pinned by its own test). Since
Deal clears the save, the screen carries #ss-resume and says what Deal
costs, so the door cannot destroy a game in progress.

Also, per Jesse, riding along rather than taking its own release: the
splash footer now names both ways to play.

868 tests pass, four new. The reproduction was a failing test written
before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2026-08-29 23:47:44 -04:00
Jesse.MarkowitzandClaude Sonnet 5 af68aac78d v0.7.7 — two releases shipped to a browser that never received them
buildStamp()'s no-git fallback was the literal "nogit", and the .s9pk
Dockerfile copies the tree in without .git — so git rev-parse fails on
every packaged build. That string is also the cache-bust key every
module URL carries, so v0.7.4, v0.7.5 and v0.7.6 all published
./web/main.js?v=nogit, byte-identical, and returning browsers refetched
nothing. v0.7.5's setup screen and v0.7.6's door fix were both correct
and neither arrived.

The fallback is now the package version plus the build timestamp, always
distinct. And serveStatic sent no Cache-Control at all, which is the
other half — a cached play.html pins a player to the whole build it
names. A request carrying ?v= is now immutable for a year; everything
else is no-cache. ?v= rather than "not HTML" because build-web.ts tags
the modules and nothing else.

Neither half is sufficient alone.

864 tests pass, two new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2026-08-29 22:23:09 -04:00
Jesse.MarkowitzandClaude Sonnet 5 b4f09f05cb 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
2026-08-29 19:59:37 -04:00
9 changed files with 370 additions and 24 deletions
+107
View File
@@ -19,6 +19,113 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.7.8 — 2026-08-30
### The setup screen was unreachable for anyone who had ever played
Third report of the same symptom, and this time the build was confirmed current on screen
(`0.7.7-mtf7hyxc`), which ruled out the caching fault v0.7.7 had just fixed and left the actual
cause with nowhere to hide.
**v0.7.5 skipped the setup screen whenever `load()` found a save**, reasoned in its own comment as
"a saved game is a game to resume". The consequence went unnoticed: a browser that has ever played
solitaire *always* has a save, so the door could never reach the screen again. Only a browser that
had never played would see it — which is exactly why a fresh private window appeared to prove
v0.7.7's cache fix. The private window had no save. Two genuine faults were stacked, the caching one
was real and is fixed, and it masked this one.
**The door now outranks a saved game.** `?solitaire` is an explicit request to set a game up;
clicking "Play solitaire" is not a request to resume. A BARE reload still resumes, which is the
zero-friction case D11 is about and is pinned by its own test.
**Dealing from the door would have destroyed a game in progress**, since `commitNewGame` calls
`clearSave()` — so the screen now carries `#ss-resume` ("Continue saved game") and states plainly
that dealing replaces the save. Resuming navigates to the bare URL rather than building a session
on the spot, so `start()` stays the only place that turns a URL into a game.
**Recorded because the failure was diagnostic, not technical.** The first two attempts each fixed
something real that was not this, and both were reported as verified. The routing fix in v0.7.6 was
verified by reading what the server served; v0.7.7's by the same. Neither ever exercised the actual
path with the actual state a returning player has. The reproduction here is a failing test asserting
the door with a save present — written before the fix, and it failed with "a saved game swallowed
the door".
### The splash footer names both ways to play
Was "solitaire runs entirely in your browser — no server code required", written when solitaire was
the only door. Now: "Multiplayer runs on StartOS server. Solitaire runs entirely in your browser."
(Jesse, 2026-08-30, asked to ride along with the next change rather than take a release of its own.)
868 tests pass, four of them new.
---
## 0.7.7 — 2026-08-30
### Two releases shipped to a browser that never received them
Jesse installed v0.7.5, clicked **Play solitaire**, and landed in a dealt game instead of the new
setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened
again, identically. The second report is what made the real cause findable: the fix was correct both
times and neither one ever reached the browser.
**`buildStamp()`'s no-git fallback was the literal `nogit`, and the `.s9pk` build has no git.** The
Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there on every
packaged build — and that string is not only the visible stamp, it is the cache-bust key every module
URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published `./web/main.js?v=nogit`, byte-identical, and
a returning player's browser correctly concluded it had the file already. The fallback is now the
package version plus the build's own timestamp, which is always distinct and needs nothing from the
environment. Proven rather than assumed: two builds of an identical git-less tree now stamp
`0.7.6-mtf6l8rm` and `0.7.6-mtf6lant`.
**And the server sent no `Cache-Control` at all**, which is the other half — the pages are the one
thing that cannot be versioned in their own URL, since a player types the address or follows a
bookmark, so a cached `play.html` pins that player to the whole build it names including every `?v=`
inside it. Fixed the exact way round that matters: a request carrying `?v=` may be stored for a year
and marked `immutable`, and anything else is `no-cache`. `?v=` rather than "not HTML" because
`build-web.ts` tags the modules and nothing else — a year of `immutable` on an untagged image or on
the replay manifest would outlive several releases of it.
Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point
at, and without the header the fresh page is itself served from cache.
**What this says about the two releases before it.** v0.7.5's setup screen and v0.7.6's door fix were
both real, both correct, and both verified on `phoenix.local` by reading what the server served —
which was true, and was never the thing in doubt. What went unverified was the browser, and a
hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no
effect": check that it arrived before re-diagnosing it.
864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build,
one pinning the header rule and that the `?v=` flag actually reaches `serveStatic`.
---
## 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
+34 -3
View File
@@ -315,9 +315,40 @@ 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.
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no
`.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release
ever reached the browser that asked for it. Both earlier fixes were correct and both were
verified by reading what the SERVER served — which was true and was never the thing in doubt.
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
before re-diagnosing it.** A hard-reload would have answered it on the first report.
**And a THIRD report, 2026-08-30, with the build confirmed current on screen — which is what
finally found it. Fixed in v0.7.8.** v0.7.5 skipped the setup screen whenever a save existed
("a saved game is a game to resume"), so any browser that had ever played solitaire could never
reach it again; the private window that seemed to vindicate v0.7.7 simply had no save. The door
outranks a save now, a bare reload still resumes, and `#ss-resume` keeps the game in progress
one button away since Deal clears it.
**Three attempts, two of them fixing something real that was not the reported fault.** Each was
reported as verified, and each verification read what the SERVER served rather than exercising
the path with the state a returning player actually has. The thing that worked was a failing
test written before the fix. Worth remembering next time a report repeats: reproduce the user's
state first, and treat "I verified it" as unearned until something failed the way they described.
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.8",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+15 -1
View File
@@ -60,7 +60,21 @@ execFileSync(
*/ */
function buildStamp(): string { function buildStamp(): string {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
let git = 'nogit'; /**
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
*
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
*
* The version plus the build's own timestamp is always distinct, needs nothing from the
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
* that says so costs one refetch, while one that lies costs a release nobody receives.
*/
let git = `${pkg.version}-${Date.now().toString(36)}`;
try { try {
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root }) const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
.toString() .toString()
+30 -4
View File
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.end(text); res.end(text);
} }
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> { async function serveStatic(
distDir: string,
urlPath: string,
res: ServerResponse,
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
buildTagged = false,
): Promise<void> {
const rel = urlPath === '/' ? '/index.html' : urlPath; const rel = urlPath === '/' ? '/index.html' : urlPath;
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request // `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from. // for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
try { try {
const info = await stat(full); const info = await stat(full);
if (!info.isFile()) throw new Error('not a file'); if (!info.isFile()) throw new Error('not a file');
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size }); /**
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
*
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
* its own.
*
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
* several releases of whatever it holds.
*/
res.writeHead(200, {
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
'Content-Length': info.size,
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
});
createReadStream(full).pipe(res); createReadStream(full).pipe(res);
} catch { } catch {
res.writeHead(404, { 'Content-Type': 'text/plain' }); res.writeHead(404, { 'Content-Type': 'text/plain' });
@@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void {
// Unset means the routes are not here — indistinguishable from any other unknown path, so // Unset means the routes are not here — indistinguishable from any other unknown path, so
// nothing advertises an administrative surface to someone probing for one. // nothing advertises an administrative surface to someone probing for one.
if (!opts.adminSecret) { if (!opts.adminSecret) {
await serveStatic(opts.distDir, url.pathname, res); await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
return; return;
} }
if (req.headers['x-admin-secret'] !== opts.adminSecret) { if (req.headers['x-admin-secret'] !== opts.adminSecret) {
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
return; return;
} }
await serveStatic(opts.distDir, url.pathname, res); await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
})().catch((err: unknown) => { })().catch((err: unknown) => {
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' }); sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
}); });
+2 -2
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
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<footer> <footer>
<span>build <span id="build">__BUILD__</span></span> <span>build <span id="build">__BUILD__</span></span>
<span>solitaire runs entirely in your browser &mdash; no server code required</span> <span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
</footer> </footer>
</main> </main>
+55 -10
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;
@@ -754,16 +773,24 @@ function start(): void {
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game"; * (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
* "asking first is the only path"). * "asking first is the only path").
* *
* A saved game or an explicit `seed=` both mean this visit is not "no plan yet" — a saved game is * Three things answer the question and so skip the screen, in this order of precedence:
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark, * `hand` (every `commitNewGame` write sets it, so this navigation IS the Deal button landing back
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the * here to deal), `seed` (a specific deal someone chose to share or bookmark), and — only when the
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this * player did not explicitly ask to set one up — an existing save, which is a game to resume.
* navigation IS the setup screen's own Deal button, landing back here to actually deal — checking *
* it is what stops the screen asking itself the question a second time. * THE DOOR OUTRANKS A SAVED GAME, and getting that wrong is what made this feature unreachable
* for three releases. v0.7.5 skipped the screen whenever `load()` found ANYTHING, reasoned as "a
* saved game is a game to resume" — but a browser that has ever played solitaire always has one,
* so the door could never reach the screen again. Reported three times (Jesse, 2026-08-29 twice
* and 2026-08-30); a private window appeared to absolve it only because it had never played and
* so had no save. Clicking "Play solitaire" is a request to set a game up, not to resume one — a
* BARE reload is the resume case, and still is. `#ss-resume` is what keeps the save reachable, so
* this costs nobody the game they were playing.
*/ */
if (!saved && requested === null && !params.has('hand')) { const askedToSetUp = params.get('solitaire') !== null;
if (requested === null && !params.has('hand') && (askedToSetUp || !saved)) {
showScreen('solitairesetup'); showScreen('solitairesetup');
runSolitaireSetup(params); runSolitaireSetup(params, saved !== null);
return; return;
} }
@@ -2154,7 +2181,7 @@ if (newBtn && dlg) {
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical * Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two. * `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
*/ */
function runSolitaireSetup(params: URLSearchParams): void { function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
const screen = document.getElementById('solitairesetup'); const screen = document.getElementById('solitairesetup');
const dealBtn = document.getElementById('ss-deal'); const dealBtn = document.getElementById('ss-deal');
if (!screen || !dealBtn) return; if (!screen || !dealBtn) return;
@@ -2166,6 +2193,24 @@ function runSolitaireSetup(params: URLSearchParams): void {
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null; const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
if (seedField) seedField.value = params.get('seed') ?? ''; if (seedField) seedField.value = params.get('seed') ?? '';
/**
* THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all.
* Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by
* clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their
* game one button away and needs to be told what Deal costs.
*
* Resuming is a navigation to the BARE url rather than a session built here: `start()` already
* resumes a save on a bare load, and routing both paths through it keeps one place that turns a
* URL into a game.
*/
const resumeBtn = document.getElementById('ss-resume');
const savedNote = document.getElementById('ss-saved-note');
if (resumeBtn) {
resumeBtn.hidden = !hasSave;
resumeBtn.onclick = () => void (location.search = '');
}
if (savedNote) savedNote.hidden = !hasSave;
ss.selectPreset('solitaire'); ss.selectPreset('solitaire');
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
} }
+8
View File
@@ -765,7 +765,15 @@ ul.blocked li{padding:2px 0}
</div> </div>
</details> </details>
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
reads as "discard what I was playing". -->
<p class="ng-note" id="ss-saved-note" hidden>You have a solitaire game in progress. Dealing a new
one below replaces it &mdash; there is no undo for that.</p>
<menu class="ng-buttons"> <menu class="ng-buttons">
<button id="ss-resume" type="button" hidden>Continue saved game</button>
<button id="ss-deal" type="button">Deal</button> <button id="ss-deal" type="button">Deal</button>
</menu> </menu>
</section> </section>
+118 -3
View File
@@ -2622,10 +2622,42 @@ 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');
}); });
it('cache-busts with a tag that varies per build even where there is no git', () => {
/**
* THE BUG THIS PINS COST TWO RELEASES. `buildStamp`'s no-git fallback was the literal `nogit`,
* and the `.s9pk` Dockerfile copies the working tree in WITHOUT `.git` — so every packaged
* release published `?v=nogit`, byte-identical to the one before it, and a returning player's
* browser refetched nothing. v0.7.5's setup screen and v0.7.6's fix to it both installed
* correctly on `phoenix.local` and neither reached the browser that asked for them.
*
* Asserted against the SCRIPT rather than a built page, because the property is about what the
* fallback does when `git rev-parse` fails, which a normal build here never exercises.
*/
const src = readFileSync(join(root, 'scripts/build-web.ts'), 'utf8');
const fallback = /let git = ([^;]+);/.exec(src)?.[1] ?? '';
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
});
it('lets a build-tagged URL be cached and nothing else', () => {
// The other half of the same bug: the pages carry the `?v=` tags but cannot be versioned in
// their own URL, so a cached `play.html` pins a player to the whole build it names. Only a
// request that actually carries `?v=` may be stored — an untagged image or the replay manifest
// has no way to announce a change.
const src = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
assert.match(src, /'Cache-Control':\s*buildTagged\s*\?/, 'static responses no longer vary their caching');
assert.match(src, /immutable/, 'a tagged asset is not allowed to be cached at all');
assert.match(src, /serveStatic\(opts\.distDir, url\.pathname, res, url\.searchParams\.has\('v'\)\)/,
'the ?v= tag is not reaching serveStatic, so every response falls back to no-cache');
});
it('opens the multiplayer door from the splash, straight into the lobby', () => { it('opens the multiplayer door from the splash, straight into the lobby', () => {
// This door sat `disabled` and labelled "Coming soon" from before the server existed until // This door sat `disabled` and labelled "Coming soon" from before the server existed until
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a // v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
@@ -3875,7 +3907,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 +3998,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 +4192,89 @@ 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 the setup screen even when a solitaire game is saved', async () => {
/**
* REPORTED THREE TIMES BY JESSE (2026-08-29, twice, and 2026-08-30). v0.7.5 skipped the setup
* screen whenever `load()` found a save, reasoned as "a saved game is a game to resume" — but
* that means ANY browser that has ever played solitaire can never reach the setup screen from
* the door again, which is the whole feature. The private window that appeared to prove the
* caching fix had simply never played, so its `localStorage` was empty.
*
* The door is an explicit request to set a game up. A BARE reload still resumes (below).
*/
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('?solitaire', { 'station-master.save.v1': save });
assert.equal(els.get('solitairesetup')!['hidden'], false, 'a saved game swallowed the door');
assert.equal(els.get('gameui')!['hidden'], true, 'the saved game was resumed instead of asking');
});
it('offers a way back to the saved game, since dealing from the door destroys it', async () => {
// Deal calls `clearSave()`. The door is reached by clicking "Play solitaire", which nobody reads
// as "discard what I was playing" — so the save has to be one button away, and the cost of Deal
// has to be stated. Resuming navigates to the bare URL and lets `start()` do it.
const save = JSON.stringify({ seed: 12345, history: [] });
const { els, nav } = await load('?solitaire', { 'station-master.save.v1': save });
assert.equal(els.get('ss-resume')!['hidden'], false, 'no way back to the game in progress');
assert.equal(els.get('ss-saved-note')!['hidden'], false, "Deal's cost to the save is not stated");
(els.get('ss-resume')!['onclick'] as () => void)();
assert.equal(nav.search, '', 'resuming did not go back to the plain resume path');
});
it('hides the resume button when there is no saved game to go back to', async () => {
const { els } = await load('?solitaire');
assert.equal(els.get('ss-resume')!['hidden'], true, 'a resume button with nothing to resume');
assert.equal(els.get('ss-saved-note')!['hidden'], true, 'warns about replacing a save that does not exist');
});
it('a bare reload still resumes a saved solitaire game rather than asking again', async () => {
// The other half: `?solitaire` is what changed, not resuming itself. Reopening the tab must not
// put a question in front of somebody who just wants their game back (D11's zero-friction case).
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('', { 'station-master.save.v1': save });
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the saved game');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen interrupted a resume');
});
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