commit e7bc07df85a2a6e208a393369d6fcc7c13932127 Author: Jesse Date: Fri Jul 31 07:19:57 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4028312 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac9f79b --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/StationMaster-PrototypeTurnChart.pdf b/docs/StationMaster-PrototypeTurnChart.pdf new file mode 100644 index 0000000..06d943c Binary files /dev/null and b/docs/StationMaster-PrototypeTurnChart.pdf differ diff --git a/docs/StationMasterPrototypeRules.pdf b/docs/StationMasterPrototypeRules.pdf new file mode 100644 index 0000000..e59d214 Binary files /dev/null and b/docs/StationMasterPrototypeRules.pdf differ diff --git a/docs/architecture/components.md b/docs/architecture/components.md new file mode 100644 index 0000000..664d369 --- /dev/null +++ b/docs/architecture/components.md @@ -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. diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..eb93954 --- /dev/null +++ b/docs/architecture/deployment.md @@ -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. diff --git a/docs/architecture/game-state.md b/docs/architecture/game-state.md new file mode 100644 index 0000000..07af211 --- /dev/null +++ b/docs/architecture/game-state.md @@ -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 -- 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 -- private, max 3 (4 with Red Flag) + redFlags : Map -- 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. diff --git a/docs/architecture/lobby-and-sessions.md b/docs/architecture/lobby-and-sessions.md new file mode 100644 index 0000000..31af061 --- /dev/null +++ b/docs/architecture/lobby-and-sessions.md @@ -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. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..7318b10 --- /dev/null +++ b/docs/architecture/overview.md @@ -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). diff --git a/docs/architecture/protocol.md b/docs/architecture/protocol.md new file mode 100644 index 0000000..3495830 --- /dev/null +++ b/docs/architecture/protocol.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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..2d7798c --- /dev/null +++ b/docs/design.md @@ -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. diff --git a/docs/rules/card-reference.md b/docs/rules/card-reference.md new file mode 100644 index 0000000..690c62e --- /dev/null +++ b/docs/rules/card-reference.md @@ -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. diff --git a/docs/rules/glossary.md b/docs/rules/glossary.md new file mode 100644 index 0000000..14c9812 --- /dev/null +++ b/docs/rules/glossary.md @@ -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 | diff --git a/docs/rules/open-questions.md b/docs/rules/open-questions.md new file mode 100644 index 0000000..0b1f316 --- /dev/null +++ b/docs/rules/open-questions.md @@ -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. diff --git a/docs/rules/rules-v0.1.md b/docs/rules/rules-v0.1.md new file mode 100644 index 0000000..f66db41 --- /dev/null +++ b/docs/rules/rules-v0.1.md @@ -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. diff --git a/docs/rules/rules-v0.2.md b/docs/rules/rules-v0.2.md new file mode 100644 index 0000000..229a1eb --- /dev/null +++ b/docs/rules/rules-v0.2.md @@ -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..05b9c11 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9ae800 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/engine/advance.ts b/src/engine/advance.ts new file mode 100644 index 0000000..4100ba8 --- /dev/null +++ b/src/engine/advance.ts @@ -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 }; diff --git a/src/engine/apply.ts b/src/engine/apply.ts new file mode 100644 index 0000000..8447f66 --- /dev/null +++ b/src/engine/apply.ts @@ -0,0 +1,1045 @@ +/** + * Component 4 — Intent validation and application. + * + * `applyIntent(state, actor, intent) -> events | rejection`. + * See architecture/components.md §2 A.4. + * + * STRUCTURE. Every intent is a `check` + `execute` pair: + * - `check` answers "is this legal right now?" and NEVER mutates. + * - `execute` reads state and emits events; it never mutates either. + * - `reduce` is the only thing that mutates, folding events into state. + * + * That keeps `state = fold(events)` true by construction, which is what makes replay and restart + * recovery work. It also lets component 6 (legalActions) call these very same `check` functions, + * so the two can never drift apart — see legal.ts. + */ + +import { + FREIGHT_PROFILES, + HAND_LIMIT, + LABORER_ACTIONS_PER_LOAD, + MAX_CONSIST, + nextOfficeTier, + officeProfile, +} from './content.ts'; +import type { CarType, FreightKind } from './content.ts'; +import type { GameEvent } from './events.ts'; +import type { Intent, RejectionCode } from './intents.ts'; +import type { + Facility, + GameState, + GridCoord, + OfficeArea, + PlayerIndex, + RollingStock, + TrackCard, + TrayId, +} from './state.ts'; +import { createRng } from './rng.ts'; +import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; +import type { Occupancy, Port } from './track.ts'; +import { + canDropCarsAt, + canPlaceAt, + facilityVariants, + reachableDestinations, + variantsFor, +} from './track.ts'; + +export type ApplyResult = + | { ok: true; events: GameEvent[] } + | { ok: false; code: RejectionCode; message: string }; + +// --------------------------------------------------------------------------- +// Lookup helpers +// --------------------------------------------------------------------------- + +export function areaOf(s: GameState, player: PlayerIndex): OfficeArea { + const a = s.officeAreas.get(player); + if (!a) throw new Error(`no Office Area for player ${player}`); + return a; +} + +function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined { + return area.grid.get(coordKey(c)); +} + +function facilityAt(s: GameState, player: PlayerIndex, c: GridCoord): Facility | null { + return cardAt(areaOf(s, player), c)?.facility ?? null; +} + +function trayCoord(s: GameState, trayId: TrayId): GridCoord | null { + const tray = s.trays.get(trayId); + if (!tray || tray.position.at !== 'grid') return null; + return tray.position.coord; +} + +/** A tray sitting on the Office card occupies an A/D track (§2.1). */ +function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy { + const area = areaOf(s, player); + return { + trayAt: (c) => { + for (const [id, tray] of s.trays) { + if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) { + return id; + } + } + return null; + }, + freeAdTracks: () => { + const cap = officeProfile(area.tier).adTracks; + return cap - area.adOccupancy.filter((t) => t !== self).length; + }, + }; +} + +/** A tray's facing, expressed as the port it would leave by going forward. */ +function facingPort(s: GameState, trayId: TrayId): Port { + const tray = s.trays.get(trayId); + return tray?.direction === 'west' ? 'w' : 'e'; +} + +// --------------------------------------------------------------------------- +// Shared predicates — used by BOTH check() below and legalActions() +// --------------------------------------------------------------------------- + +export function isActor(s: GameState, player: PlayerIndex): boolean { + return s.clock.currentActor === player; +} + +export function inPhase(s: GameState, phase: GameState['clock']['phase']): boolean { + return s.clock.phase === phase; +} + +/** + * §6 — "a player may do one of three things". You cannot do a thing that does not exist: a player + * with no Facility has no Freight Agent operation available, and one with no Crew Tray in his area + * has nothing to switch. Choosing such an option is illegal rather than a wasted turn. + * + * These deliberately inspect state directly rather than calling `check`, which would be circular. + */ +export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolean { + for (const card of areaOf(s, player).grid.values()) { + const f = card.facility; + if (!f) continue; + if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true; + if (f.inboundBox.length > 0) return true; + if (f.menAtWork.some((l) => l !== null)) return true; + if (f.outboundBox.length > 0) return true; + } + return false; +} + +/** + * §9.1 — "Loading boxes are green, with the icon of the car type that can be loaded from there." + * A facility handles exactly one commodity, so only that car type may be stocked into it. + * + * Without this check a Mine Tipple could be stocked with a coach. The load would then wait forever + * for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK + * strips the industry track of Operational Rail status (§9.3), the facility would jam permanently. + */ +export function facilityCarType(f: Facility): CarType | null { + if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null; + return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carType ?? null; +} + +export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean { + for (const tray of s.trays.values()) { + if (tray.position.at === 'grid' && tray.position.owner === player) return true; + } + return false; +} + +/** §9.1 — Laborers and Porters may be used once each per Stage. */ +export function laborersLeft(f: Facility): number { + return f.laborers - f.usedThisStage.laborers; +} + +export function portersLeft(f: Facility): number { + return f.porters - f.usedThisStage.porters; +} + +/** + * Where a load may advance to along MEN | AT | WORK (§9.3). + * + * Direction depends on which way the load is travelling. **Outbound** runs Green -> MEN -> AT -> + * WORK -> onto a spotted empty car. **Inbound** runs car -> WORK -> AT -> MEN -> red box. Getting + * this wrong conflates loading with unloading, which is exactly the bug the statistics found: + * `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point. + */ +export function canAdvanceLoad(f: Facility, box: number): boolean { + if (box < 0 || box >= f.menAtWork.length) return false; + const load = f.menAtWork[box]; + if (!load) return false; + if (laborersLeft(f) < 1) return false; + + const next = load.dir === 'out' ? box + 1 : box - 1; + + if (next >= f.menAtWork.length) { + // Off WORK and onto a spotted empty car of the right type. + return f.industryTrack.cars.some((c) => !c.loaded && c.type === load.type); + } + if (next < 0) { + // Off MEN and into the red Inbound box. + return f.inboundBox.length < f.capacity.inbound; + } + return f.menAtWork[next] === null; +} + +/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */ +export function canStartLoad(f: Facility): boolean { + if (laborersLeft(f) < 1) return false; + if (f.outboundBox.length === 0) return false; + return f.menAtWork[0] === null; +} + +/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */ +export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean { + const f = facilityAt(s, player, at); + if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; + if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false; + return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded); +} + +/** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */ +export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean { + const f = facilityAt(s, player, at); + if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; + if (f.inboundBox.length >= f.capacity.inbound) return false; + return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded); +} + +function trainAtOfficeWith( + s: GameState, + player: PlayerIndex, + pred: (c: RollingStock) => boolean, +): boolean { + const area = areaOf(s, player); + return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false); +} + +// --------------------------------------------------------------------------- +// check — never mutates +// --------------------------------------------------------------------------- + +export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null { + if (s.status !== 'active') return 'WRONG_PHASE'; + + // The clearance ruling is the one intent that arrives out of turn order: it interrupts the + // automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition). + if (i.type === 'mainline.clearance') { + if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION'; + if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT'; + return null; + } + + if (!isActor(s, player)) return 'NOT_YOUR_TURN'; + + switch (i.type) { + // -- Local Operations ----------------------------------------------------- + case 'localOps.choose': + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== null) return 'OPTION_ALREADY_CHOSEN'; + // An option with no possible follow-up is not available at all (§6). + if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY'; + if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY'; + return null; + + case 'switch.move': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; + if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; + const tray = s.trays.get(i.trayId); + if (!tray) return 'NO_SUCH_TRAY'; + const from = trayCoord(s, i.trayId); + if (!from) return 'ILLEGAL_MOVE'; + const dests = destinationsFor(s, player, i.trayId, from, i.reverse); + return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col) + ? null + : 'ILLEGAL_MOVE'; + } + + case 'switch.dropCars': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; + const tray = s.trays.get(i.trayId); + if (!tray) return 'NO_SUCH_TRAY'; + if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY'; + const here = trayCoord(s, i.trayId); + if (!here) return 'CANNOT_DROP_HERE'; + return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE'; + } + + case 'switch.end': + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN'; + + // -- Draw a card ---------------------------------------------------------- + case 'draw.fromHomeOffice': + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; + if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY'; + return null; + + case 'draw.fromDepartment': + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; + if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY'; + return s.decks.departments[i.slot] ? null : 'SLOT_EMPTY'; + + case 'card.play': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + const hand = s.decks.hands.get(player) ?? []; + if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; + return checkPlay(s, player, i.cardId, i.placement, i.variant); + } + + case 'card.discard': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + const hand = s.decks.hands.get(player) ?? []; + if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; + if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY'; + return null; + } + + case 'draw.end': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; + // §6.2 — "the player must reduce his hand to no more than three cards". + const hand = s.decks.hands.get(player) ?? []; + const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT; + return hand.length > limit ? 'HAND_LIMIT' : null; + } + + // -- Freight Agent -------------------------------------------------------- + case 'freightAgent.stockOutbound': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + if (!f.allows.outbound) return 'NO_SUCH_FACILITY'; + if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL'; + // §9.1 — the green box takes only this facility's commodity. + if (facilityCarType(f) !== i.carType) return 'WRONG_CAR_TYPE'; + if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) { + return 'NO_SUITABLE_CAR'; + } + return null; + } + + case 'freightAgent.clearInbound': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + return f.inboundBox[i.index] ? null : 'BOX_EMPTY'; + } + + case 'freightAgent.unjam': { + if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; + if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; + if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + if (i.from === 'menAtWork') return f.menAtWork[i.index] ? null : 'BOX_EMPTY'; + const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox; + return box[i.index] ? null : 'BOX_EMPTY'; + } + + // -- New Train ------------------------------------------------------------ + case 'newTrain.placeCar': { + if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; + const tray = s.trays.get(i.trayId); + if (!tray) return 'NO_SUCH_TRAY'; + if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL'; + if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) { + return 'NO_SUITABLE_CAR'; + } + return null; + } + + case 'newTrain.passCar': { + if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; + const tray = s.trays.get(i.trayId); + if (!tray) return 'NO_SUCH_TRAY'; + // §7 — "must make every effort to find a suitable car". A pass is only legal when none exists. + return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null; + } + + // -- Load / Unload -------------------------------------------------------- + case 'porter.board': + if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; + if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; + if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; + return canBoard(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE'; + + case 'porter.detrain': + if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; + if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; + if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; + return canDetrain(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE'; + + case 'laborer.startLoad': { + if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; + return canStartLoad(f) ? null : 'BOX_EMPTY'; + } + + case 'laborer.advanceLoad': { + if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; + return canAdvanceLoad(f, i.box) ? null : 'BOX_EMPTY'; + } + + case 'laborer.beginUnload': { + if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; + const f = facilityAt(s, player, i.at); + if (!f) return 'NO_SUCH_FACILITY'; + if (!f.allows.inbound) return 'NO_SUCH_FACILITY'; + if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; + const car = f.industryTrack.cars[i.carIndex]; + if (!car || !car.loaded) return 'WRONG_CAR_TYPE'; + // The load is placed on WORK, the last box, so that box must be free. + return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL'; + } + + case 'loadUnload.end': + return inPhase(s, 'loadUnload') ? null : 'WRONG_PHASE'; + + case 'redFlag.play': + return s.decks.redFlags.get(player) ? null : 'NO_SUCH_CARD'; + + default: + return 'WRONG_PHASE'; + } +} + +/** Playing a card from hand (§6.2). Track, Facility and Office cards need a placement. */ +function checkPlay( + s: GameState, + player: PlayerIndex, + cardId: string, + placement: GridCoord | undefined, + variant: number | undefined, +): RejectionCode | null { + const card = s.cards.get(cardId); + if (!card) return 'NO_SUCH_CARD'; + const area = areaOf(s, player); + + switch (card.kind.kind) { + case 'office': { + // An upgrade replaces the Office in place (Gap 8), so a placement is meaningless — and + // accepting one makes the event log claim the card was laid somewhere it was not. + if (placement) return 'NO_PLACEMENT'; + // Gap 3b — strict sequence, no skipping. + const next = nextOfficeTier(area.tier); + return next === card.kind.tier ? null : 'NOT_UPGRADEABLE'; + } + case 'track': + case 'freightFacility': { + if (!placement) return 'NO_PLACEMENT'; + const proto = protoCard(card.kind, variant); + if (!proto) return 'NO_PLACEMENT'; + return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED'; + } + case 'modifier': { + if (!placement) return 'NO_PLACEMENT'; + // §9 — a Modifier is not track; it sits adjacent to a Facility. + return area.grid.has(coordKey(placement)) ? 'NOT_CONNECTED' : null; + } + default: + // Train cards go to the timetable; the phase driver schedules them. + return null; + } +} + +function destinationsFor( + s: GameState, + player: PlayerIndex, + trayId: TrayId, + from: GridCoord, + reverse: boolean, +) { + const tray = s.trays.get(trayId)!; + const facing = facingPort(s, trayId); + const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing; + return reachableDestinations( + { + area: areaOf(s, player), + occupancy: occupancyFor(s, player, trayId), + consistSize: tray.consist.length, + self: trayId, + }, + from, + exit, + ); +} + +// --------------------------------------------------------------------------- +// execute — reads state, emits events, never mutates +// --------------------------------------------------------------------------- + +function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { + switch (i.type) { + case 'localOps.choose': + return [{ type: 'localOpsOptionChosen', player, option: i.option }]; + + case 'switch.move': { + const from = trayCoord(s, i.trayId)!; + const dests = destinationsFor(s, player, i.trayId, from, i.reverse); + const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!; + const events: GameEvent[] = [ + { + type: 'trayMoved', + trayId: i.trayId, + from, + to: i.to, + movesRemaining: s.turn.movesRemaining - 1, + }, + ]; + if (dest.couples.length > 0) { + events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples }); + } + return events; + } + + case 'switch.dropCars': { + const tray = s.trays.get(i.trayId)!; + const here = trayCoord(s, i.trayId)!; + // §A.3 — cars come off in the order they are seated in the tray. + const stock = tray.consist.slice(tray.consist.length - i.count); + return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }]; + } + + case 'switch.end': + case 'draw.end': + return [{ type: 'phaseEnded', player, phase: 'localOps' }]; + + case 'draw.fromHomeOffice': + return [ + { + type: 'cardDrawn', + player, + source: 'homeOffice', + cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!, + }, + ]; + + case 'draw.fromDepartment': + return [ + { + type: 'cardDrawn', + player, + source: 'department', + slot: i.slot, + cardId: s.decks.departments[i.slot]!, + }, + ]; + + case 'card.play': { + const card = s.cards.get(i.cardId)!; + const events: GameEvent[] = [ + i.placement + ? { + type: 'cardPlayed', + player, + cardId: i.cardId, + placement: i.placement, + variant: i.variant ?? 0, + } + : { type: 'cardPlayed', player, cardId: i.cardId }, + ]; + if (card.kind.kind === 'office') { + events.push({ + type: 'officeUpgraded', + player, + from: areaOf(s, player).tier, + to: card.kind.tier, + }); + } + if (card.kind.kind === 'timetabledTrain') { + // §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at + // the bottom. Without this no train ever runs, so nothing can ever be earned. + const rng = createRng(s.rngState); + const roll = rng.d12(); + const slot = findTimetableSlot(s, roll - 1); + if (slot !== null) { + events.push({ + type: 'trainScheduled', + player, + trainNumber: card.kind.number, + roll, + slot, + rngState: rng.getState(), + }); + } + } + return events; + } + + case 'card.discard': + return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }]; + + case 'freightAgent.stockOutbound': + return [ + { type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } }, + ]; + + case 'freightAgent.clearInbound': { + const f = facilityAt(s, player, i.at)!; + return [{ type: 'inboundCleared', player, at: i.at, stock: f.inboundBox[i.index]! }]; + } + + case 'freightAgent.unjam': { + const f = facilityAt(s, player, i.at)!; + const stock: RollingStock = + i.from === 'menAtWork' + ? { type: f.menAtWork[i.index]!.type, loaded: true } + : (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!; + return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }]; + } + + case 'newTrain.placeCar': + return [ + { + type: 'carPlacedOnTrain', + player, + trayId: i.trayId, + stock: { type: i.carType, loaded: i.loaded }, + }, + ]; + + case 'newTrain.passCar': + return [{ type: 'carPassed', player, trayId: i.trayId }]; + + case 'mainline.clearance': + return [ + { + type: 'clearanceGiven', + trainId: s.clock.pendingDecision!.train, + allow: i.allow, + }, + ]; + + case 'porter.board': + return [ + { type: 'passengersBoarded', player, at: i.at }, + { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' }, + ]; + + case 'porter.detrain': + return [ + { type: 'passengersDetrained', player, at: i.at }, + { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' }, + ]; + + case 'laborer.startLoad': { + const f = facilityAt(s, player, i.at)!; + return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }]; + } + + case 'laborer.advanceLoad': { + const f = facilityAt(s, player, i.at)!; + const load = f.menAtWork[i.box]!; + const next = load.dir === 'out' ? i.box + 1 : i.box - 1; + + if (next >= f.menAtWork.length) { + // Outbound complete: the load goes onto the spotted car (§9.3). + return [ + { type: 'loadCompleted', player, at: i.at, carType: load.type }, + { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' }, + ]; + } + if (next < 0) { + // Inbound complete: the load reaches the red Unloading box (§9.3). + return [ + { type: 'unloadCompleted', player, at: i.at, carType: load.type }, + { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' }, + ]; + } + return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }]; + } + + case 'laborer.beginUnload': { + const f = facilityAt(s, player, i.at)!; + return [ + { type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type }, + ]; + } + + case 'loadUnload.end': + return [{ type: 'phaseEnded', player, phase: 'loadUnload' }]; + + case 'redFlag.play': + return [{ type: 'phaseEnded', player, phase: 'redFlag' }]; + + default: + return []; + } +} + +function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number { + return (s.players[player]?.revenue ?? 0) + delta; +} + +/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */ +function findTimetableSlot(s: GameState, from: number): number | null { + for (let i = 0; i < s.timetable.length; i++) { + const slot = (from + i) % s.timetable.length; + if (s.timetable[slot] === null) return slot; + } + return null; +} + +// --------------------------------------------------------------------------- +// reduce — the ONLY mutator. state = fold(events). +// --------------------------------------------------------------------------- + +export function reduce(s: GameState, e: GameEvent): void { + switch (e.type) { + case 'localOpsOptionChosen': + s.turn.option = e.option; + break; + + case 'trayMoved': { + const tray = s.trays.get(e.trayId)!; + const owner = tray.position.at === 'grid' ? tray.position.owner : 0; + tray.position = { at: 'grid', owner, coord: e.to }; + s.turn.movesRemaining = e.movesRemaining; + + // An A/D track is held only while the train is actually standing at the Office (§2.1). + // Leaving it out of sync means the Office looks permanently full and every arrival collides. + const area = areaOf(s, owner); + const atOffice = + e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col; + area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId); + if (atOffice) area.adOccupancy.push(e.trayId); + break; + } + + case 'carsCoupled': { + const tray = s.trays.get(e.trayId)!; + const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); + tray.consist.push(...e.stock); + // Every card along the path is cleared; the event carries what was taken. + for (const card of area.grid.values()) { + if (card.standing.length > 0) card.standing = []; + if (card.facility) card.facility.industryTrack.cars = []; + } + break; + } + + case 'carsDropped': { + const tray = s.trays.get(e.trayId)!; + const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); + tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length); + const card = area.grid.get(coordKey(e.at)); + // On a Facility card the industry track is where cars stand (§9.3). + if (card) carsOn(card).push(...e.stock); + break; + } + + case 'cardDrawn': { + const hand = s.decks.hands.get(e.player) ?? []; + if (e.source === 'homeOffice') s.decks.homeOffice.pop(); + else if (e.slot !== undefined) s.decks.departments[e.slot] = null; + hand.push(e.cardId); + s.decks.hands.set(e.player, hand); + s.turn.drawnThisTurn = true; + break; + } + + case 'cardPlayed': { + const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId); + s.decks.hands.set(e.player, hand); + const card = s.cards.get(e.cardId)!; + const area = areaOf(s, e.player); + if (e.placement && (card.kind.kind === 'track' || card.kind.kind === 'freightFacility')) { + const built = protoCard(card.kind, e.variant); + if (built) { + if (card.kind.kind === 'freightFacility') built.facility = buildFreightFacility(card.kind.facility); + area.grid.set(coordKey(e.placement), built); + extendLimitsIfNeeded(area, e.placement); + } + } else if (e.placement && card.kind.kind === 'modifier') { + s.decks.salvageYard.push(e.cardId); + } else if (card.kind.kind !== 'office') { + s.decks.salvageYard.push(e.cardId); + } + break; + } + + case 'officeUpgraded': { + // Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track. + const area = areaOf(s, e.player); + area.tier = e.to; + const officeCard = area.grid.get(coordKey(area.officeCoord)); + if (officeCard?.facility) { + const p = officeProfile(e.to); + officeCard.facility.porters = p.porters; + officeCard.facility.capacity = { outbound: p.greenSlots, inbound: p.redSlots }; + officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility }; + } + break; + } + + case 'cardDiscarded': { + const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId); + s.decks.hands.set(e.player, hand); + s.decks.departments[e.toSlot] = e.cardId; + break; + } + + case 'stockToOutbound': { + const f = facilityAt(s, e.player, e.at)!; + const idx = s.yards.divisionYard.findIndex( + (c) => c.type === e.stock.type && c.loaded === e.stock.loaded, + ); + if (idx >= 0) s.yards.divisionYard.splice(idx, 1); + f.outboundBox.push(e.stock); + s.turn.freightAgentUsed = true; + break; + } + + case 'inboundCleared': { + const f = facilityAt(s, e.player, e.at)!; + const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); + if (idx >= 0) f.inboundBox.splice(idx, 1); + s.yards.classificationYard.push(e.stock); + s.turn.freightAgentUsed = true; + break; + } + + case 'facilityUnjammed': { + const f = facilityAt(s, e.player, e.at)!; + if (e.from === 'menAtWork') { + const idx = f.menAtWork.findIndex((l) => l !== null); + if (idx >= 0) f.menAtWork[idx] = null; + } else { + const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox; + const idx = box.findIndex((c) => c.type === e.stock.type); + if (idx >= 0) box.splice(idx, 1); + } + s.yards.classificationYard.push(e.stock); + s.turn.freightAgentUsed = true; + break; + } + + case 'trainScheduled': + s.timetable[e.slot] = e.trainNumber; + s.rngState = e.rngState; + s.decks.salvageYard.push(`train-${e.trainNumber}`); + break; + + case 'carPlacedOnTrain': { + const tray = s.trays.get(e.trayId)!; + const idx = s.yards.divisionYard.findIndex( + (c) => c.type === e.stock.type && c.loaded === e.stock.loaded, + ); + if (idx >= 0) s.yards.divisionYard.splice(idx, 1); + tray.consist.push(e.stock); + break; + } + + case 'passengersBoarded': { + const f = facilityAt(s, e.player, e.at)!; + const area = areaOf(s, e.player); + const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded); + const loaded = f.outboundBox.splice(idx, 1)[0]!; + for (const id of area.adOccupancy) { + const tray = s.trays.get(id); + const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1; + if (tray && ci >= 0) { + s.yards.classificationYard.push(tray.consist[ci]!); + tray.consist[ci] = loaded; + break; + } + } + f.usedThisStage.porters += 1; + break; + } + + case 'passengersDetrained': { + const f = facilityAt(s, e.player, e.at)!; + const area = areaOf(s, e.player); + for (const id of area.adOccupancy) { + const tray = s.trays.get(id); + const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1; + if (tray && ci >= 0) { + f.inboundBox.push(tray.consist[ci]!); + tray.consist[ci] = { type: 'coach', loaded: false }; + break; + } + } + f.usedThisStage.porters += 1; + break; + } + + case 'loadStarted': { + const f = facilityAt(s, e.player, e.at)!; + const idx = f.outboundBox.findIndex((c) => c.type === e.carType); + if (idx >= 0) f.outboundBox.splice(idx, 1); + f.menAtWork[0] = { type: e.carType, dir: 'out' }; + f.usedThisStage.laborers += 1; + break; + } + + case 'loadAdvanced': { + const f = facilityAt(s, e.player, e.at)!; + f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!; + f.menAtWork[e.fromBox] = null; + f.usedThisStage.laborers += 1; + break; + } + + case 'unloadCompleted': { + const f = facilityAt(s, e.player, e.at)!; + f.menAtWork[0] = null; + f.inboundBox.push({ type: e.carType, loaded: true }); + f.usedThisStage.laborers += 1; + break; + } + + case 'loadCompleted': { + const f = facilityAt(s, e.player, e.at)!; + f.menAtWork[f.menAtWork.length - 1] = null; + const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType); + if (ci >= 0) { + s.yards.classificationYard.push(f.industryTrack.cars[ci]!); + f.industryTrack.cars[ci] = { type: e.carType, loaded: true }; + } + f.usedThisStage.laborers += 1; + break; + } + + case 'unloadBegan': { + const f = facilityAt(s, e.player, e.at)!; + const ci = f.industryTrack.cars.findIndex((c) => c.loaded); + if (ci >= 0) f.industryTrack.cars[ci] = { type: e.carType, loaded: false }; + f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' }; + f.usedThisStage.laborers += 1; + break; + } + + case 'revenueChanged': { + const p = s.players[e.player]; + if (p) p.revenue = e.total; + break; + } + + case 'phaseEnded': + // The actor has finished; the phase driver moves on to the next player. + if (e.phase !== 'redFlag') s.turn.done = true; + break; + + case 'clearanceGiven': + s.clock.pendingDecision = null; + // Recorded for the asking train to consume; otherwise the driver asks again forever. + s.clock.clearanceRuling = { train: e.trainId, allow: e.allow }; + break; + + default: + break; + } +} + +/** + * Builds the card a play would place, at the chosen orientation (Gap 11). Returns null when the + * variant index is out of range, which `check` reports rather than silently defaulting — a wrong + * orientation is a different card, not a detail. + */ +function protoCard( + kind: { kind: string; geometry?: string; facility?: string }, + variant: number | undefined, +): TrackCard | null { + const base = { standing: [], facility: null, modifiers: [] }; + + if (kind.kind === 'track') { + const geometry = kind.geometry as 'straight' | 'turnout' | 'runAround'; + const options = variantsFor(geometry); + const v = options[variant ?? 0]; + if (!v) return null; + return { + geometry: { + kind: 'track', + geometry, + ...(v.axis ? { axis: v.axis } : {}), + ...(v.turnout ? { turnout: v.turnout } : {}), + ...(v.bypass ? { bypass: v.bypass } : {}), + }, + baseOperationalRail: geometry !== 'turnout', + ...base, + }; + } + + const options = facilityVariants(); + const v = options[variant ?? 0]; + if (!v) return null; + return { + geometry: { + kind: 'facility', + facility: kind.facility as never, + ...(v.axis ? { axis: v.axis } : {}), + }, + baseOperationalRail: true, + ...base, + }; +} + +/** + * Instantiates a Freight Facility from its catalogue profile (card-reference.md §2). + * + * This was missing: placed facility cards were built with `facility: null`, making them inert — + * they could never be stocked, worked, or scored from. Every freight card played was dead weight. + */ +function buildFreightFacility(kind: FreightKind): Facility { + const p = FREIGHT_PROFILES.find((f) => f.kind === kind); + if (!p) throw new Error(`unknown freight facility: ${kind}`); + return { + kind: 'freight', + subtype: kind, + allows: { + outbound: p.flow === 'outbound' || p.flow === 'both', + inbound: p.flow === 'inbound' || p.flow === 'both', + }, + outboundBox: [], + inboundBox: [], + capacity: { outbound: p.outboundCapacity, inbound: p.inboundCapacity }, + menAtWork: [null, null, null], + industryTrack: { length: p.industryTrackLength, cars: [] }, + laborers: p.laborers, + porters: 0, + usedThisStage: { laborers: 0, porters: 0 }, + }; +} + +/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */ +function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void { + if (placed.row !== area.runningRow) return; + if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 }; + if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 }; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult { + const code = check(s, player, i); + if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` }; + + const events = execute(s, player, i); + for (const e of events) reduce(s, e); + return { ok: true, events }; +} + +export { isOperationalRail, destinationsFor }; diff --git a/src/engine/content.ts b/src/engine/content.ts new file mode 100644 index 0000000..424e866 --- /dev/null +++ b/src/engine/content.ts @@ -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 }, + ]; +} diff --git a/src/engine/events.ts b/src/engine/events.ts new file mode 100644 index 0000000..41af70b --- /dev/null +++ b/src/engine/events.ts @@ -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']; diff --git a/src/engine/intents.ts b/src/engine/intents.ts new file mode 100644 index 0000000..161e936 --- /dev/null +++ b/src/engine/intents.ts @@ -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 }; diff --git a/src/engine/legal.ts b/src/engine/legal.ts new file mode 100644 index 0000000..d15bda2 --- /dev/null +++ b/src/engine/legal.ts @@ -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(); + 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; +} diff --git a/src/engine/rng.ts b/src/engine/rng.ts new file mode 100644 index 0000000..c08ae54 --- /dev/null +++ b/src/engine/rng.ts @@ -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(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: (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); +} diff --git a/src/engine/setup.ts b/src/engine/setup.ts new file mode 100644 index 0000000..28215d5 --- /dev/null +++ b/src/engine/setup.ts @@ -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(); + 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[0]): NonNullable { + 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(); + 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(); + for (const c of deck) cards.set(c.id, c); + + const shuffled = rng.shuffle(deck.map((c) => c.id)); + const hands = new Map(); + 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(); + 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 }; diff --git a/src/engine/state.ts b/src/engine/state.ts new file mode 100644 index 0000000..2ceb3ff --- /dev/null +++ b/src/engine/state.ts @@ -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; + 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; + redFlags: Map; +}; + +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; + trays: Map; + /** Trays not yet in play; §7 scarcity is an explicit mechanic. */ + freeTrays: TrayId[]; + cards: Map; + 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; + /** §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); +} diff --git a/src/engine/track.ts b/src/engine/track.ts new file mode 100644 index 0000000..7e76f62 --- /dev/null +++ b/src/engine/track.ts @@ -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(); + + 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; +} diff --git a/src/sim/bot.ts b/src/sim/bot.ts new file mode 100644 index 0000000..c2b1b04 --- /dev/null +++ b/src/sim/bot.ts @@ -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 => 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, + }; +} diff --git a/src/sim/harness.ts b/src/sim/harness.ts new file mode 100644 index 0000000..a75da75 --- /dev/null +++ b/src/sim/harness.ts @@ -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; + /** 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 = {}; + 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)); + } +} diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts new file mode 100644 index 0000000..ea2cad5 --- /dev/null +++ b/src/sim/narrate.ts @@ -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 }; diff --git a/src/sim/replay.ts b/src/sim/replay.ts new file mode 100644 index 0000000..cf4aa80 --- /dev/null +++ b/src/sim/replay.ts @@ -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 = { + 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 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(); + 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 ` + + +Station Master — replay seed ${rec.seed} + + +
+

Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}

+
+ + phase + actor · fedora + revenue 0 + hand 0 · deck 0 + frame 0/0 +
+
+ +
+
+

Division — west to east

+

Office Area

+

Facilities

+
+
+

What just happened

+

Blocked — why nothing is moving

    +

    Trains in play

    +

    History

    +
    +
    + +
    + Running Track is the lighter row. T4 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. +
    + +
    + + + + + + + + + +
    + +`; +} + +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}`); +} diff --git a/src/sim/stats.ts b/src/sim/stats.ts new file mode 100644 index 0000000..76a1e7a --- /dev/null +++ b/src/sim/stats.ts @@ -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; + intentCounts: Record; +}; + +function inc(map: Record, 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 = {}; + const intentCounts: Record = {}; + + 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 = {}; + const totalIntents: Record = {}; + 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 = {}; + 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'); +} diff --git a/test/advance.test.ts b/test/advance.test.ts new file mode 100644 index 0000000..37e6035 --- /dev/null +++ b/test/advance.test.ts @@ -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 => ({ + mode: 'solitaire', + victory: 'highestAfterDays', + length: 'short', + optionalRules: { + reducedVisibility: false, + sisterTrains: false, + employeeRotation: false, + emergencyToolbox: false, + }, + ...over, +}); + +const game = (seed = 1, over: Partial = {}): 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 = (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'); + }); +}); diff --git a/test/apply.test.ts b/test/apply.test.ts new file mode 100644 index 0000000..8f40537 --- /dev/null +++ b/test/apply.test.ts @@ -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 => 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); + }); +}); diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..d9562da --- /dev/null +++ b/test/setup.test.ts @@ -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(); + 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(); + 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'] }), + ); + }); +}); diff --git a/test/sim.test.ts b/test/sim.test.ts new file mode 100644 index 0000000..2b948c8 --- /dev/null +++ b/test/sim.test.ts @@ -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(); + 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'); + }); +}); diff --git a/test/track.test.ts b/test/track.test.ts new file mode 100644 index 0000000..004f321 --- /dev/null +++ b/test/track.test.ts @@ -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, officeCoord: GridCoord): OfficeArea { + const grid = new Map(); + 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 { + 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))); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe80123 --- /dev/null +++ b/tsconfig.json @@ -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"] +}