Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b6ed7e1b |
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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=[^"]*)?">/);
|
||||
|
||||
Reference in New Issue
Block a user