diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f05ce2..2d9d50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,27 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.8.6 — 2026-09-29 + +### A Competitive seat gets its save when the game is over + +`/api/save` handed any seat its save — seed and all — while the game ran. The comment said every +move was already on the player's screen, which was true of the moves and false of the seed, and the +seed is every rival's hand and the order of the deck: the exact thing the shared log strips. But a +save without a seed is not a save. **Jesse's ruling: accept the leak in Co-op; otherwise, save only +at the end of the game.** A Competitive seat is refused with `SAVE_AFTER_FINISH` until the game is +finished, and the Save replay button says why and waits; a Co-op table, which has nothing to hide +from itself, and a one-seat game download at any time. + +### A correction to the 0.8.3 entry + +It says the Second Section card went into the deck "without a line in its notes". 0.8.2's release +notes did name it — "ordering a Second Section costs the card, which was declared in the rules and +never actually dealt". What was missing was not the line but the save check after the change; the +process rule in `TODO.md` stands as written. + +--- + ## 0.8.5 — 2026-09-29 The third release from the audit: housekeeping. Nothing a player can see changes; what changes is diff --git a/TODO.md b/TODO.md index 7158bc4..616fa96 100644 --- a/TODO.md +++ b/TODO.md @@ -695,7 +695,10 @@ were NOT fixed, each with the reason, so nothing quietly evaporates. (f) `package.json`'s `test/**/*.test.ts` only works because dash has no globstar and there is exactly one nesting level; spell it `test/*/*.test.ts`. -- [ ] **#117** — **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own +- [x] **#117** — **RULED 2026-09-29 (Jesse): "accept the leak in coop. otherwise save only at end of + game."** Built the same day: `/api/save` answers `403 SAVE_AFTER_FINISH` to a Competitive seat + while the game runs and serves a Co-op or one-seat game at any time; the page's Save replay + button says so and stays disabled until the end. Originally: **`/api/save` hands every seat the seed mid-game — NEEDS JESSE.** The seat's own save download returns `seed` while the game is active; in Competitive that is every rival's hand and the deck order, the exact leak `game.ts` strips from the log. But a save without the seed cannot replay, which is the whole point of a save. Jesse (2026-09-29): "We should discuss diff --git a/docs/components.md b/docs/components.md index ebfb314..77c2424 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1,6 +1,6 @@ # Station Master — Components and Markers -**Version 0.8.5** · 2026-09-29 +**Version 0.8.6** · 2026-09-29 **Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles, hand state, timetable state and other markers are documented with their cards or in the diff --git a/docs/home-deck.md b/docs/home-deck.md index 8456acd..d3b09ec 100644 --- a/docs/home-deck.md +++ b/docs/home-deck.md @@ -1,6 +1,6 @@ # Station Master — Home Deck -**Version 0.8.5** · 2026-09-29 +**Version 0.8.6** · 2026-09-29 **Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the rules are for playing each kind of card out of it. diff --git a/docs/mainline-deck.md b/docs/mainline-deck.md index 8bdd139..0cc9d16 100644 --- a/docs/mainline-deck.md +++ b/docs/mainline-deck.md @@ -1,6 +1,6 @@ # Station Master — Mainline Deck -**Version 0.8.5** · 2026-09-29 +**Version 0.8.6** · 2026-09-29 **Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a card does to a train crossing it, and the Home Deck cards played onto one. diff --git a/docs/quickstart.md b/docs/quickstart.md index 6d224e7..a1cb450 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,6 @@ # Station Master — Quickstart -**Version 0.8.5** · 2026-09-29 +**Version 0.8.6** · 2026-09-29 For a player who has never played. diff --git a/docs/rules.md b/docs/rules.md index 1b38212..ab0e5f2 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,6 +1,6 @@ # Station Master — Rules -**Version 0.8.5** · 2026-09-29 +**Version 0.8.6** · 2026-09-29 **Authority:** observed code paths and tests. Where a card face, a prototype document and executable behaviour differ, this document reports **executable behaviour** and marks unimplemented material. @@ -372,6 +372,11 @@ stay where they are. So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card attacks, which do not exist in any mode. +**Saving a multiplayer game.** The server holds the save; **Save replay** downloads your copy. A save +carries the seed, and the seed is the whole deal — every hand and the order of the deck — so in a +**Competitive** game the download is available once the game is over. In **Co-op** it is available +at any time. + ## 7. Frequently asked questions ### Is the same seed always the same game? diff --git a/package.json b/package.json index af7fcb9..b04ce27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.5", + "version": "0.8.6", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/http.ts b/src/server/http.ts index 90e63ec..20caa37 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -845,8 +845,15 @@ export function startServer(opts: ServerOptions): Server { * The administrative export at `/api/games//save` is gated on the admin secret, which a player * does not have and should not need: a save is the seed and the moves, and every one of those moves * is already on this player's screen. So the seat's own session token is the gate, exactly as it is - * for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is - * disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`). + * for `/api/stream` and `/api/intent` — it proves which game and which chair. The page turns the + * JSON into a file (`main.ts`'s `downloadSave`). + * + * NOT WHILE A COMPETITIVE GAME IS RUNNING (Jesse's ruling, 2026-09-29, TODO #117). "Every one of + * those moves is already on this player's screen" was true of the moves and false of the SEED, + * which a save has to carry to be a save — and the seed is every rival's hand and the deck order + * for the rest of the game, the exact thing `game.ts` strips from the shared log. So a + * Competitive seat gets its save once the game is over; a Co-op table has nothing to hide from + * itself and gets it at any time, as does a one-seat game. */ if (url.pathname === '/api/save' && req.method === 'GET') { const ps = sessions.get(url.searchParams.get('token') ?? ''); @@ -855,7 +862,13 @@ export function startServer(opts: ServerOptions): Server { sendJson(res, 404, { error: 'no such game' }); return; } - sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() }); + const save = session.exportSave(); + const competitive = save.config.mode === 'competitive' && save.playerNames.length > 1; + if (competitive && save.status !== 'finished') { + sendJson(res, 403, { error: 'SAVE_AFTER_FINISH' }); + return; + } + sendJson(res, 200, { gameId: ps.gameId, save }); return; } diff --git a/src/web/main.ts b/src/web/main.ts index 3fc2f3a..a3faeed 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1497,6 +1497,19 @@ function render(): void { const f = session.view(); const menu = session.menu(); noteFirstFrame(f, rejoiningRemote); + /** + * A COMPETITIVE SAVE WAITS FOR THE END (Jesse's ruling, TODO #117). The file carries the seed, + * which is every rival's hand, so `/api/save` refuses it while the game runs — and the button + * says so here rather than silently doing nothing when clicked. + */ + const saveBtn = document.getElementById('savefile') as HTMLButtonElement | null; + if (saveBtn && !isLocal(session)) { + const locked = f.mode === 'competitive' && f.status !== 'finished'; + saveBtn.disabled = locked; + saveBtn.title = locked + ? 'In a Competitive game the save file carries the whole deal, so it can be downloaded once the game is over' + : 'Download this game as a save file you can replay or share'; + } // Which squares the selected card or track piece may go on. Highlighting them is what turns the // coordinate list into a board: you pick the thing, then click where it goes. diff --git a/test/server/http.test.ts b/test/server/http.test.ts index 41a4481..b1c7e28 100644 --- a/test/server/http.test.ts +++ b/test/server/http.test.ts @@ -151,6 +151,25 @@ describe('the HTTP layer (v0.8.4)', () => { assert.equal(reconnect['lastSeq'], 1, 'the reconnect push does not carry the count'); }); + it('hands a Competitive seat its save only once the game is over, and a Co-op seat at any time', async () => { + // Jesse's ruling on TODO #117: the seed in a save is every rival's hand, so a Competitive + // download waits for the end; a co-operative table has nothing to hide from itself. + const { host } = await table(); + assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200); + const refused = await fetch(base + `/api/save?token=${host.token}`); + assert.equal(refused.status, 403, 'a running Competitive game handed out its seed'); + assert.deepEqual(await refused.json(), { error: 'SAVE_AFTER_FINISH' }); + + const coop = await post('/api/lobby/create', { secret: SECRET, config: { ...config, mode: 'coop' }, displayName: 'Host', players: 2 }); + const coopHost = coop.json as unknown as Seat; + assert.equal((await post('/api/lobby/join', { secret: SECRET, gameCode: coopHost.gameCode, displayName: 'Guest' })).status, 200); + assert.equal((await post('/api/lobby/start', { token: coopHost.token })).status, 200); + const allowed = await fetch(base + `/api/save?token=${coopHost.token}`); + assert.equal(allowed.status, 200, 'a Co-op seat could not download its save'); + const body = (await allowed.json()) as { save: { seed: number } }; + assert.equal(typeof body.save.seed, 'number'); + }); + it('applies a burst of concurrent moves one at a time and leaves the save readable', async () => { const { host, guest } = await table(); assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200);