Submodule pinned to v0.7.9 (6058f6c), current.ts bumped in place, release
notes rewritten in all five locales, README.md and instructions.md
updated. No new version file and no migration: the outgoing 0.7.8:0's
`up` was empty, which is versions.md's common case.
GAMES IN PROGRESS DO NOT CARRY OVER, and both docs lead with it.
v0.7.9 changes a rule. advance.ts gated §3.4's collision check on
`mode === 'competitive' || mode === 'coop'`, while SOLO_CONFIG carried
both limits (3 a Day, 5 total) and the setup screen offered them as live
settings with "the game ends in a loss" printed beside them. A solitaire
player could set a limit of 1 and crash all game. Jesse's ruling was that
the settings do what they say, so the gate went rather than the controls.
A solitaire save is { seed, history, rules? } where `rules` is the house
rules only — the collision caps are NOT in the save, so a restore takes
today's defaults. A 0.7.8 save therefore replays under live caps and
stops at the move that crossed one.
MEASURED WITH A CONTROL, because two earlier attempts at measuring it
were wrong. With the caps disabled, 1200 of 1200 generated solitaire
saves replay every intent — that is the control. Of those, 74 games
crossed the 5-collision total and 70 of the 74 truncate once the caps
are enforced; one replays 141 of its 532 intents and stops. So it is
"will break for anyone who crashed", not "may break". The earlier run
that reported no breakage had in fact been failing every replay at
intent 2 on an unrelated house-rules mismatch, and said nothing at all —
the control is the only reason that was caught.
It fails safe: the save is left untouched and the game declines to open
rather than loading a position the rules could not have produced, so a
player who cares can put 0.7.8 back and finish. Nothing in this package
can act on it either way — a solitaire save lives in the player's own
browser, not on this server. MULTIPLAYER GAMES ARE UNAFFECTED:
competitive and coop have been inside that gate all along.
TWO THINGS FIXED IN PASSING, both pre-existing and both in the way of
the release check. UPDATING.md was not prettier-formatted. And there was
no .prettierignore, so `prettier --check .` walked the bundled
application — a separate repo with its own style, which this package does
not own and does not commit — and reported 171 warnings it could do
nothing about. That check now passes clean, which is the point of having
it in the release sequence at all.
Verified: npm run check clean, prettier clean, make x86 packs as
v0.7.9:0. NOT yet installed on a box and NOT played.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
330 lines
22 KiB
Markdown
330 lines
22 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.9.** Enforces solitaire's collision limits, which had been offered as
|
|
settings and never checked — and that is a rules change, so **a solitaire game in progress will not
|
|
resume if it has had collisions**.
|
|
|
|
`advance.ts` gated §3.4's check on `mode === 'competitive' || mode === 'coop'`, while `SOLO_CONFIG`
|
|
carried both limits (3 a Day, 5 total) and the setup screen offered them as live settings with "the
|
|
game ends in a loss" printed beside them. A solitaire player could set a limit of 1 and crash all
|
|
game. Jesse's ruling: the settings do what they say, so the gate went rather than the controls.
|
|
|
|
**Why saves break, and how it was measured.** A solitaire save is `{ seed, history, rules? }` where
|
|
`rules` is the house rules only — **the collision caps are not in the save**, so a restore takes
|
|
today's defaults. A 0.7.8 save therefore replays under live caps and stops at the move that crossed
|
|
one. Measured with a control, because two earlier attempts were wrong: with the caps disabled 1200
|
|
of 1200 generated saves replay every intent; of those, 74 crossed the 5-collision total and **70 of
|
|
the 74 truncate** once the caps are enforced, one at 141 of its 532 intents. An earlier run that
|
|
reported no breakage had in fact been failing every replay at intent 2 on an unrelated house-rules
|
|
mismatch — the control is what caught that. It fails safe: the save is untouched and the game
|
|
declines rather than loading a position the rules could not have produced. **Multiplayer games are
|
|
unaffected** — competitive and coop were always inside that gate.
|
|
|
|
**Also in 0.7.9.** The end-of-game dialog now asks whether to play one more Day; the buttons existed
|
|
but were written into `#actions` _underneath_ a modal whose only control was Close, so a solitaire
|
|
player reaching the end was never offered the extension — and the Gitea#11 verification missed it
|
|
because it drove the HTTP API, which renders no dialog. `Frame.actor` carried `clock.currentActor`,
|
|
null for the whole Mainline Phase, so all three interruptions reported that nobody was holding the
|
|
game up; it carries `actingPlayer` and an `awaiting` field now. The game settings moved off the top
|
|
line into a **This Game** card drawn by the same renderer as the lobby's join preview, with the
|
|
running collision counts taking their place on the top line. The Office Area's auto-hide button was
|
|
a cycle that could not reach every state and is now three controls. The history reads newest first.
|
|
A Heavy Grade card draws which way it climbs.
|
|
|
|
**Bundled version: 0.7.8.** Made 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
|
|
```
|