Initial commit
This commit is contained in:
+23
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
# Station Master
|
||||
|
||||
A railroad operations game set in the era of timetable-and-train-order railroading (1840–1950),
|
||||
being built as a multiplayer browser game with an authoritative server.
|
||||
|
||||
You are the Station Master of a lineside Office on a shared east–west Division. Trains run to a
|
||||
timetable with no radios — just pocket watches and written orders. You switch cars, work freight and
|
||||
passengers, and take your turn as Superintendent deciding whether it is safe to clear a following
|
||||
train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
||||
|
||||
## Status
|
||||
|
||||
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.
@@ -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 4–6 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 1–4, 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 | 1–7 | ~2,650 | ~3,980 |
|
||||
| B. Server | 8–12, 20 | ~610 | ~2,100 |
|
||||
| C. Browser | 13–16 | ~1,250 | ~3,300 |
|
||||
| D. Dev-side | 17–19 | ~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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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 (1840–1950), 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 8–13 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 1–6 are done** — the rules engine (components 1–7), 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.
|
||||
@@ -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, 1–12 |
|
||||
| [Extra Trains](#3-train-cards) | 4 | X9–X12 |
|
||||
| [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 1–4 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 1–4 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 5–6/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 1–12 |
|
||||
| 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.
|
||||
@@ -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 |
|
||||
@@ -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.5–4.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 1–12 |
|
||||
| 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 (X9–X12), 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 9–12 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 1–12 | §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 (X9–X12) 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 **5–8 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 1–4 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 ~2–3 drawing, leaving 5–6 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 | 5–6 |
|
||||
| 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 5–6 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 (33–66%) | 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 1–2 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.
|
||||
@@ -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.
|
||||
@@ -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 1–12 |
|
||||
| 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 1–12 |
|
||||
| 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 |
|
||||
| X9–X12 | 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.
|
||||
Generated
+411
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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'];
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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) => ({'&':'&','<':'<','>':'>'}[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) => ({ '&': '&', '<': '<', '>': '>' })[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}`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 player’s (§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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user