The generated card reference now carries the half TODO #15a said was the point of generating it: every Enhancement's `live` / `dormantSolo` / `unbuilt` status — whether its printed effect actually resolves yet — and the opponent-directed Action and Space-use cards, none of which is dealt in any deck. A transcription cannot carry either fact. Card counts are removed throughout, as ruled: they move with play balance so a document printing them is stale on the next retune. Where a count matters it is a yes/no "is this dealt at all", which is a fact about the design rather than the current tuning. #88 closes with it — it asked whether `card-reference.md`'s industry rows were stale, deliberately without rewriting them since Laborer counts are a balance decision. They are; nothing in the engine changed; that file is simply no longer where anyone looks. The balance question it guarded is #70. Save compatibility becomes a general rule in `README.md` § Design notes rather than a fact restated per version: a save is a list of moves and reopens by being re-played through the CURRENT rules, so any change that makes a once-legal move illegal stops an older one there — a deck change being the likeliest breaker. It fails safe every time. #40 generalised, #32's version-specific note dropped, #52 carries the ruling that versioned replays are a post-1.0 question. #94 opened: a Red Flag set out at an Office's Limits holds the next train from that side and is drawn nowhere. `DivisionView`'s office node has no `redFlag` field and `board-svg.ts` never mentions one, so after the single log line announcing it there is nothing on screen. Same class as Gitea#21 and #22, and already on the common board's step 1 list. No engine change. 897 tests pass, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
232 lines
17 KiB
Markdown
232 lines
17 KiB
Markdown
# Station Master
|
||
|
||
A railroad operations game set in the era of timetable-and-train-order railroading (1840–1950),
|
||
being built as a multiplayer browser game with an authoritative server.
|
||
|
||
You are the Station Master of a lineside Office on a shared east–west Division. Trains run to a
|
||
timetable with no radios — just pocket watches and written orders. You switch cars, work freight and
|
||
passengers, and take your turn as Superintendent deciding whether it is safe to clear a following
|
||
train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
||
|
||
## Status
|
||
|
||
**Solitaire is playable in a browser and multiplayer runs against a server.** The solitaire game runs
|
||
entirely client-side — the engine is pure, imports nothing outside itself, and never touches
|
||
`Math.random`, so a static host is all it needs. Multiplayer adds an authoritative server for the
|
||
seats a shared game requires.
|
||
|
||
The current version is in [`package.json`](package.json), and every page stamps it with the commit
|
||
and build date, so what is deployed can always be identified from the page itself. This section
|
||
deliberately no longer names one: it went stale for six releases.
|
||
|
||
- **Rules** — specified. Thirteen gaps in the original prototype rules were found and closed, and the
|
||
three that came after were all settled in v0.5.0: a coach may be set out at the Office (§A.4),
|
||
Poling stays out of the deck at zero copies since the source records its effect only as "TBD", and
|
||
**a Heavy Grade's orientation is rolled from the seed, never chosen by a player** — see
|
||
[`docs/rules/implications.md`](docs/rules/implications.md) §10 for each ruling and its reasoning.
|
||
- **Card faces** — every card's printed values specified.
|
||
- **Architecture** — seven documents, including a 20-component build plan and the multiplayer plan.
|
||
- **Code** — the engine, the bot, the balance harness, the replay viewer, the playable page, and the
|
||
server. A game can be saved, shared, replayed and stepped back through.
|
||
- **Multiplayer — built and running.** `src/server/` serves a lobby (create, join, preview, leave,
|
||
add a bot, start, and a stream), turn submission, per-session state and persistence, with its own
|
||
tests under `test/server/`. Games survive a release rather than being destroyed by one. A player
|
||
weighing a join reads the **whole rule set before taking a seat**; a seat survives a browser
|
||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||
under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost
|
||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||
toggle could not do anything, so both screens state the fact in words instead.
|
||
|
||
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
|
||
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
|
||
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
|
||
nobody had to work. Measured at the current defaults the bot means about **zero**.
|
||
|
||
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
|
||
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
|
||
read on its own and the alternatives played rather than argued about. Run
|
||
`node src/sim/harness.ts 400 standard` for today's number rather than trusting one written here;
|
||
`TODO.md` says which of the gap is the bot and which is the deck.
|
||
|
||
Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes,
|
||
second for a set of features, 1.0 for the first release that deserves the name.
|
||
|
||
## Layout
|
||
|
||
```
|
||
station-master/
|
||
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
|
||
├── TODO.md ← open questions, provisional numbers, things to come back to
|
||
├── docs/
|
||
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
|
||
│ ├── architecture/ ← how it is built, and what the pieces are
|
||
│ ├── plans/ ← worked plans for a single change, kept for the reasoning
|
||
│ └── design/ ← board layout studies and rendering samples
|
||
├── public/
|
||
│ ├── replays/ ← saved games published to the site's replay directory
|
||
│ └── images/ ← art the build copies into the site
|
||
├── scripts/ ← build and deploy the static site
|
||
├── src/
|
||
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||
│ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence
|
||
│ ├── sim/ ← bot, harness, replay, board rendering
|
||
│ └── web/ ← the playable site: splash, game, lobby, replay viewer
|
||
└── test/ ← including test/server/ for the server's own suite
|
||
```
|
||
|
||
Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high
|
||
level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and
|
||
[`TODO.md`](TODO.md) is what we have decided not to forget.
|
||
|
||
## Development
|
||
|
||
Requires **Node 22.18+**, which runs TypeScript directly by type stripping — so there is no compile
|
||
step, and the engine, the bot and the tests all run straight from source. (`npm run build:web` is a
|
||
separate thing: it assembles the static SITE into `dist/`.)
|
||
|
||
```sh
|
||
npm install
|
||
npm test # node --test
|
||
npm run typecheck # tsc --noEmit
|
||
```
|
||
|
||
Because Node strips types rather than compiling them, the codebase is restricted to **erasable
|
||
syntax**: no `enum`, no parameter properties, no namespaces. `tsconfig.json` enforces this.
|
||
|
||
### Measuring the bot
|
||
|
||
```sh
|
||
node src/sim/harness.ts 200 # how the bot does, with the funnel
|
||
node src/sim/compare.ts 1600 trainCapSlack=1 # one change, paired against the current bot
|
||
```
|
||
|
||
**Never judge a heuristic on an unpaired run.** Revenue has σ ≈ 9 across games, so two runs of the
|
||
*identical* bot differ by about a point through nothing but the deal. `compare.ts` gives both
|
||
policies the same seed and reports the per-seed difference, where σ is 5.3 — 1600 seeds puts the
|
||
standard error at ±0.13, in under two minutes. Keep a change at **t ≥ 3**, and read the
|
||
better/worse/identical split beside the mean: a gain carried by a few rescued games is a different
|
||
claim from one spread across the field.
|
||
|
||
Variants come from `makeDeveloperBot(tweaks)`. A tweak is **temporary** — when it measures well it
|
||
becomes the default and the flag is deleted in the same commit; when it measures badly it is deleted
|
||
with the finding recorded in `CHANGELOG.md`. A bot that accumulates switches nobody can account for
|
||
is the thing this machinery exists to prevent.
|
||
|
||
## Design notes worth knowing
|
||
|
||
- **The rules engine is pure.** No I/O, no clock, no sockets, and all randomness derives from one
|
||
stored seed — so any game is exactly replayable, and a full game can be driven in a unit test with
|
||
no server at all.
|
||
- **It has two entry points, not one.** `apply(state, intent)` for player actions, and
|
||
`advance(state)` for everything the game does on its own — Mainline movement, collisions, the Stage
|
||
clock, Superintendent rotation.
|
||
- **The canonical record is the seed plus the intents.** A game is `{ seed, history: Intent[] }` and
|
||
`fromSave` replays it exactly — that one property gives save, share, undo, restart recovery and
|
||
post-game replay. Events are a DERIVED stream: they narrate what happened and drive the display,
|
||
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
|
||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||
replays the intents.
|
||
- **A save is only guaranteed to replay on the version that wrote it.** This is the cost of the
|
||
property above and is not a bug to be fixed case by case: a save is a list of moves, so it reopens
|
||
by being *re-played through the current rules*. Any rules change that makes a once-legal move
|
||
illegal will stop an older save at that move — **a change to the deck is the likeliest breaker**,
|
||
since a history that names a card the deck no longer deals has no legal answer at all, but any
|
||
narrowing of what is permitted does it. It fails safe in every case: the load is declined, the
|
||
offending move is named, and the file is left untouched, so nothing a player has is destroyed.
|
||
Assume an older save may not open, tell players so wherever a build is announced, and read "this
|
||
save will not load" in a bug report as this before treating it as a fault. **Versioned, migratable
|
||
replays are a post-1.0 question** — deliberately not worth the effort while the rules are still
|
||
moving this fast, since every migration would have to be written against rules that changed again
|
||
next release.
|
||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||
the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor`
|
||
is the single place that maps a question to whoever must answer it — a new question adds a case
|
||
there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds
|
||
the whole phase and the driver re-enters from the top, so anything already mutated is applied
|
||
twice or left half-done.
|
||
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
||
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
||
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
||
Day it grants. `state.official` is written at the first ending and never rewritten, so the winner
|
||
is always the one decided at `config.days` however long play carries on — `config.days` itself
|
||
never moves, and `state.extraDays` counts the borrowed ones. A §3.4 collision breach is the
|
||
exception and finishes outright, during an extended Day exactly as during the scheduled game.
|
||
Because a save is a replay, the vote is an intent (`game.extend`), and it is the one intent that
|
||
**carries its own player**: every seat may vote in any order, so a replay cannot derive who did.
|
||
- **Statistics are folded, not recorded.** `state.tally` counts what the event stream says happened —
|
||
trains through the Division and how many of them did any switching, loads made up and broken — and
|
||
is hooked
|
||
at the two boundaries every event crosses exactly once, `applyIntent` and `advance`. It is not
|
||
hooked in `reduce`, which never sees the phase driver's events at all. Nothing in the rules reads
|
||
it, so adding a counter is always safe; it rides the `Frame`, so a multiplayer client gets the same
|
||
numbers as solitaire from one implementation. **What it cannot count is anything the events do not
|
||
say.** `trainStoodStill` fires once per game for the X18 Circus alone, so "the longest an engine sat
|
||
on a siding" has no signal behind it — see `TODO.md` #36 rather than assuming an event means what
|
||
its name suggests.
|
||
- **A game is one of four TYPES, and a type is a set of defaults rather than a ruleset.** Co-op,
|
||
Competitive, Cutthroat and Solitaire (`src/web/presets.ts`) each name an opening hand, an Extra
|
||
rule, three revenue rates and the victory conditions; picking one fills the form, and changing any
|
||
of them selects **Custom**, which keeps the scoring of the type it came from. The type is *derived*
|
||
from the numbers rather than stored, so a saved game carries no name that can disagree with what it
|
||
actually is. Both screens that deal a game — the lobby and the New Game dialog — ask the same
|
||
eleven questions through one shared block (`src/web/settings-form.ts`), because for two releases
|
||
they each had a question the other lacked. What separates the types: Co-op alone pays for a
|
||
transit, Cutthroat alone lets an Extra be planted in another player's district and has no shared
|
||
failure condition at all beyond three collisions in a Day, and the Revenue floor is a formula in
|
||
the table size and the length (3 per player per Day in Co-op, 2 in Competitive) rather than a
|
||
number.
|
||
- **Track is a deck card, but the opening district is dealt.** Track is the largest category in the
|
||
deck by a distance — so a district is built from what you draw, and building it costs you the
|
||
industry or train you drew instead. The opening hand is the exception, and it is **chosen when the
|
||
game is dealt**: three random cards (the prototype rule), six random cards, or three track and
|
||
three other from two separately shuffled piles. The last of those is the only one that guarantees
|
||
you a district to build; deal six and you open over the limit of three, so the first turn is spent
|
||
choosing. **Every game type now deals six** (Jesse’s call, v0.7.0) — the engine's own fallback,
|
||
`SOLO_CONFIG`, deliberately did not move with it, because every sim measurement is taken against
|
||
that. See `TODO.md`.
|
||
- **What the work pays is a setting too.** Passenger revenue per coach, freight revenue per load and
|
||
train revenue per transit each run 0–5 and are fixed when the game is dealt. The first two default
|
||
to 1 and pay at both ends of a movement — boarding *and* detraining, loading *and* unloading. The
|
||
third pays every player when a train runs off the end of the Division and **defaults to 0**: at 1
|
||
it was worth more than the entire freight and passenger economy put together, for traffic nobody
|
||
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
|
||
it, and every save records the rules it was dealt under.
|
||
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
|
||
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
|
||
in a later Stage. A train has to carry them to a different district first. The printed game turns
|
||
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
|
||
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
|
||
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
|
||
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
|
||
to the developer bot over 400 paired deals, on 78 of them.
|
||
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
|
||
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
|
||
so an upgrade can never sever an existing join. Without it a district could only hang off track that
|
||
happened to be a turnout when it went down. Blocked by a standing car or a built Enhancement; the
|
||
replaced card leaves play, as board cards always do. `checkTurnoutUpgrade` in `src/engine/apply.ts`.
|
||
- **A Subdivision is the unit of clearance, not a card.** §8.1 asks whether the next *Subdivision*
|
||
holds an opposing train (an absolute bar) or a following one (the Superintendent's call). Every
|
||
Office starts as a Whistle Post, which is not a Control Point, so the whole railroad begins as ONE
|
||
Subdivision — that is why early traffic is so constrained, and why upgrading an Office to a Control
|
||
Point splits one in two and buys capacity. `subdivisions()` in `src/engine/state.ts`.
|
||
- **A Passenger Facility handles no freight, structurally.** `menAtWork` is `null` on a Depot, Station
|
||
or Terminal rather than an unused array, so freight work is refused on the shape of the facility and
|
||
no renderer can draw a pipeline that cannot exist. The converse holds: industries carry no porters.
|
||
- **The track is 45° geometry, not a graph on a grid.** Measured off `docs/tracks.png`: the through
|
||
rail runs east–west across the *exact vertical middle* of every card, there is no north–south track
|
||
anywhere, and everything that leaves through the north or south edge does so at **45°, through the
|
||
middle of that edge**. So two ports meeting is not enough to make a rail — two 45° legs can meet at
|
||
the same point and still form a V. Adjacency is `joins()` in `src/engine/track.ts`, never a bare
|
||
pair of `hasPort()` calls. A printed card turns 180° but never flips, so its handedness fixes which
|
||
diagonal its leg lies on for good, and a run-around needs one card of each hand. **Left is `nw_se`**
|
||
— named from the points, the side a train entering a turnout sees its diverging route leave toward,
|
||
with curves following the turnout whose leg they continue. Both tables had it inverted until
|
||
playtesting caught it; the geometry was never wrong, only the words.
|