Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be reproduced and is written up in TODO.md with the two questions that would pin it down. TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain` carried no tray, so there was one button per platform however many trains stood at it and the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not even asking the same question: `check` skipped a train whose card refuses passenger work and the reducer did not. Both intents now carry an optional `trayId`, one function resolves the train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train, and the label names it. A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to another district. The load carries the seat that made it (`RollingStock.origin`), stripped by `pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400 paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf. THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the reading that "Freight House" was a collective term for exactly those two, and therefore what §9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound. Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which leaves exactly the one same-district pairing the rule above refuses. NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`. Both published replays that had gone dead were re-recorded; a rules change retires a save, and `harness.test.ts` is what catches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
155 lines
10 KiB
Markdown
155 lines
10 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.4.8 — 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 averaged 7.0 Revenue against a target of 20 —
|
||
of which ~5.4 was the "one Revenue per train that completes its run" rule, so the working freight and
|
||
passenger economy is still only ~2. That rule is now a **setting that defaults to off**, along with
|
||
the passenger and freight rates and the opening hand, so the economy can be read on its own and the
|
||
alternatives can be played rather than argued about. Nothing in this file or in `TODO.md` has been
|
||
re-measured at the new defaults; `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 opening hand is the exception, and it is now **chosen when
|
||
the game is dealt**: three random cards (the default, and 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. 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.
|