Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd2708ecc |
@@ -19,6 +19,51 @@ page as `v0.1.0 · <sha> · <date>`, 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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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/<id>`).
|
||||
*/
|
||||
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);
|
||||
|
||||
+33
-1
@@ -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);
|
||||
|
||||
+34
-1
@@ -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<string>) => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user