diff --git a/CHANGELOG.md b/CHANGELOG.md index ee14f67..3891667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,48 @@ page as `v0.1.0 · · `, 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 diff --git a/docs/architecture/multiplayer.md b/docs/architecture/multiplayer.md index 2b3fc76..8ba10cd 100644 --- a/docs/architecture/multiplayer.md +++ b/docs/architecture/multiplayer.md @@ -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 diff --git a/package.json b/package.json index 9badd87..c79a0cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server/http.ts b/src/server/http.ts index bbf60f8..ebd5f9f 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -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') { diff --git a/src/web/index.html b/src/web/index.html index 4d5ad8f..77744c6 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -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
- +

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.

+ Set up a game → +

Play solitaire

diff --git a/src/web/main.ts b/src/web/main.ts index ba77064..dfce40b 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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. diff --git a/src/web/splash.ts b/src/web/splash.ts index 043ee4a..30bb4f5 100644 --- a/src/web/splash.ts +++ b/src/web/splash.ts @@ -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) : 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()); +} diff --git a/test/web.test.ts b/test/web.test.ts index 17d8594..6695c2f 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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): Promise> => { + const els = new Map>(); + const make = (): Record => { + const classes = new Set(); + 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; + 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>(); + const make = (id: string): Record => ({ + 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; + 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(); + 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, /