Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+3
View File
@@ -21,3 +21,6 @@ __pycache__/
# Logs
*.log
# Generated replays
replay*.html
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+9 -5
View File
@@ -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
+20
View File
@@ -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 16 are done** — the rules engine (components 17), 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.
+7
View File
@@ -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.
+490
View File
@@ -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 23 capacity and 23 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 (112)
| # | 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 (X13X22) — 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 X13X22**, 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 X13X22; 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.
+13 -7
View File
@@ -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 |
---
+9 -3
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

+146 -32
View File
@@ -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<DivisionNode, { kind: 'mainline' }>,
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 })),
});
}
// ---------------------------------------------------------------------------
+136 -12
View File
@@ -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;
+416 -308
View File
@@ -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 X13X22 — 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 trains number.' },
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other trains number.' },
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other trains 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 (choosers 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 crews 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);
+12
View File
@@ -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 }
+3
View File
@@ -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 };
+6
View File
@@ -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);
+52 -17
View File
@@ -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<typeof officeProfile>[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<typeof officeProfile>[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<CardId, Card>();
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,
+34 -6
View File
@@ -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. */
+26 -13
View File
@@ -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' }];
}
}
+123 -6
View File
@@ -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;
+100 -14
View File
@@ -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)}`;
+188 -34
View File
@@ -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<string, string> = {
powerPlant: 'Power Plant',
};
function facilityView(card: { geometry: { kind: string; facility?: string }; facility: NonNullable<CellView['facility']> 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<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label);
const 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<string, string> = {
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}
</style></head><body>
<header>
@@ -329,7 +440,7 @@ input[type=range]{flex:1;min-width:180px}
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">hand <span id="hand">0</span> · deck <span id="deck">0</span></span>
<span class="dim">deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
@@ -339,9 +450,14 @@ input[type=range]{flex:1;min-width:180px}
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
<section><h2>Office Area</h2><div class="grid" id="grid"></div></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
<section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
</div>
<div>
<section><h2>What just happened</h2><div id="now"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Trains in play</h2><div id="trains" class="dim"></div></section>
<section><h2>History</h2><div class="log" id="log"></div></section>
@@ -349,7 +465,13 @@ input[type=range]{flex:1;min-width:180px}
</div>
<div class="legend">
Running Track is the lighter row. <span class="chip">T4</span> is a train. Green box = outbound loads waiting ·
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
<kbd>C</kbd> next collision.<br>
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
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.
</div>
@@ -364,7 +486,8 @@ input[type=range]{flex:1;min-width:180px}
<button id="crash">next collision ⚠</button>
<input type="range" id="scrub" min="0" value="0">
<select id="speed">
<option value="600">slow</option>
<option value="3000">extra slow</option>
<option value="1200">slow</option>
<option value="250" selected>normal</option>
<option value="90">fast</option>
<option value="20">very fast</option>
@@ -377,6 +500,17 @@ let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// The green / MEN|AT|WORK / red boxes drawn on the card itself, so a Freight Agent action or a
// Porter boarding shows up where it happened rather than only in a side panel.
function miniFacility(x) {
let h = '<div class="mini">';
if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
if (x.trackCap > 0) h += '<br><span class="dim">work </span>' +
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
return h + '</div>';
}
function boxes(items, cap, cls) {
let h = '';
for (let k = 0; k < Math.max(cap, items.length); k++) {
@@ -394,15 +528,27 @@ function render() {
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
$('hand').textContent = f.hand;
$('deck').textContent = f.deck;
$('hand').innerHTML = f.hand.length
? f.hand.map((c) => '<span class="card">' + esc(c) + '</span>').join('')
: '<span class="dim">empty</span>';
$('depts').innerHTML = f.departments.map((c) => '<span class="card">' + esc(c) + '</span>').join('');
$('tt').innerHTML = '<div class="tt">' + f.timetable.map((t, k) =>
'<div class="' + (t !== null ? 'due ' : '') + (k + 1 === f.stage ? 'now' : '') + '">' +
'S' + (k + 1) + '<br>' + (t !== null ? 'T' + t : '·') + '</div>').join('') + '</div>' +
'<div class="dim" style="margin-top:5px">The NUMBER of a train is its seniority and direction ' +
'(odd = westbound, even = eastbound). The Stage it departs is set by a 1D12 roll when its card ' +
'was played — so Train 8 leaving at Stage 6 is normal.</div>';
$('fno').textContent = i;
$('scrub').value = i;
$('division').innerHTML = f.division.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
'<div class="regions">' + n.trains.map((r) =>
'<div class="region">' + r.map((t) => '<span class="chip">' + esc(t) + '</span>').join('') + '</div>').join('') +
'<div class="region">' + r.map((t) =>
'<span class="chip">' + esc(t.label) + '</span>' +
'<div class="consist">' + (t.consist.length ? esc(t.consist.join(', ')) : 'empty') + '</div>'
).join('') + '</div>').join('') +
'</div></div>').join('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
@@ -415,11 +561,14 @@ function render() {
const cell = f.cells.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (hl ? 'hl' : '') + '">' +
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.tray ? '<span class="chip tray">' + esc(cell.tray) + '</span>' : '') +
(cell.cars.length ? '<div class="cars">' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
(cell.facility ? miniFacility(cell.facility) : '') +
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
'</div>';
}
}
@@ -476,8 +625,13 @@ $('play').onclick = () => {
$('speed').onchange = () => { if (timer) { $('play').click(); $('play').click(); } };
document.onkeydown = (e) => {
if (e.key === 'ArrowRight') go(i + 1);
if (e.key === 'ArrowLeft') go(i - 1);
if (e.key === ' ') { e.preventDefault(); $('play').click(); }
else if (e.key === 'ArrowLeft') go(i - 1);
else if (e.key === 'Home') go(0);
else if (e.key === 'End') go(FRAMES.length - 1);
else if (e.key === 's' || e.key === 'S') $('stage').click();
else if (e.key === 'r' || e.key === 'R') $('money').click();
else if (e.key === 'c' || e.key === 'C') $('crash').click();
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
};
render();
</script></body></html>`;
+10 -6
View File
@@ -120,12 +120,11 @@ export function summarize(
if (e.type === 'clearanceRequested') clearanceRequests++;
if (e.type === 'clearanceGiven' && e.allow) clearancesAllowed++;
// The driver reports train lifecycle through `phaseBegan` labels.
if (e.type === 'phaseBegan') {
if (e.phase.includes('highballed')) highballed++;
else if (e.phase.includes('arrived')) arrived++;
else if (e.phase.includes('completed')) completed++;
}
// Train lifecycle now has proper event types; it used to be smuggled through `phaseBegan`
// free text, which made the replay unable to say anything useful about it.
if (e.type === 'trainHighballed') highballed++;
else if (e.type === 'trainArrived') arrived++;
else if (e.type === 'trainCompleted') completed++;
}
for (const i of intents) inc(intentCounts, i);
@@ -213,6 +212,11 @@ export function summarize(
*/
const EXPECTED_EVENTS = [
'trainScheduled',
'extraQueued',
'trainMadeUp',
'trainHighballed',
'trainArrived',
'trainCompleted',
'carPlacedOnTrain',
'trayMoved',
'carsCoupled',
+12 -8
View File
@@ -239,8 +239,10 @@ describe('Mainline Phase (§8)', () => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
// Q1/Q2 — crossing time is counted in Stages by card speed and Fast/Slow class, so the train
// stays on the card until its transit expires rather than stepping through printed cells.
const pos2 = s.trays.get(id)!.position;
assert.ok(pos2.at === 'mainline' && pos2.region === 1, 'one region per Stage');
assert.equal(pos2.at, 'mainline', 'still crossing');
});
it('orders movement by train number, Timetabled before Extra on a tie (Gap 5)', () => {
@@ -288,11 +290,11 @@ describe('collisions are automatic (Gap 2)', () => {
engineFront: true,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = id;
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
@@ -308,6 +310,8 @@ describe('collisions are automatic (Gap 2)', () => {
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
const classBefore = s.yards.classificationYard.length;
const mlx = s.division.nodes[1];
if (mlx?.kind === 'mainline') mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
s.trays.set('x', {
id: 'x',
trainNumber: 8,
@@ -318,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => {
{ type: 'caboose', loaded: true },
],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
s.clock.phase = 'mainline';
@@ -346,11 +350,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
engineFront: true,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1, region: 1 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = 'ahead';
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
// A second train at the Western Division Point wanting to follow it.
s.trays.set('behind', {
@@ -383,11 +387,11 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
engineFront: true,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1, region: 0 },
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.regions[0]!.occupant = 'oncoming';
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
s.trays.set('waiting', {
id: 'waiting',
+9 -5
View File
@@ -127,7 +127,10 @@ describe('Local Operations: drawing (§6.2)', () => {
const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
assert.ok(r.ok);
assert.ok(s.decks.hands.get(0)!.includes(target));
assert.equal(s.decks.departments[1], null);
// §6.2 — an emptied Department slot is refilled from the Home Office deck immediately, so the
// face-up market never disappears.
assert.notEqual(s.decks.departments[1], null, 'Department slot was not refilled');
assert.notEqual(s.decks.departments[1], target, 'refilled with the same card');
});
it('allows only one draw per Stage', () => {
@@ -200,7 +203,7 @@ describe('Office upgrades (Gap 3b, Gap 8)', () => {
// Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
// Secondary Track hanging off the Office.
const s = game();
addCard(s, at(1, 0), straight());
addCard(s, at(-1, 0), straight());
const gridBefore = new Map(areaOf(s, 0).grid);
const id = handCardOfTier(s, 'depot');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
@@ -218,7 +221,8 @@ describe('Office upgrades (Gap 3b, Gap 8)', () => {
applyIntent(s, 0, { type: 'card.play', cardId: id });
const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
assert.equal(office.facility!.porters, 1);
assert.equal(office.facility!.capacity.inbound, 2);
// The design gives slots equal to porters, not one more.
assert.equal(office.facility!.capacity.inbound, 1);
});
});
@@ -302,7 +306,7 @@ describe('Local Operations: switching (§6.1, Appendix A)', () => {
describe('Freight Agent operations (§6.3)', () => {
function withFacility(s: GameState): GridCoord {
const coord = at(1, 0);
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
@@ -398,7 +402,7 @@ describe('Freight Agent operations (§6.3)', () => {
describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
function facilityWithLoad(s: GameState): GridCoord {
const coord = at(1, 0);
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
+240
View File
@@ -0,0 +1,240 @@
/**
* Component 16 — replay viewer.
*
* The important test here is narration coverage: adding an event type without narrating it should
* fail the build rather than quietly degrading the replay into "something happened".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** One representative instance of every event type the engine can emit. */
const SAMPLES: GameEvent[] = [
{ type: 'stageBegan', day: 1, stage: 7 },
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
{ type: 'cardDiscarded', player: 0, cardId: 'c1', toSlot: 1 },
{ type: 'deckReshuffled' },
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
];
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
});
it('gives every event a specific, non-empty sentence', () => {
for (const e of SAMPLES) {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
}
});
it('marks revenue gains good and losses bad', () => {
const gain = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 1, reason: 'boarding' });
const loss = narrate({
type: 'revenueChanged', player: 0, delta: -5, total: -5, reason: 'collision: no free A/D track',
});
assert.equal(gain.tone, 'good');
assert.equal(loss.tone, 'bad');
assert.match(loss.text, /COLLISION/i);
});
it('points at the board cell where something happened', () => {
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
assert.deepEqual(n.where, { row: 1, col: 2 });
});
it('names phases in words rather than identifiers', () => {
assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
assert.equal(phaseLabel('newTrain'), 'New Train');
});
it('hides only the events with nothing to show', () => {
assert.equal(isVisible({ type: 'actorChanged', player: 0 }), false);
assert.equal(isVisible({ type: 'loadCompleted', player: 0, at: { row: 0, col: 0 }, carType: 'hopper' }), true);
});
});
describe('impediments', () => {
it('reports nothing blocked on a fresh game', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
assert.deepEqual(impediments(s, 0), []);
});
it('warns when every A/D track is occupied', () => {
// Gap 2d — the next arrival is an automatic collision.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.officeAreas.get(0)!.adOccupancy = ['t0'];
const found = impediments(s, 0);
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
});
it('reports a train held for want of a Crew Tray', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.timetable[s.clock.stage - 1] = 4;
s.freeTrays = [];
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
});
});
describe('replay recording', () => {
const rec = record(1234, 'standard');
it('produces frames', () => {
assert.ok(rec.frames.length > 50, `only ${rec.frames.length} frames`);
});
it('never runs time backwards', () => {
let prev = 0;
for (const f of rec.frames) {
const t = f.day * 100 + f.stage;
assert.ok(t >= prev, `time went backwards at Day ${f.day} Stage ${f.stage}`);
prev = t;
}
});
it('always shows the Office card', () => {
for (const f of rec.frames) {
assert.ok(f.cells.some((c) => c.kind === 'office'), 'a frame lost the Office');
}
});
it('does not drift from the engine', () => {
// A replay that disagrees with the engine is worse than no replay. Replay the same seed
// through the normal bot path and compare the end state.
const s = createGame({ id: 'x', seed: 1234, config, playerNames: ['player'] });
const r = playGame(s, developerBot, pump);
const stats = summarize(1234, r.events, r.intents, s);
const last = rec.frames[rec.frames.length - 1]!;
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
assert.match(rec.outcome, new RegExp(stats.result));
});
it('narrates every frame', () => {
for (const f of rec.frames) {
assert.ok(f.lines.length > 0, 'a frame has no narration');
}
});
});
describe('replay HTML', () => {
const html = renderHtml(record(99, 'short'));
it('is a complete standalone document', () => {
assert.match(html, /^<!doctype html>/i);
assert.match(html, /<\/html>\s*$/);
});
it('embeds no external resources', () => {
// A strict requirement: the file must open anywhere, offline.
const externals = html.match(/(?:src|href)\s*=\s*"(?!#)[^"]+"/g) ?? [];
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
});
it('embeds the frame data', () => {
assert.match(html, /const FRAMES = \[/);
});
it('offers the controls that make it usable', () => {
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash']) {
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
}
});
it('emits a script that actually parses', () => {
// THE GUARD THAT WAS MISSING. A stray apostrophe inside a single-quoted JS string once broke
// the entire inline script, so no button handler ever bound — while every other test passed,
// because they only checked that the markup contained the buttons. Checking the elements exist
// says nothing about whether the page works.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
assert.doesNotThrow(() => new Function(js), 'inline script has a syntax error');
});
it('wires a handler to every control', () => {
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash']) {
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
}
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
});
it('runs its render function against the real frames', () => {
// Executes the page's own JS with a minimal DOM stub. Catches runtime errors in render(),
// not just parse errors — e.g. a field the renderer expects that frames do not carry.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
const els = new Map<string, Record<string, unknown>>();
const stub = {
getElementById: (id: string) => {
if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
return els.get(id);
},
};
const run = new Function('document', 'setInterval', 'clearInterval', js);
assert.doesNotThrow(
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the clock element should have been filled in.
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
});
it('stays a sane size', () => {
const mb = Buffer.byteLength(html) / 1024 / 1024;
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
});
});
+92 -34
View File
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts';
import { MODIFIER_PROFILES, SOLITAIRE_DECK_SIZE, TRACK_PER_PLAYER } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
@@ -48,26 +49,44 @@ const newSolitaireGame = (seed = 1234) =>
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
it('composes a 52-card deck', () => {
const total = deckComposition().reduce((n, c) => n + c.count, 0);
assert.equal(total, DECK_SIZE);
it('composes the 115-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
assert.equal(DECK_SIZE, 115);
assert.equal(buildDeck().length, DECK_SIZE);
});
it('matches card-reference.md deck composition exactly', () => {
const byCategory = Object.fromEntries(
deckComposition().map((c) => [c.category, c.count]),
);
it('matches the design deck composition exactly', () => {
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
assert.deepEqual(byCategory, {
timetabledTrain: 12,
extraTrain: 4,
office: 9,
freightFacility: 10,
modifier: 5,
track: 12,
office: 7,
industry: 9,
modifier: 23,
train: 22,
spaceUse: 12,
enhancement: 18,
mainlineModifier: 7,
maneuver: 7,
action: 10,
});
});
it('removes opponent-directed cards from a solitaire deck', () => {
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
// game they would be 22 of 115 draws (19%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 93);
const solo = buildDeck('solitaire');
assert.equal(solo.length, 93);
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
// A competitive deck keeps them.
assert.equal(buildDeck('competitive').length, 115);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
// The single biggest structural change: 26 pieces per player, 104 for four.
assert.equal(TRACK_PER_PLAYER, 26);
assert.ok(!buildDeck().some((c) => c.kind.kind === 'track'), 'track leaked into the deck');
});
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
assert.equal(TIMETABLED_TRAINS.length, 12);
for (const t of TIMETABLED_TRAINS) {
@@ -78,24 +97,38 @@ describe('card catalogue (component 1)', () => {
it('never lets a consist exceed four slots, caboose included', () => {
// §A.4 + Gap 4c — the most likely off-by-one in the whole model.
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
const slots = t.consist.count + (t.consist.requiresCaboose ? 1 : 0);
assert.ok(slots <= MAX_CONSIST, `${t.className} ${t.number} uses ${slots} slots`);
const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
assert.ok(slots <= MAX_CONSIST, `${t.name} ${t.number} uses ${slots} slots`);
}
});
it('gives Extras low seniority (X9-X12)', () => {
assert.deepEqual(EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b), [9, 10, 11, 12]);
it('numbers Extras X13-X22, all junior to every timetabled train', () => {
// This is why Gap 5's Extra-vs-Timetabled tie can no longer occur.
assert.deepEqual(
EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b),
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
);
assert.ok(EXTRA_TRAINS.every((e) => TIMETABLED_TRAINS.every((t) => t.number < e.number)));
});
it('identifies Freight Houses as the both-direction facilities', () => {
// Gap 10d — not a card, a collective term for Grocer's Warehouse and Oil Refinery.
it('gives every train a name and a speed class', () => {
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
assert.ok(t.name.length > 2, `train ${t.number} has no name`);
assert.ok(t.speed === 'fast' || t.speed === 'slow', `train ${t.number} has no speed`);
}
});
it('identifies the both-direction industry', () => {
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['grocersWarehouse', 'oilRefinery']);
assert.deepEqual(houses.sort(), ['freightHouse']);
});
it('keeps every industry track within the four-car limit', () => {
it('starts every industry at one car out and one loader', () => {
// The design is far leaner than the placeholder: capacity grows via modifier cards.
for (const f of FREIGHT_PROFILES) {
assert.ok(f.industryTrackLength <= 4, `${f.name} track ${f.industryTrackLength}`);
assert.ok(f.baseOut <= 1, `${f.name} baseOut ${f.baseOut}`);
assert.ok(f.baseIn <= 1, `${f.name} baseIn ${f.baseIn}`);
assert.equal(f.baseLoaders, 1, `${f.name} loaders`);
}
});
@@ -103,8 +136,28 @@ describe('card catalogue (component 1)', () => {
for (const f of FREIGHT_PROFILES) {
const wantsOut = f.flow === 'outbound' || f.flow === 'both';
const wantsIn = f.flow === 'inbound' || f.flow === 'both';
assert.equal(f.outboundCapacity > 0, wantsOut, `${f.name} outbound`);
assert.equal(f.inboundCapacity > 0, wantsIn, `${f.name} inbound`);
assert.equal(f.baseOut > 0, wantsOut, `${f.name} outbound`);
assert.equal(f.baseIn > 0, wantsIn, `${f.name} inbound`);
}
});
it('records lockouts symmetrically', () => {
// Column E of the sheet. Semantics are still open, but the data must be consistent.
for (const f of FREIGHT_PROFILES) {
for (const other of f.lockouts) {
const o = FREIGHT_PROFILES.find((x) => x.kind === other)!;
assert.ok(o.lockouts.includes(f.kind), `${f.kind} locks out ${other} but not vice versa`);
}
}
});
it('ties every modifier to at least one real host', () => {
for (const m of MODIFIER_PROFILES) {
assert.ok(m.hosts.length > 0, `${m.name} has no host`);
for (const h of m.hosts) {
if (h === 'office') continue;
assert.ok(FREIGHT_PROFILES.some((f) => f.kind === h), `${m.name} names unknown host ${h}`);
}
}
});
@@ -117,17 +170,17 @@ describe('card catalogue (component 1)', () => {
it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
// The design gives slots EQUAL to porters; the earlier guess was one too generous.
for (const o of OFFICE_PROFILES) {
if (o.tier === 'whistlePost') continue;
assert.ok(o.greenSlots > o.porters, `${o.tier} green slots vs porters`);
assert.ok(o.redSlots > o.porters, `${o.tier} red slots vs porters`);
assert.equal(o.passengerOut, o.porters, `${o.tier} green slots`);
assert.equal(o.passengerIn, o.porters, `${o.tier} red slots`);
}
});
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
// Gap 3b requires more Depots than Stations than Terminals.
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
assert.deepEqual(inDeck, [4, 3, 2]);
assert.deepEqual(inDeck, [4, 2, 1]);
});
it('walks the upgrade sequence without skipping', () => {
@@ -147,8 +200,8 @@ describe('card catalogue (component 1)', () => {
const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
const demand = new Map<CarType, number>();
for (const f of FREIGHT_PROFILES) {
const need = (f.outboundCapacity + f.inboundCapacity) * f.copies;
demand.set(f.carType, (demand.get(f.carType) ?? 0) + need);
const need = (f.baseOut + f.baseIn) * f.copies;
for (const t of f.carTypes) demand.set(t, (demand.get(t) ?? 0) + need);
}
for (const [type, need] of demand) {
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
@@ -245,10 +298,14 @@ describe('game setup (component 2)', () => {
);
});
it('gives every mainline card two regions', () => {
it('deals each Mainline card a terrain type', () => {
// Terrain is what sets crossing time (Q1), so every Mainline node must carry a card kind.
const g = newSolitaireGame();
for (const node of g.division.nodes) {
if (node.kind === 'mainline') assert.equal(node.regions.length, 2);
if (node.kind === 'mainline') {
assert.ok(node.card, 'mainline node has no card type');
assert.deepEqual(node.transits, []);
}
}
});
@@ -283,8 +340,9 @@ describe('game setup (component 2)', () => {
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
assert.equal(all.length, DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, DECK_SIZE, 'duplicate card ids');
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
});
it('starts with no trains scheduled and none running', () => {
+62 -8
View File
@@ -11,6 +11,7 @@ import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
import { record } from '../src/sim/replay.ts';
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
@@ -152,16 +153,22 @@ describe('the revenue chain works end to end (regression)', () => {
it('instantiates a real Facility when a freight card is placed', () => {
// REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable.
const s = createGame({ id: 'g', seed: 11, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
//
// Checked across several seeds: the design's deck holds only 9 industries in 115 cards, so a
// single game may legitimately never draw one. That dilution is itself worth knowing — under
// the old 52-card placeholder it was 10 in 52.
let placed = 0;
for (const seed of [11, 23, 47, 91, 137, 211]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
placed++;
assert.ok(card.facility, 'a placed facility card has no Facility record');
assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
}
assert.ok(placed > 0, 'no facility was placed at all');
}
assert.ok(placed > 0, 'no facility was placed across six campaign games');
});
it('earns freight revenue, not just passenger revenue', () => {
@@ -180,16 +187,17 @@ describe('the revenue chain works end to end (regression)', () => {
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area in both directions (Gap 11)', () => {
// With orientation fixed, grids only ever grew sideways and downward.
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r > 0), 'nothing was ever built north of the Running Track');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built south of the Running Track');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
});
});
@@ -233,3 +241,49 @@ describe('end-of-game statistics', () => {
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
});
});
describe('switching accomplishes something (regression)', () => {
it('does not shuttle the crew back and forth to no purpose', () => {
// REGRESSION. The bot chose to SWITCH whenever a train stood at the Office, whether or not
// there was anything to switch — then, having nothing useful to do, took the first legal move
// repeatedly and burned all six Moves oscillating between two cells. A passenger train needs
// no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
// Operations action was wasted.
const rec = record(1234, 'standard');
let worstRun = 0;
let run = 0;
let lastFrom: string | null = null;
for (const f of rec.frames) {
const move = f.lines.find((l) => /^CREW moved/.test(l.text));
const didWork = f.lines.some((l) => /Dropped|Coupled/.test(l.text));
if (!move || didWork) {
run = 0;
lastFrom = null;
continue;
}
const m = /\((-?\d+),(-?\d+)\) → \((-?\d+),(-?\d+)\)/.exec(move.text);
if (!m) continue;
const from = `${m[1]},${m[2]}`;
const to = `${m[3]},${m[4]}`;
// An oscillation is a move that lands exactly where the previous move started.
run = lastFrom === to ? run + 1 : 0;
worstRun = Math.max(worstRun, run);
lastFrom = from;
}
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
});
it('does not spend its whole Local Operations budget on motion', () => {
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
// the crew is not burning all six Moves every Stage — the shuttling bug.
const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
});
});
+29 -28
View File
@@ -128,53 +128,54 @@ describe('ports and geometry', () => {
it('joins a turnout stem to both legs but never the legs to each other', () => {
// §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
// reaches only the stem. This is an ABSENT edge, not a one-way edge.
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
assert.deepEqual(exitsFrom(t, 'e').sort(), ['n', 'w']);
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
assert.deepEqual(exitsFrom(t, 'e').sort(), ['s', 'w']);
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
assert.deepEqual(exitsFrom(t, 'n'), ['e']);
assert.deepEqual(exitsFrom(t, 's'), ['e']);
});
it('lets a train traverse a turnout in both directions', () => {
// A→B and B→A are both legal; only B↔C is missing.
const t = turnout({ stem: 'e', through: 'w', diverge: 'n' });
assert.ok(exitsFrom(t, 'e').includes('n'), 'stem to diverging leg');
assert.ok(exitsFrom(t, 'n').includes('e'), 'diverging leg back to stem');
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
assert.ok(exitsFrom(t, 'e').includes('s'), 'stem to diverging leg');
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
});
it('gives the Office card junction stubs above and below', () => {
// Gap 8 — plain junctions, so unlike a turnout no pair is missing between track and stub.
it('gives the Office card a junction stub BELOW only', () => {
// Q7 — the card art draws the through-track along the top edge with everything diverging
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
const o = officeCard();
for (const p of ['n', 's', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(exitsFrom(o, 'n').includes('e'));
assert.ok(exitsFrom(o, 'n').includes('w'));
assert.ok(!exitsFrom(o, 'n').includes('s'), 'north must not cross to south');
for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
assert.ok(exitsFrom(o, 's').includes('e'));
assert.ok(exitsFrom(o, 's').includes('w'));
});
it('gives a run-around both ends of the loop', () => {
// §A.5's facing-point move is impossible without one.
it('joins a curve from one end of the through track downward', () => {
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
// handedness decides which end of the through track it leaves from.
const r: TrackCard = {
geometry: { kind: 'track', geometry: 'runAround' },
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
};
assert.deepEqual(exitsFrom(r, 'n').sort(), ['e', 'w']);
assert.deepEqual(exitsFrom(r, 's'), ['w']);
});
});
// ---------------------------------------------------------------------------
describe('Move legality', () => {
// row 1: [B n-s straight] <- must have a SOUTH port to meet the Office stub
// row 0: [D] [E office ] [F]
// row 0: [D] [E office ] [F] <- Running Track
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
const basicArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(1, 0))]: nsStraight(),
[coordKey(at(-1, 0))]: nsStraight(),
},
at(0, 0),
);
@@ -190,7 +191,7 @@ describe('Move legality', () => {
it('branches onto Secondary Track through the Office stub', () => {
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
assert.ok(has(dests, 1, 0), 'reaches the card north of the office');
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
});
it('never reverses within a single Move', () => {
@@ -209,19 +210,19 @@ describe('Move legality', () => {
});
it('will not stop on a turnout', () => {
// row 1: [C n-s straight]
// row 0: [start] [turnout ]
// row -1: [C n-s straight]
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
[coordKey(at(1, 1))]: nsStraight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(-1, 1))]: nsStraight(),
},
at(0, 0),
);
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
assert.ok(has(dests, 1, 1), 'but a train may pass through it');
assert.ok(has(dests, -1, 1), 'but a train may pass through it');
});
it('will not stop on a facility whose track is locked by a load', () => {
@@ -346,8 +347,8 @@ describe('placement and drop-off', () => {
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(1, 0), nsStraight()), 'north of the Office');
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'south of the Office');
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
});
it('refuses a card whose ports do not meet the neighbour it touches', () => {
@@ -361,7 +362,7 @@ describe('placement and drop-off', () => {
},
at(0, 0),
);
assert.ok(!canPlaceAt(area, at(1, 0), straight()), 'e-w straight cannot meet a n-s stub');
assert.ok(!canPlaceAt(area, at(-1, 0), straight()), 'e-w straight cannot meet a n-s stub');
});
it('refuses a card that connects to nothing', () => {