v0.5.2 — the splash's multiplayer door opens, and knows whether it should

The "Play multiplayer" door on index.html had sat disabled, labelled
"Coming soon", since before the server existed — Phases 2 through 4 built
a working lobby and nothing ever linked to it. Loading the site landed on
the same solitaire splash whether a real multiplayer server was behind it
or not, with no visible way in. Found packaging Phase 6 for StartOS.

The door is now a live link to ./play.html?lobby, and main.ts's start()
routes ?lobby straight to the lobby screen — the same showScreen('lobby');
runLobby(beginRemote) the in-game Multiplayer button already used —
instead of dealing a solitaire game first.

GET /api/health is new, and exists to be failed. The same dist/ ships both
served by src/server/ and uploaded as flat files by deploy-web.ts, and the
bundle is identical either way (D4), so the page cannot know from its own
build which it is; every other route 404s an unknown path exactly as a
static host does, so nothing distinguished them. The splash probes it on
load and closes the door when nothing names itself in reply.

The door starts open and only ever closes, deliberately: a wrong "no
server" is the bug above again — invisible, and it strands a player who
does have one — while a wrong "there is one" costs a click and a lobby
that says it cannot connect. The reply must name itself rather than merely
return 200, or a host answering every path with its index page would pass.

Verified: tsc clean; 659 tests pass (656 + 3); /api/health exercised live
against a running server — 200 with the right body, unauthenticated, while
an unknown path and a wrong method both still 404, which is what makes the
probe discriminate at all.

The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
This commit is contained in:
Jesse
2026-08-21 11:00:06 -04:00
parent e76bd77099
commit 62b6ed7e1b
8 changed files with 272 additions and 7 deletions
+42
View File
@@ -19,6 +19,48 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.5.2 — 2026-08-21
Found packaging Phase 6 for StartOS: the splash's "Play multiplayer" door had sat `disabled`,
labelled "Coming soon," since before the server existed — Phases 2 through 4 built a working
lobby and nobody ever pointed a link at it. Loading the site landed on the exact same solitaire
splash whether a real multiplayer server was behind it or not, with no visible way in.
`index.html`'s door is now a real link to `./play.html?lobby`, matching the other two doors.
`main.ts`'s `start()` checks for `?lobby` and routes straight into the lobby screen — the same
`showScreen('lobby'); runLobby(beginRemote)` the in-game Multiplayer button already used — instead
of dealing a solitaire game first and leaving the player to find that button themselves.
### The page can now tell whether a server is behind it
The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files by
`scripts/deploy-web.ts` with no server at all — and the bundle is byte-identical in both, because
there is one client and the mode is decided at runtime (D4). So the splash could not know from its
own build which it was, and nothing else distinguished them either: every route in `http.ts`
answers a 404 for a path it does not have, exactly as a static host does.
`GET /api/health` is new, and exists to be failed: `{ ok, service, engineVersion }`, no
authentication (it says only that a Station Master server is answering, which is what the door is
about to offer anyway — no game, no seat). The splash probes it on load and closes the door when
nothing names itself in reply.
**The door starts open and only ever closes**, deliberately. A wrong "no server here" is the bug
above all over again — invisible, and it strands a player who *does* have a server. A wrong "there
is one" costs a click and a lobby that says it cannot reach a server, which is legible and
recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it. The
reply has to name itself rather than merely return 200, since a host that answers every path with
its own index page would otherwise pass.
Bug fix: the lobby machinery was already complete and tested (Phase 4, v0.5.1); this only
re-enables the door to it, and teaches the splash when to.
Worth recording about the tests: the first version of the probe's test passed with the naming
check deleted outright. Both of its "door closes" cases happened to reach `close()` through the
`.catch` arm, so the branch that actually reads the body was never run — and the comment claimed
otherwise. Caught by mutating `splash.ts` and re-running rather than by reading it.
---
## 0.5.1 — 2026-08-21
Multiplayer Phase 4 — lobby, sessions, reconnection (`docs/architecture/multiplayer.md` §12 steps
+7
View File
@@ -245,9 +245,16 @@ special handling: `pump` stops, and the next push simply carries a `Menu` contai
POST /api/lobby/create, /api/lobby/join lobby
POST /api/intent { gameId, seq, intent }
GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume
GET /api/health { ok, service, engineVersion } — unauthenticated; see below
GET / the client
```
`/api/health` exists because the client cannot otherwise tell a server from a static host. The same
`dist/` is served both ways and the bundle is identical (D4), and every other route 404s an unknown
path exactly as a static host does — so the splash asks, and closes its multiplayer door only when
nothing names itself in reply. It is unauthenticated on purpose: it reveals that a Station Master
server is answering and nothing else, no game and no seat.
Chosen over WebSocket because this game is **idle most of the time** — turn-based with human
think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's
reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+19
View File
@@ -197,6 +197,25 @@ export function startServer(opts: ServerOptions): void {
void (async () => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
// -- Is anyone home? --------------------------------------------------------------------
/**
* THE ONE ROUTE THAT EXISTS TO BE FAILED.
*
* The same `dist/` is served two ways: by this server, and as a plain static upload with no
* server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way
* — one client, mode decided at runtime (D4) — so the page cannot know from its own build
* which it is, and every other route here answers a 404 for a path it does not have, exactly
* as a static host would. Nothing distinguished them until this did.
*
* Unauthenticated on purpose: it says only that a Station Master server is answering, which
* is what the client is about to offer the player anyway. It reveals no game and no seat.
*/
if (url.pathname === '/api/health' && req.method === 'GET') {
sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion });
return;
}
// -- Lobby: creating and joining (the door — join-secret gated) --------------------------
if (url.pathname === '/api/lobby/create' && req.method === 'POST') {
+8 -6
View File
@@ -29,6 +29,8 @@ a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
a.door.disabled{pointer-events:none}
a.door.disabled:hover{border-color:var(--line);background:var(--panel);transform:none}
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none}
@@ -69,13 +71,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</div>
<div class="doors">
<div class="door disabled">
<a class="door" id="door-multiplayer" href="./play.html?lobby">
<h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
a Station Master server to be running.</p>
<span class="go">Coming soon</span>
</div>
<p id="door-multiplayer-blurb">Play with your friends across the internet. You each run your
own office area within the entire division. You can play a normal game, or choose co-op or
cutthroat.</p>
<span class="go" id="door-multiplayer-go">Set up a game &rarr;</span>
</a>
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
+9
View File
@@ -358,6 +358,15 @@ function start(): void {
return;
}
// The splash's "Play multiplayer" door (index.html) lands here — straight into the lobby,
// rather than dealing a solitaire game first and leaving the player to find the in-game
// Multiplayer button themselves.
if (params.get('lobby') !== null) {
showScreen('lobby');
runLobby(beginRemote);
return;
}
showScreen('gameui');
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
+43
View File
@@ -26,3 +26,46 @@ if (heroImage && lightbox) {
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
});
}
/**
* Is a Station Master server actually behind this page?
*
* The same `dist/` ships two ways — served by `src/server/`, or uploaded as flat files with no
* server at all (`scripts/deploy-web.ts`) — and the bundle is identical in both, so the only
* honest way to answer is to ask. `/api/health` is the one route that exists to be failed.
*
* THE DOOR STARTS OPEN AND ONLY EVER CLOSES. Getting this wrong in the "no server" direction is
* the bug this whole change exists to fix: a disabled door is invisible and leaves a player who
* DOES have a server with no way in, and nothing on screen to explain it. Getting it wrong the
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
*/
const mpDoor = document.getElementById('door-multiplayer');
if (mpDoor) {
const close = (): void => {
mpDoor.classList.add('disabled');
mpDoor.removeAttribute('href');
const go = document.getElementById('door-multiplayer-go');
if (go) go.textContent = 'Requires a Station Master server';
const blurb = document.getElementById('door-multiplayer-blurb');
if (blurb) {
blurb.textContent =
'Play with your friends across the internet, each running your own office area within the ' +
'entire division. This copy of the game is a plain website with no server behind it, so ' +
'there is nowhere to host a table — multiplayer needs a Station Master server.';
}
};
// Relative, never absolute: the page must work at whatever address it is reached by, and the
// server is always the origin that served it (D16).
void fetch('./api/health')
.then((r) => (r.ok ? (r.json() as Promise<unknown>) : null))
.then((body) => {
// A static host that answers unknown paths with 200 and its own index page would sail past
// an `r.ok` check, so the body has to name itself before the door is believed.
const named =
typeof body === 'object' && body !== null && (body as { service?: unknown }).service === 'station-master';
if (!named) close();
})
.catch(() => close());
}
+143
View File
@@ -2515,6 +2515,149 @@ describe('the static build', () => {
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
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
// real server in front of them saw the same dead card as everyone else.
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html\?lobby"/, 'the splash does not link to the lobby');
assert.doesNotMatch(splash, /Coming soon/, 'the multiplayer door still says it is unbuilt');
// The probe addresses all three by id; renaming one silently un-wires it, which is precisely
// the class of break that put "Coming soon" on a working feature for two releases.
for (const id of ['door-multiplayer', 'door-multiplayer-go', 'door-multiplayer-blurb']) {
assert.ok(splash.includes(`id="${id}"`), `the splash is missing #${id}`);
}
});
/**
* The probe decides whether the door above is real, so both of its answers are worth a test —
* and the OPEN one especially: a false "no server here" is invisible to the player and is the
* exact failure this release exists to remove.
*/
it('closes the multiplayer door only when nothing answers the health probe', async () => {
const served = new Set(
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const loadSplash = async (fetchImpl: () => Promise<unknown>): Promise<Record<string, unknown>> => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const classes = new Set<string>();
return {
textContent: '', innerHTML: '', hidden: false, href: './play.html?lobby',
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), has: (c: string) => classes.has(c) },
removeAttribute: (k: string) => { if (k === 'href') delete (els.get('door-multiplayer') ?? {})['href']; },
addEventListener: () => {}, appendChild: () => {},
};
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['fetch'] = fetchImpl;
await import(`file://${join(dist, 'web/splash.js')}?t=${Date.now()}${Math.random()}`);
// The probe settles a microtask or two after load; nothing in the page waits on it.
await new Promise((r) => setTimeout(r, 0));
return els.get('door-multiplayer')!;
};
const answered = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, service: 'station-master' }) }),
);
assert.equal(
(answered['classList'] as { has: (c: string) => boolean }).has('disabled'), false,
'the door closed even though a Station Master server answered',
);
const silent = await loadSplash(() => Promise.reject(new Error('nothing there')));
assert.equal(
(silent['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'the door stayed open with no server behind it',
);
assert.equal(silent['href'], undefined, 'the closed door is still clickable');
// A host that answers EVERY path 200 — with its own index page, or with some unrelated
// service's JSON — sails straight past an `ok` check, so the body has to name itself. Both
// shapes below reach the door by a DIFFERENT route through the probe than the rejection
// above does, which is the whole reason they are here: an earlier version of this test
// asserted only the rejection path and passed with the naming check deleted outright.
const unnamed = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ service: 'something-else', ok: true }) }),
);
assert.equal(
(unnamed['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 from some other service was taken for a Station Master server',
);
const unparseable = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.reject(new Error('not json')) }),
);
assert.equal(
(unparseable['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 that is not JSON at all was taken for a server',
);
});
/**
* `?lobby` is what the door above hands to `main.ts`. Asserted on the BUILT bundle rather than
* the source, because the one thing that broke here was a browser-vs-Node API difference
* (`params.has` against the stub below), which only a load of the real output catches.
*/
it('routes ?lobby straight to the lobby screen instead of dealing a solitaire game', async () => {
const shown: string[] = [];
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
const make = (id: string): Record<string, unknown> => ({
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
querySelector: () => null, querySelectorAll: () => [],
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
});
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('?'), addEventListener: () => {},
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['location'] = { search: '?lobby' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) { this.search = search; }
get(k: string): string | null {
// A bare flag with no `=value` is still present — `?lobby` is exactly that shape.
if (new RegExp(`[?&]${k}(?=$|[&=])`).test(this.search)) {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? '';
}
return null;
}
};
// The lobby screen opens an EventSource as soon as it is shown; nothing here drives a game.
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby screen stayed hidden under ?lobby');
assert.equal(els.get('gameui')!['hidden'], true, 'it dealt a solitaire game instead of opening the lobby');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);