107 lines
6.0 KiB
Markdown
107 lines
6.0 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
|
||
|
||
**v0.1.0 — 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** — fully specified. Ten gaps in the original prototype rules found and resolved.
|
||
- **Card faces** — every card's printed values specified.
|
||
- **Architecture** — six documents, including a 20-component build 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** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is
|
||
no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real
|
||
audio.
|
||
|
||
Balance is *not* where it should be: the developer bot averages 1.4 Revenue against a target of 20.
|
||
`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.
|
||
- **State is `fold(events)`.** The event log is the source of truth, which is what gives reconnection,
|
||
restart recovery and post-game replay from a single decision.
|
||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||
- **Track is a deck card.** 104 of the 243 cards in the Home Office deck 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. There is no separate supply and no one-piece-a-turn cap.
|
||
- **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.
|