Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
254 lines
19 KiB
Markdown
254 lines
19 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 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.
|
||
|
||
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||
every client, so it cannot be revised.
|
||
**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.
|