Files
station-master-startos/README.md
T
JesseandClaude Fable 5.1 644b24051a 0.8.6:0 — bundle Station Master v0.8.6, the four audit releases
Submodule pinned 3befc42 (v0.8.2) → def4820 (v0.8.6). `current.ts` bumped in place — the
outgoing `up` was empty, no migration — with the resume question measured on this server's
own thirteen saves rather than argued from the diff: 0.8.2 had refused all thirteen (the
Second Section card went into the deck after its save check), and this build restores the
three it promised while leaving the ten it refused for a rule refused. Release notes in all
five locales; README's bundled-version summary rewritten for 0.8.3 through 0.8.6.
`instructions.md` is unchanged: nothing about running the service moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 18:20:35 -04:00

256 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.8.6.** Four releases from a code audit of the game — engine, server, browser
client, tests — with every finding verified against the code before it was acted on.
**The one that matters to this server first: 0.8.2 stranded every game on it, and said it stranded
ten.** The server's own boot log refused all thirteen saves at move 3. The Second Section card had
gone into the deck after 0.8.2's save check had been run, and a deck one card larger shuffles into a
different order from the same seed. 0.8.3 deals a save that predates the card from the deck it was
dealt from, and the thirteen replay exactly as 0.8.2's notes promised: three resume, ten refuse, the
same ten at the same moves. Nothing refused under 0.8.2 for a rule is un-refused here.
**The engine (0.8.3):** a player could switch a rival's train and the rival paid the Moves; a rival's
crew blocked your own track; drawing the last card off a Department could duplicate it and destroy
the refill; the unjam cleared the first load rather than the one named; the collision limit could not
fire in Stage 12; an Expedite fault was charged once per clearance question; a train held at the
Limits was only ever released by another arrival, never by a departure.
**The transport (0.8.4):** a player who left a lobby kept a token that could stream and move for
whoever took the seat next; the first move after a page reload could be silently swallowed; two moves
at once could tear the save file, and a torn save took every game on the server down at boot; an
error after a stream had started crashed the process; request bodies were unbounded; a double-click
did the thing twice. `http.ts` has an end-to-end test suite on a real port now.
**Housekeeping (0.8.5)** and **the save rule (0.8.6):** in a Competitive game the save file — which
carries the seed, and so every rival's hand — can be downloaded once the game is over; in Co-op at
any time.
## 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
Three of the four read or change the games on the server, and all three 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.
- **Restore a Seat** (`restore-seat`) — pick a seat from a dropdown of the players actually holding
a session, and get back a one-time link that puts that player into it. **This is the answer to a
lost seat token** (Gitea#33 in the game repo): the token is the only identity the game has and it
lives in one browser's `localStorage`, so a cleared profile, a private window or a different
browser leaves a player locked out of a game that is still running with their session still on
disk. The link carries a **code, never the token** — single-use, expires in 30 minutes — which the
page trades for the real token over a POST as it loads (`lobby-and-sessions.md` §1: keep the token
out of URLs). Anyone who opens the link takes that seat, so it is sent to one person and not to a
public channel. Minting is administrative because deciding that somebody has lost a seat is a
judgement; spending needs no secret, because the player following the link holds none. The
dropdown lists only seats a human holds a token for — bots never appear, read from the server's
session map rather than guessed from player names.
## 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
```