Files
station-master/README.md
T

139 lines
9.0 KiB
Markdown
Raw 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
**v0.4.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype
rules were found and closed; three more came after, and are in `TODO.md`: where the Local's coach
stands while its engine works (a §A.4 question), Poling — the one card in the deck with no defined
behaviour — and whether a Heavy Grade's orientation is rolled or chosen at setup.
- **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 and the playable page. A
game can be saved, shared, replayed and stepped back through.
- **Not built** — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are
separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so
a remote one drops in without the page changing — but there is no server, no turn submission and no
per-player push), the 22 opponent-directed cards, and real audio.
Balance is *not* where it should be: the developer bot averages 7.0 Revenue against a target of 20 —
of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight
and passenger economy is still only ~2. `TODO.md` says why, and says which of it 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
│ └── design/ ← board layout studies and rendering samples
├── public/replays/ ← saved games published to the site's replay directory
├── scripts/ ← build and deploy the static site
├── src/
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
│ ├── sim/ ← bot, harness, replay, board rendering
│ └── web/ ← the playable site: splash, game, replay viewer
└── test/
```
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. There is no build step.
```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
fourteen of the forty-six event types are never reduced. Anything that needs to rebuild a game
replays the intents.
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
- **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the
largest category — so a district is built from what you draw, and building it costs you the
industry or train you drew instead. The one exception is setup: track is shuffled separately and
each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards.
You therefore open holding six against a limit of three, and the first turn is spent choosing.
Provisional — see `TODO.md`.
- **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid
once. It is worth +5.4 a game, more than the entire freight and passenger economy put together,
and it pays for traffic you do not have to work. Also provisional.
- **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.