Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193800a649 | ||
|
|
af68aac78d | ||
|
|
b4f09f05cb |
+107
@@ -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
|
||||
|
||||
### Solitaire asks first, the same way multiplayer already does
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
**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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
||||
*/
|
||||
function buildStamp(): 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 {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
|
||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
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;
|
||||
// `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.
|
||||
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
||||
try {
|
||||
const info = await stat(full);
|
||||
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);
|
||||
} catch {
|
||||
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
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||
});
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
||||
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
|
||||
+55
-10
@@ -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;
|
||||
@@ -754,16 +773,24 @@ function start(): void {
|
||||
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
|
||||
* "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
|
||||
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark,
|
||||
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the
|
||||
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this
|
||||
* 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.
|
||||
* Three things answer the question and so skip the screen, in this order of precedence:
|
||||
* `hand` (every `commitNewGame` write sets it, so this navigation IS the Deal button landing back
|
||||
* here to deal), `seed` (a specific deal someone chose to share or bookmark), and — only when the
|
||||
* player did not explicitly ask to set one up — an existing save, which is a game to resume.
|
||||
*
|
||||
* 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');
|
||||
runSolitaireSetup(params);
|
||||
runSolitaireSetup(params, saved !== null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2154,7 +2181,7 @@ if (newBtn && dlg) {
|
||||
* 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.
|
||||
*/
|
||||
function runSolitaireSetup(params: URLSearchParams): void {
|
||||
function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
|
||||
const screen = document.getElementById('solitairesetup');
|
||||
const dealBtn = document.getElementById('ss-deal');
|
||||
if (!screen || !dealBtn) return;
|
||||
@@ -2166,6 +2193,24 @@ function runSolitaireSetup(params: URLSearchParams): void {
|
||||
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||
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');
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
@@ -765,7 +765,15 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
</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 — there is no undo for that.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||
<button id="ss-deal" type="button">Deal</button>
|
||||
</menu>
|
||||
</section>
|
||||
|
||||
+118
-3
@@ -2622,10 +2622,42 @@ 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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// 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
|
||||
@@ -3875,7 +3907,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 +3998,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 +4192,89 @@ 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 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 () => {
|
||||
// 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