Compare commits

..
1 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Fable 5.1 def48201e4 v0.8.6 — a Competitive seat gets its save when the game is over
Jesse's ruling on TODO #117: accept the leak in Co-op; otherwise, save only at the end of
the game. `/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 Save replay button says why and
stays disabled until the end. Pinned in the HTTP suite; documented in rules.md §6.4.

Also corrects the 0.8.3 changelog entry: 0.8.2's notes did name the Second Section card
going into the deck; what was missing was the save check after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:31:23 -04:00
11 changed files with 84 additions and 10 deletions
+21
View File
@@ -19,6 +19,27 @@ page as `v0.1.0 · <sha> · <date>`, 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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+6 -1
View File
@@ -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?
+1 -1
View File
@@ -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",
+16 -3
View File
@@ -845,8 +845,15 @@ export function startServer(opts: ServerOptions): Server {
* The administrative export at `/api/games/<id>/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;
}
+13
View File
@@ -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.
+19
View File
@@ -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);