diff --git a/CHANGELOG.md b/CHANGELOG.md index 8835788..d2d8ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,51 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.5.5 — 2026-08-21 + +One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game +with no lobby and no controls, and the board was blank. + +### A remembered session for a game the server no longer has + +Three things lined up. `start()` enters a remembered multiplayer session **without checking it +still exists** — that is what makes reconnection seamless, and it is why the lobby was skipped. +The v0.5.4 update had **refused to resume** that game, because the save was recorded under v0.5.3 +and the engine-version check is exact (D7). And `createRemoteSession` had **no `onerror` at all**, +so `EventSource` retried the resulting 404 forever, in silence, while `frame` stayed null and the +page rendered nothing. + +The only escape was clearing site data, and nothing on screen said so. + +The same dead end had just been widened by v0.5.3's **Manage Game → End**, which closes every +watcher's stream: a player whose game an administrator ended would sit frozen on a stale board +indefinitely, for the same reason. + +**The fix.** `GET /api/session?token=…` is new — a cheap yes/no on whether a token still names a +live game. `EventSource` fires `error` identically for a transient blip (the expected shape of a +game idle for minutes, §9) and for a 404 it will retry forever, and exposes no status code either +way, so the client asks. Only a definite 404 closes the stream and reports the game gone; a flaky +network still self-heals as before. + +The page then forgets the stored session, says why — ended by an administrator, or the service was +updated, which does not carry games across — and drops into the lobby. Forgetting the token is what +stops the next load repeating it. + +It also stops rendering nothing while it waits: "… connecting to the game" sits in the presence +banner until the first push arrives, because a page showing nothing is indistinguishable from a +page that is broken, which is precisely what this looked like. + +### Recorded, not fixed + +`TODO.md` now carries the underlying problem: **three releases in a row destroyed every game in +progress, and v0.5.4's changes were rendering only.** The refusal is right — a move legal under old +rules may not be legal under new ones — but the test is exact equality against the *package* +version, which moves for reasons that have nothing to do with the rules. Three options are costed +there; the recommendation is to replay the save and refuse only if an intent actually rejects, +since that answers the real question rather than a proxy for it, and a full replay measures ~100 ms. + +--- + ## 0.5.4 — 2026-08-21 Six things found by playing the StartOS build, all of them about the game telling you what it diff --git a/TODO.md b/TODO.md index 9e78054..e971b70 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,9 @@ Queued 2026-08-21, from playing the StartOS build: this is not purely a UI job. 5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live, two are read by nothing at all. Reasoning in Multiplayer below. +6. **Stop every release destroying every game in progress** — the check is exact equality against + the package version, and most releases do not touch the rules. Reasoning in Multiplayer below; + the recommendation is to replay-and-see rather than to guess from a version number. --- @@ -500,6 +503,47 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite shift it", "never grows the table, whoever asks", "refuses a chair that is not at the table"). +- [ ] **EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS, AND MOST RELEASES DO NOT CHANGE THE RULES.** + Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in + turn — v0.5.4's changes were *rendering only*, and it still refused two saved games. + + **Why it happens, and why the design is right as far as it goes.** A save is a seed plus a + list of intents (D5), so loading one means replaying those intents through the current engine. + A move that was legal under the old rules may be rejected under the new ones, and a + half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion` + mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version + back makes the games loadable again. That is all correct. The problem is only that the test is + **exact equality against the package version**, which moves for reasons that have nothing to + do with the rules. + + **Why it is getting worse rather than better.** It was harmless while Jesse was the only + player. It stops being acceptable the moment other people are seated: their game is destroyed + because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug + fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page. + + Three ways out, cheapest first: + + 1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into + the save instead of `package.json`'s version, and raised only when a change can alter + whether an intent is legal. v0.5.4 would not have touched it and both games would have + survived. Cheapest and the least clever, but it is a judgement call on every release, and + getting it wrong silently corrupts a game rather than refusing it — the failure is worse + than the one it replaces. + 2. **A declared compatibility floor.** The save records the version that wrote it; the engine + declares the oldest save it will accept. Loading checks `saved >= floor` rather than + `saved === current`. Same judgement call as (1), but expressed as a range, which makes + "this release breaks saves" an explicit act rather than the default. + 3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only + if an intent actually rejects. This is the honest test and needs no judgement at all: it + answers the real question ("does this game still replay?") instead of a proxy for it. It + costs a full replay per game on boot, which is ~100 ms per finished game (measured + 2026-08-21) and only unfinished games are loaded — so at any realistic table count it is + free. The work is in reporting a partial failure well: the game is intact up to the + rejected intent, and a player would probably rather resume there than lose it entirely. + + **(3) is the one worth doing**, and (1)/(2) are what to reach for only if a replay ever + becomes too slow to do on boot. Decide before the next release that changes a rule, not after. + - [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`, diff --git a/package.json b/package.json index 1134c0b..c6aa1a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.4", + "version": "0.5.5", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/http.ts b/src/server/http.ts index 42f17a4..05ef8d2 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -481,6 +481,27 @@ export function startServer(opts: ServerOptions): void { // -- The running game (token-authenticated) ------------------------------------------------ + /** + * IS THIS TOKEN STILL GOOD FOR ANYTHING? + * + * A browser remembers its session in `localStorage` and re-enters the game on the next load + * without asking, which is what makes reconnection seamless — and what leaves it stranded + * when the game is gone. `EventSource` cannot report a status code and retries a 404 + * silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a + * game legitimately disappears under a player: an engine-version bump refuses to resume it + * (D7), and an administrator ends it (`DELETE /api/games/`). + */ + if (url.pathname === '/api/session' && req.method === 'GET') { + const ps = sessions.get(url.searchParams.get('token') ?? ''); + const live = ps ? games.get(ps.gameId) : undefined; + if (!ps || !live) { + sendJson(res, 404, { error: 'no such game' }); + return; + } + sendJson(res, 200, { gameId: ps.gameId, player: ps.player }); + return; + } + if (url.pathname === '/api/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); diff --git a/src/web/main.ts b/src/web/main.ts index 33c5e5a..e528389 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -333,7 +333,11 @@ function showScreen(which: 'lobby' | 'gameui'): void { function beginRemote(ready: LobbyReady): void { localStorage.setItem(REMOTE_KEY, JSON.stringify(ready)); showScreen('gameui'); - session = createRemoteSession(ready.token, ready.seat); + // Nothing can be drawn until the first push arrives, and a page showing nothing at all is + // indistinguishable from a page that is broken — which is exactly what a dead session used to + // look like, forever. + $('presence').textContent = '… connecting to the game'; + session = createRemoteSession(ready.token, ready.seat, abandonRemote); applyCapabilities(); // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // real Frame only exists once the SSE connection's first push arrives, so the first render waits @@ -342,6 +346,31 @@ function beginRemote(ready: LobbyReady): void { session.subscribe(render); } +/** + * The game this browser remembered is gone, so stop waiting for it and go somewhere useful. + * + * Two things legitimately destroy a game under a seated player, and both are by design: an + * engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under + * the new ones), and an administrator ends it. Neither used to be survivable here. The remembered + * token sent `start()` straight past the lobby into a game that no longer existed, `EventSource` + * retried the 404 in silence, and the player sat on a blank page with no controls and no way back + * short of clearing site data. + * + * Forgetting the token is what makes the next load land in the lobby instead of repeating it. + */ +function abandonRemote(): void { + localStorage.removeItem(REMOTE_KEY); + showScreen('lobby'); + $('presence').textContent = ''; + runLobby(beginRemote); + const note = document.getElementById('lb-create-err'); + if (note) { + note.textContent = + 'That game is no longer on this server — it was either ended by whoever runs it, or the ' + + 'service was updated, which does not carry games in progress across. Create or join a new one.'; + } +} + /** * NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through * `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL @@ -352,6 +381,9 @@ function beginRemote(ready: LobbyReady): void { function start(): void { const params = new URLSearchParams(location.search); + // 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(); if (remembered) { beginRemote(remembered); diff --git a/src/web/session.ts b/src/web/session.ts index 9e954ec..6b04e6f 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -226,7 +226,16 @@ type Push = { * not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities` * are all `false` (a `LocalSession` always has data the instant it is constructed; this does not). */ -export function createRemoteSession(token: string, seat: PlayerIndex): Session { +export function createRemoteSession( + token: string, + seat: PlayerIndex, + /** + * Called once when this session's game is established to be gone for good, so the page can stop + * waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404 + * forever and reports nothing, and `frame` never becomes non-null. + */ + onGone?: () => void, +): Session { let frame: Frame | null = null; let menu: Menu | null = null; let lines: { text: string; tone: string }[] = []; @@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session { const qs = `token=${encodeURIComponent(token)}`; const source = new EventSource(`/api/stream?${qs}`); + + /** + * A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up. + * + * `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and + * which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a + * 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is + * the cheap question that separates them: only a definite 404 closes the stream and reports the + * game gone, so a flaky network still self-heals. + */ + let reportedGone = false; + source.onerror = () => { + if (reportedGone) return; + void fetch(`/api/session?${qs}`) + .then((r) => { + if (r.status !== 404 || reportedGone) return; + reportedGone = true; + source.close(); + onGone?.(); + }) + .catch(() => { + // The probe itself failed, so this says nothing about the game — leave the retry running. + }); + }; source.onmessage = (ev: MessageEvent) => { const push = JSON.parse(ev.data) as Push; // A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this