diff --git a/.gitignore b/.gitignore index 4028312..f02f33f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ __pycache__/ # Logs *.log + +# Generated replays +replay*.html diff --git a/docs/Deck cards2.xlsx b/docs/Deck cards2.xlsx new file mode 100644 index 0000000..aff5624 Binary files /dev/null and b/docs/Deck cards2.xlsx differ diff --git a/docs/Mainline Cards.pdf b/docs/Mainline Cards.pdf new file mode 100644 index 0000000..79e97ea Binary files /dev/null and b/docs/Mainline Cards.pdf differ diff --git a/docs/Trains3.pdf b/docs/Trains3.pdf new file mode 100644 index 0000000..4a23937 Binary files /dev/null and b/docs/Trains3.pdf differ diff --git a/docs/architecture/components.md b/docs/architecture/components.md index 664d369..c1ef952 100644 --- a/docs/architecture/components.md +++ b/docs/architecture/components.md @@ -222,11 +222,15 @@ is what makes the whole system testable without a server. **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). +- **Where:** Browser · **When:** after a game ends — **pulled forward, built** +- **Does:** read-only projection over the event log, with playback controls. A Node script + precomputes one frame per visible event and writes a self-contained HTML file, so the browser + never runs the engine and there is no bundling or build step. +- **MVP: —** · **Final: S** · 0 → ~250 LOC · *built: 560 LOC (replay) + 330 (narration)* +- **Size driver:** the narration layer, not the rendering. Turning 30 event types into readable + sentences and deriving the "what is currently blocked" panel is most of it. +- **Why it came early:** the open balance questions stopped being measurable and became judgment + calls. It also exercises much of component 14, which is MVP-critical either way. ### D. Dev-side diff --git a/docs/design.md b/docs/design.md index 2d7798c..8edf257 100644 --- a/docs/design.md +++ b/docs/design.md @@ -8,9 +8,18 @@ web browser and an authoritative server manages lobbies and game state. - `StationMasterPrototypeRules.pdf` — the original prototype rules - `StationMaster-PrototypeTurnChart.pdf` — the 12-Stage turn chart +- `Deck cards2.xlsx` — the complete card list (115-card deck, 104 track pieces, 12 start cards) +- `Mainline Cards.pdf` — the ten Mainline card types with their speeds and entry points +- `Trains3.pdf` — all 22 train cards with names, speed class, consist and operating rules +- `tracks.png` — card art for track and industry cards These stay as-is. Everything below is derived from them. +> **The last four arrived after the engine was built** and expand the game roughly threefold. See +> [`rules/implications.md`](rules/implications.md): much of what is recorded below as decided was a +> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a +> ruleset that does not match the design. + ## Rules | Document | What it is | @@ -20,6 +29,7 @@ These stay as-is. Everything below is derived from them. | [`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. | +| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. | ## Architecture @@ -74,6 +84,11 @@ milestones worth knowing: 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. +> **The recovered design files are now transcribed and the nine open questions answered** — see +> [`rules/implications.md`](rules/implications.md) §1b for exactly what is implemented. The deck is +> 115 cards (93 in solitaire), Mainline cards have terrain and crossing times, and the balance +> numbers below predate all of it. + **Steps 1–6 are done** — the rules engine (components 1–7), heuristic bots (17) and the balance harness (18), with 134 tests passing. @@ -103,6 +118,11 @@ Next: step 7 begins the server (components 8, 9, 20). Run the harness with `node src/sim/harness.ts [games] [length]`. +**Watch a game:** `node src/sim/replay.ts --seed 1234 [--length standard] [--out replay.html]` +writes a self-contained HTML file — open it in any browser and step through the game. It shows the +Division, the Office Area grid, every facility's boxes and `MEN|AT|WORK` track, plain-English +narration of each event, and a **Blocked** panel explaining why nothing is moving. + 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 index 690c62e..2ec00c8 100644 --- a/docs/rules/card-reference.md +++ b/docs/rules/card-reference.md @@ -1,5 +1,12 @@ # Station Master — Card Reference +> **⚠ SUPERSEDED.** This described an invented 52-card placeholder. The real catalogue arrived on +> 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed +> in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison. +> +> Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to +> ask the design. **Do not use its numbers.** + 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. diff --git a/docs/rules/implications.md b/docs/rules/implications.md new file mode 100644 index 0000000..fc08736 --- /dev/null +++ b/docs/rules/implications.md @@ -0,0 +1,490 @@ +# Implications of the four recovered design files + +Four files were added to `docs/` on 2026-07-30 that were part of the original design but never made +it into this workspace: + +| File | What it is | +| --- | --- | +| `Deck cards2.xlsx` | The **complete card list** — every card, its count, where it may be placed, what it does | +| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Yard, and both Division Points | +| `Trains3.pdf` | The **train cards** — all 22, with names, speed class, consist and individual operating rules | +| `tracks.png` | **Card art** for track and industry cards | + +This document records what they imply and proposes what to do. **§1a below records what has since been +implemented**; everything else is still a proposal. + +--- + +## 1. The headline: the game is roughly 3× what we built + +| | Built | Recovered design | Change | +| --- | ---: | ---: | --- | +| Home Office deck | 52 | **115** | 2.2× | +| Track cards | in the deck (12) | **104**, a per-player supply of 26 | moved out of the deck entirely | +| Start cards | ad-hoc | **12** (4 Whistle Posts, 8 Limits) | formalised | +| **Total components** | ~52 | **231** | | +| Train cards | 16 | **22** | 12 timetabled + 10 Extras | +| Distinct card categories | 6 | **10** | four categories are entirely new | + +The sheet's own totals (115 / 104 / 12 / 231) reconcile exactly with the line items, so these +numbers are the design's, not an inference. + +**Most of my invented numbers are now superseded, and that is a good outcome.** Everything in +`card-reference.md` was explicitly provisional — placeholders standing in for exactly this document. + +--- + +## 1b. Implementation status after the answers (2026-07-31) + +All nine answers are implemented, **170 tests passing**: + +| Answer | Implemented as | +| --- | --- | +| Q1 crossing time | `crossingStages()` — a 60 card takes 1 Stage, a 30 takes 2. Mainline nodes now carry a **terrain type** dealt at setup, and trains count down Stages instead of stepping through regions. The `Region` model is gone. | +| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). | +| Q3 Expedite | An expedited train departs the Stage it arrives — it gets a second `moveTrain` in the same Mainline Phase, still subject to §8.1 clearance. | +| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. | +| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. | +| Q6 Space-use | **Solitaire decks omit all 22 opponent-directed cards**: `buildDeck('solitaire')` returns **93**, competitive returns 115. | +| Q7 Downward-only | Office cards have a stub **below only**; turnout and curve legs always diverge south; placement and modifier candidates above the Running Track are rejected. §9's "nine nearby spots" is really **six**. | +| Q8 Crew Tray cap | Already correct. | +| Q9 Second Section | `newTrain.secondSection` plays on a train due out; an identical train is made up behind it next New Train Phase. The **Sister trains optional rule is removed** from Appendix B. | + +**Still not implemented**, and now the largest remaining block: the behaviour of the Enhancement +(18), Mainline-modifier (7) and Maneuver (7) cards, plus the Action (10) and Space-use (12) cards in +multiplayer. All are in the deck; playing them is rejected with `NOT_IMPLEMENTED`. + +### Measurement after the answers + +100 solitaire Standard games: mean revenue **−1.3**, trains scheduled **2.8**, cards played **11.3**. + +Still no wins, and still **not a verdict** — roughly a third of the deck does nothing yet, and the +Small Yard enhancement (which is what makes the switching puzzle solvable) is among the unimplemented +cards. Gap 12 stays invalidated until the enhancements are in. + +--- + +## 1a. What has been done so far (2026-07-31) + +**Transcribed and in code**, with 169 tests passing: + +- The full **115-card deck** — `deckComposition()` reconciles exactly with the sheet's own total. +- **Track removed from the deck** and modelled as a per-player supply of **26** pieces + (`TRACK_SUPPLY`), with printed handedness and a **move cost** field (2 for sharp curves). +- **Curves** added to the track graph; the invented run-around card removed. +- All **22 trains** with names, Fast/Slow speed class, category-based consists and per-card + operating rules. +- **Industries retuned to 1-out / 1-loader**, with the real six kinds and their lockout lists. +- All **23 modifiers**, each tied to its specific hosts, with their effects applied on placement. +- **Office slots corrected** to equal porter count; deck copies corrected to Depot 4 / Station 2 / + Terminal 1. +- The **ten Mainline card types** with speeds and named entry points, as data. +- The four new categories (space-use, enhancements, mainline modifiers, maneuver, action) are in + the deck. **Playing them is rejected with `NOT_IMPLEMENTED`** rather than silently doing nothing — + the failure mode that left Modifiers as dead weight for weeks. + +**Not done, pending the questions in §10**: the speed model, Expedite, lockout enforcement, +space-use semantics, and everything the four new categories actually do. + +### First measurement on the real catalogue + +100 solitaire Standard games, same bot: mean revenue **−1.9**, trains scheduled **2.3** (was 4.8), +cards played **10.5** (was 16.3). + +Development has slowed sharply, and the reason is visible in the composition: **industries are now +9 cards in 115 (7.8%), where the placeholder had 10 in 52 (19%)**. Their density more than halved, +and trains fell from 16-in-52 to 22-in-115. The deck is much more diluted, which is exactly what the +enhancements, maneuver and action cards are there to fill — and none of those do anything yet. + +**Read no balance conclusions from this number.** It measures a half-implemented ruleset. + +--- + +## 2. Deck composition + +### What the design says — 115 cards + +| Category | Cards | Notes | +| --- | ---: | --- | +| Offices (Depot 4, Station 2, Terminal 1) | 7 | *We had 9 (4/3/2)* | +| Passenger modifiers (Waiting area 3, Restaurant 2, Hotel 1) | 6 | each adds 1 passenger-out + 1 porter | +| Industries (Freight House 2, Mine Tipple 2, Refinery 1, Power Plant 2, Packing Sheds 1, Grocer's Warehouse 1) | 9 | *We had 10 in a different mix* | +| Industry modifiers | 17 | each tied to a **specific** industry | +| Trains (12 timetabled + 10 Extras) | 22 | *We had 16* | +| **Space-use cards** | 12 | **entirely new** — cards that burn table space | +| **Enhancements** | 18 | **entirely new** | +| **Mainline modifiers** | 7 | **entirely new** | +| **Maneuver cards** | 7 | Red Flags ×5, Flying Switch, Poling | +| **Action cards ("screw you")** | 10 | **entirely new** — played *at other players* | +| **Total** | **115** | | + +### Proposal + +- Replace `content.ts` wholesale with this catalogue. It is data, so this is transcription rather + than design. +- **Gap 4a (52-card deck) is superseded.** Record it as such rather than deleting the reasoning — + the deck was a stand-in and it did its job. +- **Gap 13 (deck scaling by player count) largely dissolves.** Track is now per-player by + construction, which was the real pressure I measured. The 115-card deck is shared. Re-measure + before concluding anything further. + +--- + +## 3. Track — the biggest structural change + +### What the design says + +Track is **not in the deck**. Each player gets a fixed personal supply of **26 pieces**: + +| Piece | Per player | Total (4p) | Notes | +| --- | ---: | ---: | --- | +| Straight | 8 | 32 | | +| Curved left | 4 | 16 | **we have no curves at all** | +| Curved right | 4 | 16 | | +| Sharp curved left | 1 | 4 | **any move across is doubled — two Moves** | +| Sharp curved right | 1 | 4 | | +| Turnout left | 4 | 16 | | +| Turnout right | 4 | 16 | | + +Column J ("Train can stop here") is set for straights and curves, blank for turnouts — which matches +what we built: a turnout is not Operational Rail. + +### What this changes + +1. **Curves exist and we have none.** Our geometry is straight / turnout / run-around. The design has + straight / curved / sharp-curved / turnout, each in left and right hands. Run-around is not a card + at all — it is presumably *built* from a turnout pair. +2. **Handedness is printed, not chosen.** Left and right are distinct physical cards. + **Gap 11's decision — "orientation is chosen on placement" — is therefore superseded.** The + measurement behind it stands (a fixed default made vertical growth impossible), but the design's + answer is different and better: give the player a supply containing both hands. +3. **Sharp curves cost two Moves.** A per-card movement cost, which our Move model does not have. +4. **Track no longer competes for deck slots**, so the draw is entirely about industry, trains and + enhancements. This materially changes the economy I measured. + +### Proposal + +- Rework `track.ts` around a per-player supply with printed handedness; drop the variant machinery. +- Add a **move cost** per card (1 normally, 2 for sharp curves) and charge it against the six Moves. +- Confirm with the designer whether run-around is a built pattern rather than a card — see §10. + +--- + +## 4. Industries and modifiers — much smaller, much more specific + +### What the design says + +Each industry is **one car out and one loader**, not the 2–3 capacity and 2–3 Laborers I invented: + +| Industry | In deck | Car | Direction | Base capacity | +| --- | ---: | --- | --- | --- | +| Freight House | 2 | Box | BOTH | 1 boxcar out, 1 loader; boxcars in | +| Mine Tipple | 2 | Hopper | OUT | 1 hopper out, 1 loader | +| Refinery | 1 | Tank | OUT | 1 tank out, 1 loader | +| Power Plant | 2 | Hopper, Tank | IN | 1 loader | +| Packing Sheds | 1 | Reefer | OUT | 1 reefer out, 1 loader | +| Grocer's Warehouse | 1 | Box, Reefer | IN | — | + +Capacity is then grown by **industry-specific modifiers**, each adding "+1 out, +1 loader": + +- Mine Tipple → Prep Plant, Coal Piles, Conveyor Belts +- Refinery → Pipelines, Oil Depot, Viscosity breakers +- Power Plant → Transmission lines, Rotary Dumps, Steam Turbines +- Packing Sheds / Grocer's → Ice House, Local small groceries +- Freight House / Packing Sheds / Grocer's → Truck dock, Forklifts, Railroad Express Agency + +**Column E is a lockout list** — a concept we do not have at all. Freight House locks out Grocer's +Warehouse; Mine Tipple locks out Power Plant; Refinery locks out Power Plant; Packing Sheds locks out +Grocer's Warehouse. Presumably a player may not build both of a locked-out pair. + +### Passenger side — our guesses were right + +| Office | Design | What we built | +| --- | --- | --- | +| Depot | 1 psgr out, 1 in, 1 porter, **2 A/D** | 1 porter, 2 green, 2 red, 2 A/D | +| Station | 2 / 2 / 2 porters, **3 A/D** | 2 porters, 3 green, 3 red, 3 A/D | +| Terminal | 3 / 3 / 3 porters, **4 A/D** | 3 porters, 4 green, 4 red, 4 A/D | +| Whistle Post | 1 A/D, not in deck, 4 copies | 1 A/D ✓ | + +Porters and A/D tracks match exactly. **Our slot capacities are one too generous at every tier** — +the design gives slots equal to porters, not one more. Passenger modifiers (Waiting area, Restaurant, +Hotel) are what grow them. + +### Proposal + +- Retune every industry to **1 out / 1 loader** and rebuild capacity growth around the 17 modifiers. + This makes Gap 10a's "moderate, varied by industry" profile obsolete. +- Correct passenger slots to equal porter count. +- Implement **lockouts** — a new placement constraint. +- **This changes the economy substantially**: a base industry is far weaker than what we modelled, so + Gap 12's balance measurements must be re-run from scratch rather than adjusted. + +--- + +## 5. Trains — names, speed classes, and per-train rules + +### Timetabled (1–12) + +| # | Name | Speed | Consist | Rule | +| --- | --- | --- | --- | --- | +| 1/2 | Crack Limited | Fast | 3 coaches | Stop at Terminals only. No switching. Expedite. | +| 3/4 | Express | Fast | 2 freight | May drop or pick up one freight car at every location. Expedite. | +| 5/6 | The Sparrow | Fast | 2 coaches | No switching. Expedite. | +| 7/8 | Local | Slow | 1 freight + 1 coach | Coach must remain on station track if switching. | +| 9/10 | Heavy Freight | Slow | 3 freight + caboose | | +| 11/12 | Drag Freight | Slow | 2 freight + caboose | | + +### Extras (X13–X22) — ten distinct trains, not four generic ones + +Appleseed Extra (MT freight only, may drop but not pick up), Fruit Growers Express (reefers only), +Yard Xfer, Light Engine Move (no cars at all), Campaign Train (one turn at station for speeches), +Circus Train (**one turn stopped anywhere earns 1 point**), Military Train, Director's Private Car, +Freight Extra, Pee-Dee (per-diem, may only pick up empties). Plus a **Second Section** card that +duplicates the next scheduled train's seniority, rules and consist. + +### What this changes + +1. **Consists are by CATEGORY** — "Freight (2)" means any two freight cars, not two hoppers. Our + `allowedTypes` model is too narrow. +2. **Fast / Slow is a train property** that pairs with the Mainline cards' 60/30 speeds (§6). We have + no speed model at all. +3. **"Expedite"** is a keyword appearing on seven trains, meaning is not yet defined for us. +4. **Per-train operating rules** — "no switching", "stop at Terminals only", "may drop or pick up one + freight car at every location". We have none of these. +5. **Extras are X13–X22**, all junior to every timetabled train. **Gap 5 (Extra vs Timetabled ties) + becomes unreachable** — the tie it resolves cannot occur. The decision is harmless but moot. +6. **The Circus Train earns a point for standing still** — the first revenue source that is not a + load/unload operation. Our scoring model assumes only §9 operations and collisions. +7. **Second Section** makes the "Sister trains" optional rule concrete. + +### Proposal + +- Rewrite the train catalogue from `Trains3.pdf`. +- Add `speed: fast | slow` and a `rules` field per train; implement the operating rules as checks in + `apply.ts`. +- Define "Expedite" with the designer before implementing it — see §10. + +--- + +## 6. Mainline cards — a whole system we reduced to a constant + +We modelled a Mainline card as **2 regions, uniform**. The design has **ten distinct card types**: + +| Card | Speed | Notes | +| --- | --- | --- | +| Plains | 60 | | +| Curves | 30 | | +| Hilly | **P60 / F30** | different speeds for passenger and freight | +| Heavy Grade (Up) | **G** | player sets orientation; Brakeman/Airbrakes/Helpers unlock better start positions | +| Double Track | 60 | **trains may pass** | +| Uncontrolled Siding | 60 | trains may pass; separate "no pass" and "passing" starts | +| Tunnel | 30 | | +| Trestle | 60 | | +| Yard | 60 | **sort cars into any new order**; has a Yard Limit | +| East / West Division Point | — | the ends | + +Each card shows **Start positions** — where a train enters depending on direction, train type, and +which modifier cards are in play. The Heavy Grade card has five distinct starts (plain, brakemen, +airbrakes, plain, helpers), so playing Brakeman literally moves your entry point further along. + +`Realignment` can convert one Mainline card type into another (Plains→Double Track, Curves→Plains, +Uncontrolled Siding→Double Track, Trestle→Uncontrolled Siding). + +### What this changes + +**This is the single largest mechanical gap.** Our Mainline Phase moves every train one region per +Stage, unconditionally. The design has speed classes, per-train-type speeds, passing, grades that +modifier cards ameliorate, and yards that re-sort consists. + +It also bears directly on **Gap 12**. I measured "a train stands at the Office only 18% of Stages" +and treated it as a possible balance problem. With Double Track, passing sidings, and 60-speed +cards, traffic density is a *designed variable* — the measurement was taken against a placeholder. + +### Proposal + +- Model Mainline cards as typed, with a speed and a set of named entry points. +- Implement Fast/Slow × card-speed to derive how far a train moves per Stage. +- **Re-run every Gap 12 measurement afterwards.** The current numbers describe a game that does not + exist. + +--- + +## 7. Four entirely new card categories + +### Space-use cards (12) + +Bean House, Flop House, Watertower, Hobo Jungle, Section House, City Blocks ×4, Engine Shops, +Tenderloin District, Engineer Cemetery. All "burn tablespace" — they occupy a grid cell and do +nothing for you. Presumably played at opponents, or a cost of drawing. + +### Enhancements (18) + +- **Interlocking** (+ Facing Point Locks): may stop an inbound train on the Limit track. +- **Yard Office**: an inbound train with no coaches may arrive at the yard instead of the Train Order + Office. +- **Small Yard**: a train spending one Move in the yard **may sort itself into any order, including + cars ahead of the engine**. +- **Water Column**: lets you remove a Watertower. +- **Overpass**: removes restrictions on a Railroad Crossing. +- **Telegraph / Telephone / Radio**: once a day, when dispatching facing trains, add **+4 / +8 / +12** + to the other train's number. A genuine dispatching-technology progression. +- **ABS Signals**: trains on this Mainline card will not rear-end each other; they stop short. + +**Small Yard deserves special note.** It solves precisely the problem my bot cannot handle: §A.3 +forces cars off in seated order, so spotting a specific car needs the consist re-ordered. A Small +Yard makes that possible. The switching puzzle was *designed* to have this answer, and we did not +have the card. + +**ABS Signals directly touches Gap 2.** We made collisions unconditional; this card makes them +avoidable on a given card. + +### Mainline modifiers (7) + +Brakeman, Airbrakes (requires Brakeman), Helpers, Realignment ×2, Facing Point Locks ×2. + +### Action cards — played at other players (10) + +Derail, Broken Coupler, Railroad Crossing, Per Diem Inventory, Demurrage Charge, Customer +Complaints, Vandalism, Hotbox, Outlawed. + +**We have no player-interaction mechanic whatsoever.** Several of these cause *point losses* based on +board state — "lose one point per 2 empty cars on Secondary Tracks" — which is a scoring channel our +model does not have. In a Competitive game these are presumably a large part of the tension. + +### Proposal + +Treat these as four separate work packages, in this order: **Enhancements** (they change core +movement and switching), **Mainline modifiers**, **Action cards** (needed for multiplayer to mean +anything), **Space-use** (lowest mechanical weight). + +--- + +## 8. What this does to the decided gaps + +| Gap | Status now | Why | +| --- | --- | --- | +| 1 Phase order | **stands** | nothing in the new files contradicts it | +| 2 Collisions automatic | **amended** | ABS Signals makes them avoidable on a card | +| 3 Office upgrades | **stands**, counts confirmed | A/D and porters match exactly | +| 4a Deck composition | **superseded** | 52 → 115, track removed from deck | +| 4b Piece counts | **needs re-derivation** | Mainline speeds change train turnaround entirely | +| 4c Consist criteria | **superseded** | consists are by category, with per-train rules | +| 5 Extra/Timetabled ties | **moot** | Extras are X13–X22; the tie cannot occur | +| 6 Victory conditions | **needs re-derivation** | new revenue sources and point losses | +| 7 Terminology | **stands** | | +| 8 Office junction stubs | **needs checking against the art** | `tracks.png` shows the through-line at the card's top edge, not the middle | +| 9 Make-up round repeats | **stands** | | +| 10 Card faces | **superseded** | this is the real card list | +| 11 Track orientation | **superseded** | handedness is printed; both hands are supplied | +| 12 Balance targets | **invalidated, re-measure** | measured against a placeholder ruleset | +| 13 Deck scaling | **largely dissolves** | track is per-player by construction | + +Superseded decisions should be **marked, not deleted** — the reasoning that produced them is how we +know what questions the design answers. + +--- + +## 9. Code impact + +| Component | Impact | Notes | +| --- | --- | --- | +| 1 Card catalogue | **Rewrite** | pure data; largest single edit but lowest risk | +| 2 State model | **Major** | new card kinds, per-player track supply, typed Mainline cards, train speed/rules | +| 3 Track graph | **Major** | curves, printed handedness, per-card move cost | +| 4 apply | **Major** | per-train operating rules, enhancements, action cards played at opponents | +| 5 Phase driver | **Major** | speed model, passing, grades, yard sorting | +| 6 legalActions | Moderate | follows from 4; the discipline of delegating to `check` protects it | +| 7 RNG | None | | +| 17 Bot | **Rewrite** | Small Yard changes the switching problem fundamentally | +| 18 Harness | Minor | re-run everything | +| 16 Replay | Moderate | new card types and mechanics to narrate | +| 19 Tests | **Substantial** | most rules assertions need revisiting | + +**The engine's architecture holds.** Nothing here breaks the `check`/`execute`/`reduce` split, the +event log, or the pure-engine boundary. This is a large content and rules expansion inside a +structure that was built to take it — which is the main thing worth saying about it. + +--- + +## 9a. Answers from the designer + +Recorded as they are given, so nothing is lost between sessions. + +| # | Question | Answer | +| --- | --- | --- | +| 1 | What do Mainline 60 / 30 mean? | **Miles per hour, expressed as crossing time.** A 60 card takes **one Stage** to cross; a 30 card takes **two**. The printed cells are decoration. Realignment (Curves→Plains) therefore has real value, and 30-cards are genuine chokepoints. | +| 2 | How does Fast/Slow affect crossing? | **Slow adds one Stage to every card.** Fast 60→1, 30→2; Slow 60→2, 30→3. The label therefore matters on every card. Consequence to watch: Slow trains occupy a Crew Tray roughly twice as long, so tray scarcity (§7) bites considerably harder than the placeholder measured. | +| 3 | What does "Expedite" mean? | **The train departs the same Stage it arrives** — it does not spend a Stage standing at the Office. Still subject to the normal §8.1 clearance rules, so it can be held. Fits X17's "one turn at station *then* expedite", and partly offsets the Slow-train transit cost below. | +| 4 | What does a lockout prevent? | **Building both in your district.** If you hold a Mine Tipple, a Power Plant card is unplayable in your Office Area, and vice versa. Each locked pair is a producer and the consumer of the same commodity, so this forces traffic to flow *between* districts — a deliberate multiplayer-interdependence mechanic. In solitaire it simply narrows the build options. | +| 5 | Is a run-around a card? | **No — a pattern built from the track supply.** Opposite-hand turnouts joined by straights. Nothing to implement: reachability is computed from geometry, so a valid bypass already works. This explains the 8 turnouts per player, and makes §A.5's facing-point move a building achievement rather than a lucky draw. | +| 6 | How are Space-use cards played? | **At another player's district**, consuming a grid cell they wanted. This explains Water Column ("remove any Watertower in *your* district" — one an opponent gave you) and Vandalism (exploits a Hobo Jungle you were saddled with). A second, milder attack channel alongside the Action cards. **In solitaire they have no target** — see the note below. | +| 7 | Does a district grow both ways? | **Downward only** — the through-track sits at the card's top edge and everything diverges beneath it, matching `tracks.png`. A district is a Running Track with all Secondary Track hanging below. **Supersedes Gap 8's north-and-south stubs.** Consequences: buildable area halves; districts become long and shallow; §9's nine-spot Modifier neighbourhood needs rewording, since three of the nine spots are above the track and no longer exist. | +| 8 | Any cap on trains in play? | **No — Crew Trays are the only limit.** All 12 timetabled trains may end up scheduled; Extras run on top ad hoc; a due train with no free crew is held (§7). Already implemented. This makes the Crew Tray count (`players + 3`) the single most important balance dial, and "held" a common visible state. | +| 9 | What is the Second Section card? | **A card played on a train that is due out.** A second identical train runs immediately behind it — same number, rules and consist — and needs its own Crew Tray. It deliberately creates the following-train situation §8.1 makes the Superintendent rule on, so a player can force the game's central decision onto the table. Explains the green flags on the artwork. **Supersedes the "Sister trains" optional rule**, which should be removed from Appendix B. | + +### Solitaire consequence of Q6 + +If Space-use cards are played at opponents, then in the **solitaire MVP they have no legal target** — +12 of 115 cards (10.4%) become dead draws. Combined with the 10 Action cards, which are also +opponent-directed, **22 of 115 cards (19%) do nothing in a one-player game.** + +That is a real finding for the MVP, and there are only a few honest ways to handle it: remove them +from a solitaire deck, allow them to be discarded freely, or accept that solitaire draws are a fifth +dead. Worth deciding explicitly rather than by default. Not blocking — flagged for after the +remaining questions. + +### Consequence noted at the time of answering + +Under Q1 + Q2, on a 3-player Division (4 Mainline cards, mixed terrain): + +| | Mainline Stages | + Office stops | End-to-end | +| --- | ---: | ---: | ---: | +| Fast train | 6 | 3 | **9 Stages** | +| Slow train | 10 | 3 | **13 Stages** | + +A Day is 12 Stages, so **every Slow train is still on the road when the next Day begins**, holding +its Crew Tray. With `players + 3` trays this will throttle freight hard. Expedite (Q3) relieves it +somewhat for the trains that carry it — but none of the Slow freight trains do. Worth re-measuring +once the speed model is implemented; the Crew Tray count is the obvious dial. + +--- + +## 10. Questions only the designer can answer + +1. **"Expedite"** — appears on seven trains and is never defined. Priority? No stopping? Extra speed? +2. **Speed numbers.** Do 60 and 30 mean "cells per Stage", "Stages to cross", or literal mph against + a 2-hour Stage? A Mainline card is "roughly a hundred miles" (§2.1), so 60 mph × 2 h ≈ one card + per Stage and 30 ≈ two — but this should be confirmed, not inferred. +3. **Fast vs Slow trains** — how do they interact with a card's speed? Does a Slow train take double + on every card, or only where the card distinguishes (Hilly's P60/F30)? +4. **Lockouts** (spreadsheet column E) — does one card prevent *building* the other, or prevent both + *operating*? +5. **Run-around** — we treat it as a card; it is not in the supply. Is it a pattern built from two + turnouts? +6. **Space-use cards** — played at yourself as a cost of drawing, or at opponents? +7. **Where the Office's junction stubs are.** `tracks.png` draws the through-line along the top edge + with curves diverging downward. Gap 8 assumed stubs above *and* below a centred track. +8. **Are all 22 trains in play at once**, or is the timetable still 12 slots with Extras run on top? +9. **Second Section** — is it a card played on a scheduled train, or its own train? + +--- + +## 11. Recommended sequencing + +The temptation is to rebuild everything at once. I would not. + +1. **Transcribe the catalogue first** (`content.ts` from the spreadsheet and `Trains3.pdf`). Pure + data, no rules risk, and it makes the true scope concrete. +2. **Answer §10's questions**, particularly speed and Expedite — several later decisions hang on + them. +3. **Mainline card types and the speed model.** The largest mechanical gap and the one that + invalidates the current balance numbers. +4. **Track supply and curves**, including the sharp-curve double Move cost. +5. **Industry retune to 1-out/1-loader plus the modifier system**, then re-run the harness. Expect + very different numbers. +6. **Enhancements**, starting with Small Yard and ABS Signals — both change core loops. +7. **Action cards**, which is when multiplayer becomes a real game rather than parallel solitaire. +8. **Space-use cards** last. + +**Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder +ruleset. Re-measure after step 5 and treat everything before that as void. diff --git a/docs/rules/open-questions.md b/docs/rules/open-questions.md index 0b1f316..0ef4a5c 100644 --- a/docs/rules/open-questions.md +++ b/docs/rules/open-questions.md @@ -7,21 +7,27 @@ 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. +> **⚠ 2026-07-31 — four design files were recovered** (`Deck cards2.xlsx`, `Mainline Cards.pdf`, +> `Trains3.pdf`, `tracks.png`) that answer several of these questions directly. Where the design +> contradicts a decision below, **the design wins** and the entry is marked SUPERSEDED. The reasoning +> is kept, not deleted: it is how we know which questions the design answers. +> See [`implications.md`](implications.md). + | # | Gap | Status | | - | --- | ------ | | 1 | Phase resolution order | **DECIDED** | -| 2 | Collision resolution (and the Whistle Post highball condition) | **DECIDED** | +| 2 | Collision resolution (and the Whistle Post highball condition) | **DECIDED**, amended by ABS Signals | | 3 | Office upgrades | **DECIDED** | -| 4 | Component manifest and deck composition | **DECIDED** | -| 5 | Extra-train seniority ties | **DECIDED** | +| 4 | Component manifest and deck composition | **SUPERSEDED** — deck is 115, track is per-player | +| 5 | Extra-train seniority ties | **MOOT** — Extras are X13-X22, the tie cannot occur | | 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 | +| 10 | Card face specifications | **SUPERSEDED** — the real card list exists | +| 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied | +| 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset | +| 13 | Deck scaling by player count | **LARGELY DISSOLVED** — track is per-player by design | --- diff --git a/docs/rules/rules-v0.2.md b/docs/rules/rules-v0.2.md index 229a1eb..a9db69f 100644 --- a/docs/rules/rules-v0.2.md +++ b/docs/rules/rules-v0.2.md @@ -1,5 +1,11 @@ # Station Master — Rules v0.2 +> **⚠ Superseded in part.** Four design files recovered on 2026-07-30 (`Deck cards2.xlsx`, +> `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) replace much of §12 and parts of §7, §8 and +> §11. Where they conflict, **the design wins**. Read +> [`implications.md`](implications.md) alongside this document — it records what changed, the nine +> answers that settled the open questions, and what is implemented. + > **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. @@ -783,9 +789,9 @@ with a run-around (bypass) track in your Secondary Trackage: - **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. +- ~~**"Sister" trains.**~~ **Removed.** The design's **Second Section** card does this properly: it + is played on a train that is due out, and an identical train runs immediately behind it. See + [`implications.md`](implications.md) Q9. - **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 diff --git a/docs/tracks.png b/docs/tracks.png new file mode 100644 index 0000000..d00e2f7 Binary files /dev/null and b/docs/tracks.png differ diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 4100ba8..0b7e67a 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -18,13 +18,14 @@ import { COLLISION_PENALTY, - EXTRA_TRAINS, + MAINLINE_PROFILES, + consistSize, + crossingStages, + trainProfile, MOVES_PER_LOCAL_OPS, MOVES_PER_LOCAL_OPS_NIGHT, - REGIONS_PER_MAINLINE_CARD, STAGES_PER_DAY, STAGES_PER_SHIFT, - TIMETABLED_TRAINS, collectiveRevenueFloor, lengthProfile, officeProfile, @@ -33,7 +34,7 @@ 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 type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts'; import { coordKey, freshTurn, totalRevenue } from './state.ts'; export type AdvanceResult = { @@ -50,10 +51,7 @@ function movesForStage(s: GameState): number { : 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 @@ -173,7 +171,8 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult { 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. + // "Held train" — no crew available. It waits; the Stage moves on (§7). + events.push({ type: 'trainHeld', trainNumber: due, reason: 'no free Crew Tray' }); return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false }; } const profile = trainProfile(due, false); @@ -195,10 +194,78 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult { 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` }); + events.push({ + type: 'trainMadeUp', + trainNumber: due, + isExtra: false, + at: side === 'west' ? 'West Division Point' : 'East Division Point', + direction, + }); } } + // Q9 — a second section runs immediately behind its first, with the same number, rules and + // consist. It needs its own Crew Tray, and because it follows a train of the same number into + // the same Subdivision it will usually force the §8.1 clearance decision. + if (s.pendingSecondSections.length > 0 && s.freeTrays.length > 0) { + const number = s.pendingSecondSections.shift()!; + const profile = trainProfile(number, false); + if (profile) { + const trayId = s.freeTrays.pop()!; + const direction: Direction = profile.direction === 'east' ? 'east' : 'west'; + const side: Direction = direction === 'east' ? 'west' : 'east'; + s.trays.set(trayId, { + id: trayId, + trainNumber: number, + trainIsExtra: false, + engineFront: true, + consist: [], + direction, + position: { at: 'divisionPoint', side }, + movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side); + if (dp?.kind === 'divisionPoint') dp.holding.push(trayId); + events.push({ + type: 'trainMadeUp', + trainNumber: number, + isExtra: false, + at: side === 'west' ? 'West Division Point' : 'East Division Point', + direction, + }); + } + } + + // §7 — "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 for immediate departure." An Extra runs once and is gone. + // + // SIMPLIFICATION, flagged: the rules let the player choose the direction. This launches eastbound + // from the West Division Point. Giving the player the choice needs its own intent. + if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) { + const number = s.pendingExtras.shift()!; + const trayId = s.freeTrays.pop()!; + s.trays.set(trayId, { + id: trayId, + trainNumber: number, + trainIsExtra: true, + engineFront: true, + consist: [], + direction: 'east', + position: { at: 'divisionPoint', side: 'west' }, + movesUsed: 0, + }); + const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west'); + if (dp?.kind === 'divisionPoint') dp.holding.push(trayId); + events.push({ + type: 'trainMadeUp', + trainNumber: number, + isExtra: true, + at: 'West Division Point', + direction: 'east', + }); + } + // Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains. const filling = trainNeedingCars(s); if (filling) { @@ -223,10 +290,13 @@ function trainNeedingCars(s: GameState): TrayId | null { 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); + const want = consistSize(profile.consist); if (tray.consist.length >= want) continue; + // Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in + // the yard is potentially suitable unless the card narrows it. + const allowed = profile.consist.freightTypes; const suitable = s.yards.divisionYard.some( - (c) => profile.consist.allowedTypes.includes(c.type) || c.type === 'caboose', + (c) => c.type === 'caboose' || c.type === 'coach' || !allowed || allowed.includes(c.type), ); if (suitable) return id; } @@ -255,6 +325,11 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { if (s.movedThisPhase.has(id)) continue; const moved = moveTrain(s, id, tray, events); if (moved === 'needsClearance') return { events, needsInput: true }; + // An expedited train may act twice in one Stage: it arrives and departs (Q3). + if (moved === 'expedited') { + const again = moveTrain(s, id, tray, events); + if (again === 'needsClearance') return { events, needsInput: true }; + } s.movedThisPhase.add(id); } @@ -262,7 +337,27 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false }; } -type MoveOutcome = 'moved' | 'held' | 'needsClearance'; +type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited'; + +/** Puts a train onto a Mainline card with its crossing time already computed. */ +function enterMainline( + s: GameState, + node: Extract, + id: TrayId, + tray: CrewTray, + index: number, +): void { + const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); + const carriesPassengers = tray.consist.some((c) => c.type === 'coach'); + const stages = crossingStages(node.card, profile?.speed ?? 'slow', carriesPassengers); + node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction }); + tray.position = { at: 'mainline', index }; +} + +/** Q3 — an expedited train does not spend a Stage standing at the Office. */ +function isExpedited(tray: CrewTray): boolean { + return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true; +} function moveTrain( s: GameState, @@ -284,9 +379,7 @@ function moveTrain( 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 }; + enterMainline(s, node, id, tray, target); 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` }); @@ -317,11 +410,14 @@ function moveTrain( 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 }; + enterMainline(s, node, id, tray, target); area.adOccupancy = area.adOccupancy.filter((t) => t !== id); - events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` }); + events.push({ + type: 'trainHighballed', + trainNumber: tray.trainNumber ?? 0, + from: 'the Office', + to: 'the Mainline', + }); return 'moved'; } @@ -335,23 +431,24 @@ function moveTrain( } if (tray.position.at === 'mainline') { - const { index, region } = tray.position; + const { index } = 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 }; + const transit = node.transits.find((t) => t.tray === id); + if (!transit) return 'held'; + + // Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's + // Fast/Slow class. Count it down rather than stepping through printed cells. + if (transit.stagesRemaining > 1) { + transit.stagesRemaining -= 1; 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; + node.transits = node.transits.filter((t) => t.tray !== id); if (!dest) return 'held'; @@ -396,8 +493,12 @@ function evaluateClearance( const node = s.division.nodes[targetIndex]; if (!node || node.kind !== 'mainline') return 'clear'; - for (const region of node.regions) { - const other = region.occupant; + // Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block. + const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card); + if (profile?.trainsMayPass) return 'clear'; + + for (const t of node.transits) { + const other = t.tray; if (!other || other === id) continue; const otherTray = s.trays.get(other); if (!otherTray) continue; @@ -442,7 +543,16 @@ function arriveAtOffice( area.adOccupancy.push(id); tray.position = { at: 'grid', owner, coord: area.officeCoord }; - events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} arrived` }); + events.push({ + type: 'trainArrived', + trainNumber: tray.trainNumber ?? 0, + consist: tray.consist.map((c) => ({ ...c })), + office: officeProfile(area.tier).name, + }); + + // Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to + // movedThisPhase and gets a second chance to move before the phase ends. + if (isExpedited(tray)) return 'expedited'; return 'moved'; } @@ -491,7 +601,11 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent (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` }); + events.push({ + type: 'trainCompleted', + trainNumber: tray.trainNumber ?? 0, + consist: tray.consist.map((c) => ({ ...c })), + }); } // --------------------------------------------------------------------------- diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 8447f66..bd3f426 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -19,10 +19,12 @@ import { HAND_LIMIT, LABORER_ACTIONS_PER_LOAD, MAX_CONSIST, + industryProfile, + modifierProfile, nextOfficeTier, officeProfile, } from './content.ts'; -import type { CarType, FreightKind } from './content.ts'; +import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts'; import type { GameEvent } from './events.ts'; import type { Intent, RejectionCode } from './intents.ts'; import type { @@ -140,7 +142,7 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea */ 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; + return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[0] ?? null; } export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean { @@ -363,6 +365,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo return null; } + case 'newTrain.secondSection': { + if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; + // Only on a train that is actually due out this Stage — a second section follows a first. + if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY'; + if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY'; + return null; + } + case 'newTrain.passCar': { if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; const tray = s.trays.get(i.trayId); @@ -445,17 +455,39 @@ function checkPlay( return next === card.kind.tier ? null : 'NOT_UPGRADEABLE'; } case 'track': + 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 'freightFacility': { if (!placement) return 'NO_PLACEMENT'; + // Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and + // the consumer of the same commodity, so this forces traffic to flow between districts. + if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED'; 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; + if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED'; + // §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby + // spots) or it does nothing at all, so placing it anywhere else is not a legal play. + return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED'; } + case 'spaceUse': + case 'enhancement': + case 'mainlineModifier': + case 'maneuver': + case 'action': + // Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and + // §10. Rejecting is honest: silently accepting would make the card look playable while doing + // nothing, which is exactly the bug that made Modifiers dead weight for weeks. + return 'NOT_IMPLEMENTED'; + default: // Train cards go to the timetable; the phase driver schedules them. return null; @@ -534,8 +566,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { }, ]; - case 'draw.fromDepartment': - return [ + case 'draw.fromDepartment': { + const events: GameEvent[] = [ { type: 'cardDrawn', player, @@ -544,6 +576,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { cardId: s.decks.departments[i.slot]!, }, ]; + // §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in + // the empty spot." Without this the three face-up slots empty out permanently and the market + // silently disappears from the game. + const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1]; + if (refill) { + events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill }); + } + return events; + } case 'card.play': { const card = s.cards.get(i.cardId)!; @@ -566,6 +607,11 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { to: card.kind.tier, }); } + if (card.kind.kind === 'extraTrain') { + // §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used + // to do nothing at all, which made all four Extra cards dead weight in the deck. + events.push({ type: 'extraQueued', player, trainNumber: card.kind.number }); + } 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. @@ -621,6 +667,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { case 'newTrain.passCar': return [{ type: 'carPassed', player, trayId: i.trayId }]; + case 'newTrain.secondSection': + return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }]; + case 'mainline.clearance': return [ { @@ -748,6 +797,11 @@ export function reduce(s: GameState, e: GameEvent): void { break; } + case 'departmentRefilled': + s.decks.homeOffice.pop(); + s.decks.departments[e.slot] = e.cardId; + break; + case 'cardDrawn': { const hand = s.decks.hands.get(e.player) ?? []; if (e.source === 'homeOffice') s.decks.homeOffice.pop(); @@ -771,7 +825,16 @@ export function reduce(s: GameState, e: GameEvent): void { extendLimitsIfNeeded(area, e.placement); } } else if (e.placement && card.kind.kind === 'modifier') { - s.decks.salvageYard.push(e.cardId); + // A Modifier stays on the board beside its Facility, and its effect is applied. It used to + // be discarded straight to the Salvage Yard, so playing one did literally nothing. + area.grid.set(coordKey(e.placement), { + geometry: { kind: 'modifier', modifier: card.kind.modifier }, + baseOperationalRail: false, + standing: [], + facility: null, + modifiers: [], + }); + applyModifier(area, e.placement, card.kind.modifier); } else if (card.kind.kind !== 'office') { s.decks.salvageYard.push(e.cardId); } @@ -786,7 +849,7 @@ export function reduce(s: GameState, e: GameEvent): void { if (officeCard?.facility) { const p = officeProfile(e.to); officeCard.facility.porters = p.porters; - officeCard.facility.capacity = { outbound: p.greenSlots, inbound: p.redSlots }; + officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn }; officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility }; } break; @@ -834,6 +897,14 @@ export function reduce(s: GameState, e: GameEvent): void { break; } + case 'extraQueued': + s.pendingExtras.push(e.trainNumber); + break; + + case 'secondSectionOrdered': + s.pendingSecondSections.push(e.trainNumber); + break; + case 'trainScheduled': s.timetable[e.slot] = e.trainNumber; s.rngState = e.rngState; @@ -964,7 +1035,7 @@ function protoCard( const base = { standing: [], facility: null, modifiers: [] }; if (kind.kind === 'track') { - const geometry = kind.geometry as 'straight' | 'turnout' | 'runAround'; + const geometry = kind.geometry as TrackGeometry; const options = variantsFor(geometry); const v = options[variant ?? 0]; if (!v) return null; @@ -1013,15 +1084,68 @@ function buildFreightFacility(kind: FreightKind): Facility { }, outboundBox: [], inboundBox: [], - capacity: { outbound: p.outboundCapacity, inbound: p.inboundCapacity }, + // The design gives every industry ONE car out and ONE loader; capacity is grown by the + // industry-specific modifier cards, not printed large on the industry itself. + capacity: { outbound: p.baseOut, inbound: p.baseIn }, menAtWork: [null, null, null], - industryTrack: { length: p.industryTrackLength, cars: [] }, - laborers: p.laborers, + industryTrack: { length: Math.max(1, p.baseOut + p.baseIn), cars: [] }, + laborers: p.baseLoaders, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }; } +/** + * The Facility a Modifier at `coord` would serve — any of the nine nearby spots (§9). + * + * SIMPLIFICATION, deliberate and flagged: §9 says that when a Modifier touches two Facilities its + * effect may be used on only one per Stage. This applies the effect permanently to the first + * Facility found instead. Modelling the per-Stage choice needs an extra decision point in the + * Load/Unload phase and is not worth it until the mechanic has been played. + */ +function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null { + // Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six. + if (coord.row > area.runningRow) return null; + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + if (dr === 0 && dc === 0) continue; + const c = { row: coord.row + dr, col: coord.col + dc }; + if (c.row > area.runningRow) continue; + if (area.grid.get(coordKey(c))?.facility) return c; + } + } + return null; +} + +/** + * Q4 — would building `kind` here conflict with something already in the district? + * The relation is symmetric, so checking either direction is enough. + */ +export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean { + const wanted = industryProfile(kind); + for (const card of area.grid.values()) { + if (card.geometry.kind !== 'facility') continue; + const present = card.geometry.facility; + if (wanted.lockouts.includes(present)) return true; + if (industryProfile(present).lockouts.includes(kind)) return true; + } + return false; +} + +/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */ +function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void { + const target = adjacentFacilityCoord(area, coord); + if (!target) return; + const f = area.grid.get(coordKey(target))?.facility; + if (!f) return; + const m = modifierProfile(modifier); + f.capacity.outbound += m.addOut; + f.capacity.inbound += m.addIn; + f.laborers += m.addLoaders; + f.porters += m.addPorters; + if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn; +} + /** §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; diff --git a/src/engine/content.ts b/src/engine/content.ts index 424e866..1ab7488 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -1,132 +1,89 @@ /** * 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. + * TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30): + * docs/Deck cards2.xlsx — the complete card list and counts + * docs/Trains3.pdf — all 22 train cards + * docs/Mainline Cards.pdf — the ten Mainline card types + * docs/tracks.png — card art * - * 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. + * This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed + * and which decisions it supersedes. + * + * This is DATA, not logic. Keep tuning confined here so it never requires touching the engine. + * + * BEHAVIOUR NOT YET IMPLEMENTED, pending answers from the designer (implications.md §10): the + * meaning of Mainline speeds and Fast/Slow, the "Expedite" keyword, lockout semantics, and how + * space-use cards are played. The DATA for all of these is transcribed below and marked. */ // --------------------------------------------------------------------------- // 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. */ +/** §9 — an industry loads only, unloads only, or does both. */ export type FlowDirection = 'outbound' | 'inbound' | 'both'; export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal'; +/** The six industries in the design. Note: no "oilRefinery" — it is `refinery`. */ export type FreightKind = + | 'freightHouse' | 'mineTipple' - | 'produceShed' - | 'grocersWarehouse' - | 'oilRefinery' - | 'powerPlant'; + | 'refinery' + | 'powerPlant' + | 'packingSheds' + | 'grocersWarehouse'; -export type ModifierKind = - | 'teamTrack' - | 'loadingDock' - | 'storageShed' - | 'extraPlatform' - | 'sectionGang'; - -export type TrackGeometry = 'turnout' | 'straight' | 'runAround'; +export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout'; +export type Hand = 'left' | 'right' | 'none'; // --------------------------------------------------------------------------- -// Freight facilities — card-reference.md §2 +// Track — a PER-PLAYER supply, not deck cards // --------------------------------------------------------------------------- -export type FreightProfile = { - kind: FreightKind; +export type TrackProfile = { + geometry: TrackGeometry; + hand: Hand; 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; + perPlayer: number; + /** Column J of the sheet, "Train can stop here". Turnouts are blank. */ + isOperationalRail: boolean; + /** Sharp curves: "Any move across this track is doubled (two moves)". */ + moveCost: 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, - }, +/** + * 26 pieces per player, 104 for four players. Track is NOT in the Home Office deck — this is the + * single biggest structural change from the placeholder, and it means the draw is entirely about + * industry, trains and enhancements. + * + * Handedness is PRINTED, not chosen on placement. That supersedes Gap 11. + */ +export const TRACK_SUPPLY: readonly TrackProfile[] = [ + { geometry: 'straight', hand: 'none', name: 'Straight track', perPlayer: 8, isOperationalRail: true, moveCost: 1 }, + { geometry: 'curved', hand: 'left', name: 'Curved track (left)', perPlayer: 4, isOperationalRail: true, moveCost: 1 }, + { geometry: 'curved', hand: 'right', name: 'Curved track (right)', perPlayer: 4, isOperationalRail: true, moveCost: 1 }, + { geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', perPlayer: 1, isOperationalRail: true, moveCost: 2 }, + { geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', perPlayer: 1, isOperationalRail: true, moveCost: 2 }, + { geometry: 'turnout', hand: 'left', name: 'Turnout (left)', perPlayer: 4, isOperationalRail: false, moveCost: 1 }, + { geometry: 'turnout', hand: 'right', name: 'Turnout (right)', perPlayer: 4, isOperationalRail: false, moveCost: 1 }, ]; -/** - * "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'; -} +export const TRACK_PER_PLAYER = TRACK_SUPPLY.reduce((n, t) => n + t.perPlayer, 0); // 26 + +/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */ +export const WHISTLE_POST_SUPPLY = 4; +export const LIMITS_SUPPLY = 8; + +/** Legacy alias kept so existing call sites resolve; prefer TRACK_SUPPLY. */ +export const TRACK_PROFILES = TRACK_SUPPLY; // --------------------------------------------------------------------------- -// Offices — card-reference.md §4 +// Offices — Depot 4, Station 2, Terminal 1 // --------------------------------------------------------------------------- export type OfficeProfile = { @@ -136,65 +93,25 @@ export type OfficeProfile = { isPassengerFacility: boolean; adTracks: number; porters: number; - greenSlots: number; - redSlots: number; - /** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */ + /** Green slots. The design gives slots EQUAL to porters, not one more. */ + passengerOut: number; + /** Red slots. */ + passengerIn: number; 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. + * A/D tracks and Porter counts are exactly as designed. The slot counts are corrected downward: + * an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the + * passenger modifier cards (Waiting Area, Restaurant, Hotel). */ 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, - }, + { tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 }, + { tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 }, + { tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 }, + { tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 }, ]; -/** Upgrade order is strict — no skipping (Gap 3b). */ export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal']; export function officeProfile(tier: OfficeTier): OfficeProfile { @@ -203,163 +120,371 @@ export function officeProfile(tier: OfficeTier): OfficeProfile { 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 +// Industries — one car out, one loader. Capacity grows via modifiers. // --------------------------------------------------------------------------- +export type IndustryProfile = { + kind: FreightKind; + name: string; + carTypes: readonly CarType[]; + flow: FlowDirection; + /** Base green-box capacity. Every industry starts at ONE. */ + baseOut: number; + /** Base red-box capacity. */ + baseIn: number; + /** "Loaders" in the design; the rules PDF calls them Laborers. One to start. */ + baseLoaders: number; + /** Column E — industries that may not coexist with this one. Semantics pending (§10 Q4). */ + lockouts: readonly FreightKind[]; + copies: number; +}; + +export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [ + { kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 }, + { kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 }, + { kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 }, + { kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 }, + { kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 }, + { kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 }, +]; + +/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */ +export const FREIGHT_PROFILES = INDUSTRY_PROFILES; + +/** §9.3 — the collective term for an industry that both loads and unloads. */ +export function isFreightHouse(p: IndustryProfile): boolean { + return p.flow === 'both'; +} + +export function industryProfile(kind: FreightKind): IndustryProfile { + const f = INDUSTRY_PROFILES.find((p) => p.kind === kind); + if (!f) throw new Error(`unknown industry: ${kind}`); + return f; +} + +// --------------------------------------------------------------------------- +// Modifiers — 17 industry + 6 passenger, each tied to specific hosts +// --------------------------------------------------------------------------- + +export type ModifierKind = + | 'waitingArea' | 'restaurant' | 'hotel' + | 'truckDock' | 'railroadExpressAgency' | 'forklifts' + | 'prepPlant' | 'coalPiles' | 'conveyorBelts' + | 'pipelines' | 'oilDepot' | 'viscosityBreakers' + | 'transmissionLines' | 'rotaryDumps' | 'steamTurbines' + | 'iceHouse' | 'localSmallGroceries'; + export type ModifierProfile = { kind: ModifierKind; name: string; - effect: string; - appliesTo: 'freight' | 'passenger' | 'either'; + /** Which hosts it may sit beside. 'office' means any Passenger Facility. */ + hosts: readonly (FreightKind | 'office')[]; + addOut: number; + addIn: number; + addLoaders: number; + addPorters: number; + copies: number; }; 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', - }, + // Passenger — "Add 1 Psgn out, 1 porter" + { kind: 'waitingArea', name: 'Waiting area', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 3 }, + { kind: 'restaurant', name: 'Restaurant', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 2 }, + { kind: 'hotel', name: 'Hotel', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 1 }, + // Freight House / Packing Sheds / Grocer's + { kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 }, + { kind: 'railroadExpressAgency', name: 'Railroad Express Agency', hosts: ['freightHouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'forklifts', name: 'Forklifts', hosts: ['freightHouse', 'packingSheds'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 }, + // Mine Tipple + { kind: 'prepPlant', name: 'Prep Plant', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'coalPiles', name: 'Coal Piles', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'conveyorBelts', name: 'Conveyor Belts', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + // Refinery + { kind: 'pipelines', name: 'Pipelines', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'oilDepot', name: 'Oil Depot', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'viscosityBreakers', name: 'Viscosity breakers', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + // Power Plant — inbound, so these add loaders rather than outbound capacity + { kind: 'transmissionLines', name: 'Transmission lines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'rotaryDumps', name: 'Rotary Dumps', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + { kind: 'steamTurbines', name: 'Steam Turbines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, + // Produce + { kind: 'iceHouse', name: 'Ice House', hosts: ['packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 }, + { kind: 'localSmallGroceries', name: 'Local small groceries', hosts: ['grocersWarehouse'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 }, ]; +export function modifierProfile(kind: ModifierKind): ModifierProfile { + const m = MODIFIER_PROFILES.find((p) => p.kind === kind); + if (!m) throw new Error(`unknown modifier: ${kind}`); + return m; +} + // --------------------------------------------------------------------------- -// Trains — card-reference.md §3 +// Trains — 22 cards, named, with speed class and individual rules // --------------------------------------------------------------------------- +export type TrainSpeed = 'fast' | 'slow'; + +/** + * Consists are specified by CATEGORY, not by car type: "Freight (2)" means any two freight cars. + * `freightTypes` narrows it where a card does (X14 is reefers only). + */ export type ConsistSpec = { - /** Revenue cars, excluding the caboose. */ - count: number; - allowedTypes: readonly CarType[]; - requiresCaboose: boolean; + freight: number; + coach: number; + caboose: number; + freightTypes?: readonly CarType[]; + /** X13 Appleseed: empties only. */ + emptiesOnly?: boolean; +}; + +/** Operating rules printed on the card. Behaviour pending §10 Q1 for `expedite`. */ +export type TrainRules = { + noSwitching?: boolean; + terminalsOnly?: boolean; + expedite?: boolean; + /** Train 7/8: "Coach must remain on station track if switching." */ + coachStaysOnStationTrack?: boolean; + /** Train 3/4: may drop or pick up one freight car at every location. */ + oneFreightPerLocation?: boolean; + noPassengerWork?: boolean; + /** X13: may drop empties but not pick anything up. X22: may only pick up empties. */ + dropOnly?: boolean; + pickUpEmptiesOnly?: boolean; + /** X17 Campaign, X18 Circus: a scheduled stop that does something. */ + stopEarnsPoint?: boolean; + stopThenExpedite?: boolean; + /** The Second Section card copies the next scheduled train. */ + copiesNextScheduled?: boolean; + note?: string; }; 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). */ + name: string; + speed: TrainSpeed; direction: Direction | 'playerChoice'; consist: ConsistSpec; + rules: TrainRules; }; -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[] { +/** Odd numbers run westbound, even eastbound (§2.3). Pairs share a class. */ +function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec, rules: TrainRules): TrainProfile[] { return [ - { number: odd, isExtra: false, className, direction: 'west', consist }, - { number: odd + 1, isExtra: false, className, direction: 'east', consist }, + { number: odd, isExtra: false, name, speed, direction: 'west', consist, rules }, + { number: odd + 1, isExtra: false, name, speed, direction: 'east', consist, rules }, ]; } 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, - }), + ...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, caboose: 0 }, + { terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }), + ...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 }, + { oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }), + ...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 2, caboose: 0 }, + { noSwitching: true, expedite: true }), + ...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 }, + { coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }), + ...pair(9, 'Heavy Freight', 'slow', { freight: 3, coach: 0, caboose: 1 }, {}), + ...pair(11, 'Drag Freight', 'slow', { freight: 2, coach: 0, caboose: 1 }, {}), ]; -/** 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 }, -})); +/** + * Extras are X13–X22 — ALL junior to every timetabled train, so Gap 5's tie between an Extra and a + * Timetabled train of the same number can no longer occur. + */ +export const EXTRA_TRAINS: readonly TrainProfile[] = [ + { number: 13, isExtra: true, name: 'Appleseed Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1, emptiesOnly: true }, rules: { dropOnly: true, note: 'May drop MTs but not pick up anything.' } }, + { number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } }, + { number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} }, + { number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } }, + { number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } }, + { number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } }, + { number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } }, + { number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } }, + { number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} }, + { number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } }, +]; + +export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]; + +export function trainProfile(number: number, isExtra: boolean): TrainProfile | null { + return ALL_TRAINS.find((t) => t.number === number && t.isExtra === isExtra) ?? null; +} + +/** Total cars a consist calls for. The Crew Tray limit still applies (§A.4). */ +export function consistSize(c: ConsistSpec): number { + return c.freight + c.coach + c.caboose; +} // --------------------------------------------------------------------------- -// Track — card-reference.md §6 +// Mainline cards — ten types, with speeds and named entry points // --------------------------------------------------------------------------- -export type TrackProfile = { - geometry: TrackGeometry; +export type MainlineKind = + | 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack' + | 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard'; + +/** + * Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade + * prints "G" with the player setting orientation. + * + * WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so + * the answer can be applied without re-reading the cards. + */ +export type MainlineSpeed = + | { kind: 'uniform'; value: number } + | { kind: 'byTrainType'; passenger: number; freight: number } + | { kind: 'grade' }; + +export type MainlineProfile = { + kind: MainlineKind; name: string; - /** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */ - isOperationalRail: boolean; - copies: number; + speed: MainlineSpeed; + /** Double Track and Uncontrolled Siding: "Trains may pass". */ + trainsMayPass: boolean; + /** Yard: "Sort cars in new order". */ + sortsCars: boolean; + /** Named entry points printed on the card; some are unlocked by modifier cards. */ + entryPoints: readonly string[]; }; -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 }, +export const MAINLINE_PROFILES: readonly MainlineProfile[] = [ + { kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] }, + { kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] }, + { kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] }, + { kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] }, + { kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] }, + { kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] }, +]; + +/** + * How many Stages a train needs to cross a Mainline card. + * + * Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage, + * a 30 card takes two. The cells drawn on the cards are decoration. + * Q2 — a Slow train adds one Stage to every card. + * + * Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any + * coach is "passenger" for this purpose. + */ +export function crossingStages( + kind: MainlineKind, + trainSpeed: TrainSpeed, + carriesPassengers: boolean, +): number { + const profile = MAINLINE_PROFILES.find((m) => m.kind === kind); + if (!profile) throw new Error(`unknown mainline card: ${kind}`); + + let mph: number; + switch (profile.speed.kind) { + case 'uniform': + mph = profile.speed.value; + break; + case 'byTrainType': + mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight; + break; + case 'grade': + // Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what + // improve it. Treated as a 30 until those are implemented. + mph = 30; + break; + } + + const base = mph >= 60 ? 1 : 2; + return base + (trainSpeed === 'slow' ? 1 : 0); +} + +/** `Realignment` converts one Mainline card into another. */ +export const REALIGNMENTS: readonly { from: MainlineKind; to: MainlineKind }[] = [ + { from: 'plains', to: 'doubleTrack' }, + { from: 'curves', to: 'plains' }, + { from: 'uncontrolledSiding', to: 'doubleTrack' }, + { from: 'trestle', to: 'uncontrolledSiding' }, ]; // --------------------------------------------------------------------------- -// Rolling stock supply — card-reference.md §8 +// The four new card categories +// --------------------------------------------------------------------------- + +export type SimpleCard = { key: string; name: string; copies: number; placement: string; effect: string }; + +/** "Burns tablespace" — occupies a grid cell and does nothing useful. Semantics pending §10 Q6. */ +export const SPACE_USE_CARDS: readonly SimpleCard[] = [ + { key: 'beanHouse', name: 'Bean house', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, + { key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, + { key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' }, + { key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' }, + { key: 'sectionHouse', name: 'Section House', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, + { key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, + { key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' }, + { key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, + { key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' }, +]; + +export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [ + { key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' }, + { key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' }, + { key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' }, + { key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' }, + { key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.' }, + { key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.' }, + { key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' }, + { key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' }, + { key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' }, + { key: 'absSignals', name: 'ABS Signals', copies: 2, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' }, +]; + +export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [ + { key: 'brakeman', name: 'Brakeman', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill.' }, + { key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' }, + { key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' }, + { key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' }, + { key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.' }, +]; + +/** + * Q9 — the Second Section card. Played on a train that is DUE OUT; a second identical train runs + * immediately behind it, needing its own Crew Tray. It deliberately creates the following-train + * situation §8.1 makes the Superintendent rule on. Supersedes the "Sister trains" optional rule. + */ +export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 }; + +export const MANEUVER_CARDS: readonly SimpleCard[] = [ + { key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' }, + { key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' }, + { key: 'poling', name: 'Poling', copies: 1, placement: 'any time', effect: 'TBD in the source.' }, +]; + +/** Played AT other players. We have no player-interaction mechanic yet. */ +export const ACTION_CARDS: readonly SimpleCard[] = [ + { key: 'derail', name: 'Derail', copies: 2, placement: 'a moving train in the Local Phase', effect: 'That train must stop for the remainder of the turn.' }, + { key: 'brokenCoupler', name: 'Broken coupler', copies: 1, placement: 'a moving train in the Mainline Phase', effect: 'That train must stop and not move.' }, + { key: 'railroadCrossing', name: 'Railroad crossing', copies: 1, placement: 'any Secondary Track Straight', effect: 'May not be used as a stop point for switching. May not become an Industry.' }, + { key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' }, + { key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' }, + { key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' }, + { key: 'vandalism', name: 'Vandalism', copies: 1, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' }, + { key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' }, + { key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' }, +]; + +// --------------------------------------------------------------------------- +// Rolling stock, scale, and victory // --------------------------------------------------------------------------- export type StockSupply = { type: CarType; loaded: number; empty: number }; +/** NOT in the recovered files — still the provisional figure. */ export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [ { type: 'coach', loaded: 8, empty: 8 }, { type: 'boxcar', loaded: 6, empty: 6 }, @@ -369,54 +494,36 @@ export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [ { type: 'caboose', loaded: 6, empty: 0 }, ]; -export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce( - (n, s) => n + s.loaded + s.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). */ +/** Provisional; not in the recovered files. */ 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 REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2 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; +export const COLLISION_PENALTY = 5; +export const COLLISION_FLOOR_PER_DAY = 3; -// --------------------------------------------------------------------------- -// Victory — rules §3.2, revised by Gap 10e -// --------------------------------------------------------------------------- +export function collectiveRevenueFloor(players: number, days: number): number { + return 3 * players * days; +} export type GameLength = 'short' | 'standard' | 'campaign'; - export type LengthProfile = { length: GameLength; target: number; days: number }; +/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */ export const LENGTH_PROFILES: readonly LengthProfile[] = [ { length: 'short', target: 10, days: 3 }, { length: 'standard', target: 20, days: 5 }, @@ -429,37 +536,38 @@ export function lengthProfile(length: GameLength): LengthProfile { 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 +// Deck composition — 115 cards // --------------------------------------------------------------------------- -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. + * Cards that can only be played AT another player (Q6). In a solitaire game they have no legal + * target, so they are removed from the deck rather than sitting in hand as 19% dead draws. */ +export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action']; + +export function isOpponentOnly(category: string): boolean { + return OPPONENT_ONLY_CATEGORIES.includes(category); +} + 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); + const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.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 }, + { category: 'office', count: OFFICE_PROFILES.reduce((n, o) => n + o.copiesInDeck, 0) }, + { category: 'industry', count: sum(INDUSTRY_PROFILES) }, + { category: 'modifier', count: sum(MODIFIER_PROFILES) }, + { category: 'train', count: ALL_TRAINS.length }, + { category: 'spaceUse', count: sum(SPACE_USE_CARDS) }, + { category: 'enhancement', count: sum(ENHANCEMENT_CARDS) }, + { category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) }, + { category: 'maneuver', count: sum(MANEUVER_CARDS) }, + { category: 'action', count: sum(ACTION_CARDS) }, ]; } + +export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115 + +/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */ +export const SOLITAIRE_DECK_SIZE = deckComposition() + .filter((c) => !isOpponentOnly(c.category)) + .reduce((n, c) => n + c.count, 0); diff --git a/src/engine/events.ts b/src/engine/events.ts index 41af70b..f63c2d8 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -41,6 +41,18 @@ export type GameEvent = * the advanced RNG so that folding events reproduces the draw exactly. */ | { type: 'trainScheduled'; player: PlayerIndex; trainNumber: number; roll: number; slot: number; rngState: number } + /** + * Train lifecycle. These were originally squeezed into `phaseBegan` with a free-text label, + * which made the replay say "New Train" and nothing else. A train being made up, departing, + * arriving or finishing its run are four distinct facts and deserve four event types. + */ + | { type: 'extraQueued'; player: PlayerIndex; trainNumber: number } + | { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number } + | { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string } + | { type: 'trainHeld'; trainNumber: number; reason: string } + | { type: 'trainHighballed'; trainNumber: number; from: string; to: string } + | { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string } + | { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] } | { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock } | { type: 'carPassed'; player: PlayerIndex; trayId: TrayId } | { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId } diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 161e936..39e7b77 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -33,6 +33,8 @@ export type Intent = // -- New Train Phase (§7) | { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean } | { type: 'newTrain.passCar'; trayId: TrayId } + /** Q9 — run a second, identical section behind a train that is due out this Stage. */ + | { type: 'newTrain.secondSection'; trainNumber: number } // -- Mainline Phase (§8.1) — the Superintendent's clearance ruling | { type: 'mainline.clearance'; allow: boolean } | { type: 'redFlag.play' } @@ -80,6 +82,7 @@ export type RejectionCode = | 'NO_PENDING_DECISION' | 'NOT_UPGRADEABLE' | 'FACILITY_LOCKED' + | 'NOT_IMPLEMENTED' | 'NO_TRAIN_AT_OFFICE'; export type Rejection = { code: RejectionCode; message: string }; diff --git a/src/engine/legal.ts b/src/engine/legal.ts index d15bda2..c57f1e7 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -133,6 +133,10 @@ function newTrainCandidates(s: GameState): Intent[] { } out.push({ type: 'newTrain.passCar', trayId }); } + const due = s.timetable[s.clock.stage - 1]; + if (due !== null && due !== undefined) { + out.push({ type: 'newTrain.secondSection', trainNumber: due }); + } return out; } @@ -183,6 +187,8 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] { { row: row!, col: col! - 1 }, ]; for (const c of around) { + // Q7 — a district hangs BELOW the Running Track; there is nothing above it. + if (c.row > area.runningRow) continue; const k = `${c.row},${c.col}`; if (area.grid.has(k) || seen.has(k)) continue; seen.add(k); diff --git a/src/engine/setup.ts b/src/engine/setup.ts index 28215d5..569989c 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -6,20 +6,25 @@ */ import { + ACTION_CARDS, + ENHANCEMENT_CARDS, EXTRA_TRAINS, FREIGHT_PROFILES, + MAINLINE_MODIFIER_CARDS, + MANEUVER_CARDS, + SPACE_USE_CARDS, MOVES_PER_LOCAL_OPS, MODIFIER_PROFILES, OFFICE_PROFILES, - REGIONS_PER_MAINLINE_CARD, + MAINLINE_PROFILES, ROLLING_STOCK_SUPPLY, STAGES_PER_DAY, TIMETABLED_TRAINS, - TRACK_PROFILES, crewTrayCount, mainlineCardCount, officeProfile, } from './content.ts'; +import type { Rng } from './rng.ts'; import { createRng } from './rng.ts'; import type { Card, @@ -29,7 +34,6 @@ import type { GameState, OfficeArea, PlayerIndex, - Region, RollingStock, TrackCard, TrayId, @@ -44,7 +48,10 @@ export type SetupOptions = { }; /** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */ -export function buildDeck(): Card[] { +export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] { + // Q6 — the 22 opponent-directed cards have no legal target in a one-player game, so a solitaire + // deck omits them entirely rather than leaving 19% of draws dead. + const solitaire = mode === 'solitaire'; const cards: Card[] = []; let n = 0; const push = (kind: Card['kind']): void => { @@ -59,10 +66,29 @@ export function buildDeck(): Card[] { 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 }); + for (const m of MODIFIER_PROFILES) { + for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind }); } + if (!solitaire) { + for (const c of SPACE_USE_CARDS) { + for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key }); + } + } + for (const c of ENHANCEMENT_CARDS) { + for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key }); + } + for (const c of MAINLINE_MODIFIER_CARDS) { + for (let i = 0; i < c.copies; i++) push({ kind: 'mainlineModifier', key: c.key }); + } + for (const c of MANEUVER_CARDS) { + for (let i = 0; i < c.copies; i++) push({ kind: 'maneuver', key: c.key }); + } + if (!solitaire) { + for (const c of ACTION_CARDS) { + for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key }); + } + } + // Track is NOT in the deck any more — it is a per-player supply (content.ts TRACK_SUPPLY). return cards; } @@ -127,7 +153,7 @@ function buildPassengerFacility(tier: Parameters[0]): NonN allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility }, outboundBox: [], inboundBox: [], - capacity: { outbound: p.greenSlots, inbound: p.redSlots }, + capacity: { outbound: p.passengerOut, inbound: p.passengerIn }, menAtWork: [null, null, null], industryTrack: { length: 0, cars: [] }, laborers: 0, @@ -136,14 +162,21 @@ function buildPassengerFacility(tier: Parameters[0]): NonN }; } -/** §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. */ -function buildDivision(players: number): DivisionNode[] { +/** + * §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. + * + * Each Mainline card is now a TYPE drawn at random (§4.2, "a Mainline card is drawn at random and + * placed between each player"), which is what gives the Division its terrain and therefore its + * crossing times. + */ +function buildDivision(players: number, rng: Rng): 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 }; - }; + const kinds = MAINLINE_PROFILES.map((m) => m.kind); + const mainline = (): DivisionNode => ({ + kind: 'mainline', + card: kinds[rng.nextInt(kinds.length)]!, + transits: [], + }); nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] }); for (let p = 0; p < players; p++) { @@ -179,7 +212,7 @@ export function createGame(opts: SetupOptions): GameState { 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 deck = buildDeck(config.mode); const cards = new Map(); for (const c of deck) cards.set(c.id, c); @@ -215,7 +248,7 @@ export function createGame(opts: SetupOptions): GameState { seed, rngState: rng.getState(), players, - division: { nodes: buildDivision(playerCount) }, + division: { nodes: buildDivision(playerCount, rng) }, officeAreas, trays: new Map(), freeTrays, @@ -223,6 +256,8 @@ export function createGame(opts: SetupOptions): GameState { decks: { homeOffice, departments, salvageYard: [], hands, redFlags }, yards: { divisionYard: buildRollingStock(), classificationYard: [] }, timetable, + pendingExtras: [], + pendingSecondSections: [], clock: { day: 1, stage: 1, diff --git a/src/engine/state.ts b/src/engine/state.ts index 2ceb3ff..6980a15 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -9,6 +9,7 @@ import type { CarType, Direction, + MainlineKind, FreightKind, GameLength, ModifierKind, @@ -65,11 +66,14 @@ export type CardGeometry = geometry: TrackGeometry; turnout?: TurnoutOrientation; axis?: TrackAxis; - bypass?: 'n' | 's' | 'e' | 'w'; + /** Curves are printed left- or right-handed (design supply); not chosen on placement. */ + hand?: 'left' | 'right'; } | { kind: 'office' } | { kind: 'limits' } - | { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }; + | { kind: 'facility'; facility: FreightKind; axis?: TrackAxis } + /** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */ + | { kind: 'modifier'; modifier: ModifierKind }; export type TrackCard = { geometry: CardGeometry; @@ -165,7 +169,7 @@ export function isOperationalRail(card: TrackCard): boolean { export type NodeRef = | { at: 'divisionPoint'; side: Direction } - | { at: 'mainline'; index: number; region: number } + | { at: 'mainline'; index: number } | { at: 'grid'; owner: PlayerIndex; coord: GridCoord }; export type CrewTray = { @@ -186,11 +190,15 @@ export type CrewTray = { // The Division // --------------------------------------------------------------------------- -export type Region = { occupant: TrayId | null }; +/** + * A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the + * regions the placeholder used are gone — the cells printed on the cards are decoration. + */ +export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction }; export type DivisionNode = | { kind: 'divisionPoint'; side: Direction; holding: TrayId[] } - | { kind: 'mainline'; regions: Region[] } + | { kind: 'mainline'; card: MainlineKind; transits: Transit[] } | { kind: 'office'; owner: PlayerIndex }; /** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */ @@ -206,7 +214,17 @@ export type CardKind = | { kind: 'office'; tier: OfficeTier } | { kind: 'freightFacility'; facility: FreightKind } | { kind: 'modifier'; modifier: ModifierKind } - | { kind: 'track'; geometry: TrackGeometry }; + | { kind: 'track'; geometry: TrackGeometry } + /** + * The four categories recovered from the design. They are in the deck so the composition is + * right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so + * `check` rejects playing them and they can only be discarded. + */ + | { kind: 'spaceUse'; key: string } + | { kind: 'enhancement'; key: string } + | { kind: 'mainlineModifier'; key: string } + | { kind: 'maneuver'; key: string } + | { kind: 'action'; key: string }; export type Card = { id: CardId; kind: CardKind }; @@ -342,6 +360,16 @@ export type GameState = { yards: Yards; /** Index 0 = Stage 1. A train number, or null for an empty slot. */ timetable: (number | null)[]; + /** + * §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled: + * it runs once, immediately, then its card goes to the Salvage Yard (§2.3). + */ + pendingExtras: number[]; + /** + * Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an + * identical train behind the first, if a Crew Tray is free. + */ + pendingSecondSections: number[]; clock: Clock; turn: TurnState; /** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */ diff --git a/src/engine/track.ts b/src/engine/track.ts index 7e76f62..07053f6 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -16,6 +16,7 @@ */ import { MAX_CONSIST } from './content.ts'; +import type { TrackGeometry } from './content.ts'; import type { GridCoord, OfficeArea, @@ -78,15 +79,19 @@ type PortPair = readonly [Port, Port]; */ function connectionsFor(card: TrackCard): readonly PortPair[] { switch (card.geometry.kind) { + // A Modifier is not track: nothing connects to it and no train may enter (§9). + case 'modifier': + return []; case 'limits': return [['e', 'w']]; case 'facility': return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']]; + // Q7 — the card art draws the through-track along the TOP edge with everything diverging + // downward, so a district is a Running Track with all Secondary Track hanging beneath it. + // This supersedes Gap 8's north-and-south stubs. case 'office': return [ ['e', 'w'], - ['e', 'n'], - ['w', 'n'], ['e', 's'], ['w', 's'], ]; @@ -102,12 +107,17 @@ function connectionsFor(card: TrackCard): readonly PortPair[] { [o.stem, o.diverge], ]; } - case 'runAround': { - const b = card.geometry.bypass ?? 'n'; + // A CURVE joins the through track to one diverging side — handedness is printed on the + // card, not chosen (the design supplies both hands). A SHARP curve is geometrically the + // same but costs two Moves to cross. + // Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides + // which end of the through track the leg leaves from, not whether it goes up or down. + case 'curved': + case 'sharpCurved': { + const stem = card.geometry.hand === 'left' ? 'w' : 'e'; return [ ['e', 'w'], - ['e', b], - ['w', b], + [stem, 's'], ]; } } @@ -139,23 +149,26 @@ export type TrackVariant = { bypass?: Port; }; +/** + * Q7 — everything diverges downward, so a turnout's leg is always south. Handedness (printed on + * the card) decides which end of the through track it leaves from. + */ 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[] { +export function variantsFor(geometry: TrackGeometry): 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' }]; + case 'curved': + case 'sharpCurved': + // Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left + // to choose — one variant. + return [{ axis: 'ew' }]; } } diff --git a/src/sim/bot.ts b/src/sim/bot.ts index c2b1b04..4064807 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -20,9 +20,11 @@ */ import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts'; +import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts'; import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; +import { coordKey } from '../engine/state.ts'; import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts'; export type BotPolicy = { @@ -127,9 +129,13 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]) // 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')!; + // 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is + // actually a car to spot or collect. Choosing to switch merely because a train is present + // wasted the whole Local Operations action shuttling back and forth: a passenger train needs + // no switching at all, because §9.2 works coaches straight off the A/D track. + if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) { + 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. @@ -145,6 +151,18 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]) return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!; } +/** A face-up card worth spending the draw on rather than gambling on the deck. */ +function isWorthTaking(s: GameState, slot: number): boolean { + const id = s.decks.departments[slot]; + if (!id) return false; + const k = s.cards.get(id)?.kind; + if (!k) return false; + if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return true; + if (k.kind === 'freightFacility') return true; + if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier; + return false; +} + function isTrainCard(s: GameState, cardId: string): boolean { const k = s.cards.get(cardId)?.kind.kind; return k === 'timetabledTrain' || k === 'extraTrain'; @@ -162,6 +180,12 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean { const f = card.facility; if (!f || !f.allows.outbound) continue; if (f.outboundBox.length >= f.capacity.outbound) continue; + + // A PASSENGER facility has no industry track — passengers board straight off the platform + // (§9.2). Requiring a spotted car here meant the Office's green slot was never stocked and + // passengers essentially never boarded: one boarding in a whole 5-Day game. + if (f.kind === 'passenger') return true; + const want = facilityCarType(f); if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true; } @@ -194,9 +218,19 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In switch (s.turn.option) { case 'draw': { // Draw before playing — otherwise the hand empties and never refills. + // + // Prefer a face-up Department card only when it is actually worth having. Taking the visible + // card unconditionally meant the bot never once drew blind from the deck across 60 games, + // which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised. if (!s.turn.drawnThisTurn) { - const draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice'); - if (draw) return draw; + const useful = options.find( + (i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot), + ); + if (useful) return useful; + const blind = options.find((i) => i.type === 'draw.fromHomeOffice'); + if (blind) return blind; + const any = options.find((i) => i.type === 'draw.fromDepartment'); + if (any) return any; } // Train cards first — they take no placement and their value compounds every Day. const train = options.find( @@ -215,6 +249,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In } case 'switch': { + // Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car + // gets coupled, a siding fills — and once there is nothing left to do the fall-through would + // pick an arbitrary legal move and burn the remaining Moves shuttling. + if (!usefulSwitching(s, player)) { + const stop = options.find((i) => i.type === 'switch.end'); + if (stop) return stop; + } + // 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, @@ -294,7 +336,15 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] { 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. + // Coaches are worth carrying in BOTH states, and this is easy to get wrong. An EMPTY coach is + // what a boarding passenger is put into (§9.2); a LOADED one carries arrivals who can be + // de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train — + // which is exactly what happened: zero de-trainings across a whole game. + const office = areaOf(s, player).grid.get(coordKey(areaOf(s, player).officeCoord))?.facility; + if (office?.kind === 'passenger' && office.porters > 0) { + const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound; + if (arrivalsWaiting) out.push({ type: 'coach', loaded: true }); + } out.push({ type: 'coach', loaded: false }); return out; } @@ -346,6 +396,73 @@ function facilitiesWanting( return out; } +/** + * Is there any point switching this Stage? + * + * True when the crew is carrying a car some facility wants, or a siding holds a car that ought to + * be hauled away. False for a pure passenger train, which needs no switching at all. + */ +function usefulSwitching(s: GameState, player: PlayerIndex): boolean { + const tray = trayOf(s, player); + if (!tray) return false; + for (const car of tray.consist) { + if (facilitiesWanting(s, player, car).length > 0) return true; + } + if (sidingsWorthCollecting(s, player).length > 0) return true; + + // A car left standing on ordinary track is also worth lifting if some facility wants it — the + // hopper stranded on the Running Track that the crew kept driving past. + if (tray.consist.length < MAX_CONSIST) { + for (const card of areaOf(s, player).grid.values()) { + for (const car of card.standing) { + if (facilitiesWanting(s, player, car).length > 0) return true; + } + } + } + return false; +} + +/** + * Sidings holding a car the train should lift: a finished (loaded) car at an outbound facility, or + * an emptied car at an inbound one. Leaving them there fills the industry track and jams the + * facility — the "industry track full" impediment. + */ +function sidingsWorthCollecting( + s: GameState, + player: PlayerIndex, +): { row: number; col: number }[] { + const out: { row: number; col: number }[] = []; + const tray = trayOf(s, player); + if (!tray || tray.consist.length >= MAX_CONSIST) return out; + + for (const [key, card] of areaOf(s, player).grid) { + const f = card.facility; + if (!f || f.kind !== 'freight') continue; + // Skip both-direction facilities: a loaded car sitting there might be a finished outbound load + // OR an inbound one still waiting to be unloaded, and lifting the latter undoes the delivery. + if (f.allows.outbound && f.allows.inbound) continue; + const done = f.industryTrack.cars.some((c) => (f.allows.outbound ? c.loaded : !c.loaded)); + if (!done) continue; + const [row, col] = key.split(',').map(Number); + out.push({ row: row!, col: col! }); + } + return out; +} + +/** Cars standing on ordinary track that some facility would actually take. */ +function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] { + const out: { row: number; col: number }[] = []; + const tray = trayOf(s, player); + if (!tray || tray.consist.length >= MAX_CONSIST) return out; + for (const [key, card] of areaOf(s, player).grid) { + if (card.facility || card.standing.length === 0) continue; + if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) 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; diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index ea2cad5..ce0e1bd 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -81,7 +81,14 @@ export type Narration = { * 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 { +export type NarrateContext = { + /** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */ + cardName?: (id: string) => string; +}; + +export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { + const card = (id: string): string => ctx.cardName?.(id) ?? 'a card'; + switch (e.type) { // -- clock case 'stageBegan': @@ -100,7 +107,7 @@ export function narrate(e: GameEvent): Narration { tone: 'plain', text: e.option === 'switch' - ? 'Chose to SWITCH — six Moves to shunt cars around the yard' + ? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.' : e.option === 'draw' ? 'Chose to DRAW a card' : 'Chose FREIGHT AGENT work — one car moved to or from a facility', @@ -109,7 +116,7 @@ export function narrate(e: GameEvent): Narration { return { tone: 'plain', where: e.to, - text: `Crew moved ${at(e.from)} → ${at(e.to)} · ${e.movesRemaining} Moves left`, + text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`, }; case 'carsCoupled': return { @@ -130,23 +137,70 @@ export function narrate(e: GameEvent): Narration { 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}`, + ? `Drew ${card(e.cardId)} from the Home Office deck` + : `Took ${card(e.cardId)} 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', + text: e.placement + ? `Played ${card(e.cardId)} onto ${at(e.placement)}` + : `Played ${card(e.cardId)}`, }; 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}` }; + return { + tone: 'quiet', + text: `Discarded ${card(e.cardId)} 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` }; + return { + tone: 'quiet', + text: `Department slot ${e.slot + 1} refilled with ${card(e.cardId)}`, + }; + + // -- train lifecycle + case 'extraQueued': + return { + tone: 'good', + text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`, + }; + case 'secondSectionOrdered': + return { + tone: 'bad', + text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`, + }; + case 'trainMadeUp': + return { + tone: 'good', + text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`, + }; + case 'trainHeld': + return { + tone: 'bad', + text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`, + }; + case 'trainHighballed': + return { + tone: 'plain', + text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`, + }; + case 'trainArrived': + return { + tone: 'plain', + text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`, + }; + case 'trainCompleted': + return { + tone: 'quiet', + text: + `Train ${e.trainNumber} finished its run and left the Division carrying ` + + `${carsLabel(e.consist)} — the crew is free again`, + }; // -- freight agent case 'stockToOutbound': @@ -169,13 +223,19 @@ export function narrate(e: GameEvent): Narration { }; // -- trains - case 'trainScheduled': + case 'trainScheduled': { + // §7 — roll 1D12 for the slot; if it is taken, work down the column. Reporting only the roll + // makes a bumped train look like an arithmetic error. + const bumped = e.slot + 1 !== e.roll; return { tone: 'good', - text: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`, + text: bumped + ? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage (§7)` + : `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll}) — it will run at this time EVERY Day from now on`, }; + } case 'carPlacedOnTrain': - return { tone: 'plain', text: `Put a ${carLabel(e.stock)} on the train being made up` }; + return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` }; case 'carPassed': return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' }; case 'clearanceRequested': @@ -192,10 +252,20 @@ export function narrate(e: GameEvent): Narration { }; // -- passengers + // §9.2 — "One Porter will allow you to do any ONE of the following actions", so a Depot with a + // single Porter works exactly one coach per Stage. That is a limit, not a bug. case 'passengersBoarded': - return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)}` }; + return { + tone: 'good', + where: e.at, + text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`, + }; case 'passengersDetrained': - return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)}` }; + return { + tone: 'good', + where: e.at, + text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`, + }; // -- freight pipeline case 'loadStarted': @@ -244,6 +314,22 @@ export function isVisible(e: GameEvent): boolean { return e.type !== 'actorChanged'; } +/** + * A phase that ended with nothing done needs saying so explicitly. "Player 0 finished Load/Unload" + * with no preceding action reads as a gap in the replay when it is actually the game reporting that + * there was no work available. + */ +export function idleNote(phase: string): string { + switch (phase) { + case 'loadUnload': + return 'Nothing to do this Load/Unload — no load ready to advance, and no train at the platform with passengers to work.'; + case 'localOps': + return 'Nothing useful to do this Local Operations.'; + default: + return `Nothing to do this ${phaseLabel(phase)}.`; + } +} + // --------------------------------------------------------------------------- // Impediments — "why is nothing happening?" // --------------------------------------------------------------------------- @@ -353,7 +439,7 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`; break; case 'mainline': - where = `Mainline card ${tray.position.index}, region ${tray.position.region + 1}`; + where = `Mainline card ${tray.position.index}`; break; case 'grid': where = `Office Area ${at(tray.position.coord)}`; diff --git a/src/sim/replay.ts b/src/sim/replay.ts index cf4aa80..3cc38b7 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -21,14 +21,14 @@ 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 { MAINLINE_PROFILES, 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'; +import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts'; // --------------------------------------------------------------------------- // Frame shape — only what the viewer draws @@ -60,7 +60,8 @@ export type FacilityView = { porters: string; }; -export type DivisionView = { kind: string; label: string; trains: string[][] }; +export type TrainChip = { label: string; consist: string[] }; +export type DivisionView = { kind: string; label: string; trains: TrainChip[][] }; export type Frame = { day: number; @@ -72,11 +73,16 @@ export type Frame = { revenue: number; lines: { text: string; tone: string }[]; where: { row: number; col: number } | null; + /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */ + whereFrom: { row: number; col: number } | null; division: DivisionView[]; cells: CellView[]; facilities: FacilityView[]; - hand: number; + hand: string[]; deck: number; + departments: string[]; + /** 12 slots; the train number due out at each Stage, or null. */ + timetable: (number | null)[]; blocked: Impediment[]; trains: { label: string; where: string }[]; }; @@ -93,14 +99,22 @@ const FACILITY_NAMES: Record = { powerPlant: 'Power Plant', }; -function facilityView(card: { geometry: { kind: string; facility?: string }; facility: NonNullable extends never ? never : unknown }): FacilityView | null { +function facilityView( + card: { geometry: { kind: string; facility?: string }; facility: unknown }, + officeName: string, +): FacilityView | null { const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility; - if (!f || f.kind !== 'freight') return null; + // Passenger facilities were excluded entirely, so the Office's green and red slots never + // appeared — which is why stocking a coach into the green box looked like nothing happening. + if (!f) return null; + if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null; const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : ''; return { - name: FACILITY_NAMES[key] ?? key, + name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key), commodity: facilityCarType(f) ?? '?', - flow: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives', + flow: f.kind === 'passenger' + ? 'passengers on and off' + : 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)), @@ -117,13 +131,15 @@ function snapshot( s: GameState, lines: { text: string; tone: string }[], where: { row: number; col: number } | null, + whereFrom: { row: number; col: number } | null = 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 carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]'; + trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying); } } @@ -137,9 +153,10 @@ function snapshot( 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; + else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier; + else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry; - const fv = facilityView(card as never); + const fv = facilityView(card as never, officeProfile(area.tier).name); if (fv) facilities.push(fv); cells.push({ @@ -163,10 +180,16 @@ function snapshot( }; } if (n.kind === 'mainline') { + // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it + // with how long each still has to run. + const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card; return { kind: 'ml', - label: 'Mainline', - trains: n.regions.map((r) => (r.occupant ? [trainChip(s, r.occupant)] : [])), + label: name, + trains: [n.transits.map((t) => { + const chip = trainChip(s, t.tray); + return { ...chip, label: `${chip.label} (${t.stagesRemaining})` }; + })], }; } return { @@ -186,11 +209,14 @@ function snapshot( revenue: s.players[0]?.revenue ?? 0, lines, where, + whereFrom, division, cells, facilities, - hand: (s.decks.hands.get(0) ?? []).length, + hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)), deck: s.decks.homeOffice.length, + departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')), + timetable: [...s.timetable], blocked: impediments(s, 0), trains: [...s.trays.values()].map((t) => ({ label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, @@ -198,16 +224,59 @@ function snapshot( t.position.at === 'divisionPoint' ? `${t.position.side} Division Point` : t.position.at === 'mainline' - ? `Mainline ${t.position.index}, region ${t.position.region + 1}` + ? `Mainline card ${t.position.index}` : `Office Area (${t.position.coord.row},${t.position.coord.col})`, })), }; } -function trainChip(s: GameState, id: string): string { +/** A card id turned into something a person can read. */ +export function cardName(s: GameState, id: string): string { + const k = s.cards.get(id)?.kind; + if (!k) return 'a card'; + switch (k.kind) { + case 'timetabledTrain': + return `Train ${k.number}`; + case 'extraTrain': + return `Extra X${k.number}`; + case 'office': + return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`; + case 'freightFacility': + return FACILITY_NAMES[k.facility] ?? k.facility; + case 'modifier': + return MODIFIER_NAMES[k.modifier] ?? k.modifier; + case 'track': + return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`; + case 'spaceUse': + case 'enhancement': + case 'mainlineModifier': + case 'maneuver': + case 'action': + return prettyKey(k.key); + } +} + +/** camelCase key → readable name, for the card categories that carry only a key. */ +function prettyKey(key: string): string { + return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); +} + +const MODIFIER_NAMES: Record = { + teamTrack: 'Team Track', + loadingDock: 'Loading Dock', + storageShed: 'Storage Shed', + extraPlatform: 'Extra Platform', + sectionGang: 'Section Gang', +}; + +/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */ +function trainChip(s: GameState, id: string): TrainChip { const t = s.trays.get(id); - if (!t) return id; - return t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`; + if (!t) return { label: id, consist: [] }; + return { + label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, + consist: t.consist.map(carLabel), + }; } // --------------------------------------------------------------------------- @@ -230,16 +299,40 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re }; const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] }); const frames: Frame[] = []; + const narrateCtx = { cardName: (id: string) => cardName(s, id) }; + + // Tracks whether a phase did anything, so an empty one can say so rather than ending silently. + let didSomething = false; 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)); + + const lines: { text: string; tone: string }[] = []; + for (const e of visible) { + if (e.type === 'phaseBegan') didSomething = false; + if (e.type === 'phaseEnded' && !didSomething) { + lines.push({ text: idleNote(e.phase), tone: 'quiet' }); + } + if ( + e.type !== 'phaseBegan' && + e.type !== 'phaseEnded' && + e.type !== 'stageBegan' + ) { + didSomething = true; + } + const n = narrate(e, narrateCtx); + lines.push({ text: n.text, tone: n.tone }); + } + const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null; + const moved = visible.find((e) => e.type === 'trayMoved'); + const from = moved && moved.type === 'trayMoved' ? moved.from : null; + frames.push(snapshot(s, lines, where ?? null, from)); }; - frames.push(snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], 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); @@ -299,6 +392,10 @@ h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim) .cell{border:1px solid var(--line);border-radius:5px;padding:6px;min-height:64px;background:#20252c} .cell.run{border-color:#4a545f;background:#252b33} .cell.hl{outline:2px solid var(--warn);outline-offset:1px} +.cell.from{outline:2px dashed #6b7480;outline-offset:1px} +/* The crew is the thing you follow around the yard, so it gets the loudest treatment on the card. */ +.crew{background:var(--warn);color:#0d1117;font-weight:700;border-radius:4px;padding:3px 6px; +margin-top:5px;font-size:11px;line-height:1.35} .cell .nm{font-weight:700;font-size:11px} .cell .cars{color:var(--dim);font-size:10.5px;margin-top:3px} .cell .tray{display:inline-block;margin-top:4px} @@ -313,13 +410,27 @@ h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim) .t-quiet{color:var(--dim)}.t-plain{color:var(--fg)} .blocked li{margin-bottom:4px} .sev-stuck{color:var(--bad)}.sev-risk{color:var(--warn)}.sev-waiting{color:var(--dim)} -footer{position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--line);padding:9px 14px; -display:flex;gap:8px;align-items:center;flex-wrap:wrap;z-index:5} +/* FIXED, not sticky: a sticky footer shifts as panel heights change, so the mouse cannot stay + parked on one button and keep clicking. */ +footer{position:fixed;left:0;right:0;bottom:0;background:var(--bg);border-top:1px solid var(--line); +padding:9px 14px;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;z-index:10;overflow-x:auto} +body{padding-bottom:64px} button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 11px; font:inherit;cursor:pointer} button:hover{background:#343b45} input[type=range]{flex:1;min-width:180px} .legend{font-size:11px;color:var(--dim);padding:0 14px 14px} +kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 4px;font-size:10px} +.tt{display:grid;grid-template-columns:repeat(12,1fr);gap:3px} +.tt div{border:1px solid var(--line);border-radius:3px;padding:3px 2px;text-align:center;font-size:10.5px} +.tt div.due{background:var(--clock);color:#0d1117;font-weight:700} +.tt div.now{outline:2px solid var(--warn)} +.card{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px;background:#20252c} +.consist{font-size:10px;color:var(--dim);margin-top:2px} +.mini{margin-top:4px;font-size:9.5px;line-height:1.8} +.mini .box{padding:0 4px;font-size:9.5px} +.cell.office{border-color:var(--clock)} +.cell.modifier{border-style:dashed;opacity:.85}
@@ -329,7 +440,7 @@ input[type=range]{flex:1;min-width:180px} phase actor · fedora revenue 0 - hand 0 · deck 0 + deck 0 frame 0/0
@@ -339,9 +450,14 @@ input[type=range]{flex:1;min-width:180px}

Division — west to east

Office Area

Facilities

+

Timetable — which train is due out at each Stage

What just happened

+

Cards

+
your hand:
+
face-up Department slots:
+

Blocked — why nothing is moving

    Trains in play

    History

    @@ -349,7 +465,13 @@ input[type=range]{flex:1;min-width:180px}
    - Running Track is the lighter row. T4 is a train. Green box = outbound loads waiting · + Keyboard: / step · space play/pause · + Home/End first/last · S next Stage · R next revenue change · + C next collision.
    + Running Track is the lighter row. T4 is a train on the Division; + 🚂 T4 + is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline + marks where it came from. 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.
    @@ -364,7 +486,8 @@ input[type=range]{flex:1;min-width:180px}