Four playtest fixes off the Gitea tracker. The submodule pin moves to v0.7.1 and the version follows it; the downstream digit resets to :0, since v0.7.0 only ended on :3 because three test packs were played before it was released. - An empties-only train may couple a caboose again — a caboose carries the crew, not freight. - The end of a Day is a modal carrying the standings, the Days left and the combined target, instead of passing between one click and the next inside the automatic phases. - A Timetabled train may be discarded to a Department pile for a rival to pick up, governed by a new discardTimetabled setting that appears in both the New Game dialog and the lobby, on by default. An Extra never may. - A blocked passenger platform now gives its reason, including the coach shortage and what ends it. No new version file and no migration: current.ts's migrations.up is empty and nothing is carried from 0.7.0. GAMES IN PROGRESS SURVIVE THIS UPDATE — all three engine-visible changes only widen what is legal, so every intent a 0.7.0 game recorded still replays, and the server resumes a save by replaying it rather than by comparing version strings (src/server/index.ts in the game repo). README.md and instructions.md both named 0.7.0 and carry the three user-visible changes now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
236 lines
12 KiB
Markdown
236 lines
12 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.1.** Four playtest fixes on top of 0.7.0. An empties-only train may couple a
|
|
caboose again (a caboose carries the crew, so it is never a load); the end of a Day is a modal
|
|
carrying the standings, the Days left and the combined target, because a Day turns over inside the
|
|
automatic phases and used to pass unremarked; a Timetabled train may be discarded to a Department
|
|
pile for a rival to pick up, governed by a new `discardTimetabled` New Game setting that is on by
|
|
default, while an Extra never may; and a blocked passenger platform now reports its reason — the
|
|
coach shortage behind Gitea#2 is a ruled-in part of the game, and what was actually broken was the
|
|
silence. **Games in progress survive this update**: all three engine-visible changes only WIDEN what
|
|
is legal, so every intent a 0.7.0 game recorded still replays, and the server resumes a save by
|
|
replaying it rather than by comparing version strings.
|
|
|
|
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
|
|
```
|