Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# OS
.DS_Store
Thumbs.db
# Editors
.vscode/
.idea/
*.swp
# Dependencies / build output
node_modules/
dist/
build/
target/
__pycache__/
*.pyc
# Env
.env
.env.local
# Logs
*.log
+60
View File
@@ -0,0 +1,60 @@
# Station Master
A railroad operations game set in the era of timetable-and-train-order railroading (18401950),
being built as a multiplayer browser game with an authoritative server.
You are the Station Master of a lineside Office on a shared eastwest 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
Design complete, implementation just begun.
- **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** — build step 1 of 12 complete: card catalogue, state model, seeded RNG.
The MVP target is **solitaire, one Office, a fixed number of Days**, with a server talking to a
single browser.
## Layout
```
station-master/
├── docs/
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
│ └── architecture/ ← how it is built, and what the pieces are
├── src/
│ └── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
└── test/
```
Start with [`docs/design.md`](docs/design.md) — it indexes everything.
## 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.
## 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.
Binary file not shown.
Binary file not shown.
+437
View File
@@ -0,0 +1,437 @@
# Component Inventory & Project Sizing
The buildable pieces: what code must be written, where each runs, when each runs, and how big each
is. The five sibling documents describe the system's *concerns*; this one describes its *parts*.
Sizes are **indicative, not estimates of effort**. T-shirt size for MVP and for finished, a one-line
driver of that size, and a LOC band for comparison between components.
---
## 1. What the MVP is
**Solitaire. One Office. A fixed number of Days. A server talking to a single browser. The display
functional, not pretty.**
```
DP(W) [ML] [ Office ] [ML] DP(E)
```
Two Mainline cards, one Office Area, and the player is permanently the Superintendent. Trains enter
at a Division Point, cross a Mainline card, reach the Office, get switched and worked, highball, and
exit at the far end.
### What solitaire simplifies — and what it does not
**It removes players, not rules.** Every rule still runs with one player: the six-Move switching
game, mandatory coupling, consist ordering, the four-Laborer freight pipeline, highball conditions,
automatic collisions, the twelve-Stage clock, and the Superintendent's following-train clearance
decision (you are the Superintendent, ruling on your own trains).
| Removed at MVP | Still required at MVP |
| --- | --- |
| Lobby, matchmaking, game codes | Full rules engine |
| Hiding hands from opponents | Phase driver |
| Turn arbitration between players | Card catalogue |
| Presence, reconnection of others | Track graph and Move legality |
| Fedora rotation between players | Board and action UI |
| Competitive/Co-op victory modes | Event log and persistence |
| Collision floor (Competitive only) | Server and protocol |
**Consequence: the engine is the dominant cost and it is front-loaded.** Components 46 barely differ
between MVP and finished. Most of the MVP-to-finished delta lives in the server and client layers,
not in the rules.
---
## 2. Component inventory
Execution locations: **Engine** (pure, runs wherever it is hosted — server-side at MVP), **Server**,
**Browser**, **Dev** (developer machine or CI; ships nowhere).
### A. Engine core
Pure, no I/O, no clock, deterministic given a seed. See [`overview.md`](overview.md) — this boundary
is what makes the whole system testable without a server.
---
**1. Card catalogue / static content**
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects,
train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md)
- **MVP: S** · **Final: S** · ~200 → ~350 LOC
- **Size driver:** it is data, not logic. Grows only if card variants are added.
- **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
not code, so tuning never requires touching the engine.
**2. State model and types**
- **Where:** Engine · **When:** always resident
- **Does:** the entity model from [`game-state.md`](game-state.md) — Division, Office Areas, Crew
Trays, Facilities, decks, yards, clock, Revenue.
- **MVP: M** · **Final: M** · ~400 → ~600 LOC
- **Size driver:** breadth of the model. Nearly all of it is needed even for one Office; the delta is
multi-player and multi-Office bookkeeping.
**3. Track graph and movement**
- **Where:** Engine · **When:** per Move, and per train step in the Mainline Phase
- **Does:** the Office grid as a port graph — turnouts whose two legs are not joined to each other
(§A.1), Operational Rail, the no-reversal rule within a Move, mandatory coupling, the Office card's
plain junction stubs, and dynamic loss of Operational Rail while `MEN | AT | WORK` is occupied.
- **MVP: L** · **Final: L** · ~500 → ~700 LOC · *built: 285 LOC*
- **Size driver:** which port pairs each card joins, and the rule that a traversal never leaves by
the port it entered. The constraints in `game-state.md` §3 all land here. Barely changes from MVP
to finished.
**4. Intent validation and application**
- **Where:** Engine · **When:** per submitted player intent
- **Does:** `apply(state, intent) → events | rejection`. Every legal player action from
[`protocol.md`](protocol.md) §1 — the three-way Local Ops choice, Moves, drops, draws, card plays,
Freight Agent operations, car placement, Porter and Laborer actions, clearance rulings.
- **MVP: L** · **Final: L** · ~800 → ~1000 LOC
- **Size driver:** the number of distinct legal actions, which solitaire does not reduce.
**5. Phase driver**
- **Where:** Engine · **When:** continuously between player inputs
- **Does:** `advance(state) → events | needsInput`. Runs the whole automatic side: Mainline movement
in `(number, isExtra)` order, highball evaluation, collision resolution, train make-up, Stage and
Day advance, Fedora rotation, per-Stage resource reset, victory checks. Pauses for the
Superintendent's clearance decision.
- **MVP: M** · **Final: L** · ~400 → ~800 LOC · *built: 430 LOC*
- **Size driver:** the Mainline Phase. Ordering, highball conditions and collision triggers are all
here. Grows with multiplayer turn arbitration and the Competitive collision floor.
- **Note:** carries a deliberate safety net — if an actor somehow has no legal action, the turn ends
rather than the game hanging. It should never fire, but a hung game is far harder to diagnose than
a forfeited turn.
- **Note:** this is the component most likely to be missed entirely when planning, because it is not
a feature anyone asks for — it is simply how the game moves.
**6. Legal-action enumeration**
- **Where:** Engine · **When:** whenever the client needs affordances — every state change
- **Does:** `legalActions(state, actor) → Intent[]`. Which grid squares a Crew Tray can reach, which
cars may be dropped, whether a Laborer has work available, which cards are playable where.
- **MVP: M** · **Final: M** · ~300 → ~450 LOC
- **Size driver:** it must mirror component 4's rules exactly. The temptation is to reimplement them;
the discipline is to share predicates with 4 so the two can never disagree.
**7. Seeded RNG**
- **Where:** Engine · **When:** deck shuffles, the 1D12 timetable roll, setup rolls
- **Does:** deterministic pseudo-random from one stored seed, so any game is exactly replayable.
- **MVP: S** · **Final: S** · ~50 → ~80 LOC
- **Size driver:** trivial, but must be threaded through the engine rather than called ambiently —
a single stray `Math.random()` destroys replay.
### B. Server
---
**8. Game session host**
- **Where:** Server · **When:** per active game, for the game's lifetime
- **Does:** owns one game's state, pumps `advance` until it needs input, routes intents to `apply`,
emits events, serializes access so two intents never interleave.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with
concurrent games and per-player routing.
**9. Event log and persistence**
- **Where:** Server · **When:** append per event; full read on startup
- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and
later reconnection and the replay viewer.
- **MVP: S** · **Final: M** · ~100 → ~350 LOC
- **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing.
**10. View projection / redaction**
- **Where:** Server · **When:** per state change, per connected client
- **Does:** builds each player's permitted view. Public board, private hand, deck **count** visible
but deck **order** never sent.
- **MVP: S** · **Final: M** · ~80 → ~300 LOC
- **Size driver:** nearly free at MVP — one player, so the only secret is deck order. Grows sharply
once opponents' hands must be hidden, and it is the trust boundary, so it must be exactly right.
**11. Transport**
- **Where:** Server · **When:** per connection; pushes on every event
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
Bidirectional — most traffic is server → client.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
and reconnect-with-replay.
**12. Lobby and session lifecycle**
- **Where:** Server · **When:** before a game starts; on reconnect
- **Does:** create/join by game code, seating order (which sets adjacency *and* Superintendent
rotation), config lock at start, session tokens, reconnection.
- **MVP: XS** · **Final: M** · ~50 → ~450 LOC
- **Size driver:** essentially absent at MVP — one player, one game, start immediately. This is the
single largest MVP-to-finished delta in the system.
**20. Process bootstrap and configuration**
- **Where:** Server · **When:** once, at process start
- **Does:** reads environment configuration (bind address, port, data directory), opens or creates the
event log, constructs the session host and transport, wires them together, handles shutdown.
- **MVP: XS** · **Final: S** · ~80 → ~200 LOC
- **Size driver:** trivial in itself, but it is where
[`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process
and single port, relative URLs only, all state under one configurable directory, configurable bind
address, and no assumption of public reachability. Those rules cost nothing here and are what keep
both the plain-web-host and StartOS-service paths open.
### C. Browser
---
**13. Client state store**
- **Where:** Browser · **When:** on every received event
- **Does:** applies events in order, detects gaps and requests replay, holds the current view and
affordance set. Never mutates state locally in a way that could drift.
- **MVP: S** · **Final: M** · ~150 → ~350 LOC
- **Size driver:** ordered application is simple; gap recovery and optimistic prediction are what grow.
**14. Board renderer**
- **Where:** Browser · **When:** on every view change
- **Does:** draws the Division, the Office grid, track geometry, trains and consists, facilities with
their boxes and `MEN | AT | WORK` track, the turn chart and timetable.
- **MVP: M** · **Final: L** · ~500 → ~1500 LOC
- **Size driver:** at MVP, one Office and "functional not pretty". Grows with several Offices, the
full Division chain, animation, and responsive layout.
**15. Action and affordance UI**
- **Where:** Browser · **When:** whenever the player may act
- **Does:** presents the three-way Local Ops choice; the switching interface — six Moves, reachable
squares, mandatory coupling, drop-in-seated-order, the four-slot limit; card play and placement;
Laborer and Porter assignment; the clearance prompt.
- **MVP: L** · **Final: L** · ~600 → ~1200 LOC
- **Size driver:** **the sleeper of the project.** The switching interface is the hardest UI in the
game and it is needed in full at MVP — a player cannot do anything useful without it. It barely
simplifies for solitaire.
**16. Replay viewer**
- **Where:** Browser · **When:** after a game ends — **post-MVP**
- **Does:** read-only projection over the stored event log, with playback controls.
- **MVP: —** · **Final: S** · 0 → ~250 LOC
- **Size driver:** small *because* it reuses component 14. Cheap only if events render standalone —
see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability).
### D. Dev-side
Real code that must be written and maintained, but ships nowhere. This is the answer to "what might
need to run somewhere else."
---
**17. Heuristic bot players**
- **Where:** Dev · **When:** simulation runs and tests
- **Does:** plays legal games without a human — picks a Local Ops option, spots cars, works freight,
rules on clearances.
- **MVP: S** · **Final: M** · ~200 → ~500 LOC
- **Size driver:** a bot that plays *legally* is small; one that plays *well* enough to validate
balance numbers is much larger. Only the former is needed first.
**18. Balance simulation harness**
- **Where:** Dev · **When:** on demand, in batches
- **Does:** runs many seeded games, reports Revenue per Day, game length, collision frequency, Crew
Tray contention — to retune the provisional numbers.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** trivial to run games; the work is in reporting and in deciding what to measure.
- **Note:** this exists *only* because the phase driver lives inside the engine. It is a loop over
`advance` and `apply` with no server involved.
**19. Test fixtures and golden replays**
- **Where:** Dev · **When:** CI, every commit
- **Does:** hand-built states for tricky rules (facing-point switching, collision triggers, consist
ordering) plus recorded full games replayed to detect regressions.
- **MVP: M** · **Final: L** · ~400 → ~1000 LOC
- **Size driver:** proportional to rules surface, which is large. Under-investing here is the most
expensive mistake available, because rules bugs are silent.
---
### Estimate accuracy so far
Components 14, 6 and 7 are built. Actual against the MVP estimate:
| Component | Estimated | Actual | |
| --- | ---: | ---: | --- |
| 1 · card catalogue | ~200 | 465 | data-with-types is more verbose than allowed for |
| 2 · state model | ~400 | 349 | close |
| 3 · track graph | ~500 | 286 | the port-pair model turned out compact |
| 4 · apply | ~800 | 830 | close |
| 6 · legalActions | ~300 | 187 | small *because* it delegates every rule to `check` |
| 7 · seeded RNG | ~50 | 75 | close |
| **Engine subtotal** | **~2,250** | **2,192** | **within 3%** |
The individual figures move around by up to 2× in both directions, but the subtotal is close enough
that the group-level numbers below are worth keeping. The one systematic lesson: **static data costs
more than logic** — component 1 was the worst miss, and components 2 and 3 (types and graph) came in
under.
## 3. Dependency graph
```
[1] card catalogue
[2] state model ◄──── [7] seeded RNG
[3] track graph
┌───────┼───────┐
▼ ▼ ▼
[4]apply [5]advance [6]legalActions
└───────┼───────┘
[8] session host ──► [9] event log
[10] view projection
[11] transport ◄──── [12] lobby
│ wired together at startup by
[20] bootstrap / config ──► [9]
[13] client store
┌───────┴───────┐
▼ ▼
[14] renderer [15] action UI
[16] replay viewer
dev-side: [17] bots ──► [18] harness both depend on [4][5][6]
[19] fixtures depends on [4][5][6]
```
Acyclic. Everything flows from the engine outward.
---
## 4. Build order
Each step ends at something demonstrable.
| Step | Build | Testable when done |
| --- | --- | --- |
| 1 | 1, 2, 7 | Card data loads; a game state can be constructed and seeded |
| 2 | 3 | Move legality provable against Appendix A's worked switching examples |
| 3 | 4, 6 | Individual actions apply correctly; illegal ones rejected |
| 4 | 5 | **A full solitaire game runs to completion, headless** |
| 5 | 19, 17 | Rules regressions caught automatically; bots play legal games |
| 6 | 18 | **Provisional numbers validated or retuned against real data** |
| 7 | 8, 9, 20 | A game runs in a server process and survives restart |
| 8 | 10, 11 | A browser can connect and receive an ordered event stream |
| 9 | 13, 14 | The board renders and updates live |
| 10 | 15 | **MVP complete — a human plays a solitaire game end to end** |
| 11 | 12 | Multiple games, multiple players, lobby and reconnection |
| 12 | 16 | Post-game replay |
**Step 4 is the important milestone.** A full game running headless proves the rules before a single
pixel is drawn. **Step 6** answers whether the numbers are right — while changing them is still free.
---
## 5. Overall sizing
| Group | Components | MVP | Finished |
| --- | --- | ---: | ---: |
| A. Engine core | 17 | ~2,650 | ~3,980 |
| B. Server | 812, 20 | ~610 | ~2,100 |
| C. Browser | 1316 | ~1,250 | ~3,300 |
| D. Dev-side | 1719 | ~750 | ~1,900 |
| **Total** | **20** | **~5,260** | **~11,280** |
Read these as ratios, not promises. What they say:
- **The engine is half the MVP and it comes first.** No way around it — solitaire removes players,
not rules.
- **The MVP is roughly half the finished system.** Unusually high, because the rules do not shrink.
- **The server is the cheapest group at MVP and grows the most proportionally** (~3.5×), almost
entirely in lobby, redaction and fan-out — all multiplayer concerns.
- **Dev-side is ~15% of the work.** That is proportionate for a game whose rules bugs are silent.
---
## 6. Worked trace — one Move, end to end
Following a single action through every component, to show where the seams are. The player moves a
Crew Tray onto a card holding two standing cars, which couples them automatically (§A.4).
| # | Component | What happens |
| --- | --- | --- |
| 6 | Legal-action enumeration | Computes reachable Operational Rail squares from the tray's position, respecting turnout direction and the no-reversal rule. The occupied card is among them. |
| 15 | Action UI | Highlights those squares. Player clicks the occupied one. |
| 15 | Action UI | Emits `Switch.Move { trayId, to }`. Note it does **not** emit a couple intent — coupling is mandatory, not a choice. |
| 11 | Transport | Carries the intent to the server. |
| 8 | Session host | Checks it is this player's turn and the right phase; hands it to the engine. |
| 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. |
| 3 | Track graph | Confirms the path exists without a direction change. |
| 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. |
| 9 | Event log | Appends both events to disk. |
| 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. |
| 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. |
| 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. |
| 11 | Transport | Pushes events and the new view. |
| 13 | Client store | Applies both events in order. |
| 14 | Board renderer | Redraws the tray at its new position with the coupled cars in the consist. |
| 15 | Action UI | Updates the Moves counter and the highlighted squares. |
Two things this makes concrete. **Coupling never appears as an intent** — it is a consequence the
engine produces, so a client that offered it as a choice would be wrong. And **component 6 runs
twice**, before and after: affordances are a projection of state, not a response to an action.
## 7. Design decisions this surfaces
The point of doing this before coding — questions now answerable, or now visibly needing an answer.
**Settled by the shape above:**
- **The phase driver belongs in the engine.** Without it the balance harness (18) would have to
reimplement the phase loop, and the two would drift.
- **`legalActions` must share predicates with `apply`**, not reimplement them. Two independent copies
of turnout directionality will disagree eventually, and the failure is a legal move the UI refuses
or an illegal one it offers.
- **Persist events, not snapshots.** Components 9, 13 and 16 all assume it, and retrofitting it later
means re-instrumenting everything.
- **The RNG must be threaded, not ambient.** One stray global random call and replay silently breaks.
**Settled since:**
- **Stack: TypeScript**, chosen precisely so the engine can run in both places. Node 22 executes
TypeScript natively by type stripping, so the engine, server and tests need no build step during
development; `tsc --noEmit` provides typechecking separately. Note the constraint this implies:
**erasable syntax only** — no `enum`, no parameter properties, no namespaces.
- **The engine ships to the browser.** It is pure and has no I/O, so component 6 runs client-side for
instant affordances without a round-trip, and there is exactly one implementation of turnout
directionality rather than two that can disagree.
- **The client computes affordances locally**, following from the above. Component 10 sends state,
not affordance lists.
**Now visible, still open:**
- **How is the Office grid rendered — DOM, SVG or canvas?** Component 14's finished size varies by
roughly 2× across those choices, and component 15 depends on the hit-testing model the choice
implies.
- **What does the replay viewer show?** Hands were private during play. Revealing them afterwards is a
design question, not a technical one — noted in `overview.md` and still unanswered.
+94
View File
@@ -0,0 +1,94 @@
# Deployment
A decision record comparing the two hosting paths under consideration: a plain web host, or packaging
the server as a StartOS service. **No packaging work is done here** — this exists so the choice can be
made deliberately, and so the architecture does not accidentally foreclose either option.
Target remains **small self-hosted** scale.
---
## 1. What the server needs, either way
The requirements are modest, which is what makes both paths viable:
| Need | Why |
| --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.md)) |
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
| Static asset serving | The browser client |
| No outbound network access | The game talks to nobody |
| No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) |
No database server, no message broker, no cache, no cron. SQLite or flat files cover persistence.
That single-process, single-port, one-volume shape is the least demanding thing either platform has
to host.
---
## 2. Plain web host
**What it takes:** a small VM or container host, a reverse proxy terminating TLS, a process manager,
and a backup of the data directory.
**Advantages.** Anyone with a link can join, which matters for a game whose discovery mechanism is
"read a code aloud to your friends." Deployment is unremarkable and every stack has good support for
it.
**Costs.** You own TLS certificates, updates, backups, and uptime. And a public URL means the server
is exposed to the open internet, which raises questions the game does not otherwise have — abuse of
game creation, resource exhaustion from unbounded lobbies, and the need for at least basic rate
limiting.
---
## 3. StartOS service
**What it takes:** packaging the server as an `.s9pk`. The workspace guide is on disk at
`start-technologies/projects/start-sdk/docs/src/``recipe-basic-service.md` for the overall shape,
`interfaces.md` for how the UI is exposed, `recipe-health-checks.md` for readiness, and
`recipe-backups.md` for the data directory. Start with `recipes.md`, which is the intent index.
**Advantages.** Installation, updates, TLS and backups are the platform's job rather than yours. The
service declares what it exposes; the *user* decides where it is reachable from. For a friends-and-
family game that fits well — the person running it already controls who gets the address.
**Costs.** Players must reach the host, which is a real constraint for a browser game: everyone needs
network access to that StartOS box. How they get it is the user's configuration decision, not
something the application chooses or should claim.
**One thing the architecture must respect.** A packaged service should not assume it is reachable at
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket
URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the
client from the same origin as the API and use relative URLs throughout. This costs nothing on a
plain web host and is required on StartOS, so it should simply be the rule.
---
## 4. Recommendation
**Build for both; decide later.** The requirements in §1 are satisfied identically by either path, and
the only architectural constraint that differs is the same-origin/relative-URL rule in §3 — which is
good practice regardless.
Concretely, do this from the start:
1. **Single process, single port**, serving both the client and the API.
2. **Relative URLs everywhere** in the client. No baked-in origin, no hard-coded hostname.
3. **All state under one configurable data directory**, path supplied by environment variable.
4. **No assumption of public reachability** anywhere in the code.
5. **Bind address and port configurable** by environment variable.
That set keeps both doors open at no cost. The decision only needs making when there is something
worth deploying — and by then it will be an easier call, because you will know whether the people
playing it are on your network or scattered.
---
## 5. Explicitly not decided here
- The stack. Still open; nothing above depends on it.
- Whether to publish to a StartOS registry (`publishing.md` in the guide) — irrelevant until the game
runs.
- Domain names, TLS specifics, or backup schedules for the plain-host path.
+365
View File
@@ -0,0 +1,365 @@
# Game State Model
The entity model for one game of Station Master, derived from
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md). This is the rules engine's world — pure data,
no I/O.
Written as pseudo-structure. Field names are illustrative; the shapes and constraints are the point.
---
## 1. The game
```
Game
id
config : GameConfig
seed : integer -- all RNG derives from this; games are replayable
players : Player[] -- index order IS seating order, west → east
division : Division
decks : Decks
yards : Yards
timetable : (TrainCardId | null)[12] -- index 0 = Stage 1
clock : Clock
collisionsToday : integer -- §3.4, resets at the start of each Day
status : setup | active | finished
outcome : Outcome | null
```
```
GameConfig
mode : solitaire | competitive | coop
victory : firstToTarget | highestAfterDays
length : short | standard | campaign
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
```
Targets and Day counts derive from `length` (§3.2): short 15/3, standard 30/5, campaign 60/10. Co-op
multiplies the target by player count. Do not store the derived numbers — compute them, so a config
change cannot leave a stale target behind.
```
Clock
day : integer, from 1
stage : 1..12 -- the Pocket Watch
phase : localOps | newTrain | mainline | loadUnload | shiftChange
currentActor : PlayerIndex | null
pendingDecision : SuperintendentClearance | null -- §8.1, during mainline only
superintendent : PlayerIndex
```
**`currentActor` is the whole turn machine.** After Gap 1, exactly one player may act at any moment.
Every other client is spectating. Deriving this rather than storing it is tempting but wrong — the
acting order starts at the Superintendent and proceeds left, and that traversal needs an explicit
cursor.
**The Mainline Phase is automatic but not input-free.** §8.1's fourth condition hands the
Superintendent a judgment call: when a train would depart into a Subdivision already occupied by a
train moving *the same direction*, he decides whether it is safe to follow. Movement otherwise
proceeds with no input.
```
SuperintendentClearance
train : CrewTrayId -- the train awaiting clearance
followingInto: SubdivisionRef
occupiedBy : CrewTrayId -- the train ahead, moving the same direction
```
This is the game's central tension and must be modelled, not optimised away. Gap 2 made the
*consequences* automatic — collisions resolve with no die roll — precisely so that this decision
carries full weight. The Superintendent who clears a following train into an occupied Subdivision
owns the wreck (§10), and in Competitive mode three wrecks in a Day ends the game for everyone
(§3.4).
Note the asymmetry with §8.1's third condition: a train in the next Subdivision moving *towards* the
considered train is an absolute bar — no decision, the train simply does not depart. Only the
same-direction case is discretionary.
Shift change fires at Stages 3, 6, 9 and 12 (§5), passing the Fedora one seat left.
---
## 2. The Division
The Division is an **ordered west-to-east sequence** of nodes. For N players there are N Office Areas
and N+1 Mainline cards (§4.3), with a Division Point beyond each end.
```
Division
nodes : DivisionNode[] -- ordered west → east
DivisionNode
= DivisionPoint { side: west | east, holding: CrewTrayId[] }
| MainlineCard { regions: [Region, Region] } -- 2 regions (§2.1, Gap 4b)
| OfficeArea { ...see §3 }
Region
occupant : CrewTrayId | null
```
A concrete 3-player Division:
```
DP(W) ML Office₀ ML Office₁ ML Office₂ ML DP(E)
└── 2 regions each ──┘
```
### Subdivisions are derived, never stored
A Subdivision is the Mainline track between the Limits of **opposing Control Points** (§2.1). Whistle
Posts are not Control Points and sit *inside* a Subdivision like ordinary mainline.
```
subdivisions(division) =
split the node sequence at every Office whose type is a Control Point,
and at both Division Points
```
At game start every Office is a Whistle Post, so the entire railroad is **one** Subdivision (§8) —
which is why early traffic is so constrained. Each Office upgrade splits a Subdivision in two.
Recompute this on demand. Caching it means every Office upgrade must remember to invalidate the
cache, and forgetting is a silent, hard-to-find bug in highball legality.
---
## 3. The Office Area
A **grid** of track cards, not a row (Gap 4a). The Running Track is the horizontal row through the
Office; everything else is Secondary Track.
```
OfficeArea
owner : PlayerIndex
officeType : whistlePost | depot | station | terminal
grid : Map<GridCoord, TrackCard> -- sparse; cards are placed during play
officeCoord : GridCoord -- where the Office card sits
runningRow : integer -- the grid row that is the Running Track
limitsWest : GridCoord -- moves outward as the Running Track grows
limitsEast : GridCoord
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType)
```
```
adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1
isControlPoint = officeType != whistlePost
isPassengerFacility = officeType != whistlePost
```
**Office cards are geometrically interchangeable** (§11.3). All four tiers carry the same track
footprint — a through track plus a plain junction stub above and below — and differ *only* in the
three properties above. An upgrade therefore changes `officeType` and nothing else; it must never
touch `grid`, `connections`, or anything attached to the Office card. The junction stubs carry no
directional restriction: §A.1's turnout rule governs drawn turnout cards only.
```
TrackCard
geometry : straight | turnout | runAround | officeCard | facilityCard
isOperationalRail : boolean -- the wheel icon; turnouts are false (§A.1)
connections : Direction[] -- which edges have rails
facility : Facility | null -- facility cards carry their own track (Gap 4a)
standing : RollingStock[] -- uncoupled cars left here, in track order
```
### Constraints that are easy to lose
These are the ones that will be got wrong if they are not written down explicitly:
1. **A turnout's two legs are not joined to each other.** Entering from the stem (§A.1's "A") permits
either leg; entering from a leg permits only the stem. This is an **absent edge, not a one-way
edge** — A→B and B→A are both legal. Modelling it as a directed graph forbids legal moves. The
card's internal connections are `stem-through` and `stem-diverge`, and `through-diverge` simply
does not exist. Separately, a traversal may never leave a card by the port it entered through;
that is what "without changing direction" means in practice.
2. **A turnout is not Operational Rail.** A train may pass through but not stop (§A.1).
3. **A Move never changes direction.** One Move is travel from one Operational Rail card to another,
any distance, no reversal (§2.4, §A). Reversing costs a second Move.
4. **A Facility track locked by loads stops being Operational Rail.** While any load sits on
`MEN | AT | WORK`, no car may be picked up or dropped off there and no train may occupy or move on
it (§9.3). This is *dynamic*`isOperationalRail` for a facility card is a function of facility
state, not a constant.
5. **Coupling is mandatory during Local Ops.** Moving a Crew Tray into standing cars picks them up;
you may not go around them (§A.4).
6. **Coupling is fatal during the Mainline Phase.** The same encounter is a collision (§A.4, §8.3).
7. **The Office track accepts no drop-offs** despite being Operational Rail (§A.4).
8. **Two trains may not share a card** or pass through each other — except the Office track, which
admits as many trains as it has free A/D tracks (§A.4).
9. **An Office upgrade must preserve every connection** (§11.3). All four tiers share identical
geometry precisely so the upgrade is a property change, not a card swap. An implementation that
models the upgrade as "remove old card, place new card" will silently orphan any Secondary Track
hanging off the Office — mutate `officeType` in place instead.
10. **The Office card's stubs are not turnouts.** Do not route them through the §A.1 directional
logic; a train may pass between the Running Track and either Secondary row freely.
---
## 4. Trains and Rolling Stock
```
CrewTray
id
trainCard : TrainCardId | null -- null while switching a local crew
engineFront : boolean -- which end the engine occupies (§A.3)
consist : RollingStock[] -- ORDERED, left-to-right, max 4
direction : east | west
position : NodeRef -- division point, mainline region, or grid coord
movesUsed : integer -- within the current Local Ops Phase
```
```
RollingStock
type : coach | boxcar | reefer | hopper | tank | caboose
loaded : boolean -- coloured = loaded, white = empty (§2.2)
```
**Consist order is load-bearing and must never be modelled as a set.** Cars come off in the order
they are seated in the tray (§A.3), and §8.1 blocks a highball unless the consist matches the order
listed on the train card. An unordered collection makes both rules unimplementable.
**The four-slot limit includes the caboose** (§A.4, Gap 4c). `consist.length ≤ 4`, cabooses counted.
A train requiring a caboose therefore carries at most three revenue cars — the single most likely
off-by-one in the whole model.
```
TrainCard
number : 1..12
isExtra : boolean
direction : east | west | playerChoice -- Extras are head-on; the player picks (§2.3)
consistSpec : { count, allowedTypes[], requiresCaboose }
class : limited | mailExpress | manifest | coalDrag | oilTrain | wayFreight | extra
```
Mainline movement order is `sort by (number, isExtra)` ascending — Timetabled before Extra on a tie
(§8, Gap 5).
**Train make-up is a loop, not a single pass** (§7, Gap 9). Cycle Superintendent-then-left, one car
per player per pass, until either the consist reaches `consistSpec.count` or no suitable car remains
in the Division Yard. A single pass would make consist length a function of player count. The loop
also resolves the 5+ player case for free: it ends the moment the consist fills, mid-pass if need be,
leaving no player holding a car they cannot place.
---
## 5. Facilities
```
Facility
kind : passenger | freight
subtype : mineTipple | produceShed | grocersWarehouse | oilRefinery | powerPlant | office
allows : { outbound: boolean, inbound: boolean } -- some freight are one-way (§9)
outboundBox : RollingStock[] -- green, to-be-loaded
inboundBox : RollingStock[] -- red, just-unloaded
capacity : { outbound, inbound, combined? } -- combined when the number touches both boxes (§9.1)
menAtWork : [Load|null, Load|null, Load|null] -- freight only; one load per box
industryTrack : { length: integer, cars: RollingStock[] } -- 3 or 4 per card (§12.5); ≤4 (§9.3)
laborers : integer
porters : integer
usedThisStage : { laborers: integer, porters: integer } -- resets each Stage (§9.1)
modifiers : ModifierRef[] -- adjacent cards raising capacity, track length or workers
```
All of these are populated from [`../rules/card-reference.md`](../rules/card-reference.md), which is
the authoritative per-card catalogue.
**`industryTrack` is where cars are spotted for loading and unloading**, and it is the field that
stops being Operational Rail while `menAtWork` holds any load (constraint 4 below). Its `length` is
3 or 4 depending on the facility and may be raised by a Team Track modifier.
**A Modifier serves only one Facility per Stage** even when adjacent to two (§9). Track which one
claimed it this Stage.
**A freight load costs four Laborer-actions for one Revenue point** — Green → MEN → AT → WORK → onto
the car (§9.3). A Porter earns a point in one action. This asymmetry is deliberate; do not "fix" it.
**Neither is the binding constraint, though.** Each player gets exactly one Local Operations action
per Stage, and stocking a green box or clearing a red one consumes the whole of it (§6.3). That
one-action-per-Stage budget is what actually limits Revenue — roughly one point per action, ceiling
12 per Day. Worker counts mostly determine how often a facility idles. See
[`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the full model; it is
what §3's targets are calibrated against.
---
## 6. Decks and Yards
```
Decks
homeOffice : CardId[] -- face down, order is secret
departments : [CardId|null, CardId|null, CardId|null] -- face-up market slots (§2.6)
salvageYard : CardId[] -- face up
hands : Map<PlayerIndex, CardId[]> -- private, max 3 (4 with Red Flag)
redFlags : Map<PlayerIndex, boolean> -- separate supply; removed when played
```
The Departments are **slots fed from the one deck**, not decks with their own contents (§2.6, Gap
4a). Reshuffle sweeps the Salvage Yard *and* all three Department slots back into the Home Office
deck (§6.2).
```
Yards
divisionYard : RollingStock[] -- the supply pile
classificationYard: RollingStock[] -- used stock
```
When the Division Yard empties, everything in the Classification Yard moves back to it (§2.2). Used
engines and cabooses return **directly** to the Division Yard, skipping the Classification Yard.
---
## 7. Players and scoring
```
Player
index : integer -- seating position, fixed unless Employee Rotation is on
name
revenue : integer -- may go negative; collisions cost 5 (§10)
officeArea : OfficeAreaRef
```
Revenue sources are exactly two: **+1** per completed load/unload operation (§9.2, §9.3) and **5**
per collision charged to that player (§10). There are no others.
```
Outcome
result : win | loss
winner : PlayerIndex | null -- null for co-op, solitaire, and shared losses
reason : targetReached | daysElapsed | collisionFloor | revenueFloor
```
Termination checks, in the order they must be evaluated:
1. **Collision floor** (Competitive only) — `collisionsToday >= 3` ends the game immediately, all
players lose (§3.4). Checked the moment a collision resolves, not at end of Stage.
2. **Target reached** (firstToTarget) — checked whenever Revenue increases.
3. **Days elapsed** (highestAfterDays) — at the end of the final Day, apply the collective Revenue
floor `3 × players × Days` for Competitive (§3.5), or the mode target for Solitaire and Co-op
(§3.3). Below the floor, everyone loses regardless of score.
---
## 8. What is derived, not stored
Getting this list wrong produces stale-state bugs that are miserable to find:
| Derived | From |
| --- | --- |
| Subdivisions | Office types along the Division |
| Whether a train may highball | Subdivision occupancy + consist order + A/D availability (§8.1) — except the same-direction case, which requires the Superintendent's decision |
| Whether a collision occurs | Board state at the moment of movement (§8.3) — pure, no RNG |
| Legal Moves for a Crew Tray | Grid connectivity, turnout direction, Operational Rail, occupancy |
| Facility track's Operational Rail status | Whether `menAtWork` holds any load |
| Revenue targets and floors | `config.length`, `config.mode`, player count |
| Whose turn it is | `clock.currentActor` — the one exception; explicitly stored (§1) |
---
## 9. Notes for whoever implements this
- **Seed everything.** One seed per game, all shuffles and D12 rolls drawn from it. This makes a
reported bug reproducible, which for a rules engine this size is worth more than it costs.
- **Model the grid sparsely.** Office Areas start at three cards and grow unevenly; a fixed 2-D array
will either waste space or need resizing at exactly the wrong moment.
- **Validate consist order on every mutation**, not just at highball time. A consist that silently
reorders during a switching move produces a train that can never legally depart, with no clue as to
when it broke.
- **Treat `isOperationalRail` as a function, not a field**, for facility cards — see constraint 4.
+173
View File
@@ -0,0 +1,173 @@
# Lobby and Sessions
Everything outside the rules engine: creating and joining games, seating, reconnection, and
persistence. Target is **small self-hosted** scale — a handful of concurrent games among people who
know each other.
Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
---
## 1. Identity
No accounts to begin with. A player is a **display name** plus a **session token** the server issues
on join and the client stores locally.
```
Session
token : opaque, unguessable
gameId
playerIndex
displayName
```
The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2,"
which is the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or
pasted into a chat.
Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp.
---
## 2. Creating and joining
```
Lobby.Create { config } → { gameId, gameCode, token }
Lobby.Join { gameCode, displayName } → { token, playerIndex }
```
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No
matchmaking, no browsing, no public game list. Players are already talking to each other; the code
just needs to survive being read aloud.
The creating player is the **host**: they set the config (§3) and start the game. Host-only rights
end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent
role rotates independently (§5).
**Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a
practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card
per player (§4.3), and every added player lengthens the Superintendent's rotation and the
phase-major waiting. **6 is a sensible cap** for the prototype.
---
## 3. Configuration, and when it locks
Set before start, immutable after:
```
mode : solitaire | competitive | coop
victory : firstToTarget | highestAfterDays
length : short | standard | campaign
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
```
These must lock at `Lobby.Start`. Changing `length` mid-game would move the finish line; changing
`mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning
mid-game, so the server should refuse rather than try.
---
## 4. Seating
**Seating order is not cosmetic.** The players form a physical west-to-east chain of Offices (§4.3),
and seat order determines three separate things:
1. Which Office is adjacent to which — and therefore where each player's trains arrive from.
2. The Superintendent rotation, which passes one seat left every three Stages (§5).
3. Acting order within a phase, which starts at the Superintendent and proceeds left (Gap 1).
So seating must be settled before the opening D12 rolls, and the lobby should show the chain
visually — a player should see whose Office lies east and west of theirs before the game begins.
**The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. Emit both
as events so clients can show the rolls rather than just the outcome — it is the game's first moment
of drama and there is no reason to hide it.
**Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each
Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's
*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be
separable. This is the one optional rule with real structural consequences — worth wiring in from the
start rather than retrofitting.
---
## 5. Disconnection and reconnection
Turn-based with one actor at a time makes this far easier than it would be in a real-time game.
**On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits
if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is
happening — silence with no explanation is the worst version of this.
**On reconnect:** the client presents its token, and the server replies with a full current view plus
the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up
is a replay, not a special case.
**On a player who does not come back:** the honest options at this scale are to wait, or to let the
host end the game. An optional **turn timer** is worth having — a lobby setting, off by default,
never part of the rules — that on expiry takes the safest legal action:
| Phase | Timeout action |
| --- | --- |
| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action |
| New Train | `NewTrain.PassCar` if legal, else place any legal car |
| Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor |
| Load/Unload | `LoadUnload.End` |
Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between
the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could
end the game for everybody (§3.4).
**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other
players' scores; a bot making them on an absent player's behalf changes the game rather than
preserving it.
---
## 6. Persistence
Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists,
and it makes a mid-game server restart a replay rather than a recovery.
```
games : gameId → { config, seed, status, createdAt, gameCode }
events : gameId → ordered event list
sessions : token → { gameId, playerIndex, displayName }
```
Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node
covers this comfortably, and so would flat files with an append-only log per game. The constraint
worth honouring is that the **event log is append-only**: rewriting history breaks the one property
that makes replay trustworthy.
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's log grows long enough
that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward
from there. Do not build this until it is needed.
**Retention.** Finished games **keep their event log, seed and config**, because post-game replay is
a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
and the log is the only thing it needs. Do not delete finished games by default.
Retention is an operator setting rather than an application decision — a self-hosted instance among
friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire
by age or by total count and say so plainly in the UI, because a replay that silently stops existing
is worse than one that was never offered.
Session tokens are the exception: those can expire on a short window once their game has finished.
---
## 7. What this deliberately omits
At small self-hosted scale these are not needed, and building them early costs more than it returns:
- **Matchmaking or a public game browser.** The game code covers discovery.
- **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a
high-score table would be a natural first addition — but it is a feature, not infrastructure.
- **Spectators.** Straightforward to add later, since a spectator is just a view with no `private`
section and no ability to send intents — the same shape post-game replay needs.
- **Horizontal scaling and cross-process coordination.** One process holds every active game
in memory.
+187
View File
@@ -0,0 +1,187 @@
# Architecture Overview
Station Master as a multiplayer digital game: players connect from a web browser, an authoritative
server manages lobbies and game state. Target scale is **small self-hosted** — a handful of
concurrent games, friends-and-family — hosted on a website or packaged as a StartOS service.
**No stack is chosen.** These documents describe what any implementation must do. Where a concrete
shape helps, it is written as pseudo-structure, not as a language.
Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
---
## The server is authoritative
The server owns all game state and is the sole party that may change it. Clients render state and
submit *intents*; the server validates, applies, and broadcasts.
This is not a default choice — the rules force it:
**The game has hidden information.** Each player holds up to three cards (four with the Emergency
Toolbox) that no other player may see. The Home Office deck is face-down and its order must not be
knowable. A client holding full state could read both.
**The game has randomness that decides outcomes.** D12 rolls set the Eastern Division Point, the
opening Superintendent, and — critically — the timetable slot a newly played Timetabled Train lands
in (§7). Deck shuffles decide everything else. A client-side RNG is a client-side cheat.
**Turn legality is global, not local.** After Gap 1, phases resolve phase-major with one player
acting at a time. Whether *you* may act depends on where every other player is. Only a global
observer can answer that. The Mainline Phase adds a wrinkle: it is automatic except when §8.1's
fourth condition prompts the Superintendent to rule on a following-train clearance — an input that
arrives mid-phase, out of the normal turn order, and can be triggered by another player's train.
**Collision evaluation must be identical for everyone.** After Gap 2, collisions are automatic — a
pure function of board state at each movement step. Two clients disagreeing about a wreck is a
divergent game, and in Competitive mode a wreck can end the game for everyone (§3.4).
### What that means in practice
| Concern | Where it lives |
| --- | --- |
| Deck order, shuffles, all RNG | Server only. Seeded, so a game is replayable for debugging. |
| Player hands | Server. Sent only to their owner. |
| Board state (track, pieces, facilities, yards) | Server. Public — broadcast to everyone. |
| Whose turn it is | Server. Derived, never client-asserted. |
| Rules validation | Server. The client may pre-validate for responsiveness, but the server's answer is the only one that counts. |
| Rendering, animation, local UI state | Client. |
The client is allowed to know the rules and grey out illegal moves — that is good UX. It is never
allowed to *decide* one.
---
## State, intents, events
Three flows, and keeping them distinct is what keeps the implementation tractable:
```
client ──── intent ────► server "I want to move Crew Tray 3 to card B4"
├─ validate against rules + current phase + current actor
├─ apply to authoritative state
client ◄──── event ─────────┘ "Crew Tray 3 moved to B4; 4 Moves remain"
client ◄──── view ─────────┘ per-player redacted snapshot
```
- **Intents** are what a player wants to do. They are proposals; they can be rejected.
- **Events** are what happened. They are facts, ordered, and form the game's history.
- **Views** are per-player projections of state, with other players' hands redacted.
An event log that fully determines state is worth building even at this scale. It gives
reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay
a reported bug exactly) for one design decision.
### Post-game replay — a desired future capability
**Not to be implemented yet.** Recorded here so it is not accidentally designed out, because the
architecture above already produces almost everything it needs.
The goal: when a game finishes, players can watch it back at speed — the Division filling with
traffic, the wrecks, who was Superintendent when. For a game whose drama is largely invisible while
you are playing your own Office, this is worth more than it would be in most games. You spend the
game watching your own station; the replay is where you find out what the railroad was doing.
State is already `fold(events)` over an ordered, append-only log, and all randomness derives from a
stored seed. Replay is therefore a **read-only projection over the existing log** — no new rules-engine
surface, no second code path, nothing the server has to do differently during play.
Three constraints keep it cheap, and all three are free if honoured from the start:
1. **Events must render standalone.** A `TrainMoved` should carry its from/to, not just an id the
renderer has to resolve against live state. An event stream that only makes sense when applied in
order to a mutable world is replayable but not *displayable* — the renderer would need to
reconstruct the whole board to draw one frame.
2. **Finished games keep their log, seed and config.** See the retention note in
[`lobby-and-sessions.md`](lobby-and-sessions.md).
3. **Timing is a presentation concern.** The log has no wall-clock pacing and does not need any —
the replay UI decides how fast to advance. Do not record timestamps for the benefit of replay;
Stage and phase boundaries are the natural beats.
The one genuine open question is **whose view a replay shows**. Hands were private during play, and
revealing them afterwards is a design choice rather than a technical one — it changes what players
learn about each other between games. Worth deciding before building, not during.
---
## Transport
The game is turn-based with exactly one actor at a time, so the traffic pattern is bursty and small —
but every player needs to *watch* the acting player, and above all to watch the Mainline Phase, where
trains move and wrecks happen while the Superintendent rules on clearances.
That means the server must push. A request/response API alone would leave four players polling to
watch a fifth switch cars.
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join)
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind
if the eventual stack makes SSE materially simpler.
What matters more than the choice: **every state change reaches clients as an event on one ordered
stream**. Clients apply events in order and never mutate state locally in a way that could drift.
---
## Timing and the absence of clocks
Nothing in the rules is real-time. A Stage advances when the phase sequence completes, not when a
timer fires. There are no reflexes to test and no reason to rush a player.
The one place real time may need to intrude is a player who walks away mid-game. That is a session
concern, not a rules concern — see [`lobby-and-sessions.md`](lobby-and-sessions.md). Any turn timer
is a lobby setting layered on top of the rules, never part of them.
---
## What the server does not need
Worth stating, because small self-hosted scale makes several standard concerns disappear:
- **No horizontal scaling.** A handful of concurrent games fits in one process. Game state lives in
memory; the event log is persisted for durability, not for coordination.
- **No matchmaking service.** Players share a game code (see `lobby-and-sessions.md`).
- **No accounts system, initially.** A display name plus a session token is enough to join and
reconnect. Real accounts can be layered on later without touching the game engine.
- **No anti-cheat beyond authority.** Server authority plus redacted views is the whole model.
---
## The one boundary that matters
Keep the **rules engine** free of any knowledge of networking, storage, or players-as-connections:
```
rules engine pure. state + intent → events, or a rejection.
▲ no I/O, no clock, no sockets. deterministic given a seed.
game session owns one game's state, applies intents, emits events
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
```
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
a full game through it in a unit test with no server at all, which matters a great deal for a game
with this much rules surface. It is also what would make a future Solitaire offline mode or an AI
opponent cheap rather than a rewrite.
Everything in [`game-state.md`](game-state.md) belongs to the rules engine. Everything in
[`lobby-and-sessions.md`](lobby-and-sessions.md) belongs outside it.
**The engine is reactive *and* active.** It exposes two entry points, not one:
```
apply(state, intent) → events | rejection a player did something
advance(state) → events | needsInput the game moves on its own
```
The second matters more than it looks. Mainline movement, collision resolution, Stage and Day
advance, Fedora rotation and train make-up all happen with no player acting — at a table the players
do this collectively, and in software something must. Keeping that **phase driver** inside the engine
rather than in the session layer means all rules knowledge stays in one pure deterministic place, and
a balance simulation becomes a loop over these two functions with no server involved.
For the full list of buildable pieces — where each runs, when, and how big — see
[`components.md`](components.md).
+269
View File
@@ -0,0 +1,269 @@
# Protocol
The client↔server message vocabulary, and how per-player views are redacted. Written against
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
Three message families, matching the flows in [`overview.md`](overview.md):
- **Intent** — client → server. A proposal. May be rejected.
- **Event** — server → clients. A fact. Ordered, and the game's history.
- **View** — server → one client. A redacted state snapshot.
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
is fixed by the rules.
---
## 1. Intents
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
reconnecting client.
### 1.1 Local Operations Phase
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
Stage.
```
LocalOps.Choose { option: switch | draw | freightAgent }
```
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
during night Stages (Appendix B).
```
Switch.Move { trayId, to: GridCoord }
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
Switch.End { } -- forfeit remaining Moves
```
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules
violation.
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
only free choice is how many.
**If `draw`** (§6.2):
```
Draw.FromHomeOffice { }
Draw.FromDepartment { slot: 0|1|2 }
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
Card.Discard { cardId, toSlot: 0|1|2 }
Draw.End { } -- server rejects while hand > 3
```
`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards,
which go to the timetable or to an Extra's staging.
**If `freightAgent`** — exactly one of three operations (§6.3):
```
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
FreightAgent.InboundToClass { facilityId, stockIndex }
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
```
### 1.2 New Train Phase
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
```
NewTrain.PlaceCar { trainId, stockType, loaded }
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
```
**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass —
until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so
the client must not assume one prompt per player per train. At five or more players the round ends
mid-pass when the consist fills; players not yet reached simply are not prompted.
`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a
suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a
pass when a legal car is available. A pass by every player in a full pass is the loop's other
termination condition.
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
```
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
```
### 1.3 Mainline Phase
The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth
condition). The server pauses movement, sets `clock.pendingDecision`, and waits:
```
Mainline.Clearance { trainId, allow: boolean }
```
Only the current Superintendent may send this, and only for the train named in the pending decision.
This intent arrives out of the normal turn order and may concern another player's train — the one
place in the game where a player acts during someone else's traffic.
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
```
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
```
### 1.4 Load/Unload Phase
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
```
Porter.Board { facilityId } -- §9.2, +1 Revenue
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
LoadUnload.End { }
```
A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
economy for a new player.
### 1.5 Lobby
Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness.
```
Lobby.Create { config }
Lobby.Join { gameCode, displayName }
Lobby.Leave { }
Lobby.SetConfig { config } -- host only, before start
Lobby.Start { } -- host only
```
---
## 2. Rejections
An intent is rejected with a reason a client can render, not a stack trace:
```
Rejected { seq, code, message, ... }
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
NOT_SUPERINTENDENT | NO_PENDING_DECISION
```
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore
worth logging server-side: a spike usually means client and server rules have drifted.
---
## 3. Events
Events are the authoritative history. State is `fold(events)`, which is what gives reconnection,
persistence and replay for one design decision.
**Phase and clock**
```
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
```
**Local operations**
```
LocalOpsOptionChosen { playerIndex, option }
TrayMoved { trayId, from, to, movesRemaining }
CarsCoupled { trayId, stock[] } -- automatic, from a Move
CarsDropped { trayId, at, stock[] }
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
CardPlayed { playerIndex, cardId, placement? }
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
CardDiscarded { playerIndex, cardId, toSlot }
DeckReshuffled { }
```
**Trains**
```
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
TrainMadeUp { trainCardId, trayId, at }
CarPlacedOnTrain { playerIndex, trayId, stock }
ClearanceRequested { trainId, followingInto, occupiedBy }
ClearanceGiven { trainId, allow }
TrainHighballed { trayId, from, to }
TrainMoved { trayId, from, to }
TrainCompleted { trayId, atDivisionPoint }
```
**Consequences**
```
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
RedFlagPlayed { playerIndex, avertedAt }
RevenueChanged { playerIndex, delta, total, reason }
CollisionCountChanged { collisionsToday }
GameEnded { result, winner?, reason }
```
`CollisionOccurred` carries `faultPlayer` explicitly rather than leaving clients to derive it. Fault
depends on *where* the wreck happened — Superintendent for a Mainline card, the local player between
his Limits (§10) — and getting that wrong misattributes a 5 and, in Competitive, contributes to a
floor that ends the game.
---
## 4. Views and redaction
Each client receives a projection with other players' private state removed.
| State | Visibility |
| --- | --- |
| Board: track grids, Offices, Limits, trains, standing cars | **Public** |
| Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** |
| Division Yard, Classification Yard contents | **Public** — physical piles on the table |
| Salvage Yard | **Public** — face up, explicitly so players can audit discards (§2.6) |
| Department slots (the three face-up cards) | **Public** |
| Home Office deck **contents and order** | **Secret** — never sent, to anyone |
| Home Office deck **count** | Public — players can see the pile's height |
| A player's hand | **Owner only**; others see the count |
| Red Flag held | Public — it is a known starting card (Appendix B) |
| Revenue totals | **Public** — the race is the game |
| RNG seed | **Secret** — sending it leaks all future shuffles |
```
View {
public : PublicState -- identical for every client
private : { hand: CardId[], redFlag: boolean }
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
```
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
*count* is public but the *order* is secret; a naive implementation that ships the deck array and
tells the client not to look is not redaction.
`legalIntents` is optional but worth it: the server already computes legality to validate, so
returning the affordance set costs little and removes any need for the client to reimplement rules
like turnout directionality or the four-slot consist limit.
---
## 5. Ordering and idempotency
- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay
on a gap rather than guessing.
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
reconnecting client can safely resend anything it is unsure about.
- The server never applies two intents concurrently within a game. With one actor at a time this is
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.
+108
View File
@@ -0,0 +1,108 @@
# Station Master — design index
Station Master is a tabletop railroad-operations game set in the era of timetable-and-train-order
railroading (18401950), being developed into a multiplayer digital game: players connect from a
web browser and an authoritative server manages lobbies and game state.
## Source of record
- `StationMasterPrototypeRules.pdf` — the original prototype rules
- `StationMaster-PrototypeTurnChart.pdf` — the 12-Stage turn chart
These stay as-is. Everything below is derived from them.
## Rules
| Document | What it is |
| --- | --- |
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
| [`rules/card-reference.md`](rules/card-reference.md) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
## Architecture
Target is **small self-hosted** scale: a handful of concurrent games, hosted on a website or as a
StartOS service. Stack is deliberately undecided; these documents describe what any implementation
must do.
| Document | What it is |
| --- | --- |
| [`architecture/components.md`](architecture/components.md) | **The project plan.** All 20 buildable pieces — where each runs, when, MVP vs finished size — plus dependency graph and build order. |
| [`architecture/overview.md`](architecture/overview.md) | Why the server is authoritative; state/intent/event split; transport; the rules-engine boundary. |
| [`architecture/game-state.md`](architecture/game-state.md) | The entity model, and the constraints that are easy to lose. |
| [`architecture/protocol.md`](architecture/protocol.md) | Intents, events, and per-player view redaction. |
| [`architecture/lobby-and-sessions.md`](architecture/lobby-and-sessions.md) | Create/join, seating, reconnection, persistence. |
| [`architecture/deployment.md`](architecture/deployment.md) | Plain web host vs StartOS service — and the one constraint that differs. |
## Current status
Rules formalized, card faces specified, architecture documented, and the rules engine built and
simulated. Eleven of thirteen gaps are closed; **Gap 12 (balance variance) and Gap 13 (deck scaling)
remain open**, both with data behind them.
Gaps 813 were all found by working the design forward — building it or simulating it — rather than
by reading the PDFs. None arises in tabletop play, where a person simply does the sensible thing.
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
`card-reference.md` §7.
**Wanted, not yet designed:** post-game replay — watching a finished game back at speed. The
architecture already produces what it needs (an ordered append-only event log plus a stored seed), so
the note in `architecture/overview.md` exists to keep the capability from being designed out. It is
explicitly not scheduled.
**Provisional numbers** — the victory targets are now confirmed at the mean by simulation. Still
untested by play: Crew Tray count, Laborer counts, and whether the variance in Gap 12 is a flaw.
**Next steps.** The specification is complete enough to build against or to play on paper.
The build path is laid out in [`architecture/components.md`](architecture/components.md) — 20
components, a dependency graph, and a twelve-step order. Its **MVP is solitaire: one Office, a fixed
number of Days, a server talking to a single browser, display functional rather than pretty.** The
milestones worth knowing:
- **Step 4** — a full solitaire game runs to completion headless, proving the rules before a pixel is
drawn.
- **Step 6** — the balance harness validates or retunes the provisional numbers, while changing them
is still free.
- **Step 10** — MVP complete; a human plays end to end.
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
TypeScript natively, so there is no build step during development.
**Steps 16 are done** — the rules engine (components 17), heuristic bots (17) and the balance
harness (18), with 134 tests passing.
**The step 4 milestone is met: a full solitaire game runs to completion, headless.** Games are
reproducible from a seed, terminate from every seed tried, conserve all 62 rolling stock pieces, and
develop properly.
**End-of-game statistics** (`src/sim/stats.ts`) report revenue by source, traffic, development,
action mix and strategy buckets — plus an **anomaly detector** that treats "this event never fired"
as a finding. It caught two bugs on its first run.
**The step 6 milestone is met, with a caveat.** The harness found **five engine bugs that no test had
caught**, and once they were fixed the numbers came good: **~35% win rate and 5.0 Revenue/player/Day,
matching the Gap 10e prediction of 5-6.**
The caveat is large: those numbers were themselves measured against a **scoring bug**, since fixed.
Honest figures are ~0.9 Revenue/Day and a **0% win rate** — the targets are currently unreachable.
The bot is still the prime suspect (it uses ~10 Laborer actions per game out of ~900 available), so
this is **Gap 12** and remains open. One genuine signal did emerge: **mixed freight/passenger
strategies outscore pure ones**, with pure freight much the worst.
**Gap 11 is decided** — track orientation is chosen on placement, settled by measurement.
**Gap 13** answers the deck-size question: do not double it; scale only the buildable cards with
player count.
Next: step 7 begins the server (components 8, 9, 20).
Run the harness with `node src/sim/harness.ts [games] [length]`.
Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md`
specifies every card face, so layout and art are the only remaining work before a table playtest —
which answers the one question simulation cannot, whether it is fun.
+256
View File
@@ -0,0 +1,256 @@
# Station Master — Card Reference
The per-card catalogue: what is printed on every card. This is the specification the rules engine
instantiates from and that any print-and-play layout renders. It says *what each card says*, not how
it looks.
Derived from [`rules-v0.2.md`](rules-v0.2.md) §12 and the Gap 10 decisions in
[`open-questions.md`](open-questions.md#gap-10--card-face-specifications).
*All values provisional — calibration targets to retune after play.*
---
## 1. The Home Office deck — 52 cards
| Category | Cards | §12.1 |
| --- | ---: | --- |
| [Timetabled Trains](#3-train-cards) | 12 | one each, 112 |
| [Extra Trains](#3-train-cards) | 4 | X9X12 |
| [Office upgrades](#4-office-cards) | 9 | Depot ×4, Station ×3, Terminal ×2 |
| [Freight Facilities](#2-freight-facility-cards) | 10 | 5 types × 2 |
| [Modifier Facilities](#5-modifier-cards) | 5 | one each |
| [Plain track](#6-track-cards) | 12 | turnout ×6, straight ×3, run-around ×3 |
| **Total** | **52** | |
---
## 2. Freight Facility cards
Ten cards, **five types in identical pairs**. Every freight facility card carries: a track with the
Operational Rail wheel icon, an industry track of the stated length, Laborer icons, the
`MEN | AT | WORK` three-box turn track, and its green Outbound and/or red Inbound boxes.
| Facility | Car type | Direction | Laborers | Outbound | Inbound | Industry track | Copies |
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
outbound-only, inbound-only, and both.
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
and Freight Houses permit cars to move each direction" therefore names exactly those two.
### Throughput — why these Laborer counts
A complete freight load takes **four Laborer-actions** (§9.3): Green → MEN → AT → WORK → onto the
car. Laborers are usable once each per Stage.
**`MEN | AT | WORK` is a pipeline, not a queue.** §9.1 allows one Load on each of its three boxes, so
up to three loads are in flight at once and each Laborer advances one of them per Stage. This is the
key to reading the numbers: **Laborer count is throughput, not latency.**
```
single load, 2 Laborers → completes in 2 Stages
single load, 3 Laborers → completes in 2 Stages (no faster!)
pipeline full, 3 Laborers → one load completes EVERY Stage
```
The extra Laborer buys nothing on one load and triples output on a full pipeline.
**And the pipeline can only be fed one load per Stage**, because stocking the green box costs a whole
Local Operations action (§6.3). So a 3-Laborer facility is exactly balanced against the feed rate —
it completes loads as fast as a player can possibly supply them. The 2-Laborer facilities are the
deliberately slower industries, unable to quite keep up with a dedicated player.
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
the third Laborer is earning its keep.
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
away both cost switching actions from the same budget. See §7.
---
## 3. Train cards
Sixteen cards. Each carries its number, a direction from the card image, the consist specification,
and the required order: **engine at the front, revenue cars, caboose last where required**.
Odd numbers run westbound, even eastbound. Pairs are sister trains under the optional rule (Appendix
B). Seniority runs passenger-first — low numbers are the varnish, high numbers the drags and locals.
| Trains | Class | Consist | Direction |
| --- | --- | --- | --- |
| 1 / 2 | Limited | 4 coaches, no caboose | W / E |
| 3 / 4 | Mail-Express | 3 coaches, no caboose | W / E |
| 5 / 6 | Manifest Freight | 3 boxcars or reefers + caboose | W / E |
| 7 / 8 | Coal Drag | 3 hoppers + caboose | W / E |
| 9 / 10 | Oil Train | 3 tank cars + caboose | W / E |
| 11 / 12 | Way Freight | 3 cars of any type + caboose | W / E |
| X9, X10, X11, X12 | Extra | Any 3 cars + caboose, player's choice | Head-on — player chooses |
A Crew Tray holds at most **four** Rolling Stock and a caboose counts toward that four (§A.4), which
is why no caboose train carries more than three revenue cars.
Only trains 14 carry coaches, so passenger work at any Office depends on those four trains' visits.
---
## 4. Office cards
Nine upgrade cards in the deck (Depot ×4, Station ×3, Terminal ×2 — the pyramid Gap 3b requires).
Whistle Posts are a fixed supply outside the deck (§12.2).
| Office | Control Point | Passenger Facility | A/D tracks | Porters | Green slots | Red slots | In deck |
| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |
| Whistle Post | no | no | 1 | — | — | — | supply |
| Depot | yes | yes | 2 | 1 | 2 | 2 | 4 |
| Station | yes | yes | 3 | 2 | 3 | 3 | 3 |
| Terminal | yes | yes | 4 | 3 | 4 | 4 | 2 |
**Slot capacity deliberately exceeds Porter count.** Only trains 14 carry coaches, so an Office needs
somewhere to bank boarding passengers between visits and room to absorb a whole trainload of
arrivals. Slot counts mirror the A/D track progression.
**All four tiers carry identical track geometry** (§11.3): a through track plus a plain junction stub
above and below. Upgrades are drop-in and must never disturb a connection.
---
## 5. Modifier cards
Five cards, one each. A Modifier is **not track**. It is placed adjacent to a Facility, on any of the
nine nearby spots, and if adjacent to two Facilities its effect may be used on only one per Stage
(§9).
| Card | Effect | Applies to |
| --- | --- | --- |
| Team Track | +1 car on the industry track | Freight |
| Loading Dock | +1 green Outbound capacity | Freight |
| Storage Shed | +1 red Inbound capacity | Freight |
| Extra Platform | +1 green **and** +1 red slot | Passenger |
| Section Gang | +1 Laborer **or** +1 Porter | Either |
Capacity modifiers matter more than they sound: banking room, not worker count, is what limits
output. Section Gang is the only way to speed a facility up, and is deliberately the sole
worker-adding card.
---
## 6. Track cards
Twelve plain track cards. Every one carries the Operational Rail wheel icon **except turnouts**, which
a train may pass through but not stop on (§A.1).
| Geometry | Count | Notes |
| --- | ---: | --- |
| Turnout | 6 | Enter from A → B or C; enter from B or C → A only. Not Operational Rail. |
| Straight | 3 | Operational Rail |
| Run-around | 3 | Operational Rail. Required for the facing-point move of §A.5. |
Turnout-heavy because turnouts are how Secondary Track branches onward once the Office card's own
junction stubs are built out (§11.3).
---
## 7. Economy summary
**Local Operations actions are the main currency.** One per Stage, twelve per Day, and each is
exclusive — switch, *or* draw, *or* work the Freight Agent (§6).
| Revenue source | Local Ops cost |
| --- | --- |
| Freight load **out** | 1 to stock the green box, plus amortized switching |
| Freight **unload** | **none up front** — 1 later, only once the red box fills |
| Passenger boarding | 1 to stock a loaded coach |
| Passenger de-training | **none up front** — 1 later to clear the slot |
> **⚠ Corrected against simulation.** An earlier version of this section claimed every Revenue point
> costs about one action, giving a ceiling of 12/Day. **That is wrong**, and the error is
> instructive: the two *inbound* operations need no Local Ops action at all at the moment they
> score. A loaded car arrives on a train, per-Stage Laborers walk it off, and the point is earned
> using only free per-Stage resources. The action is spent later, and only when a box actually
> fills.
>
> That earlier correction was itself measured against a **scoring bug** — unloads were paying a full
> Revenue point after one Laborer action instead of four. With the pipeline corrected, simulation
> gives **~0.9 Revenue/player/Day and a 0% win rate** against these targets.
>
> **Treat the 56/Day figure as unfounded.** It was derived from the mistaken model above and has not
> been re-derived. The measured cost of one freight point is four Laborer actions plus a stocking
> action plus the switching to spot a car. See `open-questions.md` Gap 12.
### The development ramp
Players open with a Whistle Post — not a Passenger Facility — and no freight facilities at all. The
timetable also starts empty, so no trains run until someone draws and plays a train card.
```
Day 1 ~0 no facilities, no trains yet
Day 2 ~2 first facility placed, first train running
Day 3 ~5 developed
Day 4+ ~6 steady state
cumulative: 3 Days ≈ 7 5 Days ≈ 19 10 Days ≈ 49
```
The §3 victory targets (10 / 20 / 45) are set just under these cumulative figures, so a game is
winnable by a player running well and missed by one who is not. The Competitive collective floor of
`3 × players × Days` is about half of expected output and is comfortably met by a functioning
Division.
---
## 8. Fixed supplies (outside the deck)
| Component | Count |
| --- | --- |
| Mainline cards (tarot, 2 regions each) | N + 1 for N players |
| Division Point cards | 2 (East, West) |
| Whistle Post cards | N + spares |
| Limits cards | 2N + spares |
| Crew Trays and engines | player count + 3 |
| Number Boards | 12, numbered 112 |
| Red Flag cards | 1 per player *(optional rule only; removed from game when played)* |
| Fedora | 1 |
| Pocket Watch | 1 |
| D12 | 1 |
### Rolling Stock — 62 pieces
| Type | Loaded | Empty |
| --- | ---: | ---: |
| Coaches (blue) | 8 | 8 |
| Boxcars (brown) | 6 | 6 |
| Hoppers (brown) | 6 | 6 |
| Reefers (brown) | 4 | 4 |
| Tank cars (brown) | 4 | 4 |
| Cabooses (red) | 6 | — |
| **Total** | | **62** |
**Supply check against facility demand.** Worst case is every copy of every facility in play at once
with all boxes full:
| Car | Facility demand | Supply | Headroom |
| --- | ---: | ---: | --- |
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice —
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
watching in playtesting.
+64
View File
@@ -0,0 +1,64 @@
# Station Master — Glossary
Every defined term, alphabetized for lookup. The core comes from the Definitions section of
`rules-v0.1.md` (§2); terms introduced by gap decisions carry their `rules-v0.2.md` section. The
"Where" column points at the defining section.
| Term | Definition | Where |
| --- | --- | --- |
| **A/D (Arrival/Departure) Track** | Short sidings in front of your office where trains arrive and depart from. A train at an Office occupies one A/D track. If a train arrives and no A/D track is available, it is a collision. | §2.1, §8.3 |
| **Caboose** | Red rolling stock, for the conductor and switch crews. Returns directly to the Division Yard when used. Counts toward the Crew Tray's four-piece limit. | §2.2, §A.4 |
| **Classification Yard** | Where used Rolling Stock is placed. When the Division Yard empties, everything here moves back to it. | §2.2 |
| **Coach** | Passenger rolling stock (blue = loaded, white = empty). | §2.2 |
| **Consist** | A collection of Rolling Stock (cars) in a Crew Tray. Order is significant. | §2.2, §A.3 |
| **Control Point** | An Office where trains are reported in to the distant (NPC) dispatcher — Division Points, Depots, Stations, Terminals. Whistle Posts are not Control Points. Denoted by a train order signal on the card. | §2.1 |
| **Crew Tray** | The canoe-shaped holder containing the train pieces. Must always hold an engine; may hold at most four Rolling Stock, cabooses included. | §2.2, §A.4 |
| **Day** | A series of twelve Stages. | §2.4 |
| **Departments** | Three face-up slots beside the Home Office deck from which cards may be drawn and to which they may be discarded. Fed from the one deck, not decks of their own. | §2.6 |
| **Dispatcher** | The imaginary (NPC) person running the railroad and granting rights to trains to operate. | §2.6 |
| **Division** | Your company's tracks connecting the Eastern and Western Division Points. May represent hundreds of miles and several Offices. | §2.1 |
| **Division Point** | Where your railroad ties into other railroads. There is an Eastern and a Western Division Point. | §2.1 |
| **Division Yard** | A table-top pile of Rolling Stock. Cars for cargo, passengers, or trains are selected from here. | §2.2 |
| **East** | The player's right. | §2.4 |
| **Engine** | The engine piece. May pull cars, push cars, or both. Has couplers on the front end. | §2.2, §A.3 |
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 |
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
| **Inbound box** | A red box holding all just-unloaded freight/passengers of the designated type. | §2.5 |
| **Junction stub** | The diverging connection printed above and below every Office card's track, allowing Secondary Track to branch from the opening Stage. A plain connection — §A.1's turnout directionality does not apply. | §11.3 |
| **Laborer** | A blue man-icon; works cargo. Usable once each per Stage. Four Laborer-actions complete one load. | §2.5, §9.1, §9.3 |
| **Limits** | The sign east and west of your Office that denotes the limit of your control area. Moves outwards as your Running Track expands. | §2.1, §11.2 |
| **Loading Dock** | Modifier card: +1 green Outbound capacity at a Freight Facility. | §12.5 |
| **Mainline Card** | Tarot-sized cards representing roughly the hundred miles of track between train offices. Each has two regions. | §2.1 |
| **MEN \| AT \| WORK** | A blue three-box turn track tracking how quickly freight cars are loaded/unloaded. Only one Load per box. While any load sits on it, the industry's track loses Operational Rail status. | §2.5, §9.3 |
| **Modifier Facility** | A card placed adjacent to a Facility (any of the nine nearby spots) that increases its capacities or workers. If adjacent to two Facilities, it may only be used on one per Stage. | §2.5, §9, §12.5 |
| **Move** | A Crew Tray moving from one Operational Rail card to another, regardless of distance, without changing direction. | §2.4, §A |
| **Number board** | Small tiles numbered 1-12 recording which Stage (hour) a train departs. | §2.3 |
| **Office** | The location the Station Master sits at. A Whistle Post, Depot, Station or Terminal. All four share identical track geometry. | §2.1, §11.1, §11.3 |
| **Operational Rail** | Any track (Running or Secondary) on which a train may sit, change direction, and drop or pick up cars. Marked with a wheel icon. | §2.1 |
| **Outbound box** | A green box holding all to-be-loaded freight/passengers of the indicated type. | §2.5 |
| **Passenger Facility** | Depots, Stations and Terminals — any Office above Whistle Post. Always allow both loading and unloading. | §9, §11.1 |
| **Phase** | An operational component of a Stage. | §2.4 |
| **Porter** | A black man-icon; assists passengers. Usable once each per Stage. One Porter-action earns one Revenue. | §2.5, §9.1, §9.2 |
| **Reefer** | Refrigerated rolling stock for field produce (brown = loaded, white = empty). | §2.2 |
| **Revenue** | Points gained and lost to determine victory. +1 per load/unload operation, 5 per collision. | §2.4, §10 |
| **Rolling stock** | The non-engine pieces representing rail cars, either in a Crew Tray or sitting on an Operational Rail card. A colored car is loaded; a white car is empty. | §2.2 |
| **Running Track** | The "Mainline" running horizontally between your Limits, including your Office — the horizontal row of cards, Limit-to-Limit. | §2.1 |
| **Salvage Yard** | Where used cards are placed, face up, so no card that shouldn't be discarded is. | §2.6 |
| **Secondary Track** | All track inside your Limits that is not the Running Track. | §2.1 |
| **Section Gang** | Modifier card: +1 Laborer or +1 Porter. The only worker-adding Modifier. | §12.5 |
| **Stage** | A two-hour section of time. Twelve make a Day. Each player gets one Local Operations action per Stage. | §2.4, §6 |
| **Storage Shed** | Modifier card: +1 red Inbound capacity at a Freight Facility. | §12.5 |
| **Subdivision** | The Mainline track between the Limits of opposing Control Points. Whistle Posts are not Control Points and sit inside a Subdivision like ordinary Mainline. | §2.1 |
| **Superintendent** | The Dispatcher's right-hand-man, managing the division. The role passes player to player; a shift is six hours (three Stages). Marked by the Fedora piece. Rules on following-train clearances. | §2.6, §8.1 |
| **Tank car** | Oil rolling stock (brown = loaded, white = empty). | §2.2 |
| **Team Track** | Modifier card: +1 car on a Freight Facility's industry track. | §12.5 |
| **Timetabled Train** | A train scheduled to run every day at the same time, marked by a Number Board on the turn chart. Numbered 1-12; odd westbound, even eastbound. Lower numbers hold greater seniority, and all outrank Extras. | §2.3, §8 |
| **Turnout** | A drawn track card where a train entering from "A" may proceed to B or C, but one entering from B or C may only proceed to A. Not Operational Rail, so no stopping. Distinct from the Office card's junction stubs, which carry no directional restriction. | §A.1, §11.3 |
| **West** | The player's left. | §2.4 |
| **Whistle Post** | The most basic Office — "a shack in the middle of nowhere". Not a Control Point and not a Passenger Facility, so a Whistle Post player has no passenger business. | §2.1, §11.1 |
+972
View File
@@ -0,0 +1,972 @@
# Station Master — Open Questions
Live tracker for gaps in the prototype rules. Each entry records the problem, the options
considered, the decision, and why. Decided entries feed `rules-v0.2.md`; anything still open blocks
implementation of the systems it touches.
**Status key:** `OPEN` — not yet discussed · `DECIDED` — settled, in v0.2 · `DEFERRED` — knowingly
left open, with the consequence noted.
| # | Gap | Status |
| - | --- | ------ |
| 1 | Phase resolution order | **DECIDED** |
| 2 | Collision resolution (and the Whistle Post highball condition) | **DECIDED** |
| 3 | Office upgrades | **DECIDED** |
| 4 | Component manifest and deck composition | **DECIDED** |
| 5 | Extra-train seniority ties | **DECIDED** |
| 6 | Victory conditions | **DECIDED** |
| 7 | Terminology errata | **DECIDED** |
| 8 | First placement into an empty Office Area | **DECIDED** — found during verification |
| 9 | Consist length scales with player count | **DECIDED** — found during verification |
| 10 | Card face specifications | **DECIDED** — revised the Gap 6 targets |
| 11 | Track card orientation | **DECIDED** — orientation chosen on placement |
| 12 | Balance targets | OPEN — targets unreachable once scoring was corrected |
| 13 | Deck scaling by player count | OPEN — measured, recommendation ready |
---
## Gap 1 — Phase resolution order
**Status:** DECIDED — phase-major, strict player order.
**The problem.** §5 says "In each Stage of the day the players perform Phases, one at a time in
order from the Superintendent through the players in order to his left." This admits two readings:
- **(a) Player-major** — each player performs all four Phases, then the next player does.
- **(b) Phase-major** — every player performs Local Operations, then every player performs New
Train, and so on.
Reading (b) is supported by the Mainline Phase (§8), which is described as global and automatic —
trains move in train-number order across the whole Division, not per player — and by the New Train
Phase (§7), where "starting with the superintendent and working left, each player may place ONE
car" describes a single round inside one phase. Reading (a) would make both of those incoherent.
**Why it matters.** This is the server's entire turn state machine. Every other timing question
(when Laborer/Porter usage resets, when a collision is evaluated, what a player sees while waiting)
depends on it.
**Decision.** Reading (b), **phase-major**, with strict sequential player order:
```
STAGE N
├─ 1. Local Operations P(super) → P(+1) → P(+2) … one at a time
├─ 2. New Train per train: super → left, ONE car each
├─ 3. Mainline ALL trains move, train-number order (global)
├─ 4. Load / Unload P(super) → P(+1) → P(+2) … one at a time
└─ 5. Shift Change Stages 3, 6, 9, 12 only
```
"One at a time in order from the Superintendent through the players to his left" describes ordering
*within* a phase, not the phase loop.
**Rationale.** New Train's shared car-placement round (one train, each player adds one car in
Superintendent-then-left order) and Mainline's global lowest-train-number-first ordering are both
incoherent under player-major; phase-major is the only reading where the rules as written function.
Simultaneous resolution of the two player-local phases was considered and rejected for now: Local
Operations and Load/Unload touch shared piles (Division Yard, Home Office deck, the three face-up
Department decks), so concurrency would turn draw order into a race and change the game. It stays
available as a possible later "fast mode" but is not the rule.
**Consequences for implementation.** The server holds a single current-actor pointer — exactly one
player may act at any moment. Every other client is in a spectating state. Per-Stage resources
(Laborer and Porter usage) reset at the start of each Stage, not each phase.
The Mainline Phase is the exception to the pointer, in both directions: it is automatic, so no player
acts in turn order — but §8.1's fourth condition interrupts it to ask the **Superintendent** whether a
following train may safely depart. That decision arrives out of turn order and may concern another
player's train. It is the one point in the game where a player acts during someone else's traffic,
and Gap 2 deliberately made the *consequences* automatic so that this decision carries full weight.
---
## Gap 2 — Collision resolution
**Status:** DECIDED — collisions are automatic.
**The problem.** §8.2 gives three collision triggers and each says a collision "**may** occur":
- cars or trains sitting on the Running Track between the Limits and the Office,
- no room at the station (every A/D track occupied),
- a train ready to Highball with the track between it and the Limits occupied.
§10 gives the consequence (5 Revenue, both trains removed, timetabled trains rerun next day) and
assigns fault. But nothing says whether "may" means automatic, a die roll, or a judgment call. A
server needs a deterministic rule.
Note §A.4 states flatly that cars encountered by a train during the Mainline Phase "result in a
collision" — no "may". That suggests the triggers are in fact automatic and "may" is loose writing,
but this needs confirming rather than assuming.
**Sub-question 2b — the Whistle Post highball condition.** §8.1's final bullet reads: "If the
Subdivision contains a Whistle Post (i.e. a non-Control Point) yet a train is occupying Secondary
Track there (i.e. clear of the Running Track), the superintendent *must* Highball the train." It is
unclear which train "the train" refers to — the one being considered for departure, or the one
sitting on Secondary Track. Read as the former, it is a permission (a train parked clear of the
Running Track doesn't block the Subdivision). That reading fits the surrounding list, but the
sentence doesn't say so.
**Decision (2b) — "the train" is the train being considered for departure.** A train parked on
Secondary Track at a Whistle Post does not foul the Running Track, so it does not count as occupying
the Subdivision, and the Superintendent has no grounds to withhold clearance from the departing
train — hence "must".
*Rationale.* The definition of Highball settles it. §2.4 defines it as "when a train holding at an
Office automatically departs", and §8.1's first bullet requires that a train "must be sitting on the
A/D track before the Office or at a Division Point" to be cleared. A train on Secondary Track fails
that condition and is therefore not highball-eligible at all, so the word cannot be referring to it.
The bullet is a clarifying exception to bullets 3 and 4, not a new power: those say a train in the
next Subdivision blocks or complicates departure, and this one carves out the case where that train
is stabled clear of the Running Track. The alternative reading also has a destructive side effect —
if the Superintendent could force a parked train back onto the mainline, any player mid-way through
a six-Move switching sequence could have their train ejected, gutting the local switching game.
**Decision (2a) — collisions are automatic.** If a trigger condition holds at the moment the train
moves, the collision occurs. There is no die roll and no judgment call. "May occur" is descriptive
prose about risk, not a resolution mechanic.
*Rationale.* §A.4 states the identical situation without any hedge ("Any cars encountered by such
trains result in a collision"). The Superintendent's discretion is already spent earlier, at §8.1,
deciding whether to clear a following train — randomizing the outcome afterwards would blunt that
decision, which is the game's central tension. And the Emergency Toolbox optional rule exists so
collisions "can be avoided", which is most meaningful when they are otherwise certain.
**Decision (2c) — a train striking loose cars is a full collision.** §10's "both trains are removed"
assumes two trains; triggers 1 and 3 can involve a moving train striking uncoupled Rolling Stock. In
that case the train *and* the struck cars are destroyed and the responsible player loses 5 Revenue —
the same penalty as a train-on-train wreck.
*Disposition of destroyed pieces* (following §2.2): engines and cabooses return to the Division Yard;
all other Rolling Stock goes to the Classification Yard. Timetabled train cards return to their
timetable slot and run again the next Day; Extra train cards go to the Salvage Yard.
**Decision (2d) — no room at the station is a collision.** A train arriving at an Office where every
A/D track is occupied collides. Fault is the Office's player under §10 (it occurs inside his Limits),
costing 5 Revenue, and the train is removed.
*Rationale.* Harsh, but it makes A/D capacity a constraint the player must actively plan around and
gives Office upgrades (Gap 3) real value. The gentler alternative — holding the train short in the
Subdivision — was rejected because a blocked Subdivision cascades into later Stages and is arguably
the harsher punishment anyway, while costing more server state to evaluate.
**Consequences for implementation.** Collision evaluation is a pure function of board state at each
train movement step in the Mainline Phase, with no player input and no RNG. The only interrupt is a
Red Flag card (Emergency Toolbox optional rule), which means the Mainline Phase is automatic but not
necessarily uninterruptible — see Gap 4 for what Red Flag actually does.
---
## Gap 3 — Office upgrades
**Status:** DECIDED — cards played from hand, strict sequence, 1/2/3/4 A/D tracks.
**The problem.** The rules depend on Offices changing type but never say how. §8 notes "Updating
Offices with the Control Point icon split the Subdivision in two at that point," §2.1 lists Whistle
Post / depot / station / terminal as Office types, and §A.4 says the Office track "has the number of
A/D tracks listed" — implying the count varies by type. Setup (§4) starts everyone on a Whistle
Post. Nothing states what card or action performs the upgrade, what each type costs, or what A/D
counts each type has.
**Why it matters.** Control Point placement determines Subdivision boundaries, which determine
highball legality, which is the core of the Mainline Phase. It also gates what card types must exist
in the Home Office deck, so it precedes Gap 4.
**What the upgrade is worth.** Three benefits arrive together, which is why it needs no additional
price: passenger revenue (a Whistle Post is not a Passenger Facility, so a Whistle Post player is
restricted to freight entirely), A/D capacity (and after Gap 2, running out of A/D tracks is a 5
collision), and Subdivision splitting (which raises traffic capacity for the whole Division, not
just the upgrading player).
**Decision (3a) — upgrades are cards played from hand, at no Revenue cost.** Depot, Station and
Terminal are card types in the Home Office deck. A player plays one during the "Draw a card" option
of the Local Operations Phase (§6.2); it replaces the current Office card, and the replaced card
goes to the Salvage Yard. The Office card carries the Control Point signal, the A/D track count, and
the Passenger Facility icons (Porters and loading/unloading boxes — exact counts in Gap 4).
*Rationale.* Playing a card from hand is the only "play" verb the rules define. The cost is already
real without adding Revenue: one of three hand slots, plus a whole Local Operations action, which is
mutually exclusive with switching your trains or working freight that Stage.
**Decision (3b) — strict sequence: Whistle Post → Depot → Station → Terminal.** Each tier must be
built in order; no skipping.
*Two consequences accepted with this choice.* First, a Terminal or Station drawn early is a dead
card occupying one of only three hand slots — the player must either sit on it or discard it face-up
onto a Department deck, where an opponent may take it. This turns the Department decks into an
informal market for high-tier Office cards and is a genuine strategic wrinkle, not a defect.
Second, it constrains Gap 4: the deck must contain Office cards in a **pyramid** (more Depots than
Stations, more Stations than Terminals), or the strict chain will stall players who cannot find the
next rung.
**Decision (3c) — A/D track counts are 1 / 2 / 3 / 4** for Whistle Post / Depot / Station /
Terminal. A Whistle Post holds exactly one train, which makes the opening genuinely tense given that
overflow is an automatic collision (Gap 2d); 4 matches the maximum consist length (§A.4).
**Deferred to Gap 4.** §2.1 says Limits "may be moved outwards" as controlled track expands, which
implies separate track-expansion cards adding Secondary Track. That is a component question, not an
Office-type question.
---
## Gap 4 — Component manifest and deck composition
**Status:** OPEN
**The problem.** No quantities are specified anywhere. Unknown:
- **Home Office deck contents** — what card types exist and in what proportion (Facilities,
Modifiers, track/turnout cards, Timetabled Train cards, Extra Train cards, Office upgrades, Red
Flag, anything else).
- **Track cards** — how many Whistle Posts and Limits per player, how many Mainline cards, what
track geometries exist (straight, turnout, crossover/run-around).
- **Train cards** — 12 timetabled trains are implied by the 1-12 numbering, but how many Extras, and
what consist criteria does each train card carry.
- **Pieces** — how many Crew Trays and engines (§7 says the number is limited and it matters, since
trains are held when none are free), how much Rolling Stock of each type, how many Number Boards.
**Why it matters.** The largest gap. Deck composition drives the whole economy; Crew Tray scarcity
is an explicit mechanic that can't be tuned without a number.
**Approach.** Break into sub-discussions: (4a) card types and deck composition, (4b) piece counts,
(4c) train card consist criteria.
---
### 4a — Card types and deck composition · DECIDED
**Three structural findings, derived from the rules rather than chosen.**
1. **The Home Office deck is a single deck containing every card type.** §4.54.6 shuffles one deck,
deals three cards to each player, then turns three cards face-up beside it as the Department
decks. §6.2's reshuffle sweeps the Salvage Yard *and* all three Department decks back into it. The
Departments are therefore three face-up market slots fed from the one deck, not decks with their
own contents.
2. **A player's Office area is a card grid, not a row.** §9 permits a Modifier "adjacent (on any of
the nine nearby spots)" — a 3×3 neighbourhood. Appendix A's diagrams show a 2×3 grid: bottom row
D/E/F is the Running Track with the Depot as Office at E, top row A/B/C is Secondary Track with a
Freighthouse at C. Setup deals only three cards (§4.1), so the grid demonstrably grows in play.
3. **Facility cards are themselves track cards.** In the same diagrams the Freighthouse and Depot
both have rails drawn across them and trains move onto them. Placing a Facility places track.
**Decision — the play area grows by playing track cards.** Plain track, turnouts and Facilities are
all played from hand into an empty adjacent grid spot and must connect to existing track. One
uniform placement rule covers every card with rails on it.
**Decision — the Running Track may be extended, moving the Limits outward.** §2.1's "the sign may be
moved outwards" is given effect: extending the Running Track horizontally pushes that player's
Limits out. Growth is uncapped for the prototype. A longer Running Track is also more track to keep
clear, so it carries its own collision risk.
**Decision — 52-card deck for the prototype.** Chosen partly so the prototype can be built from a
standard deck. **Provisional:** likely to need retuning by player count once the game is played.
| Category | Count | Detail |
| --- | ---: | --- |
| Timetabled Trains | 12 | One each, numbered 112 |
| Extra Trains | 4 | X9, X10, X11, X12 |
| Office upgrades | 9 | Depot ×4, Station ×3, Terminal ×2 — the pyramid Gap 3b requires |
| Freight Facilities | 10 | 5 types × 2 (Mine Tipple, Produce Shed, Grocer's Warehouse, Oil Refinery, Power Plant) |
| Modifier Facilities | 5 | |
| Plain track / turnouts | 12 | Turnout ×6, straight ×3, run-around ×3 — see below |
| **Total** | **52** | |
**Track mix (decided alongside Gap 8): turnout 6 / straight 3 / run-around 3.** Turnout-heavy because
turnouts are how Secondary Track branches onward once the Office card's own junction stubs (Gap 8)
are used up — a player who cannot draw one cannot develop past the first ring of cards. Run-arounds
are commoner than they might otherwise be, which makes the facing-point switching move of §A.5 a
normal tool rather than a rare prize.
All twelve Timetabled Trains must be in the deck: §4 starts the Division with no trains running, and
§7 creates a scheduled train only when a player draws and plays its card. That fixed 12-card block is
23% of a 52-card deck, which is what makes the composition tight.
**Decision — Extra Trains carry high numbers (X9X12), i.e. low seniority.** They yield to nearly all
scheduled traffic, making an Extra safe filler a player can slot in without upsetting the timetable,
rather than a disruption that cuts ahead of everyone. Ties against Timetabled trains 912 remain
possible and are Gap 5's problem.
**Decision — Red Flag cards are a separate supply, one per player,** dealt at setup outside the 52
under the Emergency Toolbox optional rule, and **removed from the game when played** rather than
reshuffled. This keeps the 52-card deck identical whether or not the optional rule is in use, and
makes each player's flag a single precious escape.
**Card recirculation.** Office cards recycle: upgrading Depot → Station sends the Depot to the
Salvage Yard, and it returns on the next reshuffle. Only Whistle Posts, Limits, Mainline cards and
Division Point cards sit permanently outside the deck as fixed supplies.
---
### 4b — Piece counts · DECIDED
**Mainline card regions turned out to set the pace of the whole game.** §8.2 requires a train to move
one region (vertical bar) per Stage, and each Office costs one further Stage (arrive, stop for
orders, highball on the next Mainline Phase). With N players there are N+1 Mainline cards, so for
3 players:
| Regions per card | Stages to cross the Division |
| ---: | ---: |
| 1 | 4 + 3 = 7 |
| **2** | **8 + 3 = 11** |
| 3 | 12 + 3 = 15 |
**Decision — 2 regions per Mainline card.** A Day is 12 Stages, so a train crosses the Division in
just under a Day, completes its run, and frees its Crew Tray. Three regions would leave trains still
on the road when the next Day began, locking trays permanently.
**Decision — Crew Trays and engines = player count + 3** (3 players → 6, 4 → 7, 5 → 8). Comfortable
early while few trains are scheduled, and a real bottleneck once the timetable fills — which is the
arc §7 describes. Note the end state: a full 12-slot timetable launches roughly one train per Stage
and each occupies a tray for about a Day, so a saturated Division would want ~12 trays. Anything less
is deliberate pressure, and this number is a prime candidate for retuning after play.
**Decision — Rolling Stock supply ≈ 62 pieces.** Loaded and empty counts are paired because loading
swaps a white car for a coloured one (§9.3).
| Type | Loaded | Empty |
| --- | ---: | ---: |
| Coaches (blue) | 8 | 8 |
| Boxcars (brown) | 6 | 6 |
| Hoppers (brown) | 6 | 6 |
| Reefers (brown) | 4 | 4 |
| Tank cars (brown) | 4 | 4 |
| Cabooses (red) | 6 | — |
| **Total** | | **62** |
**Fixed supplies, derived from the rules rather than chosen.**
| Component | Count | Source |
| --- | --- | --- |
| Mainline cards (tarot) | N + 1 for N players | §4.2, plus one beyond each end Division Point |
| Division Point cards | 2 (East, West) | §4.3 |
| Whistle Post cards | N + spares | §4.1 — "all remaining … are set aside" |
| Limits cards | 2N + spares | §4.1; the same card moves outward as the Running Track extends (4a) |
| Number Boards | 12, numbered 112 | §2.3 — one per Timetabled Train |
| Fedora | 1 | §4.4 — marks the Superintendent |
| Pocket Watch | 1 | §4.4 — marks the current Stage |
| D12 | 1 | §4.3, §4.4, §7 |
---
### 4c — Train card consist criteria · DECIDED
**What a train card carries**, from §7 and §8.1: its number, its direction (from the card image), the
car types it accepts, the maximum consist, and whether a caboose is required — plus the required
**order**, since §8.1 blocks a highball unless the consist is "in correct order listed on the train's
card." A train may depart short (§8.1 explicitly permits fewer Rolling Stock than listed); it may not
depart out of order.
**The four-slot constraint.** §A.4 caps a Crew Tray at four Rolling Stock, and §2.2 lists cabooses as
Rolling Stock. A caboose therefore consumes one of the four slots, so any train requiring a caboose
carries at most **three** revenue cars. This is easy to get wrong and is baked into the table below.
**Decision — six paired classes**, odd westbound and even eastbound, arranged so the pairs work as
sister trains under the optional rule. Seniority runs passenger-first, matching real practice: low
numbers are the varnish, high numbers the drags and locals.
| Trains | Class | Consist |
| --- | --- | --- |
| 1 / 2 | Limited | 4 coaches, no caboose |
| 3 / 4 | Mail-Express | 3 coaches, no caboose |
| 5 / 6 | Manifest Freight | 3 boxcars or reefers + caboose |
| 7 / 8 | Coal Drag | 3 hoppers + caboose |
| 9 / 10 | Oil Train | 3 tank cars + caboose |
| 11 / 12 | Way Freight | 3 cars of any type + caboose |
Order in every case is engine at the front, revenue cars, caboose last where required.
**Decision — Extra Trains (X9X12) list flexible criteria: any 3 cars + caboose.** §7 lets the player
who played the Extra load it "as he chooses, subject to the listed criteria", so keeping the criteria
loose is what gives an Extra its purpose — a train tailored to whatever cargo that player actually
needs moved, rather than a duplicate of a scheduled train.
---
## Gap 5 — Extra-train seniority ties
**Status:** DECIDED — Timetabled trains always outrank Extras.
**The problem.** §8 orders Mainline movement by train number, lowest first, and says to "remove the
'X' from extras to determine their true number." So Extra X7 resolves to 7 and can tie Timetabled
Train 7 in the same Mainline Phase. No tiebreak is given. Multiple Extras sharing a number is also
possible depending on how many Extra cards exist (Gap 4).
Gap 4a settled the Extras at X9, X10, X11 and X12 — one card each — so Extras never tie *each other*.
The only tie is an Extra against the Timetabled train of the same number.
**Decision — the Timetabled train moves first; Extras yield to every scheduled train.**
*Rationale.* This is how it actually worked: extras carried no timetable authority and ran only on
train orders, inferior to all regular trains. It also reinforces the Gap 4a choice of high X-numbers,
which made Extras deliberately low-seniority filler rather than disruptions. Leaving the tiebreak to
the Superintendent was rejected — it would reintroduce a judgment call into a Mainline Phase that
Gap 2 made fully automatic.
**Ordering rule for implementation.** Sort Mainline Phase movement by `(number, isExtra)` ascending,
where `isExtra` is false before true. Numbers are the train's own for Timetabled trains and the
X-number stripped of its "X" for Extras.
---
## Gap 6 — Victory conditions
**Status:** DECIDED — mode × victory-condition matrix, with shared failure floors.
**The problem.** §3 says goals are "a specific total Revenue Points or highest Revenue within a
specific number of Days" and, separately, Co-Op where "multiple players work to achieve a shared
goal." No numbers for either, and no statement of what the Co-Op goal is. Revenue is earned at +1
per load/unload operation and lost at 5 per collision; no other sources are defined, so the scoring
range is narrow and target numbers need to be grounded in expected per-Day earnings.
**Expected earning rate, used to calibrate every number below.** §9.3 requires four separate
Laborer-actions to complete one freight load — Green Slot → MEN → AT → WORK → onto the car — for a
single Revenue point, so a Facility with 2 Laborers needs two full Stages to score once. §9.2 gives a
Porter a point for one action, but passenger work is gated on a train actually standing at the A/D
track. Netting out, a developed player earns roughly **58 Revenue per Day**, which makes a 5
collision cost about a full Day's work.
### Structure
Setup selects a **mode** and a **victory condition** independently, plus a **length**.
| Length | Target | Days |
| --- | ---: | ---: |
| Short | ~~15~~ **10** | 3 |
| Standard | ~~30~~ **20** | 5 |
| Campaign | ~~60~~ **45** | 10 |
Co-op targets are the same figures **multiplied by player count**, since Co-op scores a pooled total.
> **Superseded by [Gap 10e](#10e--revenueday-revalidated-and-the-gap-6-targets-revised).** The
> struck-through figures were set here against an estimate that assumed steady-state earning from Day
> 1. Once the card faces were specified, the development ramp made them roughly 1.5× too high. The
> structure, floors and mode matrix below all stand as decided — only these three numbers moved.
### The matrix
| Mode | First to Revenue target | Highest Revenue after N Days |
| --- | --- | --- |
| **Solitaire** | Reach the target → win | Must reach the target by the end of N Days, else **lose**. Above it, the final score stands as the result. |
| **Competitive** | First player to reach the target wins | All players' Revenue **combined** must reach `3 × players × Days`, else **everyone loses**. Otherwise highest individual Revenue wins. |
| **Co-op** | Pooled Revenue reaches `target × players` → team wins | Pooled Revenue must reach `target × players` by the end of N Days, else **lose**. Above it, the final pooled score stands as the result. |
**Decision — "Highest Revenue" becomes a threshold in Solitaire and Co-op.** A comparison between
players is meaningless with one player and self-defeating among cooperating ones, so in those modes
the timed format is pass/fail against the same target figure: below it you lose regardless of score;
above it your score stands and is what players compare between sessions.
**Decision — Competitive carries a collision floor: three collisions within a single Day and every
player loses.** Counted Division-wide across all players and the Superintendent, and **the counter
resets at the start of each Day**. Applies to Competitive only, under both victory conditions;
Solitaire and Co-op have no collision limit.
**Decision — Competitive timed games also carry a collective Revenue floor** of
`3 × players × Days` — about half the expected output of a functioning Division, so it is comfortably
met by a railroad that works and missed by one crippled with wrecks and blocked Subdivisions.
| Players | Days | Floor |
| ---: | ---: | ---: |
| 3 | 3 | 27 |
| 3 | 5 | 45 |
| 4 | 5 | 60 |
| 4 | 10 | 120 |
**Why this matters beyond scoring.** The two floors make Competitive play *partly cooperative*:
players race each other inside a railroad they all need to keep functioning. It is the mechanical
expression of the game's premise — the railroad depends on you.
**Known failure mode, accepted.** A player who is clearly losing can deliberately wreck trains to
trip the collision floor and deny the leader a win. Because the saboteur loses too, it is mutually
assured destruction rather than a free grief, and the per-Day reset means three wrecks must land
inside a single Day. Flagged in the rules rather than designed around.
---
## Gap 7 — Terminology errata
**Status:** DECIDED — all four fixes adopted.
Small wording inconsistencies, settled in one pass:
1. **"Cargo phase" vs "Load/Unload Phase."** §5.1 lists the fourth phase as "Cargo phase"; the
section heading (§9) and the turn chart both call it "Load / Unload". Pick one.
2. **"the day ends" vs "the Stage ends."** §5 says "Once all the Phases for that Stage are complete,
the day ends." A Day is twelve Stages (§2.4), so this should almost certainly read "the Stage
ends."
3. **"Scrap Yard" vs "Salvage Yard."** §2.3 sends completed Extra Train cards to "the Scrap Yard
deck"; §2.6 and §6.2 define and use "Salvage Yard". Presumed the same pile under two names.
4. **"turn" vs "Stage."** Several passages use "turn" where "Stage" is meant (§4.4 "his shift runs
three turns", §8.2 "must move one region every turn", §9 Modifier "only be used on one Facility
in a turn"). Worth normalizing so timing rules read unambiguously.
**Decision — all four adopted in v0.2.**
| Original | v0.2 | Where |
| --- | --- | --- |
| "Cargo phase" | **Load/Unload Phase** | §5.1 — the outlier against both the §9 heading and the printed turn chart |
| "the day ends" | **the Stage ends** | §5 |
| "Scrap Yard" | **Salvage Yard** | §2.3 |
| "turn" (meaning a Stage) | **Stage** | §4.4, §8.2, §9 |
The phase name follows the turn chart because the chart is a printed component players read every
Stage; renaming it would mean reprinting it to match a single line of prose.
---
## Gap 8 — First placement into an empty Office Area
**Status:** DECIDED — Office cards carry junction stubs above and below.
**The problem.** Gap 4a established that track cards must **connect to existing track** when placed.
But a player begins with exactly three cards in a horizontal row: Limits, Whistle Post, Limits (§4.2).
If those three carry only straight-through rails, nothing can ever be placed above or below them —
there is no diverging connection to attach to. The Office Area could never leave its opening row, and
Appendix A's whole switching game would be unreachable.
Appendix A's diagrams show the Running Track row (D, E, F) carrying turnout geometry that curves up to
the Secondary row (A, B, C), so turnouts clearly live *in* the Running Track. The question is how the
first one gets there.
**Candidate readings.**
- **(a) The first build must extend the Running Track.** A player's first placement can only be a
card played into the Running Track row, east or west of the Office, which moves the Limits outward
(Gap 4a). Once that card is a turnout, Secondary Track can branch from it. Consequence: every
player's opening development follows the same forced shape — widen first, then build up.
- **(b) The starting cards carry turnout stubs.** The Whistle Post and/or Limits cards are printed
with diverging connections, so Secondary Track can be placed immediately. Consequence: no forced
opening, but it means the starting cards are a specific printed geometry that has to be designed.
- **(c) Connection is not required for the first card.** A player may seed one disconnected card
adjacent to their Office and connect it later. Consequence: simplest to implement, but permits
orphaned track that trains cannot reach, which is a state the rules never contemplate.
**Decision — (b): the Office card is printed with a diverging connection above and below its
track.** Both Secondary rows are reachable from turn one, so §9's 3×3 Modifier neighbourhood is
usable immediately and players have real layout choices from the opening Stage.
```
[ ][ ↓ ][ ]
[Lim][Off][Lim]
[ ][ ↑ ][ ]
```
**The stubs are plain junctions, not turnouts.** §A.1's directional rule — enter from A and you may
proceed to B or C; enter from B or C and you may only proceed to A — applies to *drawn turnout
cards*, not to the Office card. This keeps the Office card's Operational Rail status and its A/D
track rules (§A.4) from tangling with turnout directionality, which would otherwise interact badly
with §A.4's provision that trains may pass through the Office while free A/D tracks remain.
**All four Office tiers share identical track geometry** — Whistle Post, Depot, Station and Terminal
are printed with the same stubs in the same places. This is load-bearing, not cosmetic: an upgrade
replaces the card in place (§11.1), so any difference in geometry between tiers would strand the
Secondary Track attached to the old card. The tiers differ only in Control Point status, Passenger
Facility status, and A/D track count.
*Why not (a).* Requiring the first build to extend the Running Track needed no new art, but it forced
every player through an identical opening — widen, then branch — and made vertical development
hostage to drawing a turnout card early.
*Why not (c).* Permitting a disconnected first card allows orphaned track that no train can reach, a
state the rules never contemplate and the engine would have to tolerate indefinitely.
**Component consequence.** Office cards are geometrically interchangeable. Any card art must place
the stubs identically across all four tiers.
---
## Gap 9 — Consist length scales with player count
**Status:** DECIDED — the make-up round repeats until the consist is full.
**The problem.** §7 makes up a train by having each player, "starting with the superintendent and
working left," place **ONE car**. Read literally that is a single round, so the number of cars a
train receives at make-up equals the number of players.
With the Gap 4c consist specs (4 coaches for a Limited, 3 + caboose for freights), that means:
| Players | Cars placed at make-up | Limited (wants 4) | Coal Drag (wants 3 + caboose) |
| ---: | ---: | --- | --- |
| 2 | 2 | half empty | no caboose possible |
| 3 | 3 | short one coach | full only if someone places the caboose |
| 4 | 4 | full | full |
| 5+ | 5+ | over-full — round must stop early | over-full |
At two players every train leaves permanently short; at five the round overruns the consist and has
to be cut off mid-way, which the rules do not describe.
§7 does provide a release valve — "Rolling stock might be added enroute to bring it to its maximum
length" — so a short train is playable, and picking up cars along the line is arguably the intended
texture. But it is very different at 2 players than at 4, and the rules never acknowledge it.
**Candidate readings.**
- **(a) One round, as written.** Trains at low player counts run short and fill enroute. Simple,
faithful, and makes 2-player games a distinctly different experience.
- **(b) Repeat the round until the consist is full** or no suitable cars remain in the Division Yard.
Trains always leave properly made up regardless of table size; the make-up phase just takes more
passes at low player counts.
- **(c) One round, and cap consist specs at the player count.** Train cards specify a maximum, and the
effective consist is `min(spec, players)`. Predictable, but it makes the printed train cards mean
different things at different tables.
**Decision — (b): repeat the round.** Play continues around the table — Superintendent, then left —
until the consist is full or no suitable cars remain in the Division Yard.
```
4-coach Limited, 2 players
round 1: P0 → coach P1 → coach
round 2: P0 → coach P1 → coach
full — departs with 4
5 players: round ends mid-way when the consist fills
```
*Rationale.* Trains look like trains at every table size, at the cost only of extra passes around a
short table. §7's existing requirement that a player "make every effort to find a suitable car" is
the natural stopping condition, so no new rule is needed to end the round. The 5+ player over-full
case resolves for free: the round simply ends when the consist is full, and no player is left holding
a car they cannot place.
*Why not (a).* Faithful to the literal text, but it makes a 4-coach Limited leave half empty at two
players and still leaves the over-full case undefined.
*Why not (c).* Capping the consist at player count means a printed train card denotes different
trains at different tables, which undermines the point of having consist specs on the cards at all.
---
## Gap 10 — Card face specifications
**Status:** DECIDED. Full catalogue in [`card-reference.md`](card-reference.md).
**The problem.** The rules describe the *mechanism* of Laborers, Porters, capacities and Modifiers in
detail (§9.1–§9.3), and Gap 4a fixed how many cards of each category exist — but no individual card
ever received values. Nothing stated how many Laborers a Mine Tipple has, what capacity an Oil
Refinery holds, which car types a Produce Shed accepts, or what a Modifier adds. The Modifier row in
the §12.1 deck table was blank in its detail column.
This blocked every downstream path identically: the rules engine could not instantiate the `Facility`
structure defined in `architecture/game-state.md` §5, print-and-play could not draw a card, and no
balance work was possible.
### 10a — Freight facility profiles
**Decision.** Five types, moderate and varied by industry:
| Facility | Car | Direction | Laborers | Outbound | Inbound | Track |
| --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
so raising them buys little beyond reducing idle time.
### 10b — Identical pairs
**Decision.** Both copies of each freight type carry the same values. Small/large variants were
considered and rejected: they double the specification work and make balance harder to reason about,
for texture the five distinct types already provide.
### 10c — Passenger facility profiles
**Decision.** Slot capacity deliberately exceeds Porter count:
| Office | Porters | Green slots | Red slots |
| --- | ---: | ---: | ---: |
| Whistle Post | — | — | — |
| Depot | 1 | 2 | 2 |
| Station | 2 | 3 | 3 |
| Terminal | 3 | 4 | 4 |
*Rationale.* Only trains 14 carry coaches, so an Office needs to bank boarding passengers between
visits and absorb a whole trainload of arrivals. Matching slots to Porters one-for-one would leave a
Depot idle whenever a train had not just arrived, and make a 4-coach Limited impossible to work
fully. Slot counts mirror the A/D track progression already fixed in Gap 3c.
### 10d — Modifier cards
**Decision.** Five cards, mixing capacity and workers:
| Card | Effect | Applies to |
| --- | --- | --- |
| Team Track | +1 car on the industry track | Freight |
| Loading Dock | +1 green Outbound capacity | Freight |
| Storage Shed | +1 red Inbound capacity | Freight |
| Extra Platform | +1 green **and** +1 red slot | Passenger |
| Section Gang | +1 Laborer **or** +1 Porter | Either |
*Rationale.* §2.5 and §9 say only that Modifiers "increase capacities", which taken literally would
leave no way at all to speed up a slow facility. Section Gang is the sole worker-adding card, which
keeps that power scarce. Capacity modifiers are stronger than they sound, since banking room rather
than worker count is what limits output.
**Sub-decision — what a "Freight House" is.** §9.3 and Appendix A refer to Freight Houses, but §9's
facility list never defines one. It is **not a card**: it is the collective term for a freight
facility that both loads and unloads — under 10a, the Grocer's Warehouse and the Oil Refinery. This
reconciles every reference without adding a card type or disturbing Gap 4a's deck composition.
### 10e — Revenue/Day revalidated, and the Gap 6 targets revised
**The binding constraint is Local Operations actions, not workers.** A player gets exactly one per
Stage — switch, *or* draw, *or* work the Freight Agent (§6) — so twelve per Day. Every Revenue point
costs roughly one action: stocking a green box, or clearing a red one. Capacity buffers defer that
cost but never remove it.
The ceiling is therefore **12 Revenue/Day**, reachable only by never switching and never drawing.
Realistic mid-game play spends ~4 actions switching and ~23 drawing, leaving 56 for revenue.
**The correction: the original estimate ignored the development ramp.** Players open with a Whistle
Post (not a Passenger Facility) and no freight facilities, and the timetable starts empty so no
trains run until someone draws and plays a train card.
```
Day 1 ~0 no facilities, no trains yet
Day 2 ~2 first facility placed, first train running
Day 3 ~5 developed
Day 4+ ~6 steady state
cumulative: 3 Days ≈ 7 5 Days ≈ 19 10 Days ≈ 49
```
**Decision — the Gap 6 targets are lowered from 15 / 30 / 60 to 10 / 20 / 45.** The original figures
assumed the steady-state rate applied from Day 1 and were roughly 1.5× too high. This mattered most
in Solitaire and Co-op, where the target doubles as a *minimum* — a Solitaire Standard game needed 30
in 5 Days against a realistic ~19, so it would have failed nearly every time.
| Length | Target (was) | Days | Est. cumulative |
| --- | ---: | ---: | ---: |
| Short | 10 (15) | 3 | ~7 |
| Standard | 20 (30) | 5 | ~19 |
| Campaign | 45 (60) | 10 | ~49 |
**The Competitive collective floor is unchanged** at `3 × players × Days`. It was calibrated as about
half of expected output — 15 per player over 5 Days against ~19 earned — and that still holds.
*Note on Gap 6.* Its structure, floors and mode matrix stand as decided; only the three target
figures moved, and only because Gap 10 replaced an assumption with real numbers.
---
## Gap 11 — Track card orientation
**Status:** DECIDED — orientation is chosen when the card is placed.
**The problem.** Gap 4a fixed the track mix at turnout ×6, straight ×3, run-around ×3. But a track
card has an **orientation**, and nothing says what any of them is.
- A **turnout** has handedness: which end is the stem (§A.1's "A"), and which way the leg diverges.
A stem-east/diverge-north turnout and a stem-west/diverge-south turnout are different cards.
- A **straight** has an axis: east-west along the Running Track, or north-south connecting rows.
**Why it is not cosmetic.** Card edges must actually meet for a placement to be legal (Gap 4a). An
east-west straight placed north of the Office has no south port, so it cannot attach to the Office's
junction stub at all — the placement is illegal, not merely awkward.
Taken to its conclusion: **if all three straights run east-west, the Office's junction stubs can
never be used, and Gap 8's fix is undone.** Vertical growth needs cards with north-south ports, and
the catalogue currently guarantees none.
The same applies to turnouts. Appendix A's worked examples require turnouts facing more than one way
to be performable at all; six identical turnouts would not build the layout the diagrams show.
**What the code does meanwhile.** `TurnoutOrientation` and `TrackAxis` are carried per card in
`src/engine/state.ts`, and turnouts with no stated orientation default to stem-east / diverge-north
(`DEFAULT_TURNOUT` in `src/engine/track.ts`). Tests cover both axes and several turnout handednesses,
so whichever way this is settled the engine already supports it.
**Candidate resolutions.**
- **(a) Specify a mix.** Give the six turnouts named orientations and split the three straights
between axes — e.g. 2 east-west, 1 north-south. Concrete, and it makes the draw meaningful: the
card you need may not come.
- **(b) Orientation chosen on placement.** A track card is generic, and the player picks how to lay
it when playing it. Removes the drought risk entirely and matches how model railroaders think, at
the cost of making track cards much more flexible — arguably too flexible, since a turnout is a
physical object.
- **(c) Rotationally symmetric cards.** Each card carries all four ports. Simplest to implement and
impossible to be stuck with, but it discards §A.1 entirely, which is one of the game's most
characterful rules.
**Decision — (b): orientation is chosen when the card is placed.** A track card is generic; the
player decides how to lay it. §A.1's constraint survives intact — a turnout's two legs still never
join each other — only the rotation is free.
*Measured, not argued.* With orientation fixed at a default, 100 simulated Office Areas grew only
sideways and downward, **never upward**: an east-west straight has no south port, so it could never
attach to the Office's north junction stub. Gap 8's fix was silently undone and Appendix A's
switching puzzle was unreachable.
```
before (fixed orientation) after (chosen on placement)
row 0: 561 cards row 3: 1
row -1: 324 row 2: 30
(nothing above the mainline) row 1: 193
row 0: 456
row -1: 186
row -2: 17
row -3: 2
```
*Why not (a), a specified mix.* It would have to be tuned so no plausible draw leaves a player unable
to build — hard to guarantee, and easy to break whenever the deck is retuned.
*Why not (c), rotationally symmetric cards.* It discards §A.1 entirely, one of the game's most
characterful rules.
**Implemented.** `variantsFor()` in `src/engine/track.ts` enumerates the rotations — 2 for a straight,
6 for a turnout, 2 for a run-around — and the chosen index rides the `cardPlayed` event, so replay
reproduces the same layout.
**Consequence for print-and-play.** Card art must read correctly at any rotation the card allows.
---
## Gap 12 — Balance targets are NOT reachable as specified
**Status:** OPEN, and the earlier "validated" reading is **withdrawn**.
### What changed
An earlier run reported mean 5.0 Revenue/player/Day and a ~35% win rate, apparently confirming the
Gap 10e prediction. **That was an artifact of a scoring bug.** Unloading was being scored through the
same path as loading, so an unload paid a full Revenue point after ONE Laborer action instead of
four. End-of-game statistics exposed it by showing that `loadAdvanced` never fired in 200 games.
With the pipeline corrected, honest numbers for 200 solitaire Standard games:
| | Observed | Gap 10e predicted |
| --- | ---: | ---: |
| Revenue per game | 4.6 mean | 19 |
| Revenue/player/Day | ~0.9 | 56 |
| Win rate vs target 20 | **0 / 200** | — |
| Freight share of gross | 21% | — |
**Nobody wins.** The targets of 10 / 20 / 45 are unreachable at this level of play.
### Is that the game or the bot?
Still the bot, at least in part. It spends about **10 Laborer actions per game against roughly 900
available** (5.3 facilities × ~2.5 Laborers × 60 Stages). It is leaving almost the entire freight
capacity of the railroad unused. Until that gap closes, the targets cannot be judged.
What the correction *does* establish firmly: a completed freight load costs **four** Laborer actions
plus a stocking action plus the switching to spot a car — far more than the model assumed. The
`card-reference.md` §7 estimate of 56 Revenue/Day was built on that mistake and should be treated as
unfounded until re-derived.
### Strategy signal — the first real one
Games bucketed by how their Revenue was earned:
| Strategy | Games | Mean Revenue |
| --- | ---: | ---: |
| passenger-only (freight 0%) | 94 | 4.8 |
| passenger-heavy (<33%) | 11 | **8.2** |
| balanced (3366%) | 37 | **7.7** |
| freight-heavy (>66%) | 6 | 5.3 |
| freight-only (100%) | 18 | 1.2 |
**Mixed strategies beat pure ones**, and pure freight is much the worst. That is a genuinely good
sign for the design — it means the two revenue systems complement rather than substitute. Worth
re-checking once the bot improves, since a weak bot could produce this pattern by accident.
### The bot was NOT the dominant gap — measured
The obvious suspicion was that the bot simply failed to use its Laborers. Measured over 20 games:
| | |
| --- | ---: |
| Load/Unload decision points | 1496 |
| ...where a Laborer action was **legal** | 238 (16%) |
| Laborer actions the bot **took** | 226 |
**The bot takes 95% of the Laborer actions available to it.** The problem is that they are legal
only 16% of the time. Attributing the shortfall to bot policy was wrong.
### What is actually limiting freight
**A train stands at the Office only 18% of Stages.** Measured across 20 games (1200 Stages):
| | |
| --- | ---: |
| Stages with a train at the Office | 213 (18%) |
| Mean trains in play | 2.27 of 4 Crew Trays |
| Stages where a due train was **held** for want of a crew | 140 (12%) |
| Trains scheduled per game | 6.1 of a possible 12 |
Every freight point needs a car brought *in* and the loaded car taken *out*, and both require a
train standing at the Office. At 18% of Stages that is roughly 11 opportunities per game, capping
freight at perhaps 5 points **regardless of how well the player switches**.
Two things follow:
1. **Traffic volume, not labour, is the binding constraint** on freight. The tunable that matters
most is how many trains are scheduled and how fast they turn around — Crew Tray count
(`players + 3`, so only 4 in solitaire) and the 2-region Mainline card are both implicated.
2. **Solitaire with one Office may simply be a thin configuration.** It is the MVP target, so this
matters: the economy may only come alive with more Offices and a fuller timetable.
### Bot improvements made, and what they bought
| | Before | After |
| --- | ---: | ---: |
| Freight share of gross revenue | 5% | 31% |
| Laborer actions per game | 10.1 | 13.4 |
| Cars dropped (mostly junk before) | 19.0 | 13.8 |
| Mean net revenue | 4.6 | 4.9 |
The largest single fix: the bot was dropping whichever car happened to sit at the tray's end, which
silted industry tracks up with the wrong commodity — **532 coaches were observed parked on freight
sidings across 20 games**. §A.3 forces cars off in seated order, so spotting the right car requires
re-ordering the consist with the six Moves. That is the game's central switching puzzle and this bot
still does not attempt it; it merely declines to drop a car that would do no work.
### Next steps
1. **Watch a game.** The remaining questions are about whether 18% train presence and a 6-of-12
timetable *feel* right, which is a judgment call, not a measurement.
2. Get the bot to schedule all 12 trains (it reaches only 6.1), which should roughly double freight
opportunity.
3. Only then decide whether the targets move or the economy does.
## Gap 13 — Deck scaling by player count
**Status:** OPEN — measured; recommendation below.
**The question asked:** should the 52-card deck be doubled?
**Measured answer: no.** 15 games per player count, Standard length:
| Players | Draws/game | Reshuffles | Trains scheduled | Freight facilities per player |
| ---: | ---: | ---: | ---: | ---: |
| 1 | 38 | 0.0 | 8.0 | 6.2 |
| 2 | 47 | 0.0 | 12.0 | 5.0 |
| 3 | 43 | 0.0 | 12.0 | 3.3 |
| 4 | 40 | 0.0 | 12.0 | 2.5 |
**The deck never depletes.** Zero reshuffles at every player count; a solitaire player sees about
74% of it. Deck size is not the constraint, so doubling would not relieve any pressure — and it
would actively hurt, by halving the density of Timetabled Train cards. Trains are the scarcest
useful card, and they are what drives all traffic.
Trains also cannot meaningfully double: the twelve are unique by number, and their numbers carry
seniority (§8). Two Train 5s would be ambiguous. Note the table shows all 12 already get scheduled at
2+ players.
**Where there IS real pressure: facilities, at higher player counts.** Freight facilities per player
falls from 6.2 in solitaire to 2.5 at four players, from a fixed supply of 10. A facility is the only
route to freight revenue, so that is a genuine squeeze — and it is a scaling problem, not a deck-size
problem.
**Recommendation: scale the buildable portion with player count, and leave trains fixed.** Add
roughly 4 cards per player beyond two — 2 freight facilities, 1 track, 1 Office upgrade — giving 52
at 12 players, 56 at 3, 60 at 4. This keeps train density high in the early game, where traffic
needs to get started, while giving each player material to build with.
**Not yet decided**, because it interacts with Gap 12's variance question: more facilities per player
would raise the ceiling on the runaway mode as well as the floor.
+549
View File
@@ -0,0 +1,549 @@
# Station Master — Rules v0.1
> **Status: faithful transcription.** This document reproduces
> `docs/StationMasterPrototypeRules.pdf` and `docs/StationMaster-PrototypeTurnChart.pdf` in
> markdown. Nothing has been corrected, resolved, or added — including the places where the
> original is ambiguous or self-contradictory. Those are catalogued in
> [`open-questions.md`](open-questions.md) and resolved in `rules-v0.2.md`.
>
> Section numbering is editorial (the PDF is unnumbered) and exists only so other documents can
> cite specific rules. Passages that depend on diagrams are described in text with a pointer to
> the source page.
---
## 1. Historical Setting
Between 1840 to 1950, trains operated without radios in their cabs, racing in the dark against
oncoming traffic, relying on timetables and pocket watches to avoid collisions. Station Operators
physically passed engineers orders from the dispatchers and "OS" reported ("On Station") them
through.
As the *Station Master* in your lineside Office, you'll manage a town's railroad connection,
embarking passengers and gathering freight, meeting demands from all quarters. Can you manage
trains and cargos effectively? The railroad depends on you!
---
## 2. Definitions
The following are railroad/game terms. (Also available as a lookup table in
[`glossary.md`](glossary.md).)
### 2.1 Along the right of way
- **Division Point:** Where your railroad ties into other railroads. There is an Eastern Division
Point and a Western Division Point.
- **Division:** Your company's tracks that connect the Eastern and Western Division Points. It
might represent hundreds of miles of track and several train "Offices" (each controlled by a
player serving as a Station Operator).
- **Mainline Card:** Tarot-sized cards that represent roughly the hundred miles of tracks between
train offices.
- **Office:** The location you (the Station Master) sit at to lord over your area. An Office might
be a Whistle Post (basically, a shack in the middle of nowhere), a depot, a station or a
terminal.
- **Control Point:** An office where trains are reported in to the distant (NPC) dispatcher. These
include Division Points, Depots, Stations and Terminals. Whistle posts are not Control Points.
Control Points are denoted by a train order signal on their card.
- **Limits:** The sign located on the track east and west of your Office's area that denote the
limit of your control area. As the track controlled by your office expands, the sign may be
moved outwards.
- **Subdivision:** The Mainline track between the Limits of opposing Control Points. Note that
Whistle Posts are *NOT* Control Points and are considered to be an internal part of a
Subdivision, no different than Mainline Track.
- **Running Track:** The "Mainline" that runs horizontally between your limits, including your
office. It is the horizontal row of cards, Limit-to-Limit.
- **Secondary Track:** All tracks in your limits that are not the Running Track.
- **Operational Rail:** Any track (Running and Secondary) on which a train may sit, change
direction, drop and pick up cars on. Operational Rail is marked with a wheel icon.
- **A/D (Arrival/Departure) Track:** These are short sidings in front of your office where trains
arrive and depart from. A train at an Office occupies one A/D track. Should a train arrive and
there are no available A/D tracks, there might be a collision.
### 2.2 Train makeup
- **Crew Tray:** The canoe-shaped holder that contains the train pieces.
- **Engine:** The engine piece. A Crew Tray must have an engine piece. The engine may be pulling
cars, pushing cars, or a combination of both.
- **Consist:** A collection of Rolling Stock (cars) in a Crew Tray.
- **Rolling stock:** The non-engine pieces representing rail cars that make up either a train (in a
Crew Tray) or are left sitting on an Operational Rail card. Rolling stock may be…
- Coaches for passengers (blue or white)
- Boxcars for general freight (brown or white)
- Refrigerated cars (reefers) for field produce (brown or white)
- Hoppers for coal (brown or white)
- Tank cars for oil (brown or white)
- Cabooses for the conductor and switch crews (red)
- A colored car is loaded. A white car is empty.
- **Division Yard:** A table-top pile of Rolling Stock. Rolling Stock for use for cargo, passengers
or on trains are selected from here.
- **Classification Yard:** Where used Rolling Stock is placed. Used engines and cabooses return
directly to the Division Yard. When the Division Yard is empty, all Rolling Stock in the
Classification Yard is moved back to the Division Yard.
### 2.3 Train types
- **Timetabled Trains:** These are trains scheduled to run every day at the same time. This time is
denoted by a Number Board placed on the turn chart. The Timetabled Trains are numbered 1-12, with
odd-numbers westbound (left) and even-numbered eastbound (right). The train image on their card
also defines its direction. Lower-numbered trains are more important and the dispatcher will give
them greater seniority on the railroad.
- **Number board:** The small tiles, numbered 1-12, that record which stage (hour) the train should
depart.
- **Extra Trains:** These are one-and-done trains, a train which runs once with its card returned to
the Scrap Yard deck upon completion. Since their image on the card is head-on, the player drawing
it may determine its direction. Their train number is denoted with an "X" designation, but the
actual following number denotes their seniority.
### 2.4 General terms
- **East** is to the player's right. **West** is to the player's left.
- A **Stage** is a two-hour section of time.
- A **Phase** is an operational component of a Stage.
- A **Day** is a series of twelve Stages.
- A **Move** is a Crew Tray moving from one Operational Rail card to another.
- **Highball:** When a train holding at an Office automatically departs.
- **Revenue:** Points gained and lost in the game to determine victory.
### 2.5 Passenger and Cargo elements
- A **Facility** is a business which loads/unloads cargo and freight.
- A **Modifier Facility** is a card placed adjacent to a Facility that increases its capacities.
- A blue man-icon is a **Laborer**, who works cargo.
- A black man-icon is a **Porter**, who assists passengers.
- A green **Outbound box** holds all to-be-loaded freight/passengers of the indicated type.
- A red **Inbound box** holds all just-unloaded freight/passengers of the designated type.
- A blue **"MEN | AT | WORK"** turn track follows how quickly freight cars are loaded/unloaded.
### 2.6 Railroad management
- **Dispatcher:** The imaginary person running the railroad and granting rights to trains to
operate.
- **Superintendent:** The Dispatcher's right-hand-man, who manages the division. The role of the
superintendent is passed player to player, and a Superintendent's shift is six hours (i.e. three
Stages).
- **Home Office:** The primary face-down deck cards are drawn from.
- **Departments:** Three decks (face up) from which cards may also be drawn from and discarded to.
- **Salvage Yard:** Where used cards are placed. They are face up to ensure that no cards that
should not be discarded are. We're keeping an eye on you.
---
## 3. Game Mode
The type of game should be chosen. Versions include
- **Solitaire** (player controls either one or several Offices). You may consider playing an
open-ended solitaire game to grow familiar with *Station Master's* mechanics.
- **Multiplayer** (players each control an Office)
Goals are…
- A specific total Revenue Points or highest Revenue within a specific number of Days.
- **Co-Op.** Multiple players work to achieve a shared goal.
---
## 4. Game Setup
The game begins in the early days of railroading. The Division has just been spiked down and no
trains are officially running yet. The future awaits!
1. Each player places a Whistle Post card (representing his Office) in the center of his play area.
A Limits card is placed to either side, east and west. These three cards represent his "Running
Track". All remaining Whistle Posts and Limits are set aside.
2. A "Mainline" card (tarot size) is drawn at random and placed between each player, forming a
chain of Offices.
> *Diagram (PDF p. 4):* a player's Running Track is the horizontal three-card row —
> Limits card, Office (Whistle Post) card, Limits card — with a tarot-sized Mainline card
> abutting each end.
3. Players are assumed to be in a "chain" of Offices running east to west. Each player rolls a D12.
Highest number is considered the Eastern Division point. Place the "Eastern Division Point" just
right of the player's eastward Mainline card. The player to his right (or at the opposite end of
the table space) gets the Western Division Point card to the left of another Mainline card,
placed to his left.
> *Diagram (PDF p. 4):* example with two players — West Division Point card, Mainline card,
> player 1's three-card Running Track, Mainline card, player 2's three-card Running Track,
> Mainline card, East Division Point card.
4. Each player rolls the D12. Highest begins the game as the Superintendent (his shift runs three
turns (or six hours)). Place the Fedora piece before him to signify his position. A Pocket watch
piece is placed on the "midnight" hour on the turn sheet.
5. The "Home Office" deck is shuffled and placed face down.
6. Starting from the superintendent, each player draws three cards off the Home Office deck. Once
they have these cards, three cards are drawn from the Home Office deck and placed face up next
to it, three individual decks representing the Department decks.
7. Space should be provided for the Salvage Yard deck (discards).
8. All available train car pieces should be placed the Division Yard pile. Crew trays and Number
Boards are placed near the turn sheet.
> *Diagram (PDF p. 5):* table layout — Home Office deck (face down) at top; three Department
> decks face up below-left of it; Salvage Yard deck below those; Division Yard (a pile of
> rolling stock) at right; Classification Yard (empty) below the Division Yard.
---
## 5. Order of Play
In each Stage of the day (a two-hour time period) the players perform Phases, one at a time in
order from the Superintendent through the players in order to his left. Once all the Phases for
that Stage are complete, the day ends. Every three Stages, the Superintendent's fedora piece is
given to the player to the current Superintendent's left.
### 5.1 Phases
Phases are (in order)
1. Local Operations Phase
2. New Train Phase
3. Mainline Phase
4. Cargo phase
5. Superintendent Shift Change (at 4am, 10am, 4pm and 10pm)
---
## 6. Local Operations Phase
A player may do **one of three things** in this phase:
### 6.1 Switch with a train
- If choosing this option, the player may move trains about his station area.
- He has six Moves he can use to move his Crew Trays about. He may divide these moves between
multiple Crews in any fashion he chooses.
- See [Appendix A](#appendix-a-local-switching) for details on how trains move and switch.
### 6.2 Draw a card
- The player may draw a single face-down card from the Home Office deck, or take the top face-up
card from any of the three Department decks.
- He may play any or all appropriate cards from his hand.
- Any played cards are placed on the table or into the Salvage Yard deck, face up.
- Before concluding, the player must reduce his hand to no more than three cards. If he must (or
wishes to) discard, that card is placed face up on top of one of the three Department decks.
- If drawing a card has depleted the Home Office deck, immediately collect all cards from the
Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office and
Department decks.
- If any of the Department decks is empty, draw a Home Office card and place it in the empty spot.
### 6.3 Freight Agent Operations
- You may select one Rolling Stock from the Division Yard pile and place it onto a Facility's green
Outbound box.
- Or you may select one Rolling Stock from a Facility's red Inbound box and place it into the
Classification Yard pile.
- Or if your Facility is hopelessly jammed, you may remove one car from the
Outbound / Inbound / "MEN|AT|WORK" areas and place it into the Classification yard.
---
## 7. New Train Phase
There are only a limited number of crew trays / engine pieces. If all are in play, keep any train
cards due out near the turn sheet to remind you of these "held trains". As soon as you can run a
train, build it as specified below. The lowest numbered train should go out first.
If there is a Timetabled Train (one with a number board on that Stage) and a crew is available, set
the crew on the appropriate Division Point (based on the train card's image's direction). Put an
engine piece on the "front" of the train. Then, starting with the superintendent and working left,
each player may place ONE car (loaded or empty) on the train (within the car types and numbers
specified) from the Division Yard pile. The person placing the Rolling Stock piece on the train must
make every effort to find a suitable car (within the train's specific criteria, either loaded or
empty). If no appropriate cars exist, the train will run short (Rolling stock might be added enroute
to bring it to its maximum length).
For new Timetabled Trains (when a player draws that card and plays it), roll 1D12 and place the
number board in that spot on the Timetable column of the turn chart. If occupied by a scheduled
train, work down the column, looking for the next available space. If you get to the bottom of the
chart, start again at the top. This train is now a scheduled train and will run every day at that
time. If a new timetabled train is placed on the current turn, refer to the paragraph above for
determining the consist.
Once all timetabled trains are created, if there is a played Extra Train card and an available Crew
Tray, the player who played the card may place the Crew Tray in either division point (west or east)
for immediate departure and may load the consist *as he chooses, subject to the listed criteria*.
---
## 8. Mainline Phase
Any train holding at a player's Office (Whistle Post, Depot, Station, or Terminal) or a Division
Point must attempt to move. These trains move automatically and in numeric order based on the white
number board pictured on the train's image (lowest number first). Remove the "X" from extras to
determine their true number.
Note that at the start of the game, the entire railroad consisting nothing but Whistle Posts is
considered to be one big Subdivision. Updating Offices with the Control Point icon split the
Subdivision in two at that point.
### 8.1 Highball conditions
The imaginary dispatcher will Highball (i.e. clear a train) out of an Office under the following
conditions:
- The train must be sitting on the A/D track before the Office or at a Division Point.
- The train must be in correct order listed on the train's card (engine on the front, Rolling Stock
and possibly a Caboose). It may depart with fewer Rolling Stock than listed.
- If there is a train in the next Subdivision and moving towards the considered train, the
considered train will not depart.
- If there is a train in the next Subdivision moving in the same direction (i.e. the considered
train is following the other train), the Superintendent determines if it is safe for a following
train to depart.
- If the Subdivision contains a Whistle Post (i.e. a non-Control Point) yet a train is occupying
Secondary Track there (i.e. clear of the Running Track), the superintendent must Highball the
train.
### 8.2 Movement along the Division
Trains are placed on the appropriate "Start" point of the Mainline card. They must move one region
(vertical bar) every turn. Once they run off the end of the card, they enter the adjoining player's
Limit. They are then moved immediately to the Office, where they stop for orders. When moving trains
along the division, it's a good idea to keep their train card near them for reference as to the
train number and operation details.
If any cars or trains are sitting on the Running Track between the Limits and the Office, a
collision may occur. Further, if there is no room at the station (every A/D track is occupied by a
train or Rolling Stock) a collision may occur.
If a train is ready to Highball and the track between it and the Limits is occupied by a train or
cars, a collision may occur.
---
## 9. Load/Unload Phase
Loading and unloading of Rolling Stock (passengers and freight) takes place in this phase. There are
special cards that permit this.
- **Passenger Facilities:** Depots, Stations and Terminals.
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
Plants.
- **Modifier Facilities:** A number of cards that, if placed adjacent (on any of the nine nearby
spots) increase the passenger or freight facility's capacities. If two Facilities are adjacent to
the Modifier, the effects of a Modifier Facility may only be used on one Facility in a turn.
- Note that some Freight Faculties allow only outbound cargos (loading), some inbound (unloading)
and some both. Passenger Faculties always allow both.
### 9.1 Special Icons
- Loading boxes are green, with the icon of the car type that can be loaded from there.
- Unloading boxes are red, with the icon of the car types unloaded from there.
- A number touching a box denotes the number of cars that can be held in the Loading/Unloading
boxes. If it touches *both* boxes, then that is the total of *all* loads/unloads on that card.
- Human icons in denim blue are Laborers, used for handling freight.
- Human icons in uniform black are Porters, and used for assisting passengers.
- Laborers and Porters may only be used once each in a Stage.
- For Freight Facilities, there is a blue "MEN | AT | WORK" sign. This is used as a turn track for
loading/unloading cars. Only one Load may exist on each of the three blue boxes. However, you may
"move past" another load if you have enough Laborers to hop past it.
For each Facility, add up the number of icon resources (Laborers and Porters) that you have at each
site. Resolve them one at a time.
### 9.2 Passenger Operations
One Porter will allow you to do any one of the following actions.
- **Assist passengers to board.** *Requirements:* A blue coach occupying a green Loading Slot, and a
train at the Office's A/D track with a white empty coach. *Action:* Replace the white empty coach
on the train with the loaded blue one, discard the empty coach into the Classification Yard, and
earn a Revenue point.
- **Assist passengers to de-train.** *Requirements:* White empty coach in Division Yard, and an
unoccupied red Unloading slot. Replace the blue coach with the white one on the train. Place the
blue coach on the red Unloading Slot. Earn a Revenue Point.
### 9.3 Freight Operations
Freight operations are like passenger operations with the distinct difference that passengers
self-load and unload, whereas freight can take hours to manually work. Each Laborer may do one thing
below.
- **Load the car:** *Requirements:* A load in the Green Loading Box and an empty car of the required
type on the industry's track (maximum of four cars). It takes one laborer to move the cargo one
spot: from the Green Loading Slot to "MEN", then "AT", then "WORK". If moved off "WORK", replace
the empty white car on the industry's track with the load and discard the white car into the
Classification Yard. Earn a Revenue Point.
- **Unload the car:** *Requirements:* A load in the industry's track and an empty car of that type
in the Division Yard. The first Laborer replaces the load with an empty car of that type on the
industry's track and places the load on "WORK". Additional Laborers move it to "AT" then "MEN".
The last one places the load on a red Unloading box. Earn a Revenue Point.
- **NOTE:** While ANY loads are in the "MEN | AT | WORK" track; the industry's track is locked down
for safety reasons. It loses its status as Operational Rail. No cars can be picked up or dropped
off, and no trains may occupy or move on it.
Passenger Facilities and Freight Houses permit cars to move each direction (loading and unloading).
Of course, you *may not* load a car, gain a point, then unload it and gain another. To keep track of
complex platform operations, the player should denote outbound pieces by flipping them onto their
roofs. Once the train completes operations and highballs, the pieces can be flipped right-side-up
once more.
---
## 10. Collisions
- Any collisions that occurred on a mainline card are the fault of the Superintendent who cleared
the trains out. He loses 5 Revenue points. Both trains are immediately removed. Timetabled trains
can run the next day.
- Any collisions that occur on the track between (and including) a player's Limits are his fault.
Lose 5 Revenue points. Both trains are removed. Timetabled trains run the next day.
---
## Appendix A: Local Switching
As mentioned before, *Operational Rail* is any track card that a train can stop and leave Rolling
Stock (uncouple) on. Operational Rail is any track card with a train wheel icon on it.
During the Local Operation Phase, you may operate your train if you do not draw a card or add/remove
cargo or passengers from a card. You will have six Moves that you can make, and these moves can be
split between trains if desired. A Move involves a train travelling from one Operational Rail to
another (regardless of distance) without changing direction.
### A.1 Turnouts
A train that enters a turnout from "A" may proceed to B or C. However, a train entering through B or
C may only proceed to A. Since there is no Operational Rail wheel icon, the train may not stop on
this card.
> *Diagram (PDF p. 9):* a single track card showing a turnout. "A" is the right-hand end of the
> horizontal track; "C" is the left-hand end of the same horizontal track; "B" is the upper-right
> end of the diverging leg.
### A.2 Reachable squares in one Move
> *Diagram (PDF p. 10):* a six-card grid (two rows of three) with four cards highlighted green as
> reachable in one Move from a Crew Tray sitting on the lower-right card. Two blue arrows mark the
> possible routes. The example notes he cannot end up in the upper-right card, as that would
> require a change of direction.
### A.3 Dropping and picking up cars
A train may drop off cars, either where he starts or while moving. Note that cars **must come off
the train in the order they are seated in the Crew Tray.**
> *Diagram (PDF p. 10):* the train starts with four cars (yellow, brown, blue and red, in that
> order). He drops red as he starts, then brown and blue during his move. He ends with only the
> yellow car.
Engines also have couplers on the front end. In this case, a train can pick up two cars and add them
to the Crew Tray in order that they were in, pushing them into the Facility.
> *Diagram (PDF p. 10):* a train with a yellow car and a forward-facing engine moves right, picks
> up a brown and a blue car, and arrives at the Freighthouse with the consist ordered
> yellow, engine, brown, blue.
It is critical that cars are loaded into and unloaded from the Crew Tray, and occupy the track, in
the same order they originally held, left-to-right.
### A.4 Special Rules
- While your Office Track is considered Operational Rail, Rolling Stock may not be dropped off here.
As there is a passenger platform here, it would be dangerous for the railroad to switch Rolling
Stock in this area.
- If two trains are operating in your station area, they may not share the same card or move through
each other.
- The exception to the above is your Office track. It has the number of A/D (Arrival/Departure)
tracks listed. As long as there are fewer trains holding here than A/D tracks, you may enter and
pass through.
- If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not "go
around them".
- A Crew Tray must always have an engine. It may only move a maximum of four Rolling Stock.
- While you automatically couple to any cars you come across during your Moves, the same is not true
for arriving at or departing trains during the Mainline Phase. Those trains are running at high
speed and are not expecting any trains or Rolling Stock on the line. Any cars encountered by such
trains result in a collision.
### A.5 Basic Switching Moves
There are two basic switching moves you should be aware of, *trailing* (that require a reverse move)
and *facing* (more difficult, as they require you to push the cars in). See if you can figure out how
to spot the brown car (in the middle of your train) to the freight house on Card "C".
**Trailing point.** How to get that brown car to the industry on Card C?
> *Diagram (PDF p. 11):* a six-card grid labelled A, B, C on the top row and D, E, F on the bottom
> row. C is a Freighthouse; E is a Depot. The train sits on Card F with cars yellow, brown, blue,
> red and a left-facing engine.
1. First move: Drop your back two cars (blue and red) on Card F. Move train to Card B.
2. Second move: Reverse to card C and drop the brown car.
3. Third Move: Move forward to Card B.
4. Fourth Move: Back up to Card F and collect the rest of your train. Simple!
**Facing point.** Again, get the brown car to Card C. This is more difficult and can only be done if
you have a run-around (i.e. bypass) track in your Secondary Trackage.
> *Diagram (PDF p. 11):* the same six-card grid, but the train now sits on Card D with cars
> red, blue, brown, yellow and a right-facing engine.
1. First Move: Move to Card B, dropping the red, blue and brown car. Continue with the yellow to
Card F.
2. Second Move: Back up via Card E to Card D.
3. Third Move. Move forward to Card B, collecting red, blue and brown cars on the nose of your
train. Push them into Card C, where the brown car is dropped off.
4. Fourth Move: Back up and drop off everything on the nose of your train (red and blue) on Card B.
Continue backing to Card D.
5. Fifth Move: Pull forward to Card F via Card E.
6. Sixth Move. Back up your train to Card B to rebuild your train (red, blue, yellow). If you wish to
start back where you began, continue back to Card D. However, note that, with the Sixth Move, your
Local Operations Phase is over. If the next Mainline Phase has either a train coming from the west
(left) or highballing from Card E west, you might wish to remain clear of the Running Track.
---
## Appendix B: Optional Rules
- **Reduced visibility:** The first three Stages (midnight through 4am) and the last two (8pm
through 10pm) are night operations. You only get five Moves in the Local Operations Phase during
these times.
- **"Sister" trains.** Generally, trains run with counterparts, such as Trains 1 & 2, trains 3 & 4.
Pull out all even train cards from the deck (2 through 12) and set them aside. When their companion
train (odd number) is drawn and played, bring the Sister Train in as well. This does not include
Extras.
- **Employee Rotation.** At the end of the day, all players move one chair to the left and take over
the next station up the line. Take your points (and the Fedora) with you. Railroads are known for
constantly moving people to new regions and why should you be excluded?
- **Emergency Toolbox:** Each player starts with a RED FLAG card, bringing his total hand up to
four. He must play/discard his hand down to three on his first turn. This allows more collisions to
be avoided.
---
## Appendix C: Turn Chart
Transcribed from `docs/StationMaster-PrototypeTurnChart.pdf`. The chart's columns are: Roll 1D12,
Timetable (blank, filled in with Number Boards during play), Stage, and then one column per phase —
Local Operations Phase, New Train Phase, Mainline phase, Load / Unload, and "Super" Shift Change.
The four phase columns carry the same icon in every row (a card back for Local Operations, a crew
tray for New Train, a mainline card for Mainline, and rolling-stock pieces for Load/Unload) — they
mark that every Stage runs all four phases. The Shift Change column carries a fedora icon only on
the rows listed below.
| Roll 1D12 | Stage | Clock | Shift Change |
| --------- | ----- | ----- | ------------ |
| 1 | 1 | Midnight | |
| 2 | 2 | 2:00 AM | |
| 3 | 3 | 4:00 AM | Fedora |
| 4 | 4 | 6:00 AM | |
| 5 | 5 | 8:00 AM | |
| 6 | 6 | 10:00 AM | Fedora |
| 7 | 7 | Noon | |
| 8 | 8 | 2:00 PM | |
| 9 | 9 | 4:00 PM | Fedora |
| 10 | 10 | 6:00 PM | |
| 11 | 11 | 8:00 PM | |
| 12 | 12 | 10:00 PM | Fedora |
The Stage cells are color-coded on the printed chart, suggesting daylight: deep blue at Midnight
warming through to yellow at 6:00 AM, pale blue through midday, red at 6:00 PM, and back to deep
blue by 10:00 PM. The coloring appears to be informational (it aligns with the Reduced Visibility
optional rule) rather than mechanical.
+841
View File
@@ -0,0 +1,841 @@
# Station Master — Rules v0.2
> **Status: working ruleset.** This is [`rules-v0.1.md`](rules-v0.1.md) — the faithful transcription
> of the prototype PDFs — with the seven gaps from [`open-questions.md`](open-questions.md) resolved
> and integrated in place.
>
> Every passage that differs from v0.1 is marked **[Gap N]**, so the provenance of each change stays
> visible. Section numbers match v0.1 wherever a section existed there; §11 and §12 are new.
> `open-questions.md` cites v0.1 numbering throughout.
>
> Numbers marked *provisional* are calibration targets to be retuned after the game is played.
---
## 1. Historical Setting
Between 1840 to 1950, trains operated without radios in their cabs, racing in the dark against
oncoming traffic, relying on timetables and pocket watches to avoid collisions. Station Operators
physically passed engineers orders from the dispatchers and "OS" reported ("On Station") them
through.
As the *Station Master* in your lineside Office, you'll manage a town's railroad connection,
embarking passengers and gathering freight, meeting demands from all quarters. Can you manage trains
and cargos effectively? The railroad depends on you!
---
## 2. Definitions
See [`glossary.md`](glossary.md) for the same terms as an alphabetical lookup table.
### 2.1 Along the right of way
- **Division Point:** Where your railroad ties into other railroads. There is an Eastern Division
Point and a Western Division Point.
- **Division:** Your company's tracks that connect the Eastern and Western Division Points.
- **Mainline Card:** Tarot-sized cards that represent roughly the hundred miles of tracks between
train offices. **[Gap 4b]** Each Mainline card is divided into **two regions**.
- **Office:** The location you (the Station Master) sit at to lord over your area. An Office is a
Whistle Post, a Depot, a Station, or a Terminal — see §11.
- **Control Point:** An office where trains are reported in to the distant (NPC) dispatcher. These
include Division Points, Depots, Stations and Terminals. Whistle Posts are not Control Points.
Control Points are denoted by a train order signal on their card.
- **Limits:** The sign located on the track east and west of your Office's area that denote the limit
of your control area. **[Gap 4a]** When you extend your Running Track, the Limits sign moves
outwards with it.
- **Subdivision:** The Mainline track between the Limits of opposing Control Points. Whistle Posts
are *NOT* Control Points and are an internal part of a Subdivision, no different than Mainline
Track.
- **Running Track:** The "Mainline" that runs horizontally between your limits, including your
office. It is the horizontal row of cards, Limit-to-Limit.
- **Secondary Track:** All tracks in your limits that are not the Running Track.
- **Operational Rail:** Any track (Running and Secondary) on which a train may sit, change direction,
drop and pick up cars on. Operational Rail is marked with a wheel icon.
- **A/D (Arrival/Departure) Track:** Short sidings in front of your office where trains arrive and
depart from. A train at an Office occupies one A/D track. **[Gap 2d]** If a train arrives and no
A/D track is available, it is a collision.
- **Office Area:** **[Gap 4a]** The grid of track cards a player controls. It grows during play as
track and Facility cards are placed (§11.2).
### 2.2 Train makeup
- **Crew Tray:** The canoe-shaped holder that contains the train pieces.
- **Engine:** The engine piece. A Crew Tray must have an engine piece. The engine may be pulling
cars, pushing cars, or a combination of both.
- **Consist:** A collection of Rolling Stock (cars) in a Crew Tray.
- **Rolling stock:** The non-engine pieces representing rail cars that make up either a train (in a
Crew Tray) or are left sitting on an Operational Rail card. Rolling stock may be…
- Coaches for passengers (blue or white)
- Boxcars for general freight (brown or white)
- Refrigerated cars (reefers) for field produce (brown or white)
- Hoppers for coal (brown or white)
- Tank cars for oil (brown or white)
- Cabooses for the conductor and switch crews (red)
- A colored car is loaded. A white car is empty.
- **Division Yard:** A table-top pile of Rolling Stock. Rolling Stock for use for cargo, passengers or
on trains are selected from here.
- **Classification Yard:** Where used Rolling Stock is placed. Used engines and cabooses return
directly to the Division Yard. When the Division Yard is empty, all Rolling Stock in the
Classification Yard is moved back to the Division Yard.
> **[Gap 4c]** A Crew Tray holds a maximum of four Rolling Stock (§A.4), and a caboose *is* Rolling
> Stock. A train requiring a caboose therefore carries at most **three** revenue cars.
### 2.3 Train types
- **Timetabled Trains:** Trains scheduled to run every day at the same time, denoted by a Number
Board placed on the turn chart. Numbered 1-12, with odd-numbers westbound (left) and even-numbered
eastbound (right). The train image on their card also defines its direction. Lower-numbered trains
are more important and the dispatcher will give them greater seniority on the railroad.
- **Number board:** The small tiles, numbered 1-12, that record which Stage (hour) the train should
depart. **[Gap 7]**
- **Extra Trains:** One-and-done trains, which run once with their card returned to the **Salvage
Yard** deck upon completion **[Gap 7]**. Since their image on the card is head-on, the player
drawing it may determine its direction. Their train number is denoted with an "X" designation, but
the actual following number denotes their seniority.
### 2.4 General terms
- **East** is to the player's right. **West** is to the player's left.
- A **Stage** is a two-hour section of time.
- A **Phase** is an operational component of a Stage.
- A **Day** is a series of twelve Stages.
- A **Move** is a Crew Tray moving from one Operational Rail card to another.
- **Highball:** When a train holding at an Office automatically departs.
- **Revenue:** Points gained and lost in the game to determine victory.
### 2.5 Passenger and Cargo elements
- A **Facility** is a business which loads/unloads cargo and freight.
- A **Modifier Facility** is a card placed adjacent to a Facility that increases its capacities.
- A blue man-icon is a **Laborer**, who works cargo.
- A black man-icon is a **Porter**, who assists passengers.
- A green **Outbound box** holds all to-be-loaded freight/passengers of the indicated type.
- A red **Inbound box** holds all just-unloaded freight/passengers of the designated type.
- A blue **"MEN | AT | WORK"** turn track follows how quickly freight cars are loaded/unloaded.
### 2.6 Railroad management
- **Dispatcher:** The imaginary person running the railroad and granting rights to trains to operate.
- **Superintendent:** The Dispatcher's right-hand-man, who manages the division. The role is passed
player to player; a Superintendent's shift is six hours (three Stages).
- **Home Office:** The primary face-down deck cards are drawn from.
- **Departments:** Three face-up slots beside the Home Office deck from which cards may also be drawn
and to which they may be discarded. **[Gap 4a]** These are market slots fed from the Home Office
deck, not separate decks with their own contents.
- **Salvage Yard:** Where used cards are placed. They are face up to ensure that no cards that should
not be discarded are. We're keeping an eye on you.
---
## 3. Game Mode and Victory · **[Gap 6]**
At setup the players choose a **mode**, a **victory condition**, and a **length**.
### 3.1 Modes
- **Solitaire** — one player controls one or several Offices. A good way to learn the mechanics.
- **Competitive** — players each control an Office and race each other.
- **Co-op** — players each control an Office and work toward a shared pooled total.
### 3.2 Length *(provisional)*
| Length | Revenue target | Days |
| --- | ---: | ---: |
| Short | 10 | 3 |
| Standard | 20 | 5 |
| Campaign | 45 | 10 |
**[Gap 10e]** These figures were lowered from 15 / 30 / 60 once the card faces were specified and the
development ramp accounted for. A player earns nothing on Day 1 — no facilities exist and no trains
run yet — so cumulative output is roughly 7 / 19 / 49 at those Day counts. The targets sit just under
those figures: winnable by a player running well, missed by one who is not. See
[`card-reference.md`](card-reference.md#7-economy-summary).
In **Co-op**, the Revenue target is multiplied by the number of players, since Co-op scores a pooled
total.
### 3.3 Victory conditions
| Mode | First to Revenue target | Highest Revenue after N Days |
| --- | --- | --- |
| **Solitaire** | Reach the target → win. | Must reach the target by the end of N Days, or **lose**. Above it, the final score stands as the result. |
| **Competitive** | First player to reach the target wins. | All players' Revenue **combined** must reach the collective floor (§3.5), or **everyone loses**. Otherwise the highest individual Revenue wins. |
| **Co-op** | Pooled Revenue reaches `target × players` → the team wins. | Pooled Revenue must reach `target × players` by the end of N Days, or **lose**. Above it, the final pooled score stands as the result. |
"Highest Revenue" is a comparison between rivals, which is meaningless in Solitaire and
self-defeating in Co-op. In those two modes the timed condition is therefore pass/fail against the
target figure: below it you lose regardless of score, above it your score stands and is what players
compare between sessions.
### 3.4 Collision floor — Competitive only
**Three collisions within a single Day and every player loses immediately.** Collisions are counted
Division-wide, across all players and the Superintendent alike. **The counter resets at the start of
each Day.**
This applies under both victory conditions. Solitaire and Co-op have no collision limit.
### 3.5 Collective Revenue floor — Competitive, timed games only
All players' Revenue combined must reach **`3 × players × Days`** by the end of the game. If it does
not, every player loses regardless of individual scores. *(Provisional — roughly half the expected
output of a functioning Division.)*
| Players | Days | Floor |
| ---: | ---: | ---: |
| 3 | 3 | 27 |
| 3 | 5 | 45 |
| 4 | 5 | 60 |
| 4 | 10 | 120 |
### 3.6 Note on the floors
The two floors make Competitive play partly cooperative: players race each other inside a railroad
they all need to keep functioning. This is the mechanical expression of the game's premise.
Be aware of one consequence: a player who is clearly losing can deliberately wreck trains to trip the
collision floor and deny the leader a win. The saboteur loses too, so it is mutually assured
destruction rather than a free grief, and the per-Day reset means three wrecks must land inside a
single Day.
---
## 4. Game Setup
The game begins in the early days of railroading. The Division has just been spiked down and no
trains are officially running yet. The future awaits!
1. Choose mode, victory condition and length (§3).
2. Each player places a Whistle Post card (representing his Office) in the center of his play area. A
Limits card is placed to either side, east and west. These three cards represent his Running Track.
All remaining Whistle Posts and Limits are set aside.
3. A Mainline card (tarot size) is drawn at random and placed between each player, forming a chain of
Offices. **[Gap 4b]** With N players there are N+1 Mainline cards — one between each adjacent pair,
and one beyond each end.
4. Players are assumed to be in a chain of Offices running east to west. Each player rolls a D12.
Highest is the Eastern Division Point; place that card just right of the player's eastward Mainline
card. The player at the opposite end of the chain gets the Western Division Point card to the left
of his westward Mainline card.
5. Each player rolls the D12. Highest begins as the Superintendent (his shift runs three Stages).
**[Gap 7]** Place the Fedora piece before him. A Pocket Watch piece is placed on the "midnight"
hour on the turn sheet.
6. The Home Office deck (52 cards, §12.1) is shuffled and placed face down.
7. Starting from the Superintendent, each player draws three cards off the Home Office deck. Then
three cards are drawn from the Home Office deck and placed face up next to it, forming the three
Department slots.
8. Space should be provided for the Salvage Yard deck (discards).
9. All available train car pieces are placed in the Division Yard pile. Crew Trays and Number Boards
are placed near the turn sheet.
10. *(Optional rule — Emergency Toolbox)* Each player is dealt one Red Flag card from its separate
supply, bringing his hand to four. He must play or discard down to three on his first turn.
---
## 5. Order of Play · **[Gap 1]**
Each Stage runs its Phases in order. Within a Phase, players act one at a time starting with the
Superintendent and proceeding to his left. Once all the Phases for that Stage are complete, **the
Stage ends** **[Gap 7]** and the Pocket Watch advances. After twelve Stages, the Day ends.
Every three Stages the Superintendent's Fedora passes to the player on the current Superintendent's
left.
```
STAGE N
├─ 1. Local Operations P(super) → P(+1) → P(+2) … one at a time
├─ 2. New Train per train: super → left, ONE car each
├─ 3. Mainline ALL trains move, train-number order (global)
├─ 4. Load / Unload P(super) → P(+1) → P(+2) … one at a time
└─ 5. Shift Change Stages 3, 6, 9, 12 only
```
### 5.1 Phases
Phases are, in order:
1. Local Operations Phase
2. New Train Phase
3. Mainline Phase
4. **Load/Unload Phase** **[Gap 7]**
5. Superintendent Shift Change (at 4am, 10am, 4pm and 10pm)
**[Gap 1]** Phases are resolved *phase-major*: every player completes Phase 1 before any player
begins Phase 2. The Mainline Phase is global and automatic, with one exception: §8.1's fourth
condition requires the Superintendent to rule on whether a following train may depart. No other
player acts during it. Per-Stage resources (Laborer and Porter usage) reset at the start of each
Stage, not each Phase.
---
## 6. Local Operations Phase
A player may do **one of three things** in this phase:
### 6.1 Switch with a train
- The player may move trains about his station area.
- He has six Moves to move his Crew Trays about, divisible between multiple Crews in any fashion.
- See [Appendix A](#appendix-a-local-switching) for how trains move and switch.
### 6.2 Draw a card
- The player may draw a single face-down card from the Home Office deck, or take the top face-up card
from any of the three Department slots.
- He may play any or all appropriate cards from his hand. **[Gap 3a]** This includes Office upgrade
cards (§11) and **[Gap 4a]** track and Facility cards placed into his Office Area (§11.2).
- Any played cards are placed on the table or into the Salvage Yard deck, face up.
- Before concluding, the player must reduce his hand to no more than three cards. If he must (or
wishes to) discard, that card is placed face up on top of one of the three Department slots.
- If drawing a card has depleted the Home Office deck, immediately collect all cards from the Salvage
Yard and three Department slots, reshuffle, and reestablish the Home Office deck and Department
slots.
- If any Department slot is empty, draw a Home Office card and place it in the empty spot.
### 6.3 Freight Agent Operations
- Select one Rolling Stock from the Division Yard pile and place it onto a Facility's green Outbound
box.
- Or select one Rolling Stock from a Facility's red Inbound box and place it into the Classification
Yard pile.
- Or if your Facility is hopelessly jammed, remove one car from the Outbound / Inbound /
"MEN|AT|WORK" areas and place it into the Classification Yard.
---
## 7. New Train Phase
There are only a limited number of Crew Trays and engine pieces **[Gap 4b: player count + 3]**. If
all are in play, keep any train cards due out near the turn sheet to remind you of these "held
trains". As soon as a Crew Tray frees up, build the train as specified below. The lowest numbered
train goes out first.
If there is a Timetabled Train (one with a number board on that Stage) and a crew is available, set
the crew on the appropriate Division Point (based on the train card image's direction). Put an engine
piece on the "front" of the train. Then, starting with the Superintendent and working left, each
player may place ONE car (loaded or empty) on the train, within the car types and numbers specified
on the train card (§12.4), from the Division Yard pile. The person placing the Rolling Stock must
make every effort to find a suitable car.
**[Gap 9] The round repeats.** Play continues around the table — Superintendent, then left — until
the consist is full or no suitable cars remain in the Division Yard. A single pass would tie consist
length to player count, leaving trains permanently short at two or three players and overrunning the
consist at five or more.
```
4-coach Limited, 2 players
round 1: P0 → coach P1 → coach
round 2: P0 → coach P1 → coach
full — departs with 4
5 players: the round ends mid-way, when the consist fills
```
If no appropriate cars exist, the train runs short — Rolling Stock may be added enroute to bring it
to its maximum length.
For new Timetabled Trains (when a player draws that card and plays it), roll 1D12 and place the
Number Board in that spot on the Timetable column of the turn chart. If that slot is occupied by a
scheduled train, work down the column to the next available space; if you reach the bottom, continue
from the top. This train is now scheduled and will run every Day at that time. If a new Timetabled
Train is placed on the current Stage, refer to the paragraph above for determining the consist.
Once all Timetabled Trains are created, if there is a played Extra Train card and an available Crew
Tray, the player who played the card may place the Crew Tray at either Division Point for immediate
departure and may load the consist *as he chooses, subject to the criteria listed on the card*
(§12.4).
---
## 8. Mainline Phase
Any train holding at a player's Office (Whistle Post, Depot, Station, or Terminal) or at a Division
Point must attempt to move. These trains move automatically, with **no player input**, in numeric
order based on the white number board pictured on the train's image (lowest first). Remove the "X"
from Extras to determine their true number.
> **[Gap 5]** When an Extra ties a Timetabled train on number, **the Timetabled train moves first**.
> Extras carry no timetable authority and yield to every scheduled train. Sort by
> `(number, isExtra)` ascending.
At the start of the game the entire railroad, consisting of nothing but Whistle Posts, is one big
Subdivision. Upgrading an Office to a Control Point (§11) splits the Subdivision in two at that
point.
### 8.1 Highball conditions
The imaginary dispatcher will Highball (clear) a train out of an Office under the following
conditions:
- The train must be sitting on the A/D track before the Office, or at a Division Point.
- The train must be in the order listed on the train's card (engine on the front, Rolling Stock, and
possibly a Caboose). It may depart with fewer Rolling Stock than listed, but not out of order.
- If there is a train in the next Subdivision moving towards the considered train, the considered
train will not depart.
- If there is a train in the next Subdivision moving in the same direction (i.e. the considered train
would be following it), the Superintendent determines whether it is safe for the following train to
depart.
- **[Gap 2b]** If the Subdivision contains a Whistle Post (a non-Control Point) and a train is
occupying Secondary Track there — that is, standing clear of the Running Track — that train does not
count as occupying the Subdivision. The Superintendent has no grounds to withhold clearance and
**must Highball the considered train**. A train on Secondary Track is not itself eligible to be
highballed; §8.1's first condition requires an A/D track or a Division Point.
### 8.2 Movement along the Division
Trains are placed on the appropriate "Start" point of the Mainline card and must move one region
(vertical bar) every **Stage** **[Gap 7]**. **[Gap 4b]** Each Mainline card has two regions. Once a
train runs off the end of a card it enters the adjoining player's Limit, and is then moved
immediately to the Office, where it stops for orders.
Keep each train's card near it for reference to its number and operating details.
### 8.3 Collision triggers · **[Gap 2a]**
Collisions are **automatic**. If any of the following holds at the moment a train moves, the
collision occurs — there is no die roll and no judgment call:
- Cars or trains are sitting on the Running Track between the Limits and the Office when a train
arrives.
- **[Gap 2d]** There is no room at the station — every A/D track is occupied by a train or Rolling
Stock — when a train arrives.
- A train is ready to Highball and the track between it and the Limits is occupied by a train or
cars.
Trains in the Mainline Phase run at speed and are not expecting anything on the line (§A.4). This is
the counterpart of local switching, where a Crew Tray automatically couples to whatever it meets.
---
## 9. Load/Unload Phase **[Gap 7]**
Loading and unloading of Rolling Stock (passengers and freight) takes place in this phase.
- **Passenger Facilities:** Depots, Stations and Terminals — that is, any Office above Whistle Post
(§11). A Whistle Post is not a Passenger Facility, so a player who has not upgraded has no
passenger business at all.
- **Freight Facilities:** Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power
Plants. Per-card values in §12.5.
- **Freight House:** **[Gap 10d]** not a facility type of its own, but the collective term for a
freight facility permitting both directions — the Grocer's Warehouse and the Oil Refinery.
- **Modifier Facilities:** Cards that, placed adjacent (on any of the nine nearby spots), increase a
passenger or freight facility's capacities. If two Facilities are adjacent to the Modifier, its
effects may only be used on one Facility per **Stage** **[Gap 7]**.
- Some Freight Facilities allow only outbound cargos (loading), some inbound (unloading), and some
both. Passenger Facilities always allow both.
### 9.1 Special Icons
- Loading boxes are green, with the icon of the car type that can be loaded from there.
- Unloading boxes are red, with the icon of the car types unloaded from there.
- A number touching a box denotes how many cars can be held in the Loading/Unloading boxes. If it
touches *both* boxes, that is the total of *all* loads/unloads on that card.
- Human icons in denim blue are Laborers, used for handling freight.
- Human icons in uniform black are Porters, used for assisting passengers.
- Laborers and Porters may only be used once each in a Stage.
- For Freight Facilities there is a blue "MEN | AT | WORK" sign, used as a turn track for
loading/unloading cars. Only one Load may exist on each of the three blue boxes. You may "move past"
another load if you have enough Laborers to hop past it.
For each Facility, add up the icon resources (Laborers and Porters) you have at that site. Resolve
them one at a time.
### 9.2 Passenger Operations
One Porter allows one of the following:
- **Assist passengers to board.** *Requirements:* a blue coach occupying a green Loading Slot, and a
train at the Office's A/D track with a white empty coach. *Action:* replace the white empty coach on
the train with the loaded blue one, discard the empty coach into the Classification Yard, and earn a
Revenue point.
- **Assist passengers to de-train.** *Requirements:* a white empty coach in the Division Yard and an
unoccupied red Unloading slot. Replace the blue coach on the train with the white one. Place the
blue coach on the red Unloading Slot. Earn a Revenue point.
### 9.3 Freight Operations
Passengers self-load and unload; freight can take hours to work manually. Each Laborer may do one
thing:
- **Load the car.** *Requirements:* a load in the Green Loading Box and an empty car of the required
type on the industry's track (maximum four cars). One Laborer moves the cargo one spot: Green
Loading Slot → "MEN" → "AT" → "WORK". Moved off "WORK", replace the empty white car on the
industry's track with the load and discard the white car into the Classification Yard. Earn a
Revenue point.
- **Unload the car.** *Requirements:* a load on the industry's track and an empty car of that type in
the Division Yard. The first Laborer replaces the load with an empty car of that type on the
industry's track and places the load on "WORK". Additional Laborers move it to "AT" then "MEN". The
last places the load on a red Unloading box. Earn a Revenue point.
- **NOTE:** while ANY load sits in the "MEN | AT | WORK" track, the industry's track is locked down
for safety. It loses its status as Operational Rail: no cars may be picked up or dropped off, and no
train may occupy or move on it.
> A complete freight load therefore takes **four Laborer-actions** for one Revenue point, while a
> passenger operation takes one. This asymmetry is intentional.
>
> **[Gap 10e]** Laborers are nevertheless rarely the binding constraint. Stocking a green box costs a
> whole Local Operations action (§6.3), and spotting the empty car costs switching — so it is the
> one-action-per-Stage budget, not worker count, that limits Revenue. See
> [`card-reference.md`](card-reference.md#7-economy-summary).
Passenger Facilities and Freight Houses permit cars to move each direction (loading and unloading).
You *may not* load a car, gain a point, then unload it and gain another. To track complex platform
operations, denote outbound pieces by flipping them onto their roofs; once the train completes
operations and highballs, flip them right-side-up again.
---
## 10. Collisions · **[Gap 2]**
Collisions are automatic (§8.3). When one occurs:
- **Fault and penalty.** A collision on a Mainline card is the fault of the Superintendent who cleared
the trains out; he loses 5 Revenue points. A collision on the track between (and including) a
player's Limits is that player's fault; he loses 5 Revenue points.
- **Train-on-train.** Both trains are immediately removed.
- **[Gap 2c] Train-on-cars.** A moving train striking uncoupled Rolling Stock is a full collision: the
train *and* the struck cars are destroyed, and the responsible party loses 5 Revenue points — the
same penalty as a train-on-train wreck.
- **[Gap 2c] Disposition of destroyed pieces.** Engines and cabooses return to the Division Yard; all
other Rolling Stock goes to the Classification Yard. Timetabled train cards return to their
timetable slot and run again the next Day. Extra train cards go to the Salvage Yard.
- **[Gap 3c/6]** Note that an Office with fewer A/D tracks collides more readily (§2.1, §11.1), and
that in Competitive games three collisions in one Day ends the game for everyone (§3.4).
---
## 11. Offices and the Office Area · **[Gap 3, Gap 4a]**
### 11.1 Office types
Every player begins on a Whistle Post (§4.2). Offices are upgraded by playing an Office card from
hand during the "Draw a card" option of the Local Operations Phase (§6.2). The card replaces the
current Office card; the replaced card goes to the Salvage Yard. **There is no Revenue cost** — the
cost is one of only three hand slots, plus a whole Local Operations action, which is mutually
exclusive with switching your trains or working freight that Stage.
| Office | Control Point | Passenger Facility | A/D tracks |
| --- | :---: | :---: | ---: |
| Whistle Post | no | no | 1 |
| Depot | yes | yes | 2 |
| Station | yes | yes | 3 |
| Terminal | yes | yes | 4 |
**[Gap 8]** These are the *only* differences between the tiers. All four carry identical track
geometry (§11.3), so an upgrade is a drop-in replacement that preserves every connection.
**Upgrades follow a strict sequence: Whistle Post → Depot → Station → Terminal.** No tier may be
skipped. A Station or Terminal card drawn early is therefore a dead card occupying one of three hand
slots; the player may sit on it, or discard it face-up to a Department slot where a rival may take
it.
An upgrade delivers three benefits at once: passenger revenue (a Whistle Post has none), A/D capacity
(and running out of A/D tracks is an automatic collision, §8.3), and Subdivision splitting — which
raises traffic capacity for the whole Division, not just for the upgrading player.
### 11.2 Growing the Office Area
A player's Office Area is a **grid** of track cards, not a single row. The Running Track is the
horizontal row through the Office, Limit-to-Limit; everything else is Secondary Track.
- Plain track, turnouts and Facility cards are all played from hand into an empty adjacent grid spot,
and **must connect to existing track**. Facility cards carry their own rails — placing a Facility
places track.
- Extending the **Running Track** horizontally moves that player's Limits sign outward with it (§2.1).
Growth is uncapped in the prototype.
- Modifier Facility cards are not track. They are placed adjacent to a Facility, on any of the nine
nearby spots (§9).
A longer Running Track is more track to keep clear of arriving trains, so territory carries its own
collision risk.
### 11.3 Office card geometry · **[Gap 8]**
Every Office card is printed with a **diverging junction stub above and below** its track:
```
[ ][ ↓ ][ ]
[Lim][Off][Lim]
[ ][ ↑ ][ ]
```
Both Secondary rows are therefore reachable from the opening Stage, and the nine-spot Modifier
neighbourhood (§9) is usable immediately.
**These stubs are plain junctions, not turnouts.** The directional rule of §A.1 — enter from A and you
may proceed to B or C; enter from B or C and you may only proceed to A — governs *drawn turnout
cards*. It does not apply to the Office card, which remains Operational Rail with its A/D tracks and
its §A.4 pass-through allowance intact.
**All four Office tiers carry identical track geometry.** Whistle Post, Depot, Station and Terminal
print the same stubs in the same places, and differ only in Control Point status, Passenger Facility
status, and A/D track count (§11.1). This matters because an upgrade replaces the card in place: any
difference in geometry between tiers would strand the Secondary Track attached to the old card. An
upgrade must never disturb a connection.
---
## 12. Components · **[Gap 4]**
*All counts provisional — calibration targets to retune after play.*
### 12.1 The Home Office deck — 52 cards
A single deck containing every card type. The three Department slots are face-up market slots fed
from it, not decks with their own contents (§2.6).
| Category | Count | Detail |
| --- | ---: | --- |
| Timetabled Trains | 12 | One each, numbered 112 |
| Extra Trains | 4 | X9, X10, X11, X12 |
| Office upgrades | 9 | Depot ×4, Station ×3, Terminal ×2 |
| Freight Facilities | 10 | 5 types × 2 |
| Modifier Facilities | 5 | |
| Plain track / turnouts | 12 | Turnout ×6, straight ×3, run-around ×3 |
| **Total** | **52** | |
The track mix is turnout-heavy because turnouts are how Secondary Track branches onward once the
Office card's own junction stubs (§11.3) are built out.
All twelve Timetabled Trains are in the deck: the Division starts with no trains running (§4), and a
train becomes scheduled only when a player draws and plays its card (§7).
Office cards recirculate — upgrading Depot → Station sends the Depot to the Salvage Yard, and it
returns on the next reshuffle. The Office pyramid (4/3/2) exists so the strict upgrade sequence
(§11.1) does not strand players with nowhere to climb.
### 12.2 Fixed supplies (outside the deck)
| Component | Count |
| --- | --- |
| Mainline cards (tarot, 2 regions each) | N + 1 for N players |
| Division Point cards | 2 (East, West) |
| Whistle Post cards | N + spares |
| Limits cards | 2N + spares |
| Crew Trays and engines | **player count + 3** |
| Number Boards | 12, numbered 112 |
| Red Flag cards | 1 per player *(optional rule only)* |
| Fedora | 1 |
| Pocket Watch | 1 |
| D12 | 1 |
Red Flag cards sit outside the 52 so the deck is identical whether or not the Emergency Toolbox
optional rule is used, and are **removed from the game when played** rather than reshuffled — each
player's flag is a single escape.
### 12.3 Rolling Stock — 62 pieces
Loaded and empty counts are paired because loading swaps a white car for a coloured one (§9.3).
| Type | Loaded | Empty |
| --- | ---: | ---: |
| Coaches (blue) | 8 | 8 |
| Boxcars (brown) | 6 | 6 |
| Hoppers (brown) | 6 | 6 |
| Reefers (brown) | 4 | 4 |
| Tank cars (brown) | 4 | 4 |
| Cabooses (red) | 6 | — |
| **Total** | | **62** |
### 12.4 Train consist criteria · **[Gap 4c]**
Odd numbers run westbound, even eastbound. Pairs are sister trains under the optional rule. Seniority
runs passenger-first: low numbers are the varnish, high numbers the drags and locals. Order in every
case is engine at the front, revenue cars, caboose last where required.
| Trains | Class | Consist |
| --- | --- | --- |
| 1 / 2 | Limited | 4 coaches, no caboose |
| 3 / 4 | Mail-Express | 3 coaches, no caboose |
| 5 / 6 | Manifest Freight | 3 boxcars or reefers + caboose |
| 7 / 8 | Coal Drag | 3 hoppers + caboose |
| 9 / 10 | Oil Train | 3 tank cars + caboose |
| 11 / 12 | Way Freight | 3 cars of any type + caboose |
| X9X12 | Extra | Any 3 cars + caboose — the player who played it chooses |
Remember the four-slot Crew Tray limit includes the caboose, which is why no caboose train carries
more than three revenue cars.
### 12.5 Card faces · **[Gap 10]**
What is printed on each individual card — freight facility profiles, passenger facility profiles,
Modifier effects, and track geometries — is catalogued in
**[`card-reference.md`](card-reference.md)**. Summary:
| Facility | Car | Direction | Laborers | Out | In | Track |
| --- | --- | --- | ---: | ---: | ---: | ---: |
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
| Office | Porters | Green slots | Red slots |
| --- | ---: | ---: | ---: |
| Depot | 1 | 2 | 2 |
| Station | 2 | 3 | 3 |
| Terminal | 3 | 4 | 4 |
| Modifier | Effect |
| --- | --- |
| Team Track | +1 car on the industry track |
| Loading Dock | +1 green Outbound capacity |
| Storage Shed | +1 red Inbound capacity |
| Extra Platform | +1 green and +1 red slot (passenger) |
| Section Gang | +1 Laborer or +1 Porter |
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
---
## Appendix A: Local Switching
*Operational Rail* is any track card a train can stop and leave Rolling Stock (uncouple) on, marked
with a train wheel icon.
During the Local Operations Phase you may operate your trains if you do not draw a card or work
cargo. You have six Moves, splittable between trains. A Move is a train travelling from one
Operational Rail card to another (regardless of distance) without changing direction.
### A.1 Turnouts
A train entering a turnout from "A" may proceed to B or C. A train entering through B or C may only
proceed to A. There is no Operational Rail wheel icon on a turnout, so a train may not stop there.
> *Diagram (v0.1 §A.1, PDF p. 9):* "A" is the right-hand end of the horizontal track, "C" the
> left-hand end of the same horizontal track, "B" the upper-right end of the diverging leg.
### A.2 Reachable squares in one Move
> *Diagram (v0.1 §A.2, PDF p. 10):* a 2×3 grid with four cards reachable in one Move from a Crew Tray
> on the lower-right card. The upper-right card is unreachable, as getting there would require a
> change of direction.
### A.3 Dropping and picking up cars
A train may drop off cars where it starts or while moving. Cars **must come off the train in the order
they are seated in the Crew Tray.**
Engines also have couplers on the front end, so a train can pick cars up onto its nose and push them
into a Facility, in the order they were in.
It is critical that cars are loaded into and unloaded from the Crew Tray, and occupy the track, in the
same order they originally held, left-to-right.
### A.4 Special Rules
- While your Office Track is Operational Rail, Rolling Stock may not be dropped off there — there is a
passenger platform, and switching there would be dangerous.
- If two trains are operating in your station area, they may not share a card or move through each
other.
- The exception is your Office track, which has the number of A/D tracks listed (§11.1). As long as
fewer trains are holding there than A/D tracks, you may enter and pass through.
- If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not go around
them.
- A Crew Tray must always have an engine, and may move a maximum of four Rolling Stock — cabooses
included **[Gap 4c]**.
- You automatically couple to cars you meet during your Moves. The same is **not** true of trains
arriving or departing in the Mainline Phase: those run at speed and are not expecting anything on the
line. Any cars they encounter result in a collision (§8.3).
### A.5 Basic Switching Moves
Two basic moves: *trailing* (requiring a reverse move) and *facing* (harder, requiring you to push the
cars in). Both worked examples use a 2×3 grid — A, B, C on the top row (C a Freighthouse), D, E, F on
the bottom (E a Depot).
**Trailing point** — train on Card F, cars yellow, brown, blue, red, engine facing left:
1. Drop the back two cars (blue and red) on Card F. Move train to Card B.
2. Reverse to Card C and drop the brown car.
3. Move forward to Card B.
4. Back up to Card F and collect the rest of your train.
**Facing point** — train on Card D, cars red, blue, brown, yellow, engine facing right. Possible only
with a run-around (bypass) track in your Secondary Trackage:
1. Move to Card B, dropping red, blue and brown. Continue with the yellow to Card F.
2. Back up via Card E to Card D.
3. Move forward to Card B, collecting red, blue and brown on the nose. Push them into Card C, dropping
the brown car.
4. Back up and drop red and blue on Card B. Continue backing to Card D.
5. Pull forward to Card F via Card E.
6. Back up to Card B to rebuild your train (red, blue, yellow); continue to Card D to finish where you
began. Note that this sixth Move ends your Local Operations Phase — if the next Mainline Phase has a
train coming from the west or highballing from Card E westward, you may wish to remain clear of the
Running Track.
---
## Appendix B: Optional Rules
- **Reduced visibility.** The first three Stages (midnight through 4am) and the last two (8pm through
10pm) are night operations. You get only five Moves in the Local Operations Phase during these
Stages.
- **"Sister" trains.** Trains run with counterparts — 1 & 2, 3 & 4, and so on. Pull all even train
cards (2 through 12) from the deck and set them aside. When the companion odd-numbered train is
drawn and played, bring the Sister Train in as well. Does not include Extras.
- **Employee Rotation.** At the end of the Day, all players move one chair to the left and take over
the next station up the line, taking their points and the Fedora with them.
- **Emergency Toolbox.** Each player starts with a RED FLAG card, bringing his hand to four; he must
play or discard down to three on his first turn. This allows more collisions to be avoided. **[Gap
4a]** Red Flags come from a separate supply and are removed from the game when played, not
reshuffled.
---
## Appendix C: Turn Chart
Columns: Roll 1D12, Timetable (filled with Number Boards during play), Stage, then one column per
Phase — Local Operations, New Train, Mainline, Load/Unload — and "Super" Shift Change.
| Roll 1D12 | Stage | Clock | Shift Change |
| --- | --- | --- | --- |
| 1 | 1 | Midnight | |
| 2 | 2 | 2:00 AM | |
| 3 | 3 | 4:00 AM | Fedora |
| 4 | 4 | 6:00 AM | |
| 5 | 5 | 8:00 AM | |
| 6 | 6 | 10:00 AM | Fedora |
| 7 | 7 | Noon | |
| 8 | 8 | 2:00 PM | |
| 9 | 9 | 4:00 PM | Fedora |
| 10 | 10 | 6:00 PM | |
| 11 | 11 | 8:00 PM | |
| 12 | 12 | 10:00 PM | Fedora |
Stage cells are colour-coded for daylight — deep blue at Midnight warming to yellow at 6:00 AM, pale
blue through midday, red at 6:00 PM, deep blue again by 10:00 PM. The colouring is informational and
aligns with the Reduced Visibility optional rule; it has no mechanical effect.
---
## Change log from v0.1
| Gap | Change | Sections |
| --- | --- | --- |
| 1 | Phases resolve phase-major, players in Superintendent-then-left order within a phase | §5 |
| 2 | Collisions are automatic; train-on-cars is a full collision; no A/D room is a collision; Whistle Post highball clarified | §8.1, §8.3, §10 |
| 3 | Office upgrades: cards played from hand, strict sequence, A/D counts 1/2/3/4 | §11.1 |
| 4 | 52-card deck, piece counts, 2-region Mainline cards, train consist criteria, grid-based Office Area | §2.1, §11.2, §12 |
| 5 | Timetabled trains outrank Extras on a number tie | §8 |
| 6 | Mode × victory-condition matrix, collision floor, collective Revenue floor | §3 |
| 7 | Load/Unload Phase; "the Stage ends"; Salvage Yard; turn → Stage | throughout |
| 8 | Office cards carry junction stubs above and below; all four tiers share identical geometry; track mix 6/3/3 | §11.1, §11.3, §12.1 |
| 9 | The New Train make-up round repeats until the consist is full | §7 |
| 10 | Card face specifications: freight and passenger facility profiles, Modifier effects, "Freight House" defined; §3 targets lowered to 10/20/45 | §3.2, §9, §12.5, `card-reference.md` |
Gaps 8 and 9 were found by a verification walkthrough of one full Stage against v0.2 and the
architecture model, not by reading the original PDFs. Neither question arises in tabletop play, where
a person simply does the sensible thing.
+411
View File
@@ -0,0 +1,411 @@
{
"name": "station-master",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "station-master",
"version": "0.0.1",
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^7.0.2"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/@types/node": {
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "station-master",
"version": "0.0.1",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
"engines": {
"node": ">=22.18"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.ts"
},
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^7.0.2"
}
}
+602
View File
@@ -0,0 +1,602 @@
/**
* Component 5 — The phase driver.
*
* `advance(state) -> { events, needsInput }`. Everything the game does WITHOUT a player acting:
* Mainline movement, highball evaluation, collisions, train make-up, the Stage and Day clock,
* Superintendent rotation, and victory checks.
*
* See architecture/components.md §2 A.5. This lives inside the engine rather than the session
* layer so that all rules knowledge stays in one pure deterministic place — which is also what
* makes the balance harness a plain loop over `advance` and `applyIntent`, with no server.
*
* USAGE:
* while (true) {
* const r = advance(state);
* if (r.needsInput || state.status === 'finished') break;
* }
*/
import {
COLLISION_PENALTY,
EXTRA_TRAINS,
MOVES_PER_LOCAL_OPS,
MOVES_PER_LOCAL_OPS_NIGHT,
REGIONS_PER_MAINLINE_CARD,
STAGES_PER_DAY,
STAGES_PER_SHIFT,
TIMETABLED_TRAINS,
collectiveRevenueFloor,
lengthProfile,
officeProfile,
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
import { areaOf } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, GameState, PlayerIndex, TrayId } from './state.ts';
import { coordKey, freshTurn, totalRevenue } from './state.ts';
export type AdvanceResult = {
events: GameEvent[];
/** True when the game is waiting on a player. The caller should stop pumping. */
needsInput: boolean;
};
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
function movesForStage(s: GameState): number {
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
? MOVES_PER_LOCAL_OPS_NIGHT
: MOVES_PER_LOCAL_OPS;
}
function trainProfile(number: number, isExtra: boolean) {
const pool = isExtra ? EXTRA_TRAINS : TIMETABLED_TRAINS;
return pool.find((t) => t.number === number) ?? null;
}
// ---------------------------------------------------------------------------
// Division navigation
// ---------------------------------------------------------------------------
function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number {
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
}
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
// ---------------------------------------------------------------------------
// advance
// ---------------------------------------------------------------------------
export function advance(s: GameState): AdvanceResult {
const events: GameEvent[] = [];
if (s.status === 'finished') return { events, needsInput: false };
// The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
if (s.clock.pendingDecision !== null) return { events, needsInput: true };
switch (s.clock.phase) {
case 'localOps':
return playerPhase(s, events, 'localOps');
case 'newTrain':
return newTrainPhase(s, events);
case 'mainline':
return mainlinePhase(s, events);
case 'loadUnload':
return playerPhase(s, events, 'loadUnload');
case 'shiftChange':
return shiftChange(s, events);
}
}
// ---------------------------------------------------------------------------
// Player-driven phases (Local Ops, Load/Unload)
// ---------------------------------------------------------------------------
function playerPhase(
s: GameState,
events: GameEvent[],
phase: 'localOps' | 'loadUnload',
): AdvanceResult {
// A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3).
if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) {
s.turn.done = true;
}
// Spending the last Move ends a switching turn without needing an explicit end (§6.1).
if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) {
s.turn.done = true;
}
if (!s.turn.done) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
// SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking.
// This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) —
// but a rules gap that stranded a player would otherwise hang the game rather than fail
// visibly, and a hung game is far harder to diagnose than a forfeited turn.
if (legalActions(s, s.clock.currentActor).length === 0) {
s.turn.done = true;
} else {
return { events, needsInput: true };
}
}
s.clock.actorOffset += 1;
s.turn = freshTurn(movesForStage(s));
if (s.clock.actorOffset >= s.players.length) {
return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false };
}
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
events.push({ type: 'actorChanged', player: s.clock.currentActor });
return { events, needsInput: false };
}
function actorAt(s: GameState, offset: number): PlayerIndex {
return (s.clock.superintendent + offset) % s.players.length;
}
function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] {
switch (p) {
case 'localOps':
return 'newTrain';
case 'newTrain':
return 'mainline';
case 'mainline':
return 'loadUnload';
case 'loadUnload':
return 'shiftChange';
default:
return 'localOps';
}
}
function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
s.clock.phase = phase;
s.clock.actorOffset = 0;
s.turn = freshTurn(movesForStage(s));
s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
return [
{ type: 'phaseBegan', phase },
{ type: 'actorChanged', player: s.clock.currentActor },
];
}
// ---------------------------------------------------------------------------
// New Train Phase (§7)
// ---------------------------------------------------------------------------
function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
// Make up any Timetabled Train due this Stage, if a Crew Tray is free (§7).
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && !trainRunning(s, due, false)) {
if (s.freeTrays.length === 0) {
// "Held train" — no crew available. It waits; the Stage moves on.
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
}
const profile = trainProfile(due, false);
if (profile) {
const trayId = s.freeTrays.pop()!;
const direction: Direction = profile.direction === 'east' ? 'east' : 'west';
// An eastbound train starts at the Western Division Point and runs east.
const side: Direction = direction === 'east' ? 'west' : 'east';
const tray: CrewTray = {
id: trayId,
trainNumber: due,
trainIsExtra: false,
engineFront: true,
consist: [],
direction,
position: { at: 'divisionPoint', side },
movesUsed: 0,
};
s.trays.set(trayId, tray);
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
if (dp && dp.kind === 'divisionPoint') dp.holding.push(trayId);
events.push({ type: 'phaseBegan', phase: `train ${due} made up` });
}
}
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
const filling = trainNeedingCars(s);
if (filling) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
return { events, needsInput: true };
}
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
}
function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
for (const t of s.trays.values()) {
if (t.trainNumber === number && t.trainIsExtra === isExtra) return true;
}
return false;
}
/** A made-up train still short of its consist spec, with a suitable car available. */
function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (tray.trainNumber === null) continue;
if (tray.position.at !== 'divisionPoint') continue;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) continue;
const want = profile.consist.count + (profile.consist.requiresCaboose ? 1 : 0);
if (tray.consist.length >= want) continue;
const suitable = s.yards.divisionYard.some(
(c) => profile.consist.allowedTypes.includes(c.type) || c.type === 'caboose',
);
if (suitable) return id;
}
return null;
}
// ---------------------------------------------------------------------------
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
// ---------------------------------------------------------------------------
function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
s.clock.currentActor = null;
// §8 — trains move in numeric order, lowest first. Timetabled outranks an Extra of the same
// number (Gap 5): sort by (number, isExtra) ascending.
const order = [...s.trays.entries()]
// §8 — "Any train holding at a player's Office ... or a Division Point must attempt to move."
// Trains standing on an A/D track are included: they are exactly the ones due to highball.
.filter(([, t]) => t.trainNumber !== null)
.sort(([, a], [, b]) => {
const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
});
for (const [id, tray] of order) {
if (s.movedThisPhase.has(id)) continue;
const moved = moveTrain(s, id, tray, events);
if (moved === 'needsClearance') return { events, needsInput: true };
s.movedThisPhase.add(id);
}
s.movedThisPhase = new Set();
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
}
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
function moveTrain(
s: GameState,
id: TrayId,
tray: CrewTray,
events: GameEvent[],
): MoveOutcome {
const dir = step(tray.direction);
if (tray.position.at === 'divisionPoint') {
const dpIndex = s.division.nodes.findIndex(
(n) => n.kind === 'divisionPoint' && n.side === (tray.position as { side: Direction }).side,
);
const target = dpIndex + dir;
const node = s.division.nodes[target];
if (!node || node.kind !== 'mainline') return 'held';
const clearance = evaluateClearance(s, id, tray, target, events);
if (clearance === 'blocked') return 'held';
if (clearance === 'ask') return 'needsClearance';
const region = dir > 0 ? 0 : REGIONS_PER_MAINLINE_CARD - 1;
node.regions[region]!.occupant = id;
tray.position = { at: 'mainline', index: target, region };
const dp = s.division.nodes[dpIndex];
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
return 'moved';
}
// §8.1 — a train standing on an A/D track attempts to highball onto the next Mainline card.
// Without this a train that arrives at an Office never leaves, holding an A/D track forever and
// colliding with every train that follows it.
if (tray.position.at === 'grid') {
const owner = tray.position.owner;
const area = areaOf(s, owner);
// Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
if (
tray.position.coord.row !== area.officeCoord.row ||
tray.position.coord.col !== area.officeCoord.col
) {
return 'held';
}
const officeIndex = nodeIndexOfOffice(s, owner);
const target = officeIndex + dir;
const node = s.division.nodes[target];
if (!node) return 'held';
if (node.kind === 'mainline') {
const clearance = evaluateClearance(s, id, tray, target, events);
if (clearance === 'blocked') return 'held';
if (clearance === 'ask') return 'needsClearance';
const region = dir > 0 ? 0 : REGIONS_PER_MAINLINE_CARD - 1;
node.regions[region]!.occupant = id;
tray.position = { at: 'mainline', index: target, region };
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
return 'moved';
}
if (node.kind === 'divisionPoint') {
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
retireTrain(s, id, tray, events);
return 'moved';
}
return 'held';
}
if (tray.position.at === 'mainline') {
const { index, region } = tray.position;
const node = s.division.nodes[index];
if (!node || node.kind !== 'mainline') return 'held';
const nextRegion = region + dir;
if (nextRegion >= 0 && nextRegion < REGIONS_PER_MAINLINE_CARD) {
// §8.2 — one region per Stage.
node.regions[region]!.occupant = null;
node.regions[nextRegion]!.occupant = id;
tray.position = { at: 'mainline', index, region: nextRegion };
return 'moved';
}
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
const target = index + dir;
const dest = s.division.nodes[target];
node.regions[region]!.occupant = null;
if (!dest) return 'held';
if (dest.kind === 'divisionPoint') {
// The train has run the length of the Division and leaves the game (§2.3).
dest.holding.push(id);
tray.position = { at: 'divisionPoint', side: dest.side };
retireTrain(s, id, tray, events);
return 'moved';
}
if (dest.kind === 'office') {
return arriveAtOffice(s, id, tray, dest.owner, events);
}
}
return 'held';
}
/**
* §8.1 — the highball conditions that involve the next Subdivision.
*
* A train moving TOWARDS the considered train is an absolute bar. A train moving the SAME
* direction is the Superintendent's judgment call — and Gap 2 made the consequences automatic
* precisely so that this decision carries full weight.
*/
function evaluateClearance(
s: GameState,
id: TrayId,
tray: CrewTray,
targetIndex: number,
events: GameEvent[] = [],
): 'clear' | 'blocked' | 'ask' {
// A ruling already given for this train is consumed here — this is what stops the driver from
// re-asking the same question every time it re-evaluates the train.
const ruling = s.clock.clearanceRuling;
if (ruling && ruling.train === id) {
s.clock.clearanceRuling = null;
return ruling.allow ? 'clear' : 'blocked';
}
const node = s.division.nodes[targetIndex];
if (!node || node.kind !== 'mainline') return 'clear';
for (const region of node.regions) {
const other = region.occupant;
if (!other || other === id) continue;
const otherTray = s.trays.get(other);
if (!otherTray) continue;
if (otherTray.direction !== tray.direction) return 'blocked';
// Same direction — the Superintendent must rule (§8.1, fourth condition).
s.clock.pendingDecision = { train: id, occupiedBy: other };
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
return 'ask';
}
return 'clear';
}
/**
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
* the collision happens, with no die roll and no judgment.
*/
function arriveAtOffice(
s: GameState,
id: TrayId,
tray: CrewTray,
owner: PlayerIndex,
events: GameEvent[],
): MoveOutcome {
const area = areaOf(s, owner);
const capacity = officeProfile(area.tier).adTracks;
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
if (area.adOccupancy.length >= capacity) {
collide(s, owner, [id], events, 'no free A/D track');
return 'moved';
}
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
// is not expecting them (§A.4), so this is a collision too, not a coupling.
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) {
collide(s, owner, [id], events, 'cars fouling the Running Track');
return 'moved';
}
area.adOccupancy.push(id);
tray.position = { at: 'grid', owner, coord: area.officeCoord };
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} arrived` });
return 'moved';
}
function collide(
s: GameState,
faultPlayer: PlayerIndex,
trains: TrayId[],
events: GameEvent[],
reason: string,
): void {
for (const id of trains) {
const tray = s.trays.get(id);
if (!tray) continue;
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
}
s.trays.delete(id);
s.freeTrays.push(id);
}
s.collisionsToday += 1;
const player = s.players[faultPlayer];
if (player) {
player.revenue -= COLLISION_PENALTY;
events.push({
type: 'revenueChanged',
player: faultPlayer,
delta: -COLLISION_PENALTY,
total: player.revenue,
reason: `collision: ${reason}`,
});
}
}
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
}
s.trays.delete(id);
s.freeTrays.push(id);
const dp = s.division.nodes.find(
(n) => n.kind === 'divisionPoint' && n.holding.includes(id),
);
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} completed` });
}
// ---------------------------------------------------------------------------
// Shift change, Stage and Day advance
// ---------------------------------------------------------------------------
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = (s.clock.superintendent + 1) % s.players.length;
events.push({ type: 'actorChanged', player: s.clock.superintendent });
}
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
for (const area of s.officeAreas.values()) {
for (const card of area.grid.values()) {
if (card.facility) card.facility.usedThisStage = { laborers: 0, porters: 0 };
}
}
if (s.clock.stage >= STAGES_PER_DAY) {
s.clock.day += 1;
s.clock.stage = 1;
s.collisionsToday = 0;
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
const finished = checkVictory(s, events);
if (finished) return { events, needsInput: false };
} else {
s.clock.stage += 1;
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
}
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
s.status = 'finished';
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
return { events, needsInput: false };
}
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
}
/** §3.3 — evaluated at the end of a Day. */
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
const profile = lengthProfile(s.config.length);
const daysElapsed = s.clock.day - 1;
if (s.config.victory === 'firstToTarget') {
const target =
s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
if (score >= target) {
const winner =
s.config.mode === 'coop'
? null
: s.players.findIndex((p) => p.revenue === score);
s.status = 'finished';
s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
return true;
}
return false;
}
if (daysElapsed < profile.days) return false;
s.status = 'finished';
if (s.config.mode === 'competitive') {
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
const best = Math.max(...s.players.map((p) => p.revenue));
s.outcome = {
result: 'win',
winner: s.players.findIndex((p) => p.revenue === best),
reason: 'daysElapsed',
};
return true;
}
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
s.outcome =
score >= target
? { result: 'win', winner: null, reason: 'daysElapsed' }
: { result: 'loss', winner: null, reason: 'revenueFloor' };
return true;
}
// ---------------------------------------------------------------------------
// Pump helper
// ---------------------------------------------------------------------------
/** Runs automatic work until a player must act or the game ends. */
export function pump(s: GameState, maxSteps = 10_000): GameEvent[] {
const all: GameEvent[] = [];
for (let i = 0; i < maxSteps; i++) {
const r = advance(s);
all.push(...r.events);
if (r.needsInput || s.status === 'finished') return all;
}
throw new Error('phase driver failed to settle — probable infinite loop');
}
export { nodeIndexOfOffice };
+1045
View File
File diff suppressed because it is too large Load Diff
+465
View File
@@ -0,0 +1,465 @@
/**
* Component 1 — Card catalogue / static content.
*
* The printed values of every card. Source of truth: docs/rules/card-reference.md.
* See architecture/components.md §2 A.1.
*
* This is DATA, not logic. Every number here is provisional and will be retuned repeatedly
* against playtesting — keep tuning confined to this file so it never requires touching the
* engine.
*/
// ---------------------------------------------------------------------------
// Primitives
// ---------------------------------------------------------------------------
/** §2.2. A coloured car is loaded; a white car is empty. */
export type CarType = 'coach' | 'boxcar' | 'reefer' | 'hopper' | 'tank' | 'caboose';
export type Direction = 'east' | 'west';
/** §9 — some freight facilities load only, some unload only, some both. */
export type FlowDirection = 'outbound' | 'inbound' | 'both';
export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal';
export type FreightKind =
| 'mineTipple'
| 'produceShed'
| 'grocersWarehouse'
| 'oilRefinery'
| 'powerPlant';
export type ModifierKind =
| 'teamTrack'
| 'loadingDock'
| 'storageShed'
| 'extraPlatform'
| 'sectionGang';
export type TrackGeometry = 'turnout' | 'straight' | 'runAround';
// ---------------------------------------------------------------------------
// Freight facilities — card-reference.md §2
// ---------------------------------------------------------------------------
export type FreightProfile = {
kind: FreightKind;
name: string;
carType: CarType;
flow: FlowDirection;
laborers: number;
/** Green box capacity; 0 when the facility does not load. */
outboundCapacity: number;
/** Red box capacity; 0 when the facility does not unload. */
inboundCapacity: number;
/** Cars that fit on the industry track. Never above 4 (§9.3). */
industryTrackLength: number;
/** Copies in the deck. Identical pairs — Gap 10b. */
copies: number;
};
export const FREIGHT_PROFILES: readonly FreightProfile[] = [
{
kind: 'mineTipple',
name: 'Mine Tipple',
carType: 'hopper',
flow: 'outbound',
laborers: 3,
outboundCapacity: 3,
inboundCapacity: 0,
industryTrackLength: 4,
copies: 2,
},
{
kind: 'produceShed',
name: 'Produce Shed',
carType: 'reefer',
flow: 'outbound',
laborers: 2,
outboundCapacity: 2,
inboundCapacity: 0,
industryTrackLength: 3,
copies: 2,
},
{
kind: 'grocersWarehouse',
name: "Grocer's Warehouse",
carType: 'boxcar',
flow: 'both',
laborers: 2,
outboundCapacity: 2,
inboundCapacity: 2,
industryTrackLength: 3,
copies: 2,
},
{
kind: 'oilRefinery',
name: 'Oil Refinery',
carType: 'tank',
flow: 'both',
laborers: 3,
outboundCapacity: 2,
inboundCapacity: 2,
industryTrackLength: 4,
copies: 2,
},
{
kind: 'powerPlant',
name: 'Power Plant',
carType: 'hopper',
flow: 'inbound',
laborers: 3,
outboundCapacity: 0,
inboundCapacity: 3,
industryTrackLength: 4,
copies: 2,
},
];
/**
* "Freight House" (§9.3, Appendix A) is not a card — it is the collective term for a freight
* facility permitting both directions. Gap 10d.
*/
export function isFreightHouse(profile: FreightProfile): boolean {
return profile.flow === 'both';
}
// ---------------------------------------------------------------------------
// Offices — card-reference.md §4
// ---------------------------------------------------------------------------
export type OfficeProfile = {
tier: OfficeTier;
name: string;
isControlPoint: boolean;
isPassengerFacility: boolean;
adTracks: number;
porters: number;
greenSlots: number;
redSlots: number;
/** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */
copiesInDeck: number;
};
/**
* Gap 8: all four tiers carry IDENTICAL track geometry — a through track plus a plain junction
* stub above and below. They differ only in the three flags and the counts below, which is what
* makes an upgrade a drop-in replacement that never disturbs a connection.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{
tier: 'whistlePost',
name: 'Whistle Post',
isControlPoint: false,
isPassengerFacility: false,
adTracks: 1,
porters: 0,
greenSlots: 0,
redSlots: 0,
copiesInDeck: 0,
},
{
tier: 'depot',
name: 'Depot',
isControlPoint: true,
isPassengerFacility: true,
adTracks: 2,
porters: 1,
greenSlots: 2,
redSlots: 2,
copiesInDeck: 4,
},
{
tier: 'station',
name: 'Station',
isControlPoint: true,
isPassengerFacility: true,
adTracks: 3,
porters: 2,
greenSlots: 3,
redSlots: 3,
copiesInDeck: 3,
},
{
tier: 'terminal',
name: 'Terminal',
isControlPoint: true,
isPassengerFacility: true,
adTracks: 4,
porters: 3,
greenSlots: 4,
redSlots: 4,
copiesInDeck: 2,
},
];
/** Upgrade order is strict — no skipping (Gap 3b). */
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
export function officeProfile(tier: OfficeTier): OfficeProfile {
const found = OFFICE_PROFILES.find((p) => p.tier === tier);
if (!found) throw new Error(`unknown office tier: ${tier}`);
return found;
}
/** The next tier up, or null at Terminal. Strict sequence, no skipping. */
export function nextOfficeTier(tier: OfficeTier): OfficeTier | null {
const i = OFFICE_ORDER.indexOf(tier);
return i >= 0 && i + 1 < OFFICE_ORDER.length ? OFFICE_ORDER[i + 1]! : null;
}
// ---------------------------------------------------------------------------
// Modifiers — card-reference.md §5
// ---------------------------------------------------------------------------
export type ModifierProfile = {
kind: ModifierKind;
name: string;
effect: string;
appliesTo: 'freight' | 'passenger' | 'either';
};
export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
{
kind: 'teamTrack',
name: 'Team Track',
effect: '+1 car on the industry track',
appliesTo: 'freight',
},
{
kind: 'loadingDock',
name: 'Loading Dock',
effect: '+1 green Outbound capacity',
appliesTo: 'freight',
},
{
kind: 'storageShed',
name: 'Storage Shed',
effect: '+1 red Inbound capacity',
appliesTo: 'freight',
},
{
kind: 'extraPlatform',
name: 'Extra Platform',
effect: '+1 green and +1 red slot',
appliesTo: 'passenger',
},
{
kind: 'sectionGang',
name: 'Section Gang',
effect: '+1 Laborer or +1 Porter',
appliesTo: 'either',
},
];
// ---------------------------------------------------------------------------
// Trains — card-reference.md §3
// ---------------------------------------------------------------------------
export type ConsistSpec = {
/** Revenue cars, excluding the caboose. */
count: number;
allowedTypes: readonly CarType[];
requiresCaboose: boolean;
};
export type TrainProfile = {
/** 1..12. For an Extra this is the number following the "X" (§2.3). */
number: number;
isExtra: boolean;
className: string;
/** Extras are head-on: the player choosing determines direction (§2.3). */
direction: Direction | 'playerChoice';
consist: ConsistSpec;
};
const ALL_CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank'];
/**
* Odd numbers run westbound, even eastbound (§2.3). Pairs are sister trains under the optional
* rule. Seniority runs passenger-first: low numbers are the varnish, high the drags and locals.
*
* NOTE the four-slot Crew Tray limit INCLUDES the caboose (§A.4), which is why no caboose train
* carries more than three revenue cars.
*/
function timetabledPair(
odd: number,
className: string,
consist: ConsistSpec,
): readonly TrainProfile[] {
return [
{ number: odd, isExtra: false, className, direction: 'west', consist },
{ number: odd + 1, isExtra: false, className, direction: 'east', consist },
];
}
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
...timetabledPair(1, 'Limited', {
count: 4,
allowedTypes: ['coach'],
requiresCaboose: false,
}),
...timetabledPair(3, 'Mail-Express', {
count: 3,
allowedTypes: ['coach'],
requiresCaboose: false,
}),
...timetabledPair(5, 'Manifest Freight', {
count: 3,
allowedTypes: ['boxcar', 'reefer'],
requiresCaboose: true,
}),
...timetabledPair(7, 'Coal Drag', {
count: 3,
allowedTypes: ['hopper'],
requiresCaboose: true,
}),
...timetabledPair(9, 'Oil Train', {
count: 3,
allowedTypes: ['tank'],
requiresCaboose: true,
}),
...timetabledPair(11, 'Way Freight', {
count: 3,
allowedTypes: ALL_CAR_TYPES,
requiresCaboose: true,
}),
];
/** Gap 4a: Extras carry high numbers, i.e. low seniority. They yield to every scheduled train. */
export const EXTRA_TRAINS: readonly TrainProfile[] = [9, 10, 11, 12].map((n) => ({
number: n,
isExtra: true,
className: 'Extra',
direction: 'playerChoice' as const,
consist: { count: 3, allowedTypes: ALL_CAR_TYPES, requiresCaboose: true },
}));
// ---------------------------------------------------------------------------
// Track — card-reference.md §6
// ---------------------------------------------------------------------------
export type TrackProfile = {
geometry: TrackGeometry;
name: string;
/** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */
isOperationalRail: boolean;
copies: number;
};
export const TRACK_PROFILES: readonly TrackProfile[] = [
{ geometry: 'turnout', name: 'Turnout', isOperationalRail: false, copies: 6 },
{ geometry: 'straight', name: 'Straight', isOperationalRail: true, copies: 3 },
{ geometry: 'runAround', name: 'Run-around', isOperationalRail: true, copies: 3 },
];
// ---------------------------------------------------------------------------
// Rolling stock supply — card-reference.md §8
// ---------------------------------------------------------------------------
export type StockSupply = { type: CarType; loaded: number; empty: number };
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
{ type: 'coach', loaded: 8, empty: 8 },
{ type: 'boxcar', loaded: 6, empty: 6 },
{ type: 'hopper', loaded: 6, empty: 6 },
{ type: 'reefer', loaded: 4, empty: 4 },
{ type: 'tank', loaded: 4, empty: 4 },
{ type: 'caboose', loaded: 6, empty: 0 },
];
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce(
(n, s) => n + s.loaded + s.empty,
0,
);
// ---------------------------------------------------------------------------
// Fixed supplies and scale — card-reference.md §8, rules §12.2
// ---------------------------------------------------------------------------
/** §4.3 — one Mainline card between each adjacent pair, and one beyond each end. */
export function mainlineCardCount(players: number): number {
return players + 1;
}
/** Gap 4b — scarcity is an explicit mechanic (§7). */
export function crewTrayCount(players: number): number {
return players + 3;
}
/** Each Mainline card is divided into two regions (Gap 4b). */
export const REGIONS_PER_MAINLINE_CARD = 2;
export const STAGES_PER_DAY = 12;
/** §5 — the Fedora passes every three Stages; shift changes at Stages 3, 6, 9, 12. */
export const STAGES_PER_SHIFT = 3;
/** §6.2 — hand limit, before the optional Red Flag. */
export const HAND_LIMIT = 3;
/** §A.4 — a Crew Tray may hold at most four Rolling Stock, cabooses included. */
export const MAX_CONSIST = 4;
/** §6.1 — Moves per Local Operations Phase; five during night Stages under Reduced Visibility. */
export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
/** §9.3 — Green -> MEN -> AT -> WORK -> car. */
export const LABORER_ACTIONS_PER_LOAD = 4;
// ---------------------------------------------------------------------------
// Victory — rules §3.2, revised by Gap 10e
// ---------------------------------------------------------------------------
export type GameLength = 'short' | 'standard' | 'campaign';
export type LengthProfile = { length: GameLength; target: number; days: number };
export const LENGTH_PROFILES: readonly LengthProfile[] = [
{ length: 'short', target: 10, days: 3 },
{ length: 'standard', target: 20, days: 5 },
{ length: 'campaign', target: 45, days: 10 },
];
export function lengthProfile(length: GameLength): LengthProfile {
const found = LENGTH_PROFILES.find((p) => p.length === length);
if (!found) throw new Error(`unknown game length: ${length}`);
return found;
}
/** §10 — a collision costs the party at fault 5 Revenue. */
export const COLLISION_PENALTY = 5;
/** §3.4 — Competitive only: three collisions in one Day and everyone loses. */
export const COLLISION_FLOOR_PER_DAY = 3;
/** §3.5 — Competitive timed games: all players' Revenue combined must reach this. */
export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days;
}
// ---------------------------------------------------------------------------
// Deck composition — card-reference.md §1
// ---------------------------------------------------------------------------
export const DECK_SIZE = 52;
/**
* The Home Office deck is a SINGLE deck containing every card type (§2.6, Gap 4a). The three
* Department slots are face-up market slots fed from it, not decks with their own contents.
*/
export function deckComposition(): { category: string; count: number }[] {
const office = OFFICE_PROFILES.reduce((n, p) => n + p.copiesInDeck, 0);
const freight = FREIGHT_PROFILES.reduce((n, p) => n + p.copies, 0);
const track = TRACK_PROFILES.reduce((n, p) => n + p.copies, 0);
return [
{ category: 'timetabledTrain', count: TIMETABLED_TRAINS.length },
{ category: 'extraTrain', count: EXTRA_TRAINS.length },
{ category: 'office', count: office },
{ category: 'freightFacility', count: freight },
{ category: 'modifier', count: MODIFIER_PROFILES.length },
{ category: 'track', count: track },
];
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Events — protocol.md §3.
*
* An event is a FACT. Events are ordered, append-only, and fully determine state:
* `state = fold(events)`. That one property gives reconnection, restart recovery and post-game
* replay together.
*
* DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to,
* not just an id the renderer has to resolve against live state — otherwise a replay viewer has to
* reconstruct the whole board to draw one frame.
*/
import type { CarType, OfficeTier } from './content.ts';
import type { LocalOpsOption } from './intents.ts';
import type { CardId, GridCoord, PlayerIndex, RollingStock, TrayId } from './state.ts';
export type GameEvent =
// -- clock
| { type: 'stageBegan'; day: number; stage: number }
| { type: 'phaseBegan'; phase: string }
| { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
| { type: 'deckReshuffled' }
| { type: 'departmentRefilled'; slot: number; cardId: CardId }
// -- freight agent
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
| { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock }
// -- trains
/**
* §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries
* the advanced RNG so that folding events reproduces the draw exactly.
*/
| { type: 'trainScheduled'; player: PlayerIndex; trainNumber: number; roll: number; slot: number; rngState: number }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
// -- load / unload
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
// -- consequences
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
export type EventType = GameEvent['type'];
+95
View File
@@ -0,0 +1,95 @@
/**
* The intent vocabulary — every legal player choice, from docs/architecture/protocol.md §1.
*
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
*/
import type { CarType, Direction, OfficeTier } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
// ---------------------------------------------------------------------------
// Local Operations Phase (§6) — a three-way exclusive choice
// ---------------------------------------------------------------------------
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A)
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
| { type: 'switch.end' }
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
| { type: 'draw.fromDepartment'; slot: number }
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'card.discard'; cardId: CardId; toSlot: number }
| { type: 'draw.end' }
// -- freight agent (§6.3)
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
| { type: 'freightAgent.clearInbound'; at: GridCoord; index: number }
| { type: 'freightAgent.unjam'; at: GridCoord; from: 'outbound' | 'inbound' | 'menAtWork'; index: number }
// -- New Train Phase (§7)
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
| { type: 'newTrain.passCar'; trayId: TrayId }
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
| { type: 'mainline.clearance'; allow: boolean }
| { type: 'redFlag.play' }
// -- Load/Unload Phase (§9)
| { type: 'porter.board'; at: GridCoord }
| { type: 'porter.detrain'; at: GridCoord }
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
| { type: 'laborer.startLoad'; at: GridCoord }
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
| { type: 'loadUnload.end' };
export type IntentType = Intent['type'];
// ---------------------------------------------------------------------------
// Rejections — protocol.md §2
// ---------------------------------------------------------------------------
export type RejectionCode =
| 'NOT_YOUR_TURN'
| 'WRONG_PHASE'
| 'OPTION_ALREADY_CHOSEN'
| 'OPTION_NOT_CHOSEN'
| 'NO_MOVES_REMAINING'
| 'ILLEGAL_MOVE'
| 'NO_SUCH_TRAY'
| 'NO_SUCH_CARD'
| 'NO_SUCH_FACILITY'
| 'CANNOT_DROP_HERE'
| 'CONSIST_EMPTY'
| 'CONSIST_FULL'
| 'HAND_LIMIT'
| 'CARD_NOT_IN_HAND'
| 'NO_PLACEMENT'
| 'NOT_CONNECTED'
| 'DECK_EMPTY'
| 'SLOT_EMPTY'
| 'RESOURCE_SPENT'
| 'BOX_FULL'
| 'BOX_EMPTY'
| 'NO_SUITABLE_CAR'
| 'SUITABLE_CAR_EXISTS'
| 'WRONG_CAR_TYPE'
| 'NOT_SUPERINTENDENT'
| 'NO_PENDING_DECISION'
| 'NOT_UPGRADEABLE'
| 'FACILITY_LOCKED'
| 'NO_TRAIN_AT_OFFICE';
export type Rejection = { code: RejectionCode; message: string };
export function reject(code: RejectionCode, message: string): Rejection {
return { code, message };
}
// ---------------------------------------------------------------------------
// Re-exports used by handlers
// ---------------------------------------------------------------------------
export type { CardId, Direction, GridCoord, OfficeTier, PlayerIndex, TrayId };
+193
View File
@@ -0,0 +1,193 @@
/**
* Component 6 — Legal-action enumeration.
*
* `legalActions(state, actor) -> Intent[]` — what the UI greys out, and what a bot picks from.
* See architecture/components.md §2 A.6.
*
* THE DISCIPLINE. This module enumerates *candidate* intents and then filters them through
* component 4's `check`. It contains no rules of its own. That is deliberate: two independent
* implementations of turnout directionality or the four-slot limit would eventually disagree, and
* the failure mode is a legal move the UI refuses or an illegal one it offers.
*
* If you find yourself writing a rule here, it belongs in apply.ts.
*/
import type { CarType } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
/** Every intent `player` may legally submit right now. */
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
return candidates(s, player).filter((i) => check(s, player, i) === null);
}
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
return check(s, player, i) === null;
}
/**
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
* turns out to be illegal simply gets filtered. Being generous here is what stops this module
* from quietly acquiring rules.
*/
function candidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
if (s.clock.pendingDecision !== null) {
out.push({ type: 'mainline.clearance', allow: true });
out.push({ type: 'mainline.clearance', allow: false });
}
switch (s.clock.phase) {
case 'localOps':
out.push(...localOpsCandidates(s, player));
break;
case 'newTrain':
out.push(...newTrainCandidates(s));
break;
case 'loadUnload':
out.push(...loadUnloadCandidates(s, player));
break;
default:
break;
}
out.push({ type: 'redFlag.play' });
return out;
}
function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
// §6 — the three-way exclusive choice.
out.push({ type: 'localOps.choose', option: 'switch' });
out.push({ type: 'localOps.choose', option: 'draw' });
out.push({ type: 'localOps.choose', option: 'freightAgent' });
const area = areaOf(s, player);
// -- switch (§6.1)
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
}
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
}
}
out.push({ type: 'switch.end' });
// -- draw (§6.2)
out.push({ type: 'draw.fromHomeOffice' });
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
const placements = placementCandidates(s, player);
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
for (const placement of placements) {
for (let variant = 0; variant < 6; variant++) {
out.push({ type: 'card.play', cardId, placement, variant });
}
}
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
}
out.push({ type: 'draw.end' });
// -- freight agent (§6.3)
for (const coord of facilityCoords(s, player)) {
for (const carType of CAR_TYPES) {
out.push({ type: 'freightAgent.stockOutbound', at: coord, carType });
}
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
if (f) {
for (let idx = 0; idx < f.inboundBox.length; idx++) {
out.push({ type: 'freightAgent.clearInbound', at: coord, index: idx });
}
for (const from of ['outbound', 'inbound', 'menAtWork'] as const) {
for (let idx = 0; idx < 3; idx++) {
out.push({ type: 'freightAgent.unjam', at: coord, from, index: idx });
}
}
}
}
return out;
}
function newTrainCandidates(s: GameState): Intent[] {
const out: Intent[] = [];
for (const [trayId] of s.trays) {
for (const carType of CAR_TYPES) {
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: true });
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: false });
}
out.push({ type: 'newTrain.passCar', trayId });
}
return out;
}
function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
const area = areaOf(s, player);
for (const coord of facilityCoords(s, player)) {
out.push({ type: 'porter.board', at: coord });
out.push({ type: 'porter.detrain', at: coord });
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
if (f) {
out.push({ type: 'laborer.startLoad', at: coord });
for (let box = 0; box < f.menAtWork.length; box++) {
out.push({ type: 'laborer.advanceLoad', at: coord, box });
}
for (let ci = 0; ci < f.industryTrack.cars.length; ci++) {
out.push({ type: 'laborer.beginUnload', at: coord, carIndex: ci });
}
}
}
out.push({ type: 'loadUnload.end' });
return out;
}
function facilityCoords(s: GameState, player: PlayerIndex): GridCoord[] {
const out: GridCoord[] = [];
for (const [key, card] of areaOf(s, player).grid) {
if (!card.facility) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/** Empty cells adjacent to occupied ones — `check` decides which actually connect. */
function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
const area = areaOf(s, player);
const seen = new Set<string>();
const out: GridCoord[] = [];
for (const key of area.grid.keys()) {
const [row, col] = key.split(',').map(Number);
const around: GridCoord[] = [
{ row: row! + 1, col: col! },
{ row: row! - 1, col: col! },
{ row: row!, col: col! + 1 },
{ row: row!, col: col! - 1 },
];
for (const c of around) {
const k = `${c.row},${c.col}`;
if (area.grid.has(k) || seen.has(k)) continue;
seen.add(k);
out.push(c);
}
}
return out;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Component 7 — Seeded RNG.
*
* Every random decision in the game derives from one stored seed, so any game is exactly
* replayable. See architecture/components.md §2 A.7.
*
* IMPORTANT: never call Math.random() anywhere in the engine. A single ambient random call
* silently breaks replay, and the failure is invisible until someone tries to reproduce a bug.
*/
/** An RNG is a value, not a global. Thread it through explicitly. */
export type Rng = {
/** Raw 32-bit unsigned draw. */
next(): number;
/** Uniform integer in [0, bound). Throws if bound < 1. */
nextInt(bound: number): number;
/** A single twelve-sided die roll, 1..12 (§4.3, §4.4, §7). */
d12(): number;
/** Fisher-Yates. Returns a new array; does not mutate the input. */
shuffle<T>(items: readonly T[]): T[];
/** The current internal state, so a game can be snapshotted mid-play. */
getState(): number;
};
/**
* mulberry32 — small, fast, and good enough for a board game. Chosen because its entire state
* is one 32-bit integer, which makes snapshotting and restoring an in-progress game trivial.
*/
export function createRng(seed: number): Rng {
let state = seed >>> 0;
const next = (): number => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
};
const nextInt = (bound: number): number => {
if (!Number.isInteger(bound) || bound < 1) {
throw new Error(`nextInt bound must be a positive integer, got ${bound}`);
}
// Rejection sampling, so the distribution stays uniform rather than modulo-biased.
const limit = Math.floor(0x100000000 / bound) * bound;
let draw = next();
while (draw >= limit) draw = next();
return draw % bound;
};
return {
next,
nextInt,
d12: () => nextInt(12) + 1,
shuffle: <T,>(items: readonly T[]): T[] => {
const out = items.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = nextInt(i + 1);
const a = out[i]!;
const b = out[j]!;
out[i] = b;
out[j] = a;
}
return out;
},
getState: () => state,
};
}
/** Restore an RNG mid-stream, for rebuilding a game from a snapshot. */
export function restoreRng(state: number): Rng {
// mulberry32 advances from its state before drawing, and createRng seeds state directly,
// so restoring is just seeding with the saved state.
return createRng(state);
}
+252
View File
@@ -0,0 +1,252 @@
/**
* Game setup — rules §4.
*
* Builds the initial state deterministically from a seed. Part of component 2; the D12 rolls
* come from component 7.
*/
import {
EXTRA_TRAINS,
FREIGHT_PROFILES,
MOVES_PER_LOCAL_OPS,
MODIFIER_PROFILES,
OFFICE_PROFILES,
REGIONS_PER_MAINLINE_CARD,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
TRACK_PROFILES,
crewTrayCount,
mainlineCardCount,
officeProfile,
} from './content.ts';
import { createRng } from './rng.ts';
import type {
Card,
CardId,
DivisionNode,
GameConfig,
GameState,
OfficeArea,
PlayerIndex,
Region,
RollingStock,
TrackCard,
TrayId,
} from './state.ts';
import { coordKey, freshTurn } from './state.ts';
export type SetupOptions = {
id: string;
seed: number;
config: GameConfig;
playerNames: string[];
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(): Card[] {
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
cards.push({ id: `c${n++}`, kind });
};
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
for (const o of OFFICE_PROFILES) {
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
}
for (const f of FREIGHT_PROFILES) {
for (let i = 0; i < f.copies; i++) push({ kind: 'freightFacility', facility: f.kind });
}
for (const m of MODIFIER_PROFILES) push({ kind: 'modifier', modifier: m.kind });
for (const t of TRACK_PROFILES) {
for (let i = 0; i < t.copies; i++) push({ kind: 'track', geometry: t.geometry });
}
return cards;
}
/** The Division Yard starts with every rolling stock piece (§4.9). */
export function buildRollingStock(): RollingStock[] {
const out: RollingStock[] = [];
for (const s of ROLLING_STOCK_SUPPLY) {
for (let i = 0; i < s.loaded; i++) out.push({ type: s.type, loaded: true });
for (let i = 0; i < s.empty; i++) out.push({ type: s.type, loaded: false });
}
return out;
}
/**
* A player's opening three cards: Limits, Whistle Post, Limits, in one horizontal row (§4.2).
*
* Gap 8 — the Office card carries a plain junction stub above and below, so Secondary Track can
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only.
*/
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
const row = 0;
const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 };
const limitsEast = { row, col: 1 };
const officeCard: TrackCard = {
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
};
const limitsCard = (): TrackCard => ({
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
});
const grid = new Map<string, TrackCard>();
grid.set(coordKey(officeCoord), officeCard);
grid.set(coordKey(limitsWest), limitsCard());
grid.set(coordKey(limitsEast), limitsCard());
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
}
/**
* A Whistle Post is not a Passenger Facility (§9), so it has no Porters and no slots — but the
* Office card still carries a facility record so an upgrade is a property change rather than a
* card swap (game-state.md constraint 9).
*/
function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonNullable<TrackCard['facility']> {
const p = officeProfile(tier);
return {
kind: 'passenger',
subtype: 'office',
allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility },
outboundBox: [],
inboundBox: [],
capacity: { outbound: p.greenSlots, inbound: p.redSlots },
menAtWork: [null, null, null],
industryTrack: { length: 0, cars: [] },
laborers: 0,
porters: p.porters,
usedThisStage: { laborers: 0, porters: 0 },
};
}
/** §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. */
function buildDivision(players: number): DivisionNode[] {
const nodes: DivisionNode[] = [];
const mainline = (): DivisionNode => {
const regions: Region[] = [];
for (let i = 0; i < REGIONS_PER_MAINLINE_CARD; i++) regions.push({ occupant: null });
return { kind: 'mainline', regions };
};
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
nodes.push(mainline());
nodes.push({ kind: 'office', owner: p });
}
nodes.push(mainline());
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
return nodes;
}
export function createGame(opts: SetupOptions): GameState {
const { id, seed, config, playerNames } = opts;
if (playerNames.length < 1) throw new Error('a game needs at least one player');
if (config.mode === 'solitaire' && playerNames.length !== 1) {
throw new Error('solitaire is a one-player mode');
}
const rng = createRng(seed);
const playerCount = playerNames.length;
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
const officeAreas = new Map<PlayerIndex, OfficeArea>();
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
const divisionRolls = players.map(() => rng.d12());
const superRolls = players.map(() => rng.d12());
const superintendent = argmax(superRolls);
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
// §4.6-4.7 - shuffle, deal three each, then turn three face-up as the Department slots.
const deck = buildDeck();
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
const shuffled = rng.shuffle(deck.map((c) => c.id));
const hands = new Map<PlayerIndex, CardId[]>();
let cursor = 0;
for (let i = 0; i < playerCount; i++) {
const p = (superintendent + i) % playerCount;
hands.set(p, shuffled.slice(cursor, cursor + 3));
cursor += 3;
}
const departments: (CardId | null)[] = [
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
];
const homeOffice = shuffled.slice(cursor);
const redFlags = new Map<PlayerIndex, boolean>();
for (let p = 0; p < playerCount; p++) {
redFlags.set(p, config.optionalRules.emergencyToolbox);
}
// §4.9 - Crew Trays near the turn sheet. Scarcity is an explicit mechanic (§7).
const freeTrays: TrayId[] = [];
for (let i = 0; i < crewTrayCount(playerCount); i++) freeTrays.push(`tray${i}`);
const timetable: (number | null)[] = new Array(STAGES_PER_DAY).fill(null);
return {
id,
config,
seed,
rngState: rng.getState(),
players,
division: { nodes: buildDivision(playerCount) },
officeAreas,
trays: new Map(),
freeTrays,
cards,
decks: { homeOffice, departments, salvageYard: [], hands, redFlags },
yards: { divisionYard: buildRollingStock(), classificationYard: [] },
timetable,
clock: {
day: 1,
stage: 1,
phase: 'localOps',
currentActor: superintendent,
pendingDecision: null,
clearanceRuling: null,
superintendent,
actorOffset: 0,
},
turn: freshTurn(MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
status: 'active',
outcome: null,
};
}
function argmax(values: number[]): number {
let best = 0;
for (let i = 1; i < values.length; i++) {
if (values[i]! > values[best]!) best = i;
}
return best;
}
export { mainlineCardCount };
+404
View File
@@ -0,0 +1,404 @@
/**
* Component 2 — State model and types.
*
* The entity model from docs/architecture/game-state.md. Pure data; no behaviour beyond a few
* derivations that must never be cached (see DERIVED note below).
* See architecture/components.md §2 A.2.
*/
import type {
CarType,
Direction,
FreightKind,
GameLength,
ModifierKind,
OfficeTier,
TrackGeometry,
} from './content.ts';
import { officeProfile } from './content.ts';
// ---------------------------------------------------------------------------
// Identifiers
// ---------------------------------------------------------------------------
export type PlayerIndex = number;
export type CardId = string;
export type TrayId = string;
/** A cell in a player's Office Area grid. Sparse — cards are placed during play. */
export type GridCoord = { row: number; col: number };
export function coordKey(c: GridCoord): string {
return `${c.row},${c.col}`;
}
// ---------------------------------------------------------------------------
// Rolling stock
// ---------------------------------------------------------------------------
/** §2.2 — a coloured car is loaded, a white car is empty. */
export type RollingStock = { type: CarType; loaded: boolean };
// ---------------------------------------------------------------------------
// Track and Office Area
// ---------------------------------------------------------------------------
/**
* A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
* matters is that `through` and `diverge` are NOT joined to each other.
*
* OPEN (Gap 11) — the rules specify "turnout ×6" without saying how many face which way. Until
* that is settled, orientation is carried per card and defaults to stem-east / diverge-north.
*/
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };
/**
* A straight card's axis. Also part of Gap 11 — the catalogue says "straight ×3" without saying
* how many run each way, yet a layout with no north-south straight can never use the Office's
* junction stubs at all.
*/
export type TrackAxis = 'ew' | 'ns';
export type CardGeometry =
| {
kind: 'track';
geometry: TrackGeometry;
turnout?: TurnoutOrientation;
axis?: TrackAxis;
bypass?: 'n' | 's' | 'e' | 'w';
}
| { kind: 'office' }
| { kind: 'limits' }
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis };
export type TrackCard = {
geometry: CardGeometry;
/**
* DERIVED for facility cards — a Facility track locked by loads on MEN|AT|WORK stops being
* Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
*/
baseOperationalRail: boolean;
/** Uncoupled cars left here, in track order (§A.3). */
standing: RollingStock[];
facility: Facility | null;
modifiers: ModifierKind[];
};
export type OfficeArea = {
owner: PlayerIndex;
tier: OfficeTier;
grid: Map<string, TrackCard>;
officeCoord: GridCoord;
/** The grid row that is the Running Track (§2.1). */
runningRow: number;
limitsWest: GridCoord;
limitsEast: GridCoord;
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
adOccupancy: TrayId[];
};
// ---------------------------------------------------------------------------
// Facilities
// ---------------------------------------------------------------------------
/**
* A load in transit along the MEN | AT | WORK track (§9.3).
*
* Direction matters and is easy to miss. **Outbound** loading runs Green -> MEN -> AT -> WORK ->
* onto a spotted empty car. **Inbound** unloading runs the other way: car -> WORK -> AT -> MEN ->
* red Inbound box. Same three boxes, opposite traversal.
*/
export type Load = { type: CarType; dir: 'out' | 'in' };
export type Facility = {
kind: 'freight' | 'passenger';
subtype: FreightKind | 'office';
allows: { outbound: boolean; inbound: boolean };
outboundBox: RollingStock[];
inboundBox: RollingStock[];
capacity: { outbound: number; inbound: number };
/** Freight only. One load per box; three boxes, so it is a pipeline (§9.1). */
menAtWork: [Load | null, Load | null, Load | null];
/** Where cars are spotted for loading and unloading. */
industryTrack: { length: number; cars: RollingStock[] };
laborers: number;
porters: number;
/** Resets at the start of each Stage (§9.1). */
usedThisStage: { laborers: number; porters: number };
};
/**
* §9.3 — while ANY load sits on MEN|AT|WORK the industry's track is locked down and loses its
* Operational Rail status. This is why isOperationalRail is a function, not a stored field.
*/
/**
* The cars physically standing on a card.
*
* On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
* car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
* at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
* freight load could ever complete and freight revenue was structurally zero.
*/
export function carsOn(card: TrackCard): RollingStock[] {
return card.facility && card.facility.industryTrack.length > 0
? card.facility.industryTrack.cars
: card.standing;
}
/** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */
export function spaceOn(card: TrackCard): number {
if (card.facility && card.facility.industryTrack.length > 0) {
return card.facility.industryTrack.length - card.facility.industryTrack.cars.length;
}
return Number.MAX_SAFE_INTEGER;
}
export function isOperationalRail(card: TrackCard): boolean {
if (!card.baseOperationalRail) return false;
if (card.facility && card.facility.menAtWork.some((slot) => slot !== null)) return false;
return true;
}
// ---------------------------------------------------------------------------
// Trains
// ---------------------------------------------------------------------------
export type NodeRef =
| { at: 'divisionPoint'; side: Direction }
| { at: 'mainline'; index: number; region: number }
| { at: 'grid'; owner: PlayerIndex; coord: GridCoord };
export type CrewTray = {
id: TrayId;
/** null while a local crew is switching without a train card. */
trainNumber: number | null;
trainIsExtra: boolean;
/** Which end the engine occupies (§A.3). */
engineFront: boolean;
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
consist: RollingStock[];
direction: Direction;
position: NodeRef;
movesUsed: number;
};
// ---------------------------------------------------------------------------
// The Division
// ---------------------------------------------------------------------------
export type Region = { occupant: TrayId | null };
export type DivisionNode =
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
| { kind: 'mainline'; regions: Region[] }
| { kind: 'office'; owner: PlayerIndex };
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
export type Division = { nodes: DivisionNode[] };
// ---------------------------------------------------------------------------
// Decks and yards
// ---------------------------------------------------------------------------
export type CardKind =
| { kind: 'timetabledTrain'; number: number }
| { kind: 'extraTrain'; number: number }
| { kind: 'office'; tier: OfficeTier }
| { kind: 'freightFacility'; facility: FreightKind }
| { kind: 'modifier'; modifier: ModifierKind }
| { kind: 'track'; geometry: TrackGeometry };
export type Card = { id: CardId; kind: CardKind };
export type Decks = {
/** Face down. Order is SECRET — never projected to any client. */
homeOffice: CardId[];
/** Three face-up market slots fed from the deck (§2.6). */
departments: (CardId | null)[];
/** Face up, so players can audit discards (§2.6). */
salvageYard: CardId[];
/** Private to the owner. Max 3, or 4 with a Red Flag. */
hands: Map<PlayerIndex, CardId[]>;
redFlags: Map<PlayerIndex, boolean>;
};
export type Yards = {
divisionYard: RollingStock[];
classificationYard: RollingStock[];
};
// ---------------------------------------------------------------------------
// Clock and phases
// ---------------------------------------------------------------------------
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
/** §8.1 fourth condition — the Superintendent rules on a following train. */
export type SuperintendentClearance = {
train: TrayId;
occupiedBy: TrayId;
};
export type Clock = {
day: number;
/** 1..12 — the Pocket Watch. */
stage: number;
phase: Phase;
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
currentActor: PlayerIndex | null;
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
pendingDecision: SuperintendentClearance | null;
/**
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
* driver would re-evaluate the same train and ask the same question forever.
*/
clearanceRuling: { train: TrayId; allow: boolean } | null;
superintendent: PlayerIndex;
/**
* How far round the table the current phase has got. Acting order starts at the Superintendent
* and proceeds left (Gap 1), so `currentActor = (superintendent + actorOffset) % players`.
* When it reaches the player count, the phase is complete.
*/
actorOffset: number;
};
// ---------------------------------------------------------------------------
// Players and game
// ---------------------------------------------------------------------------
export type Player = {
index: PlayerIndex;
name: string;
/** May go negative — a collision costs 5 (§10). */
revenue: number;
};
export type GameMode = 'solitaire' | 'competitive' | 'coop';
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
export type GameConfig = {
mode: GameMode;
victory: VictoryCondition;
length: GameLength;
optionalRules: {
reducedVisibility: boolean;
sisterTrains: boolean;
employeeRotation: boolean;
emergencyToolbox: boolean;
};
};
export type OutcomeReason =
| 'targetReached'
| 'daysElapsed'
| 'collisionFloor'
| 'revenueFloor';
export type Outcome = {
result: 'win' | 'loss';
winner: PlayerIndex | null;
reason: OutcomeReason;
};
/**
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
*
* §6 — the three Local Operations options are mutually exclusive: choosing one forecloses the
* others for that Stage. That exclusivity lives here.
*/
export type TurnState = {
option: 'switch' | 'draw' | 'freightAgent' | null;
movesRemaining: number;
drawnThisTurn: boolean;
freightAgentUsed: boolean;
/** Set when the actor finishes; the phase driver then moves to the next player. */
done: boolean;
};
export function freshTurn(moves: number): TurnState {
return {
option: null,
movesRemaining: moves,
drawnThisTurn: false,
freightAgentUsed: false,
done: false,
};
}
export type GameState = {
id: string;
config: GameConfig;
/** All RNG derives from this. Games are exactly replayable. */
seed: number;
rngState: number;
players: Player[];
division: Division;
officeAreas: Map<PlayerIndex, OfficeArea>;
trays: Map<TrayId, CrewTray>;
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */
freeTrays: TrayId[];
cards: Map<CardId, Card>;
decks: Decks;
yards: Yards;
/** Index 0 = Stage 1. A train number, or null for an empty slot. */
timetable: (number | null)[];
clock: Clock;
turn: TurnState;
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
movedThisPhase: Set<TrayId>;
/** §3.4 — resets at the start of each Day. */
collisionsToday: number;
status: 'setup' | 'active' | 'finished';
outcome: Outcome | null;
};
// ---------------------------------------------------------------------------
// Derivations — never stored, never cached
// ---------------------------------------------------------------------------
/**
* A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1).
* Whistle Posts are NOT Control Points and sit inside a Subdivision like ordinary mainline.
*
* At game start every Office is a Whistle Post, so the entire railroad is ONE Subdivision (§8) —
* which is why early traffic is so constrained. Each Office upgrade splits one in two.
*
* Recomputed on demand. Caching this means every Office upgrade must remember to invalidate,
* and forgetting is a silent bug in highball legality.
*/
export function subdivisions(state: GameState): number[][] {
const out: number[][] = [];
let current: number[] = [];
state.division.nodes.forEach((node, i) => {
const isBoundary =
node.kind === 'divisionPoint' ||
(node.kind === 'office' && isControlPoint(state, node.owner));
if (isBoundary) {
if (current.length > 0) out.push(current);
current = [];
} else {
current.push(i);
}
});
if (current.length > 0) out.push(current);
return out;
}
export function isControlPoint(state: GameState, owner: PlayerIndex): boolean {
const area = state.officeAreas.get(owner);
if (!area) throw new Error(`no Office Area for player ${owner}`);
return officeProfile(area.tier).isControlPoint;
}
export function adTrackCount(state: GameState, owner: PlayerIndex): number {
const area = state.officeAreas.get(owner);
if (!area) throw new Error(`no Office Area for player ${owner}`);
return officeProfile(area.tier).adTracks;
}
export function totalRevenue(state: GameState): number {
return state.players.reduce((n, p) => n + p.revenue, 0);
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Component 3 — Track graph and movement.
*
* The Office Area grid as a traversable graph, and the rules governing a Move.
* See architecture/components.md §2 A.3 and docs/rules/rules-v0.2.md Appendix A.
*
* THE CENTRAL SUBTLETY. §A.1 says a train entering a turnout from "A" may proceed to B or C, but
* one entering through B or C may only proceed to A. That is NOT a one-way restriction — A→B and
* B→A are both legal. What it means is that **B and C are not connected to each other**: the frog
* offers no route between the two diverging legs. Modelling this as a directed graph would forbid
* legal moves. It is an undirected graph over *port pairs*, and the constraint is which pairs
* exist on each card.
*
* The second constraint is that a traversal may never leave a card through the port it entered by.
* That is what "without changing direction" (§2.4) means in practice.
*/
import { MAX_CONSIST } from './content.ts';
import type {
GridCoord,
OfficeArea,
RollingStock,
TrackCard,
TrayId,
TurnoutOrientation,
} from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
// ---------------------------------------------------------------------------
// Ports and geometry
// ---------------------------------------------------------------------------
/** The four edges of a card. East is the player's right, west their left (§2.4). */
export type Port = 'n' | 's' | 'e' | 'w';
export function opposite(p: Port): Port {
switch (p) {
case 'n':
return 's';
case 's':
return 'n';
case 'e':
return 'w';
case 'w':
return 'e';
}
}
/** The grid neighbour across a given edge. Row increases northward. */
export function neighbour(c: GridCoord, p: Port): GridCoord {
switch (p) {
case 'n':
return { row: c.row + 1, col: c.col };
case 's':
return { row: c.row - 1, col: c.col };
case 'e':
return { row: c.row, col: c.col + 1 };
case 'w':
return { row: c.row, col: c.col - 1 };
}
}
type PortPair = readonly [Port, Port];
/**
* Which ports a card joins internally.
*
* - **straight / limits** — a plain through track.
* - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist;
* `w-n` deliberately does not. That absence is §A.1's rule.
* - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around
* standing cars. §A.5's facing-point move is impossible without one.
* - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through
* track. These are plain junctions, so unlike a turnout there is no missing pair. North and south
* are not joined to each other — that would be crossing the running track.
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
* than a separate port.
*/
function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) {
case 'limits':
return [['e', 'w']];
case 'facility':
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
case 'office':
return [
['e', 'w'],
['e', 'n'],
['w', 'n'],
['e', 's'],
['w', 's'],
];
case 'track':
switch (card.geometry.geometry) {
case 'straight':
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
case 'turnout': {
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
// stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
return [
[o.stem, o.through],
[o.stem, o.diverge],
];
}
case 'runAround': {
const b = card.geometry.bypass ?? 'n';
return [
['e', 'w'],
['e', b],
['w', b],
];
}
}
}
}
/** A turnout with no stated orientation takes this one. */
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };
// ---------------------------------------------------------------------------
// Orientation (Gap 11)
// ---------------------------------------------------------------------------
/**
* How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**:
* a track card is generic and the player decides how to lay it.
*
* The measurement that settled it: with orientation fixed, an east-west straight has no north or
* south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew
* only sideways and downward — never upward — purely as an artifact of the default. That silently
* undid Gap 8 and put Appendix A's switching puzzle out of reach.
*
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
* rotation is free.
*/
export type TrackVariant = {
axis?: 'ew' | 'ns';
turnout?: TurnoutOrientation;
bypass?: Port;
};
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
{ stem: 'e', through: 'w', diverge: 'n' },
{ stem: 'e', through: 'w', diverge: 's' },
{ stem: 'w', through: 'e', diverge: 'n' },
{ stem: 'w', through: 'e', diverge: 's' },
{ stem: 'n', through: 's', diverge: 'e' },
{ stem: 's', through: 'n', diverge: 'w' },
];
export function variantsFor(geometry: 'straight' | 'turnout' | 'runAround'): TrackVariant[] {
switch (geometry) {
case 'straight':
return [{ axis: 'ew' }, { axis: 'ns' }];
case 'turnout':
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
case 'runAround':
return [{ bypass: 'n' }, { bypass: 's' }];
}
}
/** Facility and Office cards have fixed geometry; only plain track rotates. */
export function facilityVariants(): TrackVariant[] {
return [{ axis: 'ew' }, { axis: 'ns' }];
}
/** Ports reachable from `from` within this card, never including `from` itself. */
export function exitsFrom(card: TrackCard, from: Port): Port[] {
const out: Port[] = [];
for (const [a, b] of connectionsFor(card)) {
if (a === from && b !== from) out.push(b);
else if (b === from && a !== from) out.push(a);
}
return out;
}
export function hasPort(card: TrackCard, p: Port): boolean {
return connectionsFor(card).some(([a, b]) => a === p || b === p);
}
// ---------------------------------------------------------------------------
// Occupancy
// ---------------------------------------------------------------------------
export type Occupancy = {
/** Which tray sits on a given card, if any. */
trayAt(coord: GridCoord): TrayId | null;
/** Free A/D tracks at the Office card, for the pass-through allowance (§A.4). */
freeAdTracks(): number;
};
// ---------------------------------------------------------------------------
// Move reachability
// ---------------------------------------------------------------------------
export type MoveStep = { coord: GridCoord; entry: Port; exit: Port };
export type MoveDestination = {
coord: GridCoord;
/** The port the train arrived through; its new facing is the opposite. */
entry: Port;
path: MoveStep[];
/** Standing cars coupled along the way, in the order encountered (§A.4). */
couples: RollingStock[];
};
export type MoveContext = {
area: OfficeArea;
occupancy: Occupancy;
/** Cars already in the tray; coupling may not push the consist past four (§A.4). */
consistSize: number;
/** The tray making the move, so it does not block itself. */
self: TrayId;
};
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
return area.grid.get(coordKey(c));
}
function sameCoord(a: GridCoord, b: GridCoord): boolean {
return a.row === b.row && a.col === b.col;
}
/**
* Every card a tray may finish a single Move on, starting from `start` and leaving through
* `initialExit`.
*
* A Move travels any distance without changing direction (§2.4) and must finish on Operational
* Rail (§A.1 — a turnout carries no wheel icon, so a train may pass through but not stop). Cars
* met along the way are coupled automatically and mandatorily; you may not go around them (§A.4).
*
* Direction is expressed by which port the train first leaves through. Reversing is a separate
* Move with the opposite initial exit, which is why §A.5's worked examples spend a Move on each
* change of direction.
*/
export function reachableDestinations(
ctx: MoveContext,
start: GridCoord,
initialExit: Port,
): MoveDestination[] {
const { area, occupancy } = ctx;
const startCard = cardAt(area, start);
if (!startCard) return [];
if (!hasPort(startCard, initialExit)) return [];
const results: MoveDestination[] = [];
const seen = new Set<string>();
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
const first = neighbour(start, initialExit);
const queue: Frontier[] = [
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
];
while (queue.length > 0) {
const node = queue.shift()!;
const card = cardAt(area, node.coord);
if (!card) continue;
// Two trains may not share a card or move through each other (§A.4). The Office track is the
// exception: while it has free A/D tracks you may enter and pass through.
const occupant = occupancy.trayAt(node.coord);
if (occupant !== null && occupant !== ctx.self) {
const isOffice = sameCoord(node.coord, area.officeCoord);
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
}
if (!hasPort(card, node.entry)) continue;
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
// overfill the tray is illegal, not a move that picks up fewer cars.
const couples = [...node.couples, ...carsOn(card)];
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;
seen.add(key);
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
}
for (const exit of exitsFrom(card, node.entry)) {
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
queue.push({
coord: neighbour(node.coord, exit),
entry: opposite(exit),
path: [...node.path, step],
couples,
});
}
}
return results;
}
/** Both directions at once — what the UI highlights when a tray is selected. */
export function allReachable(
ctx: MoveContext,
start: GridCoord,
facing: Port,
): { forward: MoveDestination[]; reverse: MoveDestination[] } {
return {
forward: reachableDestinations(ctx, start, facing),
reverse: reachableDestinations(ctx, start, opposite(facing)),
};
}
// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------
/**
* Gap 4a — a placed card must connect to existing track. Gap 8 gives the Office card junction
* stubs above and below so this is satisfiable from the opening Stage.
*/
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
if (cardAt(area, coord)) return false;
const ports: Port[] = ['n', 's', 'e', 'w'];
for (const p of ports) {
if (!hasPort(card, p)) continue;
const neighbourCard = cardAt(area, neighbour(coord, p));
if (neighbourCard && hasPort(neighbourCard, opposite(p))) return true;
}
return false;
}
/**
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
* An industry track also has a finite length (§9.3), so it can be full.
*/
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
if (sameCoord(coord, area.officeCoord)) return false;
const card = cardAt(area, coord);
if (!card || !isOperationalRail(card)) return false;
return spaceOn(card) >= count;
}
+458
View File
@@ -0,0 +1,458 @@
/**
* Component 17 — Heuristic bot players.
*
* Dev-side only: this ships nowhere. Its job is to play well enough that the balance harness
* (component 18) produces numbers worth trusting. See architecture/components.md §2 D.17.
*
* A bot that plays LEGALLY is easy; one that plays WELL enough to judge balance is the harder
* half, and this is only the first attempt. Read the numbers it produces as a floor on what a
* competent human would score, not a prediction of one.
*
* THE POLICY, from the economy in docs/rules/card-reference.md §7. Local Operations actions are
* the currency — one per Stage, twelve per Day, roughly one Revenue point each — so the bot's
* whole job is deciding which of the three options to spend the Stage on:
*
* 1. Develop first. A Whistle Post is not a Passenger Facility and earns nothing from
* passengers, so upgrading and placing freight facilities dominates early.
* 2. Then keep the pipeline fed. Stocking a green box is what converts an action into a point.
* 3. Switch when a train is standing at the Office, because spotted cars are what let a load
* complete at all.
*/
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
export type BotPolicy = {
name: string;
choose(s: GameState, player: PlayerIndex, options: Intent[]): Intent;
};
// ---------------------------------------------------------------------------
function pickFirst(options: Intent[], ...types: Intent['type'][]): Intent | null {
for (const t of types) {
const found = options.find((i) => i.type === t);
if (found) return found;
}
return null;
}
/**
* §8.1 — the Superintendent's judgment call, and the bot's most consequential decision.
*
* Denying costs the following train one Stage. Allowing risks a collision at 5 Revenue, roughly a
* full Day's earnings, plus a train destroyed. At ~0.5 Revenue per Stage the arithmetic favours
* caution heavily, so this bot always denies. That is a deliberate policy choice, not an oversight:
* a bolder policy is worth simulating separately to see what following moves are actually worth.
*/
function ruleOnClearance(options: Intent[]): Intent | null {
return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? null;
}
// ---------------------------------------------------------------------------
export const developerBot: BotPolicy = {
name: 'developer',
choose(s, player, options) {
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
//
// Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
if (s.clock.phase === 'loadUnload') {
const work = pickFirst(
options,
'porter.board',
'porter.detrain',
'laborer.advanceLoad',
'laborer.startLoad',
'laborer.beginUnload',
);
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
}
// --- New Train: fill the consist, but with the RIGHT cars.
//
// This is subtler than it looks. §9.3 requires an EMPTY car of the matching type spotted on an
// outbound facility's track before a load can be worked onto it, and a LOADED car before an
// inbound facility can unload one. So the useful car to put on a train is the one this
// player's facilities are short of — not simply a loaded one.
if (s.clock.phase === 'newTrain') {
const wanted = wantedCars(s, player);
for (const w of wanted) {
const match = options.find(
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
);
if (match) return match;
}
return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
}
// --- Local Operations.
if (s.clock.phase === 'localOps') {
if (s.turn.option === null) return chooseLocalOption(s, player, options);
return followThrough(s, player, options);
}
return options[0]!;
},
};
/**
* The one decision that matters: which of §6's three things to spend this Stage on.
*
* The ordering below is the result of measurement, not intuition. An earlier version picked
* Freight Agent whenever it was available, which is *always* once a facility exists with box room.
* It therefore stopped drawing after roughly two Days, scheduled only 3 of a possible 12 trains,
* and spent the rest of the game stuffing green boxes whose loads could never complete for want of
* a car to load them onto.
*/
function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
const choices = options.filter(
(i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose',
);
const can = (o: 'switch' | 'draw' | 'freightAgent') => choices.find((c) => c.option === o);
const area = areaOf(s, player);
const hand = s.decks.hands.get(player) ?? [];
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
// whose value compounds. Playing one needs the draw option.
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
// 2. A train standing at the Office is a fleeting chance to spot cars; it highballs next
// Mainline Phase whether or not anything was done with it.
if (area.adOccupancy.length > 0 && can('switch')) return can('switch')!;
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
return can('freightAgent')!;
}
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
if (can('draw')) return can('draw')!;
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
}
/**
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
*
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
* in to rescue it. Stocking speculatively jams the facility.
*/
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || !f.allows.outbound) continue;
if (f.outboundBox.length >= f.capacity.outbound) continue;
const want = facilityCarType(f);
if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
}
return false;
}
/** A load on MEN|AT|WORK that cannot advance, with Laborers free to move it. */
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || laborersLeft(f) < 1) continue;
for (let box = 0; box < f.menAtWork.length; box++) {
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
}
}
return false;
}
function needsClearing(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f) continue;
if (f.inboundBox.length >= Math.max(1, f.capacity.inbound)) return true;
}
return false;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
case 'draw': {
// Draw before playing — otherwise the hand empties and never refills.
if (!s.turn.drawnThisTurn) {
const draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice');
if (draw) return draw;
}
// Train cards first — they take no placement and their value compounds every Day.
const train = options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
if (train) return train;
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
const play = options.find((i) => i.type === 'card.play');
if (play) return play;
const end = options.find((i) => i.type === 'draw.end');
if (end) return end;
return pickFirst(options, 'card.discard') ?? options[0]!;
}
case 'switch': {
// Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
//
// Measured: an earlier version dropped whichever car happened to be at the tray's end,
// which silted industry tracks up with coaches and cabooses — 532 coaches were observed
// sitting on freight sidings across 20 games. A facility whose track is full of the wrong
// commodity cannot accept the car it actually needs, and the freight chain starves.
//
// §A.3 forces the issue: cars come off in seated order, so only the end car is droppable.
// A strong player would use the six Moves to re-order the consist — that is the game's
// central switching puzzle, and this bot does not attempt it. It simply declines to drop a
// car that would do no work.
const tray = trayOf(s, player);
const here = trayLocation(s, player);
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
if (endCar && here) {
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
if (hereFacility && facilityWants(hereFacility, endCar)) {
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (drop) return drop;
}
// Otherwise head for a facility that does want this particular car.
const wanted = facilitiesWanting(s, player, endCar);
const toward = options.find(
(i) =>
i.type === 'switch.move' &&
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
);
if (toward) return toward;
}
const move = options.find((i) => i.type === 'switch.move');
if (move) return move;
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
}
case 'freightAgent': {
// A stuck load is the worst state a facility can be in: it blocks the pipeline AND strips
// the industry track of Operational Rail status (§9.3), so no car can be brought in to
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
if (hasStuckLoad(s, player)) {
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
if (unjam) return unjam;
}
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
if (clear) return clear;
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return stock;
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
}
default:
return options[0]!;
}
}
// ---------------------------------------------------------------------------
// What this player's facilities are actually short of
// ---------------------------------------------------------------------------
type WantedCar = { type: string; loaded: boolean };
/**
* An outbound facility needs EMPTY cars of its type to load onto; an inbound one needs LOADED cars
* to unload. Ordered so the most useful car comes first.
*/
function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
const out: WantedCar[] = [];
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const spotted = f.industryTrack.cars.length;
if (spotted >= f.industryTrack.length) continue;
const type = carTypeOf(card);
if (!type) continue;
if (f.allows.outbound) out.push({ type, loaded: false });
if (f.allows.inbound) out.push({ type, loaded: true });
}
// A coach is worth carrying too: passenger work is a point per Porter action.
out.push({ type: 'coach', loaded: false });
return out;
}
function carTypeOf(card: { geometry: { kind: string; facility?: string } }): string | null {
if (card.geometry.kind !== 'facility') return null;
switch (card.geometry.facility) {
case 'mineTipple':
case 'powerPlant':
return 'hopper';
case 'produceShed':
return 'reefer';
case 'grocersWarehouse':
return 'boxcar';
case 'oilRefinery':
return 'tank';
default:
return null;
}
}
/**
* Would spotting this car here do any work?
*
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
* industry track (§9.3).
*/
function facilityWants(f: Facility, car: RollingStock): boolean {
if (f.kind !== 'freight') return false;
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
if (car.type !== facilityCarType(f)) return false;
if (!car.loaded && f.allows.outbound) return true;
if (car.loaded && f.allows.inbound) return true;
return false;
}
function facilitiesWanting(
s: GameState,
player: PlayerIndex,
car: RollingStock,
): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
for (const [key, card] of areaOf(s, player).grid) {
if (!card.facility || !facilityWants(card.facility, car)) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
}
return null;
}
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) {
return tray.position.coord;
}
}
return null;
}
// ---------------------------------------------------------------------------
/** Picks uniformly at random. A control, to show what the policy above is actually worth. */
export function randomBot(seed: number): BotPolicy {
let rng = seed >>> 0;
return {
name: 'random',
choose(_s, _p, options) {
rng = (rng * 1103515245 + 12345) >>> 0;
return options[rng % options.length]!;
},
};
}
// ---------------------------------------------------------------------------
export type PlayOutcome = {
finished: boolean;
turns: number;
days: number;
revenue: number[];
collisions: number;
trainsScheduled: number;
cardsPlayed: number;
outcome: GameState['outcome'];
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
};
/**
* Drives a game to completion with the given policy. This is the whole reason the phase driver
* lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
*/
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
let collisions = 0;
let trainsScheduled = 0;
let cardsPlayed = 0;
let turns = 0;
const tally = (batch: GameEvent[]): void => {
events.push(...batch);
for (const e of batch) {
if (e.type === 'trainScheduled') trainsScheduled++;
else if (e.type === 'cardPlayed') cardsPlayed++;
else if (
e.type === 'revenueChanged' &&
'reason' in e &&
String((e as { reason: string }).reason).startsWith('collision')
) {
collisions++;
}
}
};
for (; turns < maxTurns; turns++) {
tally(pumpFn(s));
if (s.status === 'finished') break;
const actor =
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
const r = applyIntent(s, actor, chosen);
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
tally(r.events);
}
return {
finished: s.status === 'finished',
turns,
days: s.clock.day,
revenue: s.players.map((p) => p.revenue),
collisions,
trainsScheduled,
cardsPlayed,
outcome: s.outcome,
events,
intents,
};
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Component 18 — Balance simulation harness.
*
* Dev-side only. Runs many seeded games and reports the numbers that the provisional values in
* docs/rules/card-reference.md were guessed at. See architecture/components.md §2 D.18.
*
* The point is narrow and worth stating: this can tell you whether the ARITHMETIC of the game
* works — whether targets are reachable, whether Crew Trays bottleneck, whether collisions fire at
* a sane rate. It cannot tell you whether the game is any fun. Only a table can do that.
*
* Run with: node src/sim/harness.ts [games] [length]
*/
import { pump } from '../engine/advance.ts';
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
import { createGame } from '../engine/setup.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
import type { BotPolicy, PlayOutcome } from './bot.ts';
import { developerBot, playGame, randomBot } from './bot.ts';
import type { GameStats } from './stats.ts';
import { formatAggregate, summarize } from './stats.ts';
export type SimOptions = {
games: number;
length: GameLength;
mode: GameMode;
players: string[];
policy: BotPolicy;
};
export type SimReport = {
policy: string;
games: number;
finished: number;
wins: number;
revenuePerPlayer: Stats;
revenuePerPlayerPerDay: Stats;
collisionsPerGame: Stats;
trainsScheduled: Stats;
cardsPlayed: Stats;
daysPlayed: Stats;
outcomeReasons: Record<string, number>;
/** Per-game detail, for end-of-game statistics and anomaly detection. */
perGame: GameStats[];
};
export type Stats = { min: number; max: number; mean: number; median: number };
function statsOf(xs: number[]): Stats {
if (xs.length === 0) return { min: 0, max: 0, mean: 0, median: 0 };
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
min: sorted[0]!,
max: sorted[sorted.length - 1]!,
mean: xs.reduce((a, b) => a + b, 0) / xs.length,
median:
sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!,
};
}
function configFor(mode: GameMode, length: GameLength): GameConfig {
return {
mode,
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
}
export function simulate(opts: SimOptions): SimReport {
const results: PlayOutcome[] = [];
const stats: GameStats[] = [];
for (let i = 0; i < opts.games; i++) {
const seed = 1000 + i * 7919; // a prime stride, so seeds do not correlate
const s = createGame({
id: `sim-${i}`,
seed,
config: configFor(opts.mode, opts.length),
playerNames: opts.players,
});
const r = playGame(s, opts.policy, pump);
results.push(r);
stats.push(summarize(seed, r.events, r.intents, s));
}
const finished = results.filter((r) => r.finished);
const perPlayer = finished.flatMap((r) => r.revenue);
const days = finished.map((r) => Math.max(1, r.days - 1));
const perDay = finished.flatMap((r) =>
r.revenue.map((v) => v / Math.max(1, r.days - 1)),
);
const reasons: Record<string, number> = {};
for (const r of finished) {
const key = r.outcome ? `${r.outcome.result}/${r.outcome.reason}` : 'none';
reasons[key] = (reasons[key] ?? 0) + 1;
}
return {
policy: opts.policy.name,
games: opts.games,
finished: finished.length,
wins: finished.filter((r) => r.outcome?.result === 'win').length,
revenuePerPlayer: statsOf(perPlayer),
revenuePerPlayerPerDay: statsOf(perDay),
collisionsPerGame: statsOf(finished.map((r) => r.collisions)),
trainsScheduled: statsOf(finished.map((r) => r.trainsScheduled)),
cardsPlayed: statsOf(finished.map((r) => r.cardsPlayed)),
daysPlayed: statsOf(days),
outcomeReasons: reasons,
perGame: stats,
};
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
const f = (n: number): string => n.toFixed(1).padStart(6);
function line(label: string, s: Stats): string {
return ` ${label.padEnd(26)} ${f(s.mean)} ${f(s.median)} ${f(s.min)} ${f(s.max)}`;
}
export function formatReport(r: SimReport, length: GameLength, players: number): string {
const profile = lengthProfile(length);
const out: string[] = [];
out.push(`\n=== ${r.policy} bot · ${length} · ${players}p · ${r.games} games ===`);
out.push(` finished ${r.finished}/${r.games} wins ${r.wins}/${r.finished}`);
out.push(` ${''.padEnd(26)} mean median min max`);
out.push(line('revenue per player', r.revenuePerPlayer));
out.push(line('revenue per player per Day', r.revenuePerPlayerPerDay));
out.push(line('collisions per game', r.collisionsPerGame));
out.push(line('trains scheduled', r.trainsScheduled));
out.push(line('cards played', r.cardsPlayed));
out.push(line('days played', r.daysPlayed));
out.push('\n outcomes:');
for (const [k, v] of Object.entries(r.outcomeReasons).sort((a, b) => b[1] - a[1])) {
out.push(` ${k.padEnd(28)} ${v}`);
}
// The provisional numbers this exists to test.
out.push('\n against the provisional targets:');
out.push(` target ${profile.target} over ${profile.days} Days`);
out.push(
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
);
out.push(
` collective floor (competitive) ${collectiveRevenueFloor(players, profile.days)}` +
`; observed total mean ${(r.revenuePerPlayer.mean * players).toFixed(1)}`,
);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith('harness.ts') ?? false;
if (isMain) {
const games = Number(process.argv[2] ?? 200);
const length = (process.argv[3] ?? 'standard') as GameLength;
for (const policy of [developerBot, randomBot(12345)]) {
const report = simulate({
games,
length,
mode: 'solitaire',
players: ['bot'],
policy,
});
console.log(formatReport(report, length, 1));
if (policy === developerBot) console.log(formatAggregate(report.perGame));
}
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Plain-English narration for the replay viewer.
*
* Two jobs, and the second matters more than it looks:
*
* 1. `narrate()` — turn each engine event into a sentence a person can read.
* 2. `impediments()` — say what is currently BLOCKED and why.
*
* Actions are easy to show. Blocked states are what diagnosis actually needs: the open questions
* are all of the form "why is nothing happening?" — why is a train at the Office only 18% of
* Stages, why do Laborers sit idle, why does a facility stop working. A log of things that did
* happen answers none of those.
*
* The impediment checks call the engine's own predicates rather than reimplementing them, so the
* panel cannot drift from the rules.
*/
import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
// Small formatters
// ---------------------------------------------------------------------------
const CLOCK: readonly string[] = [
'Midnight', '2:00 AM', '4:00 AM', '6:00 AM', '8:00 AM', '10:00 AM',
'Noon', '2:00 PM', '4:00 PM', '6:00 PM', '8:00 PM', '10:00 PM',
];
export function clockTime(stage: number): string {
return CLOCK[stage - 1] ?? `Stage ${stage}`;
}
export function carLabel(c: RollingStock): string {
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
}
export function carsLabel(cars: RollingStock[]): string {
if (cars.length === 0) return 'nothing';
return cars.map(carLabel).join(', ');
}
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
export function phaseLabel(phase: string): string {
switch (phase) {
case 'localOps':
return 'Local Operations';
case 'newTrain':
return 'New Train';
case 'mainline':
return 'Mainline';
case 'loadUnload':
return 'Load / Unload';
case 'shiftChange':
return 'Shift Change';
default:
return phase;
}
}
// ---------------------------------------------------------------------------
// Event narration
// ---------------------------------------------------------------------------
export type Narration = {
text: string;
/** Colours the line in the viewer. */
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
/** Board cell to highlight, if the event happened somewhere. */
where?: GridCoord;
};
/**
* Every member of `GameEvent` must produce a specific sentence. A test asserts the fallback is
* never reached, so adding an event type without narrating it fails the build rather than quietly
* degrading the replay.
*/
export function narrate(e: GameEvent): Narration {
switch (e.type) {
// -- clock
case 'stageBegan':
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage}${clockTime(e.stage)} ──` };
case 'phaseBegan':
return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
case 'actorChanged':
return {
tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
};
// -- local operations
case 'localOpsOptionChosen':
return {
tone: 'plain',
text:
e.option === 'switch'
? 'Chose to SWITCH — six Moves to shunt cars around the yard'
: e.option === 'draw'
? 'Chose to DRAW a card'
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
};
case 'trayMoved':
return {
tone: 'plain',
where: e.to,
text: `Crew moved ${at(e.from)}${at(e.to)} · ${e.movesRemaining} Moves left`,
};
case 'carsCoupled':
return {
tone: 'plain',
where: e.at,
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
};
case 'carsDropped':
return {
tone: 'plain',
where: e.at,
text: `Dropped ${carsLabel(e.stock)} at ${at(e.at)}`,
};
// -- cards
case 'cardDrawn':
return {
tone: 'plain',
text:
e.source === 'homeOffice'
? 'Drew a card from the Home Office deck'
: `Took the face-up card from Department slot ${(e.slot ?? 0) + 1}`,
};
case 'cardPlayed':
return {
tone: 'plain',
...(e.placement ? { where: e.placement } : {}),
text: e.placement ? `Played a card onto ${at(e.placement)}` : 'Played a card',
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from}${e.to}` };
case 'cardDiscarded':
return { tone: 'quiet', text: `Discarded a card face-up to Department slot ${e.toSlot + 1}` };
case 'deckReshuffled':
return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
case 'departmentRefilled':
return { tone: 'quiet', text: `Department slot ${e.slot + 1} refilled from the deck` };
// -- freight agent
case 'stockToOutbound':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
};
case 'inboundCleared':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
};
case 'facilityUnjammed':
return {
tone: 'bad',
where: e.at,
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
};
// -- trains
case 'trainScheduled':
return {
tone: 'good',
text: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`,
};
case 'carPlacedOnTrain':
return { tone: 'plain', text: `Put a ${carLabel(e.stock)} on the train being made up` };
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
case 'clearanceRequested':
return {
tone: 'bad',
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
};
case 'clearanceGiven':
return {
tone: e.allow ? 'bad' : 'plain',
text: e.allow
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
};
// -- passengers
case 'passengersBoarded':
return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)}` };
case 'passengersDetrained':
return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)}` };
// -- freight pipeline
case 'loadStarted':
return {
tone: 'plain',
where: e.at,
text: `Laborer moved a ${e.carType} load from the green box onto MEN`,
};
case 'loadAdvanced':
return {
tone: 'plain',
where: e.at,
text: `Laborer advanced the load ${boxName(e.fromBox)}${boxName(e.toBox)}`,
};
case 'loadCompleted':
return {
tone: 'good',
where: e.at,
text: `LOAD FINISHED — ${e.carType} loaded onto the spotted car`,
};
case 'unloadBegan':
return {
tone: 'plain',
where: e.at,
text: `Laborer began unloading a ${e.carType} — load lifted onto WORK`,
};
case 'unloadCompleted':
return {
tone: 'good',
where: e.at,
text: `UNLOAD FINISHED — ${e.carType} delivered into the red Inbound box`,
};
// -- consequences
case 'revenueChanged':
return e.delta < 0
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
case 'phaseEnded':
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
}
}
/** Events that change nothing a viewer can see. Skipped when capturing frames. */
export function isVisible(e: GameEvent): boolean {
return e.type !== 'actorChanged';
}
// ---------------------------------------------------------------------------
// Impediments — "why is nothing happening?"
// ---------------------------------------------------------------------------
export type Impediment = { where: string; why: string; severity: 'stuck' | 'waiting' | 'risk' };
/**
* Everything currently preventing progress. Derived live from engine predicates, never cached.
*
* This is the panel that should answer the standing questions: whether facilities jam, whether
* trains are held for want of a crew, whether the Office is about to cause a collision.
*/
export function impediments(s: GameState, player = 0): Impediment[] {
const out: Impediment[] = [];
const area = s.officeAreas.get(player);
if (!area) return out;
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
const want = facilityCarType(f);
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
// it also strips the industry track of Operational Rail status (§9.3), so no car can be
// brought in to rescue it.
for (let box = 0; box < f.menAtWork.length; box++) {
const load = f.menAtWork[box];
if (!load || canAdvanceLoad(f, box)) continue;
const reason =
laborersLeft(f) < 1
? 'all Laborers already used this Stage'
: load.dir === 'out'
? `no empty ${want} spotted to load onto`
: 'red Inbound box is full';
out.push({
where: `${name} ${key}`,
why: `load STUCK on ${boxName(box)}${reason}`,
severity: laborersLeft(f) < 1 ? 'waiting' : 'stuck',
});
}
if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) {
out.push({
where: `${name} ${key}`,
why: 'green box has a load but MEN is occupied',
severity: 'waiting',
});
}
if (f.allows.outbound && f.outboundBox.length === 0) {
out.push({
where: `${name} ${key}`,
why: 'green box empty — nothing to load (needs a Freight Agent action)',
severity: 'waiting',
});
}
if (f.industryTrack.cars.length >= f.industryTrack.length) {
out.push({
where: `${name} ${key}`,
why: `industry track full (${f.industryTrack.length} cars) — no room to spot another`,
severity: 'stuck',
});
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
out.push({
where: `Train ${due}`,
why: 'due to depart but HELD — no free Crew Tray',
severity: 'stuck',
});
}
// A full Office means the next arrival is an automatic collision (Gap 2d).
const cap = adTrackCount(s, player);
if (area.adOccupancy.length >= cap) {
out.push({
where: 'Office',
why: `all ${cap} A/D track(s) occupied — the next arrival COLLIDES`,
severity: 'risk',
});
}
if (s.clock.pendingDecision) {
out.push({
where: 'Superintendent',
why: 'must rule on a following-train clearance before the Mainline Phase continues',
severity: 'waiting',
});
}
return out;
}
/** A one-line summary of where every train currently is. */
export function trainPositions(s: GameState): { id: TrayId; label: string; where: string }[] {
const out: { id: TrayId; label: string; where: string }[] = [];
for (const [id, tray] of s.trays) {
const label = tray.trainNumber === null ? 'local crew' : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
let where: string;
switch (tray.position.at) {
case 'divisionPoint':
where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`;
break;
case 'mainline':
where = `Mainline card ${tray.position.index}, region ${tray.position.region + 1}`;
break;
case 'grid':
where = `Office Area ${at(tray.position.coord)}`;
break;
}
out.push({ id, label, where });
}
return out;
}
export { coordKey };
+510
View File
@@ -0,0 +1,510 @@
/**
* Component 16 — Game replay viewer.
*
* Records one game and writes a **self-contained HTML file** that plays it back step by step.
*
* node src/sim/replay.ts [--seed 1234] [--length standard] [--out replay.html]
*
* The browser never runs the engine. Frames are precomputed here in Node and embedded as JSON, so
* the page is a dumb renderer with no bundling and no build step.
*
* The recorder drives `advance()` and `applyIntent()` itself rather than reusing `playGame`'s pump
* loop, because `pump` batches a whole automatic phase into one call — which would collapse an
* entire Mainline Phase into a single frame. Driving `advance` gives one frame per step.
*
* Priority is CLARITY over polish: plain boxes, words not symbols, and an explicit panel for what
* is currently blocked.
*/
import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import { lengthProfile, officeProfile } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState } from '../engine/state.ts';
import { developerBot } from './bot.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
// ---------------------------------------------------------------------------
export type CellView = {
row: number;
col: number;
kind: string;
label: string;
running: boolean;
tray: string | null;
cars: string[];
facility: FacilityView | null;
};
export type FacilityView = {
name: string;
commodity: string;
flow: string;
green: string[];
greenCap: number;
maw: (string | null)[];
red: string[];
redCap: number;
track: string[];
trackCap: number;
laborers: string;
porters: string;
};
export type DivisionView = { kind: string; label: string; trains: string[][] };
export type Frame = {
day: number;
stage: number;
clock: string;
phase: string;
actor: number | null;
superintendent: number;
revenue: number;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
division: DivisionView[];
cells: CellView[];
facilities: FacilityView[];
hand: number;
deck: number;
blocked: Impediment[];
trains: { label: string; where: string }[];
};
// ---------------------------------------------------------------------------
// Snapshotting
// ---------------------------------------------------------------------------
const FACILITY_NAMES: Record<string, string> = {
mineTipple: 'Mine Tipple',
produceShed: 'Produce Shed',
grocersWarehouse: "Grocer's Warehouse",
oilRefinery: 'Oil Refinery',
powerPlant: 'Power Plant',
};
function facilityView(card: { geometry: { kind: string; facility?: string }; facility: NonNullable<CellView['facility']> extends never ? never : unknown }): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
if (!f || f.kind !== 'freight') return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
name: FACILITY_NAMES[key] ?? key,
commodity: facilityCarType(f) ?? '?',
flow: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
green: f.outboundBox.map(carLabel),
greenCap: f.capacity.outbound,
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
trackCap: f.industryTrack.length,
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
};
}
function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
): Frame {
const area = areaOf(s, 0);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label);
}
}
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
for (const [key, card] of area.grid) {
const [row, col] = key.split(',').map(Number);
const g = card.geometry;
const kind = g.kind;
let label: string;
if (g.kind === 'office') label = officeProfile(area.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else label = g.geometry === 'runAround' ? 'run-around' : g.geometry;
const fv = facilityView(card as never);
if (fv) facilities.push(fv);
cells.push({
row: row!,
col: col!,
kind,
label,
running: row === area.runningRow,
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
}
const division: DivisionView[] = s.division.nodes.map((n) => {
if (n.kind === 'divisionPoint') {
return {
kind: 'dp',
label: n.side === 'west' ? 'West DP' : 'East DP',
trains: [n.holding.map((id) => trainChip(s, id))],
};
}
if (n.kind === 'mainline') {
return {
kind: 'ml',
label: 'Mainline',
trains: n.regions.map((r) => (r.occupant ? [trainChip(s, r.occupant)] : [])),
};
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
};
});
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
lines,
where,
division,
cells,
facilities,
hand: (s.decks.hands.get(0) ?? []).length,
deck: s.decks.homeOffice.length,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
t.position.at === 'divisionPoint'
? `${t.position.side} Division Point`
: t.position.at === 'mainline'
? `Mainline ${t.position.index}, region ${t.position.region + 1}`
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
})),
};
}
function trainChip(s: GameState, id: string): string {
const t = s.trays.get(id);
if (!t) return id;
return t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
}
// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
const frames: Frame[] = [];
const push = (events: GameEvent[]): void => {
const visible = events.filter(isVisible);
if (visible.length === 0) return;
const narrated = visible.map(narrate);
const where = narrated.find((n) => n.where)?.where ?? null;
frames.push(snapshot(s, narrated.map((n) => ({ text: n.text, tone: n.tone })), where ?? null));
};
frames.push(snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], null));
for (let step = 0; step < maxSteps; step++) {
const r = advance(s);
push(r.events);
if (s.status === 'finished') break;
if (!r.needsInput) continue;
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const applied = applyIntent(s, actor, developerBot.choose(s, actor, options));
if (!applied.ok) break;
push(applied.events);
}
const o = s.outcome;
return {
seed,
length,
frames,
outcome: o ? `${o.result}${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
};
}
// ---------------------------------------------------------------------------
// HTML
// ---------------------------------------------------------------------------
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replay seed ${rec.seed}</title>
<style>
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
header{padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:14px;margin:0 0 6px;font-weight:600}
.bar{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline}
.big{font-size:19px;font-weight:700}
.dim{color:var(--dim)}
.wrap{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:14px;padding:14px}
@media(max-width:900px){.wrap{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin-bottom:14px}
h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:0 0 8px;font-weight:600}
.div-strip{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:5px;padding:6px 8px;min-width:96px;background:#20252c}
.node.office{border-color:var(--clock)}
.regions{display:flex;gap:4px;margin-top:5px}
.region{flex:1;min-height:22px;border:1px dashed var(--line);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:11px}
.chip{background:var(--clock);color:#0d1117;border-radius:3px;padding:1px 5px;font-weight:700;font-size:11px}
.grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:6px;min-height:64px;background:#20252c}
.cell.run{border-color:#4a545f;background:#252b33}
.cell.hl{outline:2px solid var(--warn);outline-offset:1px}
.cell .nm{font-weight:700;font-size:11px}
.cell .cars{color:var(--dim);font-size:10.5px;margin-top:3px}
.cell .tray{display:inline-block;margin-top:4px}
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto}
.line{padding:2px 0;border-bottom:1px solid #23272e}
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
.blocked li{margin-bottom:4px}
.sev-stuck{color:var(--bad)}.sev-risk{color:var(--warn)}.sev-waiting{color:var(--dim)}
footer{position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--line);padding:9px 14px;
display:flex;gap:8px;align-items:center;flex-wrap:wrap;z-index:5}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 11px;
font:inherit;cursor:pointer}
button:hover{background:#343b45}
input[type=range]{flex:1;min-width:180px}
.legend{font-size:11px;color:var(--dim);padding:0 14px 14px}
</style></head><body>
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="big" id="when">—</span>
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">hand <span id="hand">0</span> · deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
<div class="wrap">
<div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
<section><h2>Office Area</h2><div class="grid" id="grid"></div></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
<div>
<section><h2>What just happened</h2><div id="now"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Trains in play</h2><div id="trains" class="dim"></div></section>
<section><h2>History</h2><div class="log" id="log"></div></section>
</div>
</div>
<div class="legend">
Running Track is the lighter row. <span class="chip">T4</span> is a train. Green box = outbound loads waiting ·
MEN / AT / WORK = the three-step loading track (→ outbound, ← inbound) · red box = delivered loads.
Laborers and Porters show remaining/total for this Stage.
</div>
<footer>
<button id="first">⏮ start</button>
<button id="back">◀ step</button>
<button id="play">▶ play</button>
<button id="fwd">step ▶</button>
<button id="stage">next Stage ⏭</button>
<button id="money">next revenue 💰</button>
<button id="crash">next collision ⚠</button>
<input type="range" id="scrub" min="0" value="0">
<select id="speed">
<option value="600">slow</option>
<option value="250" selected>normal</option>
<option value="90">fast</option>
<option value="20">very fast</option>
</select>
</footer>
<script>
const FRAMES = ${JSON.stringify(rec.frames)};
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
function boxes(items, cap, cls) {
let h = '';
for (let k = 0; k < Math.max(cap, items.length); k++) {
const v = items[k];
h += '<span class="box ' + (v ? cls : 'empty') + '">' + (v ? esc(v) : '·') + '</span>';
}
return h || '<span class="dim">—</span>';
}
function render() {
const f = FRAMES[i];
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
$('phase').textContent = f.phase;
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
$('hand').textContent = f.hand;
$('deck').textContent = f.deck;
$('fno').textContent = i;
$('scrub').value = i;
$('division').innerHTML = f.division.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
'<div class="regions">' + n.trains.map((r) =>
'<div class="region">' + r.map((t) => '<span class="chip">' + esc(t) + '</span>').join('') + '</div>').join('') +
'</div></div>').join('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
const g = $('grid');
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
let cellsHtml = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (hl ? 'hl' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.tray ? '<span class="chip tray">' + esc(cell.tray) + '</span>' : '') +
(cell.cars.length ? '<div class="cars">' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
'</div>';
}
}
g.innerHTML = cellsHtml;
$('facs').innerHTML = f.facilities.length === 0
? '<span class="dim">no freight facilities built yet</span>'
: f.facilities.map((x) =>
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
'<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
'</div>').join('');
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
$('blocked').innerHTML = f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
$('trains').innerHTML = f.trains.length === 0
? 'no trains running'
: f.trains.map((t) => esc(t.label) + ' — ' + esc(t.where)).join('<br>');
let hist = '';
for (let k = Math.max(0, i - 24); k <= i; k++) {
for (const l of FRAMES[k].lines) hist += '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>';
}
$('log').innerHTML = hist;
$('log').scrollTop = $('log').scrollHeight;
}
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
$('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1;
$('scrub').oninput = (e) => go(+e.target.value);
$('first').onclick = () => go(0);
$('back').onclick = () => go(i - 1);
$('fwd').onclick = () => go(i + 1);
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('play').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
$('play').textContent = '⏸ pause';
timer = setInterval(() => {
if (i >= FRAMES.length - 1) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
go(i + 1);
}, +$('speed').value);
};
$('speed').onchange = () => { if (timer) { $('play').click(); $('play').click(); } };
document.onkeydown = (e) => {
if (e.key === 'ArrowRight') go(i + 1);
if (e.key === 'ArrowLeft') go(i - 1);
if (e.key === ' ') { e.preventDefault(); $('play').click(); }
};
render();
</script></body></html>`;
}
function esc(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c] ?? c);
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith('replay.ts') ?? false;
if (isMain) {
const arg = (name: string, dflt: string): string => {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? (process.argv[i + 1] ?? dflt) : dflt;
};
const seed = Number(arg('seed', '1234'));
const length = arg('length', 'standard') as GameLength;
const out = arg('out', 'replay.html');
const rec = record(seed, length);
writeFileSync(out, renderHtml(rec));
const kb = (Buffer.byteLength(renderHtml(rec)) / 1024).toFixed(0);
console.log(`wrote ${out}${rec.frames.length} frames, ${kb} KB`);
console.log(`seed ${seed} · ${length} · ${rec.outcome}`);
}
+458
View File
@@ -0,0 +1,458 @@
/**
* End-of-game statistics.
*
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
* engine to produce any of this.
*
* Three purposes, in ascending order of usefulness:
*
* 1. It is interesting to see what happens in a game.
* 2. It may reveal strategies — whether freight specialists outscore passenger specialists, say.
* 3. **It exposes bugs.** Every serious bug found so far was found by noticing a number that was
* wrong or absent, not by a failing test: facilities that were never worked, cars that were
* never spotted, trains that never departed. So `anomalies()` below treats "this never
* happened" as a first-class finding rather than something to scroll past.
*/
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.ts';
// ---------------------------------------------------------------------------
// Per-game summary
// ---------------------------------------------------------------------------
export type RevenueBreakdown = {
freightLoad: number;
freightUnload: number;
passengerBoard: number;
passengerDetrain: number;
collisionLoss: number;
net: number;
};
export type GameStats = {
seed: number;
result: string;
reason: string;
days: number;
turns: number;
revenue: RevenueBreakdown;
/** Freight's share of gross positive Revenue, 0..1. The strategy signal. */
freightShare: number;
trains: {
scheduled: number;
highballed: number;
arrived: number;
completed: number;
clearanceRequests: number;
clearancesAllowed: number;
};
development: {
finalTier: string;
gridSize: number;
freightFacilities: number;
cardsDrawn: number;
cardsPlayed: number;
cardsDiscarded: number;
officeUpgrades: number;
};
actions: {
switch: number;
draw: number;
freightAgent: number;
moves: number;
drops: number;
couples: number;
};
workers: { laborerActions: number; porterActions: number };
collisions: number;
eventCounts: Record<string, number>;
intentCounts: Record<string, number>;
};
function inc(map: Record<string, number>, key: string, by = 1): void {
map[key] = (map[key] ?? 0) + by;
}
export function summarize(
seed: number,
events: GameEvent[],
intents: Intent['type'][],
final: GameState,
): GameStats {
const eventCounts: Record<string, number> = {};
const intentCounts: Record<string, number> = {};
const rev: RevenueBreakdown = {
freightLoad: 0,
freightUnload: 0,
passengerBoard: 0,
passengerDetrain: 0,
collisionLoss: 0,
net: 0,
};
let clearanceRequests = 0;
let clearancesAllowed = 0;
let highballed = 0;
let arrived = 0;
let completed = 0;
for (const e of events) {
inc(eventCounts, e.type);
if (e.type === 'revenueChanged') {
const reason = e.reason;
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
else if (reason === 'boarding') rev.passengerBoard += e.delta;
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
}
if (e.type === 'clearanceRequested') clearanceRequests++;
if (e.type === 'clearanceGiven' && e.allow) clearancesAllowed++;
// The driver reports train lifecycle through `phaseBegan` labels.
if (e.type === 'phaseBegan') {
if (e.phase.includes('highballed')) highballed++;
else if (e.phase.includes('arrived')) arrived++;
else if (e.phase.includes('completed')) completed++;
}
}
for (const i of intents) inc(intentCounts, i);
// An unload scores through the same event as a load completion in the reducer, so count the
// distinct operations directly.
rev.freightUnload = eventCounts['unloadBegan'] ?? 0;
rev.net = final.players.reduce((n, p) => n + p.revenue, 0);
const grossFreight = rev.freightLoad;
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
const gross = grossFreight + grossPassenger;
const area = final.officeAreas.get(0);
let freightFacilities = 0;
if (area) {
for (const card of area.grid.values()) {
if (card.facility?.kind === 'freight') freightFacilities++;
}
}
return {
seed,
result: final.outcome?.result ?? 'unfinished',
reason: final.outcome?.reason ?? 'none',
days: Math.max(1, final.clock.day - 1),
turns: intents.length,
revenue: rev,
freightShare: gross > 0 ? grossFreight / gross : 0,
trains: {
scheduled: eventCounts['trainScheduled'] ?? 0,
highballed,
arrived,
completed,
clearanceRequests,
clearancesAllowed,
},
development: {
finalTier: area?.tier ?? 'unknown',
gridSize: area?.grid.size ?? 0,
freightFacilities,
cardsDrawn: eventCounts['cardDrawn'] ?? 0,
cardsPlayed: eventCounts['cardPlayed'] ?? 0,
cardsDiscarded: eventCounts['cardDiscarded'] ?? 0,
officeUpgrades: eventCounts['officeUpgraded'] ?? 0,
},
actions: {
switch: intentCounts['switch.move'] ?? 0,
draw: (intentCounts['draw.fromHomeOffice'] ?? 0) + (intentCounts['draw.fromDepartment'] ?? 0),
freightAgent:
(intentCounts['freightAgent.stockOutbound'] ?? 0) +
(intentCounts['freightAgent.clearInbound'] ?? 0) +
(intentCounts['freightAgent.unjam'] ?? 0),
moves: eventCounts['trayMoved'] ?? 0,
drops: eventCounts['carsDropped'] ?? 0,
couples: eventCounts['carsCoupled'] ?? 0,
},
workers: {
laborerActions: (eventCounts['loadAdvanced'] ?? 0) + (eventCounts['loadCompleted'] ?? 0) + (eventCounts['unloadBegan'] ?? 0),
porterActions: (eventCounts['passengersBoarded'] ?? 0) + (eventCounts['passengersDetrained'] ?? 0),
},
collisions: events.filter(
(e) => e.type === 'revenueChanged' && e.reason.startsWith('collision'),
).length,
eventCounts,
intentCounts,
};
}
// ---------------------------------------------------------------------------
// Anomaly detection — purpose 3
// ---------------------------------------------------------------------------
/**
* Everything the engine can emit or accept. A member of these lists that NEVER fires across a whole
* run is the signal this module exists for: it means a rule is unreachable, and unreachable rules
* are where the bugs have been.
*/
const EXPECTED_EVENTS = [
'trainScheduled',
'carPlacedOnTrain',
'trayMoved',
'carsCoupled',
'carsDropped',
'cardDrawn',
'cardPlayed',
'cardDiscarded',
'officeUpgraded',
'stockToOutbound',
'inboundCleared',
'loadAdvanced',
'loadCompleted',
'unloadBegan',
'passengersBoarded',
'passengersDetrained',
'revenueChanged',
'clearanceRequested',
'clearanceGiven',
] as const;
const EXPECTED_INTENTS = [
'localOps.choose',
'switch.move',
'switch.dropCars',
'draw.fromHomeOffice',
'draw.fromDepartment',
'card.play',
'card.discard',
'freightAgent.stockOutbound',
'freightAgent.clearInbound',
'newTrain.placeCar',
'porter.board',
'porter.detrain',
'laborer.advanceLoad',
'laborer.beginUnload',
'mainline.clearance',
] as const;
export type Anomaly = { severity: 'never' | 'rare' | 'odd'; what: string; detail: string };
export function anomalies(all: GameStats[]): Anomaly[] {
const out: Anomaly[] = [];
if (all.length === 0) return out;
const totalEvents: Record<string, number> = {};
const totalIntents: Record<string, number> = {};
for (const g of all) {
for (const [k, v] of Object.entries(g.eventCounts)) inc(totalEvents, k, v);
for (const [k, v] of Object.entries(g.intentCounts)) inc(totalIntents, k, v);
}
for (const e of EXPECTED_EVENTS) {
const n = totalEvents[e] ?? 0;
if (n === 0) {
out.push({
severity: 'never',
what: `event ${e}`,
detail: `never fired in ${all.length} games — the rule producing it may be unreachable`,
});
} else if (n < all.length / 20) {
out.push({
severity: 'rare',
what: `event ${e}`,
detail: `only ${n} across ${all.length} games`,
});
}
}
for (const i of EXPECTED_INTENTS) {
const n = totalIntents[i] ?? 0;
if (n === 0) {
out.push({
severity: 'never',
what: `intent ${i}`,
detail: `never chosen in ${all.length} games — unreachable, or the bot never wants it`,
});
}
}
// Structural checks: things that must hold if the model is coherent.
const scheduled = all.reduce((n, g) => n + g.trains.scheduled, 0);
const arrived = all.reduce((n, g) => n + g.trains.arrived, 0);
if (scheduled > 0 && arrived === 0) {
out.push({
severity: 'never',
what: 'train arrivals',
detail: `${scheduled} trains scheduled but none ever reached an Office`,
});
}
const highballed = all.reduce((n, g) => n + g.trains.highballed, 0);
if (arrived > 0 && highballed < arrived) {
out.push({
severity: 'odd',
what: 'trains departing',
detail: `${arrived} arrivals but only ${highballed} departures — trains may be parking`,
});
}
const tiers = new Set(all.map((g) => g.development.finalTier));
if (tiers.size === 1) {
out.push({
severity: 'odd',
what: 'Office tier',
detail: `every game ended at "${[...tiers][0]}" — upgrades may be unreachable or automatic`,
});
}
return out;
}
// ---------------------------------------------------------------------------
// Strategy analysis — purpose 2
// ---------------------------------------------------------------------------
export type StrategyBucket = {
label: string;
games: number;
meanRevenue: number;
winRate: number;
};
/**
* Buckets games by how their Revenue was earned, then compares outcomes. If freight specialists
* consistently outscore passenger specialists (or vice versa), it shows up here.
*/
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
{ label: 'passenger-heavy (<33%)', test: (g) => g.freightShare > 0 && g.freightShare < 0.33 },
{ label: 'balanced (33-66%)', test: (g) => g.freightShare >= 0.33 && g.freightShare <= 0.66 },
{ label: 'freight-heavy (>66%)', test: (g) => g.freightShare > 0.66 && g.freightShare < 1 },
{ label: 'freight-only (100%)', test: (g) => g.freightShare === 1 },
];
return buckets.map(({ label, test }) => {
const members = scored.filter(test);
return {
label,
games: members.length,
meanRevenue:
members.length > 0 ? members.reduce((n, g) => n + g.revenue.net, 0) / members.length : 0,
winRate:
members.length > 0 ? members.filter((g) => g.result === 'win').length / members.length : 0,
};
});
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
const num = (n: number, w = 7, dp = 1): string => n.toFixed(dp).padStart(w);
function meanOf(all: GameStats[], get: (g: GameStats) => number): number {
return all.length === 0 ? 0 : all.reduce((n, g) => n + get(g), 0) / all.length;
}
export function formatGameStats(g: GameStats): string {
const out: string[] = [];
out.push(`\n--- game seed ${g.seed} · ${g.result}/${g.reason} · ${g.days} Days · ${g.turns} turns`);
out.push(` revenue net ${g.revenue.net}`);
out.push(` freight loads ${g.revenue.freightLoad}`);
out.push(` freight unloads ${g.revenue.freightUnload}`);
out.push(` passengers on ${g.revenue.passengerBoard}`);
out.push(` passengers off ${g.revenue.passengerDetrain}`);
out.push(` lost to collisions -${g.revenue.collisionLoss}`);
out.push(` freight share ${(g.freightShare * 100).toFixed(0)}%`);
out.push(
` trains scheduled ${g.trains.scheduled} highballed ${g.trains.highballed}` +
` arrived ${g.trains.arrived} completed ${g.trains.completed}`,
);
out.push(
` office ${g.development.finalTier} grid ${g.development.gridSize}` +
` facilities ${g.development.freightFacilities}`,
);
return out.join('\n');
}
export function formatAggregate(all: GameStats[]): string {
const out: string[] = [];
out.push(`\n=== end-of-game statistics · ${all.length} games ===\n`);
out.push(' REVENUE (mean per game)');
out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
out.push(` freight share of gross ${num(meanOf(all, (g) => g.freightShare) * 100, 6, 0)}%`);
out.push('\n TRAFFIC (mean per game)');
out.push(` trains scheduled ${num(meanOf(all, (g) => g.trains.scheduled))}`);
out.push(` highballs ${num(meanOf(all, (g) => g.trains.highballed))}`);
out.push(` arrivals at an Office ${num(meanOf(all, (g) => g.trains.arrived))}`);
out.push(` runs completed ${num(meanOf(all, (g) => g.trains.completed))}`);
out.push(` clearance requests ${num(meanOf(all, (g) => g.trains.clearanceRequests))}`);
out.push(` collisions ${num(meanOf(all, (g) => g.collisions))}`);
out.push('\n DEVELOPMENT (mean per game)');
out.push(` cards drawn ${num(meanOf(all, (g) => g.development.cardsDrawn))}`);
out.push(` cards played ${num(meanOf(all, (g) => g.development.cardsPlayed))}`);
out.push(` office upgrades ${num(meanOf(all, (g) => g.development.officeUpgrades))}`);
out.push(` final grid size ${num(meanOf(all, (g) => g.development.gridSize))}`);
out.push(` freight facilities ${num(meanOf(all, (g) => g.development.freightFacilities))}`);
const tierCounts: Record<string, number> = {};
for (const g of all) inc(tierCounts, g.development.finalTier);
out.push(' final Office tier:');
for (const [t, n] of Object.entries(tierCounts).sort((a, b) => b[1] - a[1])) {
out.push(` ${t.padEnd(14)} ${n} (${((n / all.length) * 100).toFixed(0)}%)`);
}
out.push('\n ACTION MIX (mean per game)');
out.push(` switch moves ${num(meanOf(all, (g) => g.actions.moves))}`);
out.push(` cars dropped ${num(meanOf(all, (g) => g.actions.drops))}`);
out.push(` cars coupled ${num(meanOf(all, (g) => g.actions.couples))}`);
out.push(` freight agent ops ${num(meanOf(all, (g) => g.actions.freightAgent))}`);
out.push(` laborer actions ${num(meanOf(all, (g) => g.workers.laborerActions))}`);
out.push(` porter actions ${num(meanOf(all, (g) => g.workers.porterActions))}`);
out.push('\n STRATEGY — does the revenue mix predict the score?');
out.push(` ${'bucket'.padEnd(30)} games mean rev win rate`);
for (const b of strategyBuckets(all)) {
out.push(
` ${b.label.padEnd(30)} ${String(b.games).padStart(5)} ${num(b.meanRevenue, 8)} ${num(b.winRate * 100, 6, 0)}%`,
);
}
const found = anomalies(all);
out.push('\n ANOMALIES');
if (found.length === 0) {
out.push(' none — every expected event and intent occurred at a plausible rate');
} else {
for (const a of found) {
out.push(` [${a.severity.toUpperCase().padEnd(5)}] ${a.what}: ${a.detail}`);
}
}
return out.join('\n');
}
+540
View File
@@ -0,0 +1,540 @@
/**
* Build order step 4 — "A full solitaire game runs to completion, headless."
* See architecture/components.md §4. This is the milestone that proves the rules before a single
* pixel is drawn.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
...over,
});
const game = (seed = 1, over: Partial<GameConfig> = {}): GameState =>
createGame({ id: 'g', seed, config: baseConfig(over), playerNames: ['Jesse'] });
type PlayStats = { turns: number; scheduled: number; collisions: number; cardsPlayed: number };
/**
* A deliberately simple bot — the seed of component 17. It draws, then plays, then ends its turn.
*
* The draw-first ordering matters: an earlier version never drew, so it burned its three opening
* cards and then held an empty hand for the rest of the game. Every seed still "completed", but
* nothing ever happened — no trains, no revenue. That is why the milestone tests below assert on
* what the game DID, not merely that it terminated.
*/
function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats {
let rng = seed >>> 0;
const pick = <T,>(xs: T[]): T => {
rng = (rng * 1103515245 + 12345) >>> 0;
return xs[rng % xs.length]!;
};
const stats: PlayStats = { turns: 0, scheduled: 0, collisions: 0, cardsPlayed: 0 };
const tally = (events: { type: string }[]): void => {
for (const e of events) {
if (e.type === 'trainScheduled') stats.scheduled++;
if (e.type === 'cardPlayed') stats.cardsPlayed++;
if (e.type === 'revenueChanged' && 'reason' in e && String(e.reason).startsWith('collision')) {
stats.collisions++;
}
}
};
for (; stats.turns < maxTurns; stats.turns++) {
tally(pump(s));
if (s.status === 'finished') break;
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const draws = options.filter((i) => i.type.startsWith('draw.from'));
const plays = options.filter((i) => i.type === 'card.play');
const enders = options.filter(
(i) =>
i.type === 'switch.end' ||
i.type === 'draw.end' ||
i.type === 'loadUnload.end' ||
i.type === 'mainline.clearance',
);
const choice =
draws.length > 0
? pick(draws)
: plays.length > 0
? pick(plays)
: enders.length > 0
? pick(enders)
: pick(options);
const r = applyIntent(s, actor, choice);
assert.ok(r.ok, `bot chose an illegal action: ${choice.type}`);
tally(r.events);
}
return stats;
}
// ---------------------------------------------------------------------------
describe('phase sequencing (Gap 1)', () => {
it('waits for the actor in a player-driven phase', () => {
const s = game();
const r = advance(s);
assert.equal(r.needsInput, true);
assert.equal(s.clock.phase, 'localOps');
assert.equal(s.clock.currentActor, 0);
});
it('runs the phases in order once the player finishes', () => {
const s = game();
const seen: string[] = [];
for (let i = 0; i < 40 && s.status === 'active'; i++) {
const r = advance(s);
seen.push(...r.events.filter((e) => e.type === 'phaseBegan').map(() => s.clock.phase));
if (r.needsInput) {
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.end' });
}
}
// Phase-major: the whole sequence runs per Stage, with no player acting in mainline.
assert.ok(seen.includes('newTrain'));
assert.ok(seen.includes('mainline'));
assert.ok(seen.includes('loadUnload'));
});
it('has no current actor on entering the automatic Mainline Phase', () => {
// Entering `mainline` nulls the actor — nobody acts in turn order there (Gap 1). With no
// trains running the phase then completes immediately, so this checks the moment of entry.
const s = game();
s.clock.phase = 'newTrain';
advance(s);
assert.equal(s.clock.phase, 'mainline');
assert.equal(s.clock.currentActor, null);
});
it('advances the Stage and resets per-Stage worker usage', () => {
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
const s = game();
const office = s.officeAreas.get(0)!.grid.get('0,0')!;
office.facility!.usedThisStage = { laborers: 2, porters: 1 };
s.clock.phase = 'shiftChange';
advance(s);
assert.deepEqual(office.facility!.usedThisStage, { laborers: 0, porters: 0 });
assert.equal(s.clock.stage, 2);
});
it('rolls over into a new Day after twelve Stages', () => {
const s = game();
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.day, 2);
assert.equal(s.clock.stage, 1);
});
it('passes the Fedora every three Stages', () => {
// §5 — shift changes at Stages 3, 6, 9 and 12. With one player it returns to them.
const s = createGame({
id: 'g',
seed: 3,
config: baseConfig({ mode: 'competitive' }),
playerNames: ['A', 'B', 'C'],
});
const before = s.clock.superintendent;
s.clock.stage = 3;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.superintendent, (before + 1) % 3);
});
it('does not pass the Fedora on a non-shift-change Stage', () => {
const s = createGame({
id: 'g',
seed: 3,
config: baseConfig({ mode: 'competitive' }),
playerNames: ['A', 'B', 'C'],
});
const before = s.clock.superintendent;
s.clock.stage = 4;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.superintendent, before);
});
it('resets the collision count each Day', () => {
// §3.4 — the counter resets at the start of each Day.
const s = game();
s.collisionsToday = 2;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.collisionsToday, 0);
});
});
// ---------------------------------------------------------------------------
describe('Mainline Phase (§8)', () => {
function scheduleTrain(s: GameState, stage: number, number: number): void {
s.timetable[stage - 1] = number;
}
it('makes up a train due this Stage at the correct Division Point', () => {
const s = game();
scheduleTrain(s, 1, 2); // train 2 is even, therefore eastbound
s.clock.phase = 'newTrain';
advance(s);
const trays = [...s.trays.values()];
assert.equal(trays.length, 1);
assert.equal(trays[0]!.trainNumber, 2);
assert.equal(trays[0]!.direction, 'east');
// An eastbound train starts in the west.
assert.deepEqual(trays[0]!.position, { at: 'divisionPoint', side: 'west' });
});
it('holds a train when no Crew Tray is free (§7)', () => {
const s = game();
s.freeTrays = [];
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
advance(s);
assert.equal(s.trays.size, 0, 'the train is held, not made up');
assert.equal(s.clock.phase, 'mainline', 'and the Stage moves on');
});
it('moves trains one region per Stage (§8.2)', () => {
const s = game();
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
pump(s);
const id = [...s.trays.keys()][0]!;
s.trays.get(id)!.consist = [{ type: 'coach', loaded: false }];
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
const pos = s.trays.get(id)!.position;
assert.equal(pos.at, 'mainline', 'the train highballed onto the mainline');
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
const pos2 = s.trays.get(id)!.position;
assert.ok(pos2.at === 'mainline' && pos2.region === 1, 'one region per Stage');
});
it('orders movement by train number, Timetabled before Extra on a tie (Gap 5)', () => {
const s = game();
const order: number[] = [];
for (const [i, spec] of [
{ n: 9, extra: true },
{ n: 9, extra: false },
{ n: 3, extra: false },
].entries()) {
s.trays.set(`t${i}`, {
id: `t${i}`,
trainNumber: spec.n,
trainIsExtra: spec.extra,
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
}
const sorted = [...s.trays.values()].sort((a, b) => {
const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
});
for (const t of sorted) order.push(t.trainIsExtra ? -t.trainNumber! : t.trainNumber!);
assert.deepEqual(order, [3, 9, -9], 'train 3, then Timetabled 9, then Extra X9');
});
});
// ---------------------------------------------------------------------------
describe('collisions are automatic (Gap 2)', () => {
it('collides when a train arrives with every A/D track occupied', () => {
// Gap 2d — no room at the station, the local player's fault, 5 Revenue.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = ['blocker']; // a Whistle Post has exactly one A/D track
const id = 'inbound';
s.trays.set(id, {
id,
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = id;
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.players[0]!.revenue, -5, 'the fault is the local players (§10)');
assert.equal(s.collisionsToday, 1);
assert.ok(!s.trays.has(id), 'the train is removed');
});
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
const s = game();
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
const classBefore = s.yards.classificationYard.length;
s.trays.set('x', {
id: 'x',
trainNumber: 8,
trainIsExtra: false,
engineFront: true,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.yards.classificationYard.length, classBefore + 1, 'the hopper');
assert.ok(
s.yards.divisionYard.some((c) => c.type === 'caboose'),
'the caboose goes straight back to the Division Yard',
);
});
});
// ---------------------------------------------------------------------------
describe('the Superintendent clearance interrupt (§8.1)', () => {
it('pauses the Mainline Phase for a following-train decision', () => {
const s = game();
// A train already on the first Mainline card, moving east.
s.trays.set('ahead', {
id: 'ahead',
trainNumber: 4,
trainIsExtra: false,
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = 'ahead';
// A second train at the Western Division Point wanting to follow it.
s.trays.set('behind', {
id: 'behind',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
const r = advance(s);
assert.equal(r.needsInput, true, 'the phase must stop and ask');
assert.notEqual(s.clock.pendingDecision, null);
assert.equal(s.clock.pendingDecision!.train, 'behind');
assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead');
});
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
const s = game();
s.trays.set('oncoming', {
id: 'oncoming',
trainNumber: 3,
trainIsExtra: false,
engineFront: true,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1, region: 0 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[0]!.occupant = 'oncoming';
s.trays.set('waiting', {
id: 'waiting',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.clock.pendingDecision, null, 'no judgment call — the train simply holds');
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'divisionPoint', side: 'west' });
});
it('clears the decision once the Superintendent rules', () => {
const s = game();
s.clock.phase = 'mainline';
s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' };
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
assert.ok(r.ok);
assert.equal(s.clock.pendingDecision, null);
});
});
// ---------------------------------------------------------------------------
describe('victory conditions (§3, Gap 10e)', () => {
it('loses a timed Solitaire game that misses the target', () => {
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'revenueFloor');
});
it('wins a timed Solitaire game that clears the target', () => {
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = lengthProfile('short').target;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'win');
});
it('ends a first-to-target game the moment the target is reached', () => {
const s = game(1, { victory: 'firstToTarget' });
s.players[0]!.revenue = lengthProfile('short').target;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'targetReached');
});
});
// ---------------------------------------------------------------------------
describe('MILESTONE: a full solitaire game runs headless', () => {
it('plays a short game to completion', () => {
const s = game(42);
playToCompletion(s, 42);
assert.equal(s.status, 'finished', 'the game must reach an outcome');
assert.ok(s.outcome, 'and record one');
assert.ok(s.clock.day >= 1);
});
it('terminates from many different seeds', () => {
// The driver must settle regardless of how the deck falls.
for (const seed of [1, 7, 23, 99, 256, 1013]) {
const s = game(seed);
playToCompletion(s, seed);
assert.equal(s.status, 'finished', `seed ${seed} did not finish`);
}
});
it('is fully reproducible from a seed', () => {
const a = game(555);
const b = game(555);
playToCompletion(a, 555);
playToCompletion(b, 555);
assert.equal(a.clock.day, b.clock.day);
assert.equal(a.players[0]!.revenue, b.players[0]!.revenue);
assert.deepEqual(a.outcome, b.outcome);
});
it('never offers the bot an illegal action along the way', () => {
// playToCompletion asserts this on every step; this test states the guarantee explicitly.
const s = game(31);
const stats = playToCompletion(s, 31);
assert.ok(stats.turns > 0, 'the game should take at least one turn');
});
it('actually plays the game — trains get scheduled and cards get played', () => {
// GUARD. An earlier bot completed every seed while doing nothing at all: no trains, no
// revenue, every game a loss. Asserting only on termination hid two real bugs. These assert
// the game DEVELOPS.
let scheduled = 0;
let cardsPlayed = 0;
for (const seed of [7, 42, 99, 256]) {
const s = game(seed);
const stats = playToCompletion(s, seed);
scheduled += stats.scheduled;
cardsPlayed += stats.cardsPlayed;
}
assert.ok(scheduled > 0, 'no train was ever scheduled across four games');
assert.ok(cardsPlayed > 4, `only ${cardsPlayed} cards played across four games`);
});
it('terminates even when the Superintendent denies clearance repeatedly', () => {
// A denied ruling must be CONSUMED. Without that the driver re-evaluates the same train and
// asks the same question forever — a livelock that only showed up on one seed.
const s = game(7);
const stats = playToCompletion(s, 7, 5_000);
assert.equal(s.status, 'finished');
assert.ok(stats.turns < 5_000, `took ${stats.turns} turns — probable livelock`);
});
it('conserves rolling stock across a whole game', () => {
// Nothing may be created or destroyed: 62 pieces, wherever they sit.
const s = game(88);
playToCompletion(s, 88);
let count = s.yards.divisionYard.length + s.yards.classificationYard.length;
for (const tray of s.trays.values()) count += tray.consist.length;
for (const area of s.officeAreas.values()) {
for (const card of area.grid.values()) {
count += card.standing.length;
if (card.facility) {
count += card.facility.outboundBox.length;
count += card.facility.inboundBox.length;
count += card.facility.industryTrack.cars.length;
}
}
}
assert.equal(count, 62, 'rolling stock leaked or was duplicated');
});
});
+589
View File
@@ -0,0 +1,589 @@
/**
* Build order step 3 — "Individual actions apply correctly; illegal ones rejected."
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const game = (seed = 77): GameState =>
createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
/** Puts a tray on the player's grid so switching intents have something to act on. */
function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id,
trainNumber: null,
trainIsExtra: false,
engineFront: true,
consist,
direction: 'east',
position: { at: 'grid', owner: 0, coord },
movesUsed: 0,
});
return id;
}
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
areaOf(s, 0).grid.set(coordKey(coord), card);
}
const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
facility: null,
modifiers: [],
});
// ---------------------------------------------------------------------------
describe('turn and phase gating', () => {
it('rejects an intent from a player who is not the actor', () => {
const s = game();
s.clock.currentActor = 1;
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
});
it('rejects an intent belonging to another phase', () => {
const s = game();
s.clock.phase = 'loadUnload';
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
});
it('rejects everything once the game is finished', () => {
const s = game();
s.status = 'finished';
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: the three-way exclusive choice (§6)', () => {
it('accepts a first choice and records it', () => {
const s = game();
const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.ok(r.ok);
assert.equal(s.turn.option, 'draw');
});
it('forecloses the other two options for the Stage', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(
check(s, 0, { type: 'localOps.choose', option: 'switch' }),
'OPTION_ALREADY_CHOSEN',
);
});
it('refuses sub-intents of an option that was not chosen', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: drawing (§6.2)', () => {
it('draws from the Home Office deck into the hand', () => {
const s = game();
const before = s.decks.homeOffice.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.equal(s.decks.homeOffice.length, before - 1);
assert.equal(s.decks.hands.get(0)!.length, 4);
});
it('takes the face-up card from a Department slot', () => {
const s = game();
const target = s.decks.departments[1]!;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(target));
assert.equal(s.decks.departments[1], null);
});
it('allows only one draw per Stage', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN');
});
it('will not end the phase over the hand limit', () => {
// §6.2 — "must reduce his hand to no more than three cards".
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1);
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
});
it('discards face up onto a Department slot, restoring the limit', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
const spare = s.decks.hands.get(0)![0]!;
const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
assert.ok(r.ok);
assert.equal(s.decks.departments[0], spare, 'discards go face up onto a Department');
assert.equal(check(s, 0, { type: 'draw.end' }), null);
});
it('refuses to play a card that is not in hand', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(
check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
'CARD_NOT_IN_HAND',
);
});
});
// ---------------------------------------------------------------------------
describe('Office upgrades (Gap 3b, Gap 8)', () => {
function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'office' && card.kind.tier === tier) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error('no such office card');
}
it('upgrades a Whistle Post to a Depot', () => {
const s = game();
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok);
assert.equal(areaOf(s, 0).tier, 'depot');
});
it('refuses to skip a tier', () => {
const s = game();
const id = handCardOfTier(s, 'station');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
});
it('preserves every grid connection through an upgrade', () => {
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
// Secondary Track hanging off the Office.
const s = game();
addCard(s, at(1, 0), straight());
const gridBefore = new Map(areaOf(s, 0).grid);
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'card.play', cardId: id });
const gridAfter = areaOf(s, 0).grid;
assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
});
it('grants the Depot its Porters and slots on upgrade', () => {
const s = game();
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'card.play', cardId: id });
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
assert.equal(office.facility!.porters, 1);
assert.equal(office.facility!.capacity.inbound, 2);
});
});
// ---------------------------------------------------------------------------
describe('Local Operations: switching (§6.1, Appendix A)', () => {
it('moves a tray and spends a Move', () => {
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok);
assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
});
it('couples standing cars automatically and mandatorily', () => {
// §A.4 — "you MUST pick them up".
const s = game();
addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
assert.ok(r.ok);
assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
assert.equal(s.trays.get(tray)!.consist.length, 1);
});
it('refuses a move to an unreachable card', () => {
const s = game();
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
'ILLEGAL_MOVE',
);
});
it('runs out of Moves after six', () => {
const s = game();
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
s.turn.movesRemaining = 0;
assert.equal(
check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
'NO_MOVES_REMAINING',
);
});
it('drops cars from the seated end, in order', () => {
// §A.3 — cars must come off in the order they are seated in the Crew Tray.
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 2), [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
assert.ok(r.ok);
const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
assert.equal(left.length, 1);
assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
assert.equal(s.trays.get(tray)!.consist.length, 1);
});
it('refuses to drop cars at the Office', () => {
// §A.4 — a passenger platform is no place to switch Rolling Stock.
const s = game();
const office = areaOf(s, 0).officeCoord;
const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
'CANNOT_DROP_HERE',
);
});
});
// ---------------------------------------------------------------------------
describe('Freight Agent operations (§6.3)', () => {
function withFacility(s: GameState): GridCoord {
const coord = at(1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 4, cars: [] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
});
return coord;
}
it('stocks the green Outbound box from the Division Yard', () => {
const s = game();
const coord = withFacility(s);
const before = s.yards.divisionYard.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
assert.ok(r.ok);
assert.equal(s.yards.divisionYard.length, before - 1);
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
});
it('allows only one Freight Agent operation per Stage', () => {
// This is the bottleneck the whole economy rests on (card-reference.md §7).
const s = game();
const coord = withFacility(s);
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'OPTION_ALREADY_CHOSEN',
);
});
it('refuses to overfill the Outbound box', () => {
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.outboundBox = [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
];
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'BOX_FULL',
);
});
it('refuses to load a facility that only unloads', () => {
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.allows = { outbound: false, inbound: true };
// Give it something to clear, so the Freight Agent option itself remains available.
f.inboundBox = [{ type: 'hopper', loaded: true }];
const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
assert.ok(chose.ok);
assert.equal(
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
'NO_SUCH_FACILITY',
);
});
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
// A player with no Facility cannot choose an option that has no possible follow-up.
const s = game();
assert.equal(
check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
'NO_SUCH_FACILITY',
);
});
it('makes the switch option unavailable with no train to switch', () => {
const s = game();
assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
});
});
// ---------------------------------------------------------------------------
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
function facilityWithLoad(s: GameState): GridCoord {
const coord = at(1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
});
s.clock.phase = 'loadUnload';
return coord;
}
it('advances a load one box per Laborer action', () => {
const s = game();
const coord = facilityWithLoad(s);
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
assert.ok(r.ok);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.equal(f.menAtWork[0], null);
assert.notEqual(f.menAtWork[1], null);
assert.equal(f.usedThisStage.laborers, 1);
});
it('spends each Laborer only once per Stage', () => {
// §9.1 — Laborers and Porters may be used once each in a Stage.
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.usedThisStage.laborers = f.laborers;
assert.equal(
check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
'RESOURCE_SPENT',
);
});
it('scores exactly one Revenue when a load comes off WORK', () => {
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
assert.equal(s.players[0]!.revenue, 0);
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
assert.ok(r.ok);
assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
});
it('will not complete a load with no empty car spotted', () => {
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
f.industryTrack.cars = [];
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
});
it('takes four Laborer actions in total for one point', () => {
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
const s = game();
const coord = facilityWithLoad(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.laborers = 99; // isolate the pipeline from the per-Stage cap
let actions = 0;
for (let box = 0; box < 3; box++) {
const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
assert.ok(r.ok, `advance from box ${box}`);
actions++;
}
assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
});
});
// ---------------------------------------------------------------------------
describe('the Superintendent clearance ruling (§8.1)', () => {
it('is refused when no decision is pending', () => {
const s = game();
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
});
it('is accepted out of turn order, because it interrupts an automatic phase', () => {
const s = game();
s.clock.phase = 'mainline';
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
assert.ok(r.ok);
assert.equal(s.clock.pendingDecision, null);
});
it('is refused to a player who is not the Superintendent', () => {
const s = game();
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
s.clock.superintendent = 1;
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
});
});
// ---------------------------------------------------------------------------
describe('legalActions shares its rules with apply (component 6)', () => {
it('offers only intents that apply would accept', () => {
// The whole discipline of legal.ts in one assertion.
const s = game();
for (const i of legalActions(s, 0)) {
assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`);
}
});
const chooseOptions = (s: GameState) =>
legalActions(s, 0)
.filter((i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose')
.map((i) => i.option)
.sort();
it('offers only "draw" on the opening Stage', () => {
// §6 — the other two options have no possible follow-up yet: a player opens with no Facility
// and no train. This is why the very first Stages are forced development.
assert.deepEqual(chooseOptions(game()), ['draw']);
});
it('offers all three once the player has a facility and a train', () => {
const s = game();
addCard(s, at(0, 2), straight());
placeTray(s, at(0, 2));
addCard(s, at(1, 0), {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 4, cars: [] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
});
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
});
it('narrows to one option once a choice is made', () => {
const s = game();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const kinds = new Set(legalActions(s, 0).map((i) => i.type));
assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
});
it('offers reachable destinations for a placed tray', () => {
const s = game();
addCard(s, at(0, 2), straight());
const tray = placeTray(s, at(0, 1));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
for (const m of moves) assert.equal(check(s, 0, m), null);
assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
});
it('offers nothing illegal after the game ends', () => {
const s = game();
s.status = 'finished';
assert.equal(legalActions(s, 0).length, 0);
});
});
+313
View File
@@ -0,0 +1,313 @@
/**
* Build order step 1 — "Card data loads; a game state can be constructed and seeded."
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
FREIGHT_PROFILES,
LENGTH_PROFILES,
MAX_CONSIST,
OFFICE_PROFILES,
ROLLING_STOCK_SUPPLY,
TIMETABLED_TRAINS,
TOTAL_ROLLING_STOCK,
crewTrayCount,
deckComposition,
isFreightHouse,
lengthProfile,
mainlineCardCount,
nextOfficeTier,
officeProfile,
} from '../src/engine/content.ts';
import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { subdivisions } from '../src/engine/state.ts';
const solitaireConfig: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const newSolitaireGame = (seed = 1234) =>
createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
it('composes a 52-card deck', () => {
const total = deckComposition().reduce((n, c) => n + c.count, 0);
assert.equal(total, DECK_SIZE);
assert.equal(buildDeck().length, DECK_SIZE);
});
it('matches card-reference.md deck composition exactly', () => {
const byCategory = Object.fromEntries(
deckComposition().map((c) => [c.category, c.count]),
);
assert.deepEqual(byCategory, {
timetabledTrain: 12,
extraTrain: 4,
office: 9,
freightFacility: 10,
modifier: 5,
track: 12,
});
});
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
assert.equal(TIMETABLED_TRAINS.length, 12);
for (const t of TIMETABLED_TRAINS) {
assert.equal(t.direction, t.number % 2 === 1 ? 'west' : 'east', `train ${t.number}`);
}
});
it('never lets a consist exceed four slots, caboose included', () => {
// §A.4 + Gap 4c — the most likely off-by-one in the whole model.
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
const slots = t.consist.count + (t.consist.requiresCaboose ? 1 : 0);
assert.ok(slots <= MAX_CONSIST, `${t.className} ${t.number} uses ${slots} slots`);
}
});
it('gives Extras low seniority (X9-X12)', () => {
assert.deepEqual(EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b), [9, 10, 11, 12]);
});
it('identifies Freight Houses as the both-direction facilities', () => {
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
});
it('keeps every industry track within the four-car limit', () => {
for (const f of FREIGHT_PROFILES) {
assert.ok(f.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
}
});
it('gives a facility capacity only in the directions it allows', () => {
for (const f of FREIGHT_PROFILES) {
const wantsOut = f.flow === 'outbound' || f.flow === 'both';
const wantsIn = f.flow === 'inbound' || f.flow === 'both';
assert.equal(f.outboundCapacity > 0, wantsOut, `${f.name} outbound`);
assert.equal(f.inboundCapacity > 0, wantsIn, `${f.name} inbound`);
}
});
it('makes only the Whistle Post a non-Control-Point', () => {
for (const o of OFFICE_PROFILES) {
assert.equal(o.isControlPoint, o.tier !== 'whistlePost', o.tier);
assert.equal(o.isPassengerFacility, o.tier !== 'whistlePost', o.tier);
}
});
it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
for (const o of OFFICE_PROFILES) {
if (o.tier === 'whistlePost') continue;
assert.ok(o.greenSlots > o.porters, `${o.tier} green slots vs porters`);
assert.ok(o.redSlots > o.porters, `${o.tier} red slots vs porters`);
}
});
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
// Gap 3b requires more Depots than Stations than Terminals.
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
assert.deepEqual(inDeck, [4, 3, 2]);
});
it('walks the upgrade sequence without skipping', () => {
assert.equal(nextOfficeTier('whistlePost'), 'depot');
assert.equal(nextOfficeTier('depot'), 'station');
assert.equal(nextOfficeTier('station'), 'terminal');
assert.equal(nextOfficeTier('terminal'), null);
});
it('supplies 62 rolling stock pieces', () => {
assert.equal(TOTAL_ROLLING_STOCK, 62);
assert.equal(buildRollingStock().length, 62);
});
it('never demands more cars than the supply can furnish', () => {
// card-reference.md §8 supply check, as an executable assertion.
const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
const demand = new Map<CarType, number>();
for (const f of FREIGHT_PROFILES) {
const need = (f.outboundCapacity + f.inboundCapacity) * f.copies;
demand.set(f.carType, (demand.get(f.carType) ?? 0) + need);
}
for (const [type, need] of demand) {
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
}
});
it('uses the Gap 10e revised victory targets', () => {
assert.deepEqual(
LENGTH_PROFILES.map((p) => [p.target, p.days]),
[[10, 3], [20, 5], [45, 10]],
);
assert.equal(lengthProfile('standard').target, 20);
});
it('scales fixed supplies with player count', () => {
assert.equal(mainlineCardCount(1), 2);
assert.equal(mainlineCardCount(3), 4);
assert.equal(crewTrayCount(1), 4);
assert.equal(crewTrayCount(3), 6);
});
});
// ---------------------------------------------------------------------------
describe('seeded RNG (component 7)', () => {
it('is deterministic for a given seed', () => {
const a = createRng(42);
const b = createRng(42);
const drawsA = Array.from({ length: 50 }, () => a.nextInt(1000));
const drawsB = Array.from({ length: 50 }, () => b.nextInt(1000));
assert.deepEqual(drawsA, drawsB);
});
it('differs between seeds', () => {
const a = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(1)));
const b = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(2)));
assert.notDeepEqual(a, b);
});
it('rolls a D12 strictly within 1..12', () => {
const rng = createRng(7);
const seen = new Set<number>();
for (let i = 0; i < 5000; i++) {
const roll = rng.d12();
assert.ok(roll >= 1 && roll <= 12, `roll out of range: ${roll}`);
seen.add(roll);
}
assert.equal(seen.size, 12, 'every face should appear over 5000 rolls');
});
it('shuffles without mutating or losing elements', () => {
const rng = createRng(99);
const input = Array.from({ length: 52 }, (_, i) => i);
const out = rng.shuffle(input);
assert.deepEqual(input, Array.from({ length: 52 }, (_, i) => i), 'input mutated');
assert.deepEqual([...out].sort((x, y) => x - y), input, 'elements lost');
assert.notDeepEqual(out, input, 'shuffle was a no-op');
});
it('rejects an invalid bound', () => {
const rng = createRng(1);
assert.throws(() => rng.nextInt(0));
assert.throws(() => rng.nextInt(-3));
});
});
// ---------------------------------------------------------------------------
describe('game setup (component 2)', () => {
it('constructs a solitaire game from a seed', () => {
const g = newSolitaireGame();
assert.equal(g.status, 'active');
assert.equal(g.players.length, 1);
assert.equal(g.clock.day, 1);
assert.equal(g.clock.stage, 1);
assert.equal(g.clock.phase, 'localOps');
assert.equal(g.clock.superintendent, 0);
assert.equal(g.clock.currentActor, 0);
});
it('is reproducible from the same seed and differs on another', () => {
assert.deepEqual(newSolitaireGame(5).decks.homeOffice, newSolitaireGame(5).decks.homeOffice);
assert.notDeepEqual(
newSolitaireGame(5).decks.homeOffice,
newSolitaireGame(6).decks.homeOffice,
);
});
it('builds the MVP division: DP - ML - Office - ML - DP', () => {
const g = newSolitaireGame();
assert.deepEqual(
g.division.nodes.map((n) => n.kind),
['divisionPoint', 'mainline', 'office', 'mainline', 'divisionPoint'],
);
});
it('gives every mainline card two regions', () => {
const g = newSolitaireGame();
for (const node of g.division.nodes) {
if (node.kind === 'mainline') assert.equal(node.regions.length, 2);
}
});
it('opens with the whole railroad as one Subdivision', () => {
// §8 — every Office is a Whistle Post, which is not a Control Point.
const g = newSolitaireGame();
const subs = subdivisions(g);
assert.equal(subs.length, 1, 'expected exactly one Subdivision at game start');
});
it('starts each player on a Whistle Post with three cards in a row', () => {
const g = newSolitaireGame();
const area = g.officeAreas.get(0)!;
assert.equal(area.tier, 'whistlePost');
assert.equal(area.grid.size, 3, 'Limits, Whistle Post, Limits');
assert.equal(officeProfile(area.tier).adTracks, 1);
assert.equal(area.adOccupancy.length, 0);
});
it('deals three cards and turns three Department slots face up', () => {
const g = newSolitaireGame();
assert.equal(g.decks.hands.get(0)!.length, 3);
assert.equal(g.decks.departments.length, 3);
assert.ok(g.decks.departments.every((c) => c !== null));
});
it('accounts for every card exactly once', () => {
const g = newSolitaireGame();
const all = [
...g.decks.homeOffice,
...g.decks.departments.filter((c): c is string => c !== null),
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
assert.equal(all.length, DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, DECK_SIZE, 'duplicate card ids');
});
it('starts with no trains scheduled and none running', () => {
// §4 — "no trains are officially running yet". This is the Day-1 revenue ramp (Gap 10e).
const g = newSolitaireGame();
assert.equal(g.timetable.length, 12);
assert.ok(g.timetable.every((slot) => slot === null));
assert.equal(g.trays.size, 0);
});
it('provides player-count + 3 crew trays', () => {
assert.equal(newSolitaireGame().freeTrays.length, 4);
});
it('puts all 62 rolling stock pieces in the Division Yard', () => {
const g = newSolitaireGame();
assert.equal(g.yards.divisionYard.length, 62);
assert.equal(g.yards.classificationYard.length, 0);
});
it('rejects a solitaire game with more than one player', () => {
assert.throws(() =>
createGame({ id: 'x', seed: 1, config: solitaireConfig, playerNames: ['a', 'b'] }),
);
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Build order steps 5-6 — bots play legal games, and the harness produces stable numbers.
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const game = (seed: number): GameState =>
createGame({ id: `g${seed}`, seed, config, playerNames: ['bot'] });
describe('bots (component 17)', () => {
it('plays only legal actions — playGame throws otherwise', () => {
for (const seed of [1, 2, 3, 5, 8, 13]) {
const r = playGame(game(seed), developerBot, pump);
assert.ok(r.finished, `seed ${seed} did not finish`);
}
});
it('the random control also plays only legal actions', () => {
for (const seed of [21, 34, 55]) {
const r = playGame(game(seed), randomBot(seed), pump);
assert.ok(r.finished, `seed ${seed} did not finish`);
}
});
it('develops the railroad rather than idling', () => {
// Guards the class of bug where every game "completes" while nothing happens.
const r = playGame(game(42), developerBot, pump);
assert.ok(r.cardsPlayed > 0, 'no cards played');
assert.ok(r.days > 1, 'game did not span a Day');
});
it('is reproducible from a seed', () => {
const a = playGame(game(99), developerBot, pump);
const b = playGame(game(99), developerBot, pump);
assert.deepEqual(a.revenue, b.revenue);
assert.deepEqual(a.outcome, b.outcome);
assert.equal(a.turns, b.turns);
});
});
describe('trains complete their runs (regression)', () => {
it('does not leave trains parked at an Office forever', () => {
// REGRESSION. `moveTrain` originally handled only Division Point and Mainline positions, so a
// train that arrived at an Office never departed. It held its A/D track permanently and every
// train behind it collided — 3.7 collisions per game, mean revenue 17, zero wins.
//
// A run of games should now show collisions as the exception, not the rule.
const report = simulate({
games: 40,
length: 'short',
mode: 'solitaire',
players: ['bot'],
policy: developerBot,
});
assert.ok(
report.collisionsPerGame.median <= 1,
`median ${report.collisionsPerGame.median} collisions/game — trains are piling up again`,
);
});
it('keeps A/D occupancy in step with where trains actually are', () => {
// The other half of the same bug: adOccupancy was only ever appended to, so an Office looked
// permanently full once any train had visited.
const s = game(7);
playGame(s, developerBot, pump);
for (const [owner, area] of s.officeAreas) {
for (const id of area.adOccupancy) {
const tray = s.trays.get(id);
assert.ok(tray, `A/D track holds ${id}, which no longer exists`);
assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`);
if (tray.position.at === 'grid') {
assert.equal(tray.position.owner, owner);
}
}
}
});
});
describe('balance harness (component 18)', () => {
it('produces a stable report over many games', () => {
const report = simulate({
games: 25,
length: 'short',
mode: 'solitaire',
players: ['bot'],
policy: developerBot,
});
assert.equal(report.games, 25);
assert.equal(report.finished, 25, 'every game must reach an outcome');
assert.ok(report.daysPlayed.mean > 0);
});
it('is deterministic — the same options give the same report', () => {
const opts = {
games: 10,
length: 'short' as const,
mode: 'solitaire' as const,
players: ['bot'],
policy: developerBot,
};
assert.deepEqual(simulate(opts), simulate(opts));
});
it('shows the heuristic bot outperforming the random control', () => {
// If the policy is worth anything it must beat picking uniformly at random.
const base = { games: 60, length: 'short' as const, mode: 'solitaire' as const, players: ['bot'] };
const smart = simulate({ ...base, policy: developerBot });
const dumb = simulate({ ...base, policy: randomBot(4242) });
assert.ok(
smart.revenuePerPlayer.mean > dumb.revenuePerPlayer.mean,
`heuristic ${smart.revenuePerPlayer.mean.toFixed(1)} did not beat random ${dumb.revenuePerPlayer.mean.toFixed(1)}`,
);
});
});
describe('the revenue chain works end to end (regression)', () => {
it('spots cars on industry tracks', () => {
// REGRESSION. Cars dropped at a facility went into the card's `standing` list, while §9.3's
// loading rule looked at `industryTrack`. Across 50 games ZERO cars were ever spotted and
// freight revenue was structurally zero. Win rate went 0% -> ~35% when these were unified.
let spotted = 0;
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
}
}
assert.ok(spotted > 0, 'no car ever reached an industry track across eight games');
});
it('instantiates a real Facility when a freight card is placed', () => {
// REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable.
const s = createGame({ id: 'g', seed: 11, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
let placed = 0;
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
placed++;
assert.ok(card.facility, 'a placed facility card has no Facility record');
assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
}
assert.ok(placed > 0, 'no facility was placed at all');
});
it('earns freight revenue, not just passenger revenue', () => {
// Asserts the FREIGHT chain specifically. An earlier version asserted `wins > 0`, which passed
// only because unloads were mis-scored as completed loads after one Laborer action instead of
// four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so
// "did anyone win" is no longer a safe proxy for "does freight work".
const report = simulate({
games: 40,
length: 'standard',
mode: 'solitaire',
players: ['bot'],
policy: developerBot,
});
const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area in both directions (Gap 11)', () => {
// With orientation fixed, grids only ever grew sideways and downward.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r > 0), 'nothing was ever built north of the Running Track');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built south of the Running Track');
});
});
describe('end-of-game statistics', () => {
it('accounts for revenue by source', () => {
const report = simulate({
games: 20, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
for (const g of report.perGame) {
const gross =
g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain;
assert.ok(gross >= 0, 'gross revenue cannot be negative');
assert.ok(g.freightShare >= 0 && g.freightShare <= 1, 'freight share out of range');
}
});
it('reports no anomalies — every rule in the engine is reachable', () => {
// THE POINT OF THIS MODULE. Every serious bug so far was an unreachable rule: a pipeline with
// no entry point, a facility that could not be worked, a car that could not be spotted. If an
// expected event stops firing, something has become unreachable again.
const report = simulate({
games: 60, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const found = anomalies(report.perGame);
const never = found.filter((a) => a.severity === 'never');
assert.deepEqual(
never.map((a) => a.what),
[],
`unreachable rules: ${never.map((a) => `${a.what} (${a.detail})`).join('; ')}`,
);
});
it('buckets games by strategy without losing any', () => {
const report = simulate({
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const scored = report.perGame.filter(
(g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
);
const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
});
});
+400
View File
@@ -0,0 +1,400 @@
/**
* Build order step 2 — "Move legality provable against Appendix A's worked switching examples."
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type {
GridCoord,
OfficeArea,
RollingStock,
TrackCard,
TurnoutOrientation,
} from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
allReachable,
canDropCarsAt,
canPlaceAt,
exitsFrom,
hasPort,
neighbour,
opposite,
reachableDestinations,
} from '../src/engine/track.ts';
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
facility: null,
modifiers: [],
});
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
baseOperationalRail: true,
standing,
facility: null,
modifiers: [],
});
const turnout = (o?: TurnoutOrientation): TrackCard => ({
geometry: o
? { kind: 'track', geometry: 'turnout', turnout: o }
: { kind: 'track', geometry: 'turnout' },
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
standing: [],
facility: null,
modifiers: [],
});
const officeCard = (): TrackCard => ({
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
});
const lockedFacility = (): TrackCard => ({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
facility: {
kind: 'freight',
subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
industryTrack: { length: 4, cars: [] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
});
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
const grid = new Map<string, TrackCard>();
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
return {
owner: 0,
tier: 'whistlePost',
grid,
officeCoord,
runningRow: officeCoord.row,
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
adOccupancy: [],
};
}
const emptyOccupancy: Occupancy = { trayAt: () => null, freeAdTracks: () => 1 };
function ctxFor(area: OfficeArea, over: Partial<MoveContext> = {}): MoveContext {
return { area, occupancy: emptyOccupancy, consistSize: 0, self: 'tray0', ...over };
}
const at = (r: number, c: number): GridCoord => ({ row: r, col: c });
const has = (dests: { coord: GridCoord }[], r: number, c: number): boolean =>
dests.some((d) => d.coord.row === r && d.coord.col === c);
// ---------------------------------------------------------------------------
describe('ports and geometry', () => {
it('pairs opposite ports and grid neighbours consistently', () => {
for (const p of ['n', 's', 'e', 'w'] as Port[]) {
assert.equal(opposite(opposite(p)), p);
const there = neighbour(at(0, 0), p);
const back = neighbour(there, opposite(p));
assert.deepEqual(back, at(0, 0));
}
});
it('joins a turnout stem to both legs but never the legs to each other', () => {
// §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
// reaches only the stem. This is an ABSENT edge, not a one-way edge.
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
assert.deepEqual(exitsFrom(t, 'e').sort(), ['n', 'w']);
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
assert.deepEqual(exitsFrom(t, 'n'), ['e']);
});
it('lets a train traverse a turnout in both directions', () => {
// A→B and B→A are both legal; only B↔C is missing.
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
assert.ok(exitsFrom(t, 'e').includes('n'), 'stem to diverging leg');
assert.ok(exitsFrom(t, 'n').includes('e'), 'diverging leg back to stem');
});
it('gives the Office card junction stubs above and below', () => {
// Gap 8 — plain junctions, so unlike a turnout no pair is missing between track and stub.
const o = officeCard();
for (const p of ['n', 's', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(exitsFrom(o, 'n').includes('e'));
assert.ok(exitsFrom(o, 'n').includes('w'));
assert.ok(!exitsFrom(o, 'n').includes('s'), 'north must not cross to south');
});
it('gives a run-around both ends of the loop', () => {
// §A.5's facing-point move is impossible without one.
const r: TrackCard = {
geometry: { kind: 'track', geometry: 'runAround' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
};
assert.deepEqual(exitsFrom(r, 'n').sort(), ['e', 'w']);
});
});
// ---------------------------------------------------------------------------
describe('Move legality', () => {
// row 1: [B n-s straight] <- must have a SOUTH port to meet the Office stub
// row 0: [D] [E office ] [F]
const basicArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(1, 0))]: nsStraight(),
},
at(0, 0),
);
it('travels any distance in a straight line', () => {
// §2.4 — "regardless of distance".
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
assert.ok(has(dests, 0, 0), 'reaches the office');
assert.ok(has(dests, 0, -1), 'reaches past it');
});
it('branches onto Secondary Track through the Office stub', () => {
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
assert.ok(has(dests, 1, 0), 'reaches the card north of the office');
});
it('never reverses within a single Move', () => {
// Leaving east from the west end cannot double back to the west end.
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
assert.ok(!has(dests, 0, -1), 'a Move may not return to its own start');
});
it('separates forward and reverse into different Moves', () => {
const area = basicArea();
const both = allReachable(ctxFor(area), at(0, 0), 'e');
assert.ok(has(both.forward, 0, 1));
assert.ok(has(both.reverse, 0, -1));
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
});
it('will not stop on a turnout', () => {
// row 1: [C n-s straight]
// row 0: [start] [turnout ]
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
[coordKey(at(1, 1))]: nsStraight(),
},
at(0, 0),
);
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
assert.ok(has(dests, 1, 1), 'but a train may pass through it');
});
it('will not stop on a facility whose track is locked by a load', () => {
// §9.3 — while any load sits on MEN|AT|WORK the industry track is not Operational Rail.
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: lockedFacility(),
},
at(0, 0),
);
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'locked industry track must not accept a train');
});
});
// ---------------------------------------------------------------------------
describe('coupling', () => {
const withCars = (n: number): OfficeArea =>
areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(Array.from({ length: n }, car)),
[coordKey(at(0, 2))]: straight(),
},
at(0, 0),
);
it('couples cars met along the way, mandatorily', () => {
// §A.4 — "you MUST pick them up. You may not go around them."
const dests = reachableDestinations(ctxFor(withCars(2)), at(0, 0), 'e');
const beyond = dests.find((d) => d.coord.col === 2);
assert.ok(beyond, 'should be able to pass the occupied card');
assert.equal(beyond.couples.length, 2, 'cars must be collected, not stepped over');
});
it('forbids a Move that would overfill the tray', () => {
// §A.4 — max four Rolling Stock, cabooses included. Overfilling makes the move illegal
// rather than picking up fewer cars.
const ctx = ctxFor(withCars(3), { consistSize: 3 });
const dests = reachableDestinations(ctx, at(0, 0), 'e');
assert.equal(dests.length, 0, '3 in tray + 3 standing exceeds 4');
});
it('permits a Move that exactly fills the tray', () => {
const ctx = ctxFor(withCars(2), { consistSize: 2 });
const dests = reachableDestinations(ctx, at(0, 0), 'e');
assert.ok(dests.length > 0, '2 + 2 = 4 is legal');
});
});
// ---------------------------------------------------------------------------
describe('occupancy', () => {
const twoTrainArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5), // office elsewhere, so col 1 is ordinary track
);
it('blocks a card another train occupies', () => {
// §A.4 — two trains may not share a card or move through each other.
const occupancy: Occupancy = {
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
freeAdTracks: () => 1,
};
const dests = reachableDestinations(ctxFor(twoTrainArea(), { occupancy }), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'occupied card');
assert.ok(!has(dests, 0, 2), 'and no passing through it');
});
it('allows passing through the Office while A/D tracks remain free', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: officeCard(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 1),
);
const occupancy: Occupancy = {
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
freeAdTracks: () => 1,
};
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
assert.ok(has(dests, 0, 2), 'the Office is the exception to the blocking rule (§A.4)');
});
it('blocks the Office once every A/D track is taken', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: officeCard(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 1),
);
const occupancy: Occupancy = {
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
freeAdTracks: () => 0,
};
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
assert.ok(!has(dests, 0, 2));
});
});
// ---------------------------------------------------------------------------
describe('placement and drop-off', () => {
it('lets the first Facility attach to the Office stub', () => {
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(1, 0), nsStraight()), 'north of the Office');
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'south of the Office');
});
it('refuses a card whose ports do not meet the neighbour it touches', () => {
// Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
// so it cannot attach to the stub. Orientation is not cosmetic.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
},
at(0, 0),
);
assert.ok(!canPlaceAt(area, at(1, 0), straight()), 'e-w straight cannot meet a n-s stub');
});
it('refuses a card that connects to nothing', () => {
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
});
it('refuses to place on an occupied cell', () => {
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
assert.ok(!canPlaceAt(area, at(0, 0), straight()));
});
it('forbids dropping cars at the Office', () => {
// §A.4 — a passenger platform is no place to switch Rolling Stock.
const area = areaFrom(
{
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
},
at(0, 0),
);
assert.ok(!canDropCarsAt(area, at(0, 0)), 'Office track');
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
});
it('forbids dropping cars on a locked industry track', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: lockedFacility(),
},
at(0, 0),
);
assert.ok(!canDropCarsAt(area, at(0, 1)));
});
});
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "NodeNext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}