Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a29de54ee | ||
|
|
d04f4c2fe4 | ||
|
|
def48201e4 |
@@ -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
|
||||
|
||||
@@ -91,7 +91,7 @@ station-master/
|
||||
├── public/
|
||||
│ ├── replays/ ← saved games published to the site's replay directory
|
||||
│ └── images/ ← art the build copies into the site
|
||||
├── scripts/ ← build and deploy the static site
|
||||
├── scripts/ ← build the site the package serves (and a static-host deploy, unused since 2026-09-29)
|
||||
├── src/
|
||||
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence
|
||||
|
||||
@@ -54,9 +54,11 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
- **Commits and tags are GPG-signed and I cannot make them.** Stage the work, write the message to
|
||||
a file, hand over a `!` command. Tags must be `git tag -s` — a bare `git tag` makes a lightweight
|
||||
tag and `git push --follow-tags` skips it *without any error*.
|
||||
- **There is one line now.** The 0.4.9 playtest line was retired on 2026-09-29 (Jesse: "no longer
|
||||
being maintained"), so `npm run deploy:web` from this directory is the deploy. The public site
|
||||
serves the last 0.4.9h build until that is run once from `main`.
|
||||
- **There is one line and one place the game is served.** The 0.4.9 playtest line was retired on
|
||||
2026-09-29 (Jesse: "no longer being maintained"), and the static site on the File Browser host
|
||||
with it: "the game is served on the StartOS service for station master." Releasing is the wrapper
|
||||
bump and `make install`; there is no site deploy. `npm run deploy:web` stays in the repo only in
|
||||
case a static host ever returns, and nothing depends on it.
|
||||
- **The save check is the LAST thing before the tag, not a thing done during the work.** 0.8.2
|
||||
replayed every server save, wrote "three resume, ten refuse" into its notes, and then put a card
|
||||
into the deck — and shipped with zero of thirteen resuming. Replay the server's saves against the
|
||||
@@ -695,7 +697,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
|
||||
|
||||
@@ -245,6 +245,7 @@ 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/save ?token= — the seat's own save; 403 SAVE_AFTER_FINISH in a running Competitive game
|
||||
GET /api/health { ok, service, engineVersion, games } — unauthenticated; see below
|
||||
GET /api/games every game and lobby, summarised ┐
|
||||
GET /api/games/<id>/save the save, for keeping or replaying ├ ADMIN_SECRET
|
||||
|
||||
+1
-1
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -110,4 +110,4 @@ Each extraction above names the test it makes possible:
|
||||
- Do not refactor `render()` and fix a screen bug in the same commit. The audit's value was that
|
||||
each finding could be verified against unchanged code.
|
||||
- Do not introduce a framework or a runtime dependency to do any of this. The zero-dependency rule
|
||||
is what makes the package a 63 MB `.s9pk` and the site a static upload.
|
||||
is what keeps the package a 63 MB `.s9pk`.
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
@@ -190,7 +190,9 @@ Please do report anything that looks like a bug or an area to be improved.
|
||||
- **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||
no coaches" is worth more than "passengers seem broken".
|
||||
- **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||
made, so it replays exactly and the problem can be looked at directly.
|
||||
made, so it replays exactly and the problem can be looked at directly. In a Competitive
|
||||
multiplayer game the button waits until the game is over, because the file carries the seed and
|
||||
the seed is every rival's hand; note the Day and Stage and save it at the end.
|
||||
- **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
|
||||
when the game was right.
|
||||
- **Anything you went looking for and could not find.**
|
||||
|
||||
+6
-1
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user