Files
station-master/README.md
T
Jesse.MarkowitzandClaude Opus 5 76c6e103b3 v0.8.0.9 — the bot plans its switching turn, stops wasting its draws, and the engine walks each route once
The developer bot, re-measured decision by decision against the bot before it,
goes from about -0.3 revenue a game to about 4.8:

- plans the whole switching turn before its first Move (sim/switch-planner.ts),
  +2.89 over 1600 paired seeds; closes TODO #53
- takes a face-up card only if it could play it, +1.52 over 1600 seeds
- stops running Second Sections by accident in the New Train phase, +0.32
- lays track by what the district can do afterwards, +0.12 over 6400 seeds,
  run-arounds in 22 of 60 districts against 9

The engine is 2.8x faster with play proven identical: a route cache scoped to
one unchanged position, applyIntent split into prepareIntent + commitEvents,
and less allocation in exploreMoves. npm test now leaves out the bot
simulations, which run as npm run test:sim.

No rule changed; games in progress resume. Rejected candidates and the
Second Section card question are in CHANGELOG.md and TODO.md (#104-#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 15:30:42 -04:00

245 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 lost
session token still locks someone out of a running game from a genuinely fresh browser.
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
of arriving already rearranged. This is what closes "a player cannot see what the others did",
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
where its automatic phases finally get a visible beat.
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
the page is proven not to throw, but nobody has watched a bot switch on screen.
- **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 meant about **zero** until it began
planning its switching turns (2026-09-14), which put it near **2.8**.
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, everything except the bot simulations — run after every change
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
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.