"If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or wish to complete switching." REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a stopped train out on the Mainline and protected it from a rear-ender: offered 4,212 times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already does that job better. The flag is now planted on one side of your own district and holds the next train arriving from that side. SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to build, nothing to forget, and a flag cannot quietly strangle the Division. The held train loses one Mainline Phase and comes in on the next — it buys a Stage to clear the lead, which is what "wish to complete switching" asks for. PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would certainly collide and the district's owner holds the card, the phase breaks in and asks. Offered ONLY to somebody holding one — a prompt with a single button is not a choice, and it would leak that a collision is coming. The danger is read from §8.3's own two triggers rather than restated, so the prompt cannot offer a flag against a collision that will not happen. Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and nothing else structural. THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the out-of-phase prompt unconditionally — the engine has already established the danger, so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one game in seven) to coincide with holding the card from a three-card hand out of 121. So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the bot is unwilling: it is measuring deck luck. What is left to fix is the half of the card a human would use, planting a flag on purpose to buy switching time, and TODO.md now says that instead of the old finding. A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was originally taken down in a `reduce` case, which never fires for an event advance.ts emits — the phase driver mutates state and then describes it. The flag stayed up and held every train that came. test/events.test.ts's unreduced-event registry is what makes that class of mistake visible, and `redFlagSpent` is on it deliberately now, with the reasoning. 858 tests pass. Closes #19 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
220 lines
16 KiB
Markdown
220 lines
16 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 player cannot see what the others did**, and that a lost
|
||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||
- **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 means about **zero**.
|
||
|
||
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
|
||
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.
|
||
- **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.
|