JesseandClaude Opus 5 ea8e9dbcb8 0.7.1:0 — bundle Station Master v0.7.1
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
2026-08-25 10:58:14 -04:00

Station Master Logo

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

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

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
S
Description
Multiplayer Service for StationMaster Game
Readme
861 KiB
Languages
TypeScript 98.5%
Dockerfile 1.3%
Makefile 0.2%