Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af68aac78d | ||
|
|
b4f09f05cb |
@@ -19,6 +19,72 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 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,27 @@ 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.
|
||||
|
||||
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.7",
|
||||
"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' });
|
||||
});
|
||||
|
||||
+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;
|
||||
|
||||
+75
-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,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