Submodule pinned to v0.7.8 (193800a). current.ts bumped in place at 0.7.8:0 — no new version file, no migration: the change is which screen a solitaire visit lands on, plus the splash footer's wording. Both are client-side, and a solitaire save lives in the browser rather than on this server, so nothing here is involved either way. 0.7.5 skipped the setup screen whenever the browser held a save, and any browser that has ever played solitaire holds one — so the door could never reach the screen again. The door outranks a save now; a bare reload still resumes; #ss-resume keeps the game in progress reachable since Deal clears it. Verified on phoenix.local against what the server returns: the door carries ?solitaire, play.html carries #ss-resume and #ss-saved-note, the footer names both ways to play, and the build tag is 0.7.8-mtfaojtv. Migration 0.7.7:0 -> 0.7.8:0 ran empty and both multiplayer games in progress (WHISTLE-4086, COAL-7370) resumed with their same intent counts. The behaviour itself is covered by four new tests upstream that reproduce the reported state (door + existing save) rather than by reading served output — which is what the two previous attempts did, and why they were reported as verified while still being wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
299 lines
17 KiB
Markdown
299 lines
17 KiB
Markdown
<p align="center">
|
|
<img src="icon.png" alt="Station Master Logo" width="21%">
|
|
</p>
|
|
|
|
# Station Master on StartOS
|
|
|
|
> Everything not listed in this document should behave the same as upstream
|
|
> Station Master. If a feature, setting, or behavior is not mentioned here,
|
|
> the upstream documentation is accurate and fully applicable — see the
|
|
> Documentation section of `instructions.md` for links.
|
|
|
|
Station Master is a railroad operations board game with an authoritative multiplayer server;
|
|
solitaire needs no server and is not what this package is for. This package runs that server —
|
|
the browser client, the lobby, and the intent/SSE API — as a single StartOS service.
|
|
|
|
**Bundled version: 0.7.8.** Makes the solitaire setup screen reachable at all. 0.7.5 skipped it
|
|
whenever `load()` found a saved game — reasoned as "a saved game is a game to resume" — and a browser
|
|
that has ever played solitaire always has one, so the door could never reach the screen again. The
|
|
door (`?solitaire`) outranks a save now; a bare reload still resumes. Since dealing calls
|
|
`clearSave()`, the screen carries a **Continue saved game** button and states what Deal costs, so the
|
|
door cannot destroy a game in progress. A solitaire save is browser-side, so nothing on this server
|
|
is involved either way. The splash footer also now names both ways to play.
|
|
|
|
**This was reported three times before it was found, and the first two fixes were real bugs that were
|
|
not it** — a routing fault (0.7.6) and a caching fault (0.7.7). Both were reported as verified, and
|
|
both verifications read what the SERVER returned rather than exercising the path with the state a
|
|
returning player actually has. What found it was a failing test written before the fix. Worth knowing
|
|
when the next "that didn't work" arrives: reproduce the reporter's state first.
|
|
|
|
**0.7.7 fixed the client caching** that stopped the two releases before it from ever reaching a
|
|
browser. `build-web.ts` stamps a build tag onto every module URL as a cache key, and
|
|
its fallback when `git rev-parse` fails was the literal `nogit` — which is precisely the `.s9pk`
|
|
case, since the Dockerfile copies the working tree in without `.git`. So every packaged release
|
|
published `./web/main.js?v=nogit`, byte-identical to the one before, and a returning browser refetched
|
|
nothing. `serveStatic` also sent no `Cache-Control` at all, so the pages that carry those tags were
|
|
themselves served from cache. The tag is now the package version plus the build timestamp, and a
|
|
request carrying `?v=` is `immutable` for a year while everything else is `no-cache`.
|
|
|
|
**Diagnosing "my fix did not ship".** A hard reload is NOT a sufficient check — confirmed in the
|
|
field on 0.7.6: the document refetches but ES module sub-imports keep their cached `?v=` URLs, so the
|
|
module graph stays stale. A fresh private window is the reliable test. Reading what the server
|
|
returns (`curl` inside the container) proves what was installed, never what a browser is running.
|
|
|
|
**0.7.6 fixed the solitaire door 0.7.5 introduced**: a browser that had ever held a multiplayer seat
|
|
could not reach the new setup screen at all — a bare page load could not tell "clicked Play
|
|
solitaire" apart from "reloaded mid multiplayer game", so the door lost to whatever game or lobby
|
|
that browser last touched. The door marks its intent explicitly now (`?solitaire`), the same fix
|
|
`?lobby` already carries for the door on the other side.
|
|
|
|
**0.7.5 was a client-side flow change**: a genuinely fresh visit to the solitaire page opens a setup
|
|
screen and asks for the game's options before dealing, the same question the multiplayer lobby has
|
|
asked before a game starts since 0.6.0.
|
|
|
|
**0.7.4 bundled three rules corrections** off the tracker (Gitea#13, #5, #19), all of them places
|
|
where the code and the cards disagreed.
|
|
|
|
**The Yard Office is offered rather than imposed (Gitea#5).** It was implemented in a form missing
|
|
all three of its conditions: a qualifying train was teleported onto the card, so nobody was asked, no
|
|
route was walked — the card's printed "that can reach the yard office in one move" was unenforced —
|
|
and nothing was ever met on the way in. The arrival now interrupts the Mainline Phase to ask the
|
|
district's owner, reachability is the engine's own move walk, and cars on the lead collide. Where no
|
|
route exists the offer is withheld and the history says why.
|
|
|
|
**Some Extras must run loaded (Gitea#13).** Circus, Campaign and Military trains take a loaded car
|
|
while the Division Yard can supply one, an empty once it cannot, and may depart short. The per-stop
|
|
point is earned once per Office Area rather than once per game, and only by a fully loaded train.
|
|
Two long-standing bugs went with it: `emptiesOnly` was rendered to the player and enforced nowhere,
|
|
and a set-up out on the Mainline paid its point to player 0 whoever was playing.
|
|
|
|
**Red Flags is a different card (Gitea#19).** The old rule protected a stopped train on the Mainline
|
|
and was played 4 times in 4,212 offers across 600 games. It is now a directional flag on your own
|
|
Limits, spent on the train it stops, playable either in phase or at the moment the engine sees a
|
|
certain collision.
|
|
|
|
**GAMES IN PROGRESS MAY NOT SURVIVE AN UPDATE FROM BEFORE 0.7.4.** Unlike 0.7.3, which changed no
|
|
rules, each of the three above can stop an older history replaying: the Red Flags intent changed
|
|
shape, a make-up that was legal may now be refused, and a Yard Office arrival asks a question no
|
|
older history has an answer for. It fails safe — `src/server/index.ts` refuses to resume a save the
|
|
rules reject, logs which move it stopped at, and **leaves the file untouched**, so an operator can
|
|
put 0.7.3 back on to finish a game that matters. A game that never meets one of the three carries on
|
|
normally, which is why this is "may not" rather than 0.7.2's "will not". **0.7.5 carries every game
|
|
forward without exception** — see above.
|
|
|
|
**Diagnosing the interruptions.** The Mainline Phase can now stop and ask three different questions,
|
|
of three different players: §8.1's clearance goes to the Superintendent, the Yard Office offer and
|
|
the Red Flag prompt to the owner of the district a train is arriving at. A game sitting on one is
|
|
waiting on a person, not stuck. `Games in Progress` shows it as active with nobody to wait for,
|
|
because an interruption is not a turn — the same reading as an extension vote.
|
|
|
|
0.7.0 remains the shape of the service: the lobby sets a game up from one of four game types
|
|
(Solitaire, Co-op, Competitive, Cutthroat) plus Custom, offers the whole rule set for reading before
|
|
a seat is taken, and lets a player leave a lobby or a running game and come back to it. None of this
|
|
changes anything the package itself does: the submodule pin is the version, and the service is the
|
|
same server it always was.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [Image and Container Runtime](#image-and-container-runtime)
|
|
- [Volume and Data Layout](#volume-and-data-layout)
|
|
- [File Models](#file-models)
|
|
- [Dependencies](#dependencies)
|
|
- [Network Access and Interfaces](#network-access-and-interfaces)
|
|
- [Installation and First-Run Flow](#installation-and-first-run-flow)
|
|
- [Actions](#actions)
|
|
- [Tasks](#tasks)
|
|
- [Health Checks](#health-checks)
|
|
- [Backups and Restore](#backups-and-restore)
|
|
- [Limitations and Differences](#limitations-and-differences)
|
|
- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers)
|
|
|
|
---
|
|
|
|
## Image and Container Runtime
|
|
|
|
Built from source with a custom `Dockerfile` — there is no published Station Master image.
|
|
|
|
| What to Document | Value |
|
|
| --- | --- |
|
|
| Image source | Custom multi-stage `Dockerfile`: `node:*-slim` builder runs `npm run build:web` to produce the static client, then a second `node:*-slim` stage copies only `package.json`, `src/`, and the built `dist/` — no `node_modules` in the runtime stage, since the server has zero runtime dependencies |
|
|
| Architectures | x86_64, aarch64 |
|
|
| Entrypoint | `node src/server/index.ts` — runs straight from TypeScript source; no compile step, see Limitations |
|
|
|
|
One subcontainer: `station-master-sub`, running the single daemon `server`.
|
|
|
|
## Volume and Data Layout
|
|
|
|
One volume, `data`, mounted at `/data` (`DATA_DIR`).
|
|
|
|
| What to Document | Value |
|
|
| --- | --- |
|
|
| Volume names | `data` |
|
|
| Mount points | `/data` |
|
|
| StartOS files | `store.json` — holds the join and admin secrets (see File Models) |
|
|
| Database | None — flat files. The server holds any number of games at once: each is `games/<gameId>/game.json` (`{ engineVersion, seed, config, playerNames, history, status, createdAt, lastMoveAt, botSeats }`) plus `turn-timings.json`, with a top-level `index.json` naming every game and lobby state for those not yet started |
|
|
|
|
## File Models
|
|
|
|
One StartOS-managed file, `store.json`, on the `data` volume.
|
|
|
|
- **`store.json`** — JSON, holding two independent secrets, both seeded on install by
|
|
`init/generateSecrets.ts` and both read reactively by `main.ts`, so rewriting either restarts the
|
|
daemon with the new value.
|
|
- `joinSecret` — 24 characters. What players need to create or join a game. The daemon will not
|
|
start without one, so it is never left unset. Rewritten only by the **Get Join Secret**
|
|
action, which mints a new value on every run.
|
|
- `adminSecret` — 32 characters. Gates the server's `/api/games` routes, which the two game
|
|
actions use. **Deliberately not the join secret**: every player holds that one, so gating a
|
|
delete with it would let anyone at the table destroy anyone else's game. It is never shown to
|
|
a player and never leaves the package except as the daemon's `ADMIN_SECRET`. Backfilled on
|
|
update for a volume written before this field existed, and otherwise never rewritten.
|
|
|
|
Neither key is re-asserted on start, so a hand edit to either survives until the relevant action
|
|
is next run.
|
|
|
|
Everything else under `/data` — `games/`, `index.json`, lobby and session state — is the
|
|
application's own persistence, written directly by the server process, not by a StartOS file
|
|
model.
|
|
|
|
## Dependencies
|
|
|
|
None.
|
|
|
|
## Network Access and Interfaces
|
|
|
|
One interface, `ui`, on the single port the daemon listens on. It serves the browser client, the
|
|
lobby and intent HTTP API, and the per-seat SSE game stream — all from the same origin, which is
|
|
what lets a player reach the server from a LAN address and another player reach it from a
|
|
different one in the same game.
|
|
|
|
## Installation and First-Run Flow
|
|
|
|
No setup wizard. On install, both secrets are generated and stored immediately (see File Models),
|
|
and a critical task is raised pointing at **Get Join Secret** — the service starts and is usable
|
|
the moment the daemon is healthy, but a player cannot create or join a game until the join secret
|
|
has been retrieved and shared with them.
|
|
|
|
**Updating the package keeps games in progress, unless the rules actually changed.** On boot the
|
|
server replays each saved game's moves through the current engine and resumes it if they all still
|
|
apply — the version that wrote the file is recorded and reported but decides nothing. When a move
|
|
*is* rejected, that game is refused and the log names it: `move 3 of 8 (localOps.choose) is
|
|
rejected by the current rules with OPTION_ALREADY_CHOSEN`. A refused game is never modified or
|
|
deleted, so reinstalling the previous version makes it loadable again and it can be played out.
|
|
|
|
Earlier versions of this package compared version strings instead, which destroyed every game in
|
|
progress on every update, including updates that changed only how the board is drawn.
|
|
|
|
## Actions
|
|
|
|
Two of the three read or change the games on the server, and both are `only-running`: what they
|
|
report exists only inside the live server process. A save is a seed plus a list of moves, so
|
|
"whose turn is it" is answerable only by replaying the game through the engine — which lives in
|
|
the game repo, not in this package. The server has already done that work and is asked for the
|
|
answer.
|
|
|
|
- **Get Join Secret** (`get-join-secret`) — run this any time you want to read the current join
|
|
secret, or to invalidate it and issue a new one. Every run **generates a fresh secret,
|
|
overwrites the stored one, and restarts the daemon** with it — there is no read-only mode.
|
|
Rotating does not disconnect players already seated in a running game (D14's join secret gates
|
|
the lobby door, not an in-progress session); it only invalidates the old value for anyone who
|
|
has not yet joined or created a game. Completes in a few seconds, safe to repeat.
|
|
|
|
- **Games in Progress** (`games-in-progress`) — read-only, changes nothing, safe to run at any
|
|
time. Lists every game and lobby on the server with its code, players, Day/Stage/phase, who it
|
|
is waiting on, when it started and when it last moved. Run it to find a game that has stalled —
|
|
a `Last move` days old with a named player under `Waiting on` is someone who is not coming back.
|
|
Returns quickly; the server answers from memory.
|
|
|
|
- **Manage Game** (`manage-game`) — pick a game from a dropdown built live from the server, then
|
|
either **Export** it (returns the complete save as copyable text and changes nothing) or **End**
|
|
it (deletes it from the server, disconnects anyone still watching, and removes its files and
|
|
index entry). **Ending cannot be undone and is not idempotent** — a second attempt reports that
|
|
the game no longer exists. It always returns the deleted game's save, so nothing is destroyed
|
|
without being handed back first; that text is the only remaining copy, so keep it if the game is
|
|
worth replaying. This is the only way a game ends other than being played to a finish: an
|
|
abandoned game otherwise stays active and is resumed on every restart indefinitely.
|
|
|
|
## Tasks
|
|
|
|
- **Get the join secret to share with players** — raised on install, severity `critical`. Points
|
|
at the **Get Join Secret** action. Clears the moment that action is run for the first time; it
|
|
does not return afterward (running it again to rotate does not re-raise it).
|
|
|
|
## Health Checks
|
|
|
|
- **Multiplayer Server** — fetches the server's own `/api/health` and reports what it says:
|
|
*"Multiplayer server is ready — 3 games in progress"*, with *", 1 waiting to start"* appended
|
|
only when a lobby exists, and *"no games in progress"* on an idle server. So the check that
|
|
proves the server is answering also says how much is going on.
|
|
|
|
A server that does not answer is reported as **starting, never failed**. The server replays its
|
|
saved games before binding its port, so a boot legitimately looks like nothing is listening, and
|
|
calling that a failure would make an ordinary restart look like a crash. Replaying measures
|
|
around 100 ms per game and only unfinished games are replayed, so in practice this window is a
|
|
fraction of a second. A check stuck on "starting" for much longer means the daemon is failing to
|
|
come up — read the service logs, where a refused resume names the game and the version that
|
|
wrote it.
|
|
|
|
## Backups and Restore
|
|
|
|
Strategy: the entire `data` volume is backed up wholesale (`ofVolumes`) — every file copied
|
|
exactly as it sits on disk, nothing dumped and replayed.
|
|
|
|
That includes every game's full intent history, so a restore can resume any in-progress game
|
|
exactly where it left off, and the join secret in `store.json`, so previously shared join links
|
|
keep working after a restore. Nothing needs to be rebuilt or re-entered after a restore; the
|
|
server resumes every saved game on boot the same way it does after an ordinary restart.
|
|
|
|
## Limitations and Differences
|
|
|
|
1. **No compile step.** The image runs the server directly from TypeScript source using Node's
|
|
native type stripping, rather than building a `dist/` for the server the way the browser
|
|
client is built. This is upstream's own deployment shape (`docs/architecture/deployment.md` in
|
|
the game repo), not something this package changed.
|
|
2. **The join secret has no per-player identity.** It is a single server-wide value (D14) that
|
|
gates who may create or join a game — it is not a username or password, and StartOS has no
|
|
visibility into who a player is once they've joined.
|
|
3. **No accounts, and one game at a time per person is expected but not enforced** — a deliberate
|
|
upstream design decision (`docs/architecture/multiplayer.md` §11 D13), not a StartOS-specific
|
|
limitation. Since 0.7.0 a browser can hold seats in several games at once and picks between them
|
|
in the lobby, which is a client convenience rather than a change to that decision.
|
|
4. **A player's identity lives in their browser.** The session token issued at join is the only
|
|
proof of who a player is; it is kept in that browser's `localStorage` and nowhere else. A player
|
|
may leave a running game and rejoin it (the lobby lists every game the browser is in), but a
|
|
token lost with the browser — cleared site data, a different device — cannot be recovered from
|
|
this package, and the seat stays in the game waiting. Upstream `TODO.md` tracks an
|
|
administrator-issued rejoin link; it does not exist yet.
|
|
|
|
---
|
|
|
|
## Quick Reference for AI Consumers
|
|
|
|
```yaml
|
|
package_id: station-master
|
|
image: built from source (Dockerfile)
|
|
architectures: [x86_64, aarch64]
|
|
subcontainers: [station-master-sub]
|
|
volumes:
|
|
data: /data
|
|
file_models:
|
|
- store.json
|
|
startos_managed_env_vars:
|
|
- DATA_DIR
|
|
- JOIN_SECRET
|
|
- ADMIN_SECRET
|
|
dependencies: none
|
|
interfaces:
|
|
ui: { type: ui, port: 8081 }
|
|
actions:
|
|
- get-join-secret
|
|
- games-in-progress
|
|
- manage-game
|
|
tasks:
|
|
- { action: get-join-secret, severity: critical }
|
|
health_checks:
|
|
- Multiplayer Server
|
|
```
|