Expand game engine, replay, tests, and documentation
This commit is contained in:
@@ -21,3 +21,6 @@ __pycache__/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Generated replays
|
||||
replay*.html
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -8,9 +8,18 @@ web browser and an authoritative server manages lobbies and game state.
|
||||
|
||||
- `StationMasterPrototypeRules.pdf` — the original prototype rules
|
||||
- `StationMaster-PrototypeTurnChart.pdf` — the 12-Stage turn chart
|
||||
- `Deck cards2.xlsx` — the complete card list (115-card deck, 104 track pieces, 12 start cards)
|
||||
- `Mainline Cards.pdf` — the ten Mainline card types with their speeds and entry points
|
||||
- `Trains3.pdf` — all 22 train cards with names, speed class, consist and operating rules
|
||||
- `tracks.png` — card art for track and industry cards
|
||||
|
||||
These stay as-is. Everything below is derived from them.
|
||||
|
||||
> **The last four arrived after the engine was built** and expand the game roughly threefold. See
|
||||
> [`rules/implications.md`](rules/implications.md): much of what is recorded below as decided was a
|
||||
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
|
||||
> ruleset that does not match the design.
|
||||
|
||||
## Rules
|
||||
|
||||
| Document | What it is |
|
||||
@@ -20,6 +29,7 @@ These stay as-is. Everything below is derived from them.
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
|
||||
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
|
||||
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
|
||||
| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -74,6 +84,11 @@ milestones worth knowing:
|
||||
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
|
||||
TypeScript natively, so there is no build step during development.
|
||||
|
||||
> **The recovered design files are now transcribed and the nine open questions answered** — see
|
||||
> [`rules/implications.md`](rules/implications.md) §1b for exactly what is implemented. The deck is
|
||||
> 115 cards (93 in solitaire), Mainline cards have terrain and crossing times, and the balance
|
||||
> numbers below predate all of it.
|
||||
|
||||
**Steps 1–6 are done** — the rules engine (components 1–7), heuristic bots (17) and the balance
|
||||
harness (18), with 134 tests passing.
|
||||
|
||||
@@ -103,6 +118,11 @@ Next: step 7 begins the server (components 8, 9, 20).
|
||||
|
||||
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
||||
|
||||
**Watch a game:** `node src/sim/replay.ts --seed 1234 [--length standard] [--out replay.html]`
|
||||
writes a self-contained HTML file — open it in any browser and step through the game. It shows the
|
||||
Division, the Office Area grid, every facility's boxes and `MEN|AT|WORK` track, plain-English
|
||||
narration of each event, and a **Blocked** panel explaining why nothing is moving.
|
||||
|
||||
Running alongside, and independent of all of it: **print-and-play components.** `card-reference.md`
|
||||
specifies every card face, so layout and art are the only remaining work before a table playtest —
|
||||
which answers the one question simulation cannot, whether it is fun.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
# Implications of the four recovered design files
|
||||
|
||||
Four files were added to `docs/` on 2026-07-30 that were part of the original design but never made
|
||||
it into this workspace:
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `Deck cards2.xlsx` | The **complete card list** — every card, its count, where it may be placed, what it does |
|
||||
| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Yard, and both Division Points |
|
||||
| `Trains3.pdf` | The **train cards** — all 22, with names, speed class, consist and individual operating rules |
|
||||
| `tracks.png` | **Card art** for track and industry cards |
|
||||
|
||||
This document records what they imply and proposes what to do. **§1a below records what has since been
|
||||
implemented**; everything else is still a proposal.
|
||||
|
||||
---
|
||||
|
||||
## 1. The headline: the game is roughly 3× what we built
|
||||
|
||||
| | Built | Recovered design | Change |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Home Office deck | 52 | **115** | 2.2× |
|
||||
| Track cards | in the deck (12) | **104**, a per-player supply of 26 | moved out of the deck entirely |
|
||||
| Start cards | ad-hoc | **12** (4 Whistle Posts, 8 Limits) | formalised |
|
||||
| **Total components** | ~52 | **231** | |
|
||||
| Train cards | 16 | **22** | 12 timetabled + 10 Extras |
|
||||
| Distinct card categories | 6 | **10** | four categories are entirely new |
|
||||
|
||||
The sheet's own totals (115 / 104 / 12 / 231) reconcile exactly with the line items, so these
|
||||
numbers are the design's, not an inference.
|
||||
|
||||
**Most of my invented numbers are now superseded, and that is a good outcome.** Everything in
|
||||
`card-reference.md` was explicitly provisional — placeholders standing in for exactly this document.
|
||||
|
||||
---
|
||||
|
||||
## 1b. Implementation status after the answers (2026-07-31)
|
||||
|
||||
All nine answers are implemented, **170 tests passing**:
|
||||
|
||||
| Answer | Implemented as |
|
||||
| --- | --- |
|
||||
| Q1 crossing time | `crossingStages()` — a 60 card takes 1 Stage, a 30 takes 2. Mainline nodes now carry a **terrain type** dealt at setup, and trains count down Stages instead of stepping through regions. The `Region` model is gone. |
|
||||
| Q2 Fast/Slow | Slow adds one Stage to every card. Hilly reads the consist (any coach = passenger). |
|
||||
| Q3 Expedite | An expedited train departs the Stage it arrives — it gets a second `moveTrain` in the same Mainline Phase, still subject to §8.1 clearance. |
|
||||
| Q4 Lockouts | `isLockedOut()` rejects the placement with `FACILITY_LOCKED`. |
|
||||
| Q5 Run-around | Nothing to do — reachability is geometric, so a built bypass already works. |
|
||||
| Q6 Space-use | **Solitaire decks omit all 22 opponent-directed cards**: `buildDeck('solitaire')` returns **93**, competitive returns 115. |
|
||||
| Q7 Downward-only | Office cards have a stub **below only**; turnout and curve legs always diverge south; placement and modifier candidates above the Running Track are rejected. §9's "nine nearby spots" is really **six**. |
|
||||
| Q8 Crew Tray cap | Already correct. |
|
||||
| Q9 Second Section | `newTrain.secondSection` plays on a train due out; an identical train is made up behind it next New Train Phase. The **Sister trains optional rule is removed** from Appendix B. |
|
||||
|
||||
**Still not implemented**, and now the largest remaining block: the behaviour of the Enhancement
|
||||
(18), Mainline-modifier (7) and Maneuver (7) cards, plus the Action (10) and Space-use (12) cards in
|
||||
multiplayer. All are in the deck; playing them is rejected with `NOT_IMPLEMENTED`.
|
||||
|
||||
### Measurement after the answers
|
||||
|
||||
100 solitaire Standard games: mean revenue **−1.3**, trains scheduled **2.8**, cards played **11.3**.
|
||||
|
||||
Still no wins, and still **not a verdict** — roughly a third of the deck does nothing yet, and the
|
||||
Small Yard enhancement (which is what makes the switching puzzle solvable) is among the unimplemented
|
||||
cards. Gap 12 stays invalidated until the enhancements are in.
|
||||
|
||||
---
|
||||
|
||||
## 1a. What has been done so far (2026-07-31)
|
||||
|
||||
**Transcribed and in code**, with 169 tests passing:
|
||||
|
||||
- The full **115-card deck** — `deckComposition()` reconciles exactly with the sheet's own total.
|
||||
- **Track removed from the deck** and modelled as a per-player supply of **26** pieces
|
||||
(`TRACK_SUPPLY`), with printed handedness and a **move cost** field (2 for sharp curves).
|
||||
- **Curves** added to the track graph; the invented run-around card removed.
|
||||
- All **22 trains** with names, Fast/Slow speed class, category-based consists and per-card
|
||||
operating rules.
|
||||
- **Industries retuned to 1-out / 1-loader**, with the real six kinds and their lockout lists.
|
||||
- All **23 modifiers**, each tied to its specific hosts, with their effects applied on placement.
|
||||
- **Office slots corrected** to equal porter count; deck copies corrected to Depot 4 / Station 2 /
|
||||
Terminal 1.
|
||||
- The **ten Mainline card types** with speeds and named entry points, as data.
|
||||
- The four new categories (space-use, enhancements, mainline modifiers, maneuver, action) are in
|
||||
the deck. **Playing them is rejected with `NOT_IMPLEMENTED`** rather than silently doing nothing —
|
||||
the failure mode that left Modifiers as dead weight for weeks.
|
||||
|
||||
**Not done, pending the questions in §10**: the speed model, Expedite, lockout enforcement,
|
||||
space-use semantics, and everything the four new categories actually do.
|
||||
|
||||
### First measurement on the real catalogue
|
||||
|
||||
100 solitaire Standard games, same bot: mean revenue **−1.9**, trains scheduled **2.3** (was 4.8),
|
||||
cards played **10.5** (was 16.3).
|
||||
|
||||
Development has slowed sharply, and the reason is visible in the composition: **industries are now
|
||||
9 cards in 115 (7.8%), where the placeholder had 10 in 52 (19%)**. Their density more than halved,
|
||||
and trains fell from 16-in-52 to 22-in-115. The deck is much more diluted, which is exactly what the
|
||||
enhancements, maneuver and action cards are there to fill — and none of those do anything yet.
|
||||
|
||||
**Read no balance conclusions from this number.** It measures a half-implemented ruleset.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deck composition
|
||||
|
||||
### What the design says — 115 cards
|
||||
|
||||
| Category | Cards | Notes |
|
||||
| --- | ---: | --- |
|
||||
| Offices (Depot 4, Station 2, Terminal 1) | 7 | *We had 9 (4/3/2)* |
|
||||
| Passenger modifiers (Waiting area 3, Restaurant 2, Hotel 1) | 6 | each adds 1 passenger-out + 1 porter |
|
||||
| Industries (Freight House 2, Mine Tipple 2, Refinery 1, Power Plant 2, Packing Sheds 1, Grocer's Warehouse 1) | 9 | *We had 10 in a different mix* |
|
||||
| Industry modifiers | 17 | each tied to a **specific** industry |
|
||||
| Trains (12 timetabled + 10 Extras) | 22 | *We had 16* |
|
||||
| **Space-use cards** | 12 | **entirely new** — cards that burn table space |
|
||||
| **Enhancements** | 18 | **entirely new** |
|
||||
| **Mainline modifiers** | 7 | **entirely new** |
|
||||
| **Maneuver cards** | 7 | Red Flags ×5, Flying Switch, Poling |
|
||||
| **Action cards ("screw you")** | 10 | **entirely new** — played *at other players* |
|
||||
| **Total** | **115** | |
|
||||
|
||||
### Proposal
|
||||
|
||||
- Replace `content.ts` wholesale with this catalogue. It is data, so this is transcription rather
|
||||
than design.
|
||||
- **Gap 4a (52-card deck) is superseded.** Record it as such rather than deleting the reasoning —
|
||||
the deck was a stand-in and it did its job.
|
||||
- **Gap 13 (deck scaling by player count) largely dissolves.** Track is now per-player by
|
||||
construction, which was the real pressure I measured. The 115-card deck is shared. Re-measure
|
||||
before concluding anything further.
|
||||
|
||||
---
|
||||
|
||||
## 3. Track — the biggest structural change
|
||||
|
||||
### What the design says
|
||||
|
||||
Track is **not in the deck**. Each player gets a fixed personal supply of **26 pieces**:
|
||||
|
||||
| Piece | Per player | Total (4p) | Notes |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Straight | 8 | 32 | |
|
||||
| Curved left | 4 | 16 | **we have no curves at all** |
|
||||
| Curved right | 4 | 16 | |
|
||||
| Sharp curved left | 1 | 4 | **any move across is doubled — two Moves** |
|
||||
| Sharp curved right | 1 | 4 | |
|
||||
| Turnout left | 4 | 16 | |
|
||||
| Turnout right | 4 | 16 | |
|
||||
|
||||
Column J ("Train can stop here") is set for straights and curves, blank for turnouts — which matches
|
||||
what we built: a turnout is not Operational Rail.
|
||||
|
||||
### What this changes
|
||||
|
||||
1. **Curves exist and we have none.** Our geometry is straight / turnout / run-around. The design has
|
||||
straight / curved / sharp-curved / turnout, each in left and right hands. Run-around is not a card
|
||||
at all — it is presumably *built* from a turnout pair.
|
||||
2. **Handedness is printed, not chosen.** Left and right are distinct physical cards.
|
||||
**Gap 11's decision — "orientation is chosen on placement" — is therefore superseded.** The
|
||||
measurement behind it stands (a fixed default made vertical growth impossible), but the design's
|
||||
answer is different and better: give the player a supply containing both hands.
|
||||
3. **Sharp curves cost two Moves.** A per-card movement cost, which our Move model does not have.
|
||||
4. **Track no longer competes for deck slots**, so the draw is entirely about industry, trains and
|
||||
enhancements. This materially changes the economy I measured.
|
||||
|
||||
### Proposal
|
||||
|
||||
- Rework `track.ts` around a per-player supply with printed handedness; drop the variant machinery.
|
||||
- Add a **move cost** per card (1 normally, 2 for sharp curves) and charge it against the six Moves.
|
||||
- Confirm with the designer whether run-around is a built pattern rather than a card — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 4. Industries and modifiers — much smaller, much more specific
|
||||
|
||||
### What the design says
|
||||
|
||||
Each industry is **one car out and one loader**, not the 2–3 capacity and 2–3 Laborers I invented:
|
||||
|
||||
| Industry | In deck | Car | Direction | Base capacity |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| Freight House | 2 | Box | BOTH | 1 boxcar out, 1 loader; boxcars in |
|
||||
| Mine Tipple | 2 | Hopper | OUT | 1 hopper out, 1 loader |
|
||||
| Refinery | 1 | Tank | OUT | 1 tank out, 1 loader |
|
||||
| Power Plant | 2 | Hopper, Tank | IN | 1 loader |
|
||||
| Packing Sheds | 1 | Reefer | OUT | 1 reefer out, 1 loader |
|
||||
| Grocer's Warehouse | 1 | Box, Reefer | IN | — |
|
||||
|
||||
Capacity is then grown by **industry-specific modifiers**, each adding "+1 out, +1 loader":
|
||||
|
||||
- Mine Tipple → Prep Plant, Coal Piles, Conveyor Belts
|
||||
- Refinery → Pipelines, Oil Depot, Viscosity breakers
|
||||
- Power Plant → Transmission lines, Rotary Dumps, Steam Turbines
|
||||
- Packing Sheds / Grocer's → Ice House, Local small groceries
|
||||
- Freight House / Packing Sheds / Grocer's → Truck dock, Forklifts, Railroad Express Agency
|
||||
|
||||
**Column E is a lockout list** — a concept we do not have at all. Freight House locks out Grocer's
|
||||
Warehouse; Mine Tipple locks out Power Plant; Refinery locks out Power Plant; Packing Sheds locks out
|
||||
Grocer's Warehouse. Presumably a player may not build both of a locked-out pair.
|
||||
|
||||
### Passenger side — our guesses were right
|
||||
|
||||
| Office | Design | What we built |
|
||||
| --- | --- | --- |
|
||||
| Depot | 1 psgr out, 1 in, 1 porter, **2 A/D** | 1 porter, 2 green, 2 red, 2 A/D |
|
||||
| Station | 2 / 2 / 2 porters, **3 A/D** | 2 porters, 3 green, 3 red, 3 A/D |
|
||||
| Terminal | 3 / 3 / 3 porters, **4 A/D** | 3 porters, 4 green, 4 red, 4 A/D |
|
||||
| Whistle Post | 1 A/D, not in deck, 4 copies | 1 A/D ✓ |
|
||||
|
||||
Porters and A/D tracks match exactly. **Our slot capacities are one too generous at every tier** —
|
||||
the design gives slots equal to porters, not one more. Passenger modifiers (Waiting area, Restaurant,
|
||||
Hotel) are what grow them.
|
||||
|
||||
### Proposal
|
||||
|
||||
- Retune every industry to **1 out / 1 loader** and rebuild capacity growth around the 17 modifiers.
|
||||
This makes Gap 10a's "moderate, varied by industry" profile obsolete.
|
||||
- Correct passenger slots to equal porter count.
|
||||
- Implement **lockouts** — a new placement constraint.
|
||||
- **This changes the economy substantially**: a base industry is far weaker than what we modelled, so
|
||||
Gap 12's balance measurements must be re-run from scratch rather than adjusted.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trains — names, speed classes, and per-train rules
|
||||
|
||||
### Timetabled (1–12)
|
||||
|
||||
| # | Name | Speed | Consist | Rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1/2 | Crack Limited | Fast | 3 coaches | Stop at Terminals only. No switching. Expedite. |
|
||||
| 3/4 | Express | Fast | 2 freight | May drop or pick up one freight car at every location. Expedite. |
|
||||
| 5/6 | The Sparrow | Fast | 2 coaches | No switching. Expedite. |
|
||||
| 7/8 | Local | Slow | 1 freight + 1 coach | Coach must remain on station track if switching. |
|
||||
| 9/10 | Heavy Freight | Slow | 3 freight + caboose | |
|
||||
| 11/12 | Drag Freight | Slow | 2 freight + caboose | |
|
||||
|
||||
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
||||
|
||||
Appleseed Extra (MT freight only, may drop but not pick up), Fruit Growers Express (reefers only),
|
||||
Yard Xfer, Light Engine Move (no cars at all), Campaign Train (one turn at station for speeches),
|
||||
Circus Train (**one turn stopped anywhere earns 1 point**), Military Train, Director's Private Car,
|
||||
Freight Extra, Pee-Dee (per-diem, may only pick up empties). Plus a **Second Section** card that
|
||||
duplicates the next scheduled train's seniority, rules and consist.
|
||||
|
||||
### What this changes
|
||||
|
||||
1. **Consists are by CATEGORY** — "Freight (2)" means any two freight cars, not two hoppers. Our
|
||||
`allowedTypes` model is too narrow.
|
||||
2. **Fast / Slow is a train property** that pairs with the Mainline cards' 60/30 speeds (§6). We have
|
||||
no speed model at all.
|
||||
3. **"Expedite"** is a keyword appearing on seven trains, meaning is not yet defined for us.
|
||||
4. **Per-train operating rules** — "no switching", "stop at Terminals only", "may drop or pick up one
|
||||
freight car at every location". We have none of these.
|
||||
5. **Extras are X13–X22**, all junior to every timetabled train. **Gap 5 (Extra vs Timetabled ties)
|
||||
becomes unreachable** — the tie it resolves cannot occur. The decision is harmless but moot.
|
||||
6. **The Circus Train earns a point for standing still** — the first revenue source that is not a
|
||||
load/unload operation. Our scoring model assumes only §9 operations and collisions.
|
||||
7. **Second Section** makes the "Sister trains" optional rule concrete.
|
||||
|
||||
### Proposal
|
||||
|
||||
- Rewrite the train catalogue from `Trains3.pdf`.
|
||||
- Add `speed: fast | slow` and a `rules` field per train; implement the operating rules as checks in
|
||||
`apply.ts`.
|
||||
- Define "Expedite" with the designer before implementing it — see §10.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mainline cards — a whole system we reduced to a constant
|
||||
|
||||
We modelled a Mainline card as **2 regions, uniform**. The design has **ten distinct card types**:
|
||||
|
||||
| Card | Speed | Notes |
|
||||
| --- | --- | --- |
|
||||
| Plains | 60 | |
|
||||
| Curves | 30 | |
|
||||
| Hilly | **P60 / F30** | different speeds for passenger and freight |
|
||||
| Heavy Grade (Up) | **G** | player sets orientation; Brakeman/Airbrakes/Helpers unlock better start positions |
|
||||
| Double Track | 60 | **trains may pass** |
|
||||
| Uncontrolled Siding | 60 | trains may pass; separate "no pass" and "passing" starts |
|
||||
| Tunnel | 30 | |
|
||||
| Trestle | 60 | |
|
||||
| Yard | 60 | **sort cars into any new order**; has a Yard Limit |
|
||||
| East / West Division Point | — | the ends |
|
||||
|
||||
Each card shows **Start positions** — where a train enters depending on direction, train type, and
|
||||
which modifier cards are in play. The Heavy Grade card has five distinct starts (plain, brakemen,
|
||||
airbrakes, plain, helpers), so playing Brakeman literally moves your entry point further along.
|
||||
|
||||
`Realignment` can convert one Mainline card type into another (Plains→Double Track, Curves→Plains,
|
||||
Uncontrolled Siding→Double Track, Trestle→Uncontrolled Siding).
|
||||
|
||||
### What this changes
|
||||
|
||||
**This is the single largest mechanical gap.** Our Mainline Phase moves every train one region per
|
||||
Stage, unconditionally. The design has speed classes, per-train-type speeds, passing, grades that
|
||||
modifier cards ameliorate, and yards that re-sort consists.
|
||||
|
||||
It also bears directly on **Gap 12**. I measured "a train stands at the Office only 18% of Stages"
|
||||
and treated it as a possible balance problem. With Double Track, passing sidings, and 60-speed
|
||||
cards, traffic density is a *designed variable* — the measurement was taken against a placeholder.
|
||||
|
||||
### Proposal
|
||||
|
||||
- Model Mainline cards as typed, with a speed and a set of named entry points.
|
||||
- Implement Fast/Slow × card-speed to derive how far a train moves per Stage.
|
||||
- **Re-run every Gap 12 measurement afterwards.** The current numbers describe a game that does not
|
||||
exist.
|
||||
|
||||
---
|
||||
|
||||
## 7. Four entirely new card categories
|
||||
|
||||
### Space-use cards (12)
|
||||
|
||||
Bean House, Flop House, Watertower, Hobo Jungle, Section House, City Blocks ×4, Engine Shops,
|
||||
Tenderloin District, Engineer Cemetery. All "burn tablespace" — they occupy a grid cell and do
|
||||
nothing for you. Presumably played at opponents, or a cost of drawing.
|
||||
|
||||
### Enhancements (18)
|
||||
|
||||
- **Interlocking** (+ Facing Point Locks): may stop an inbound train on the Limit track.
|
||||
- **Yard Office**: an inbound train with no coaches may arrive at the yard instead of the Train Order
|
||||
Office.
|
||||
- **Small Yard**: a train spending one Move in the yard **may sort itself into any order, including
|
||||
cars ahead of the engine**.
|
||||
- **Water Column**: lets you remove a Watertower.
|
||||
- **Overpass**: removes restrictions on a Railroad Crossing.
|
||||
- **Telegraph / Telephone / Radio**: once a day, when dispatching facing trains, add **+4 / +8 / +12**
|
||||
to the other train's number. A genuine dispatching-technology progression.
|
||||
- **ABS Signals**: trains on this Mainline card will not rear-end each other; they stop short.
|
||||
|
||||
**Small Yard deserves special note.** It solves precisely the problem my bot cannot handle: §A.3
|
||||
forces cars off in seated order, so spotting a specific car needs the consist re-ordered. A Small
|
||||
Yard makes that possible. The switching puzzle was *designed* to have this answer, and we did not
|
||||
have the card.
|
||||
|
||||
**ABS Signals directly touches Gap 2.** We made collisions unconditional; this card makes them
|
||||
avoidable on a given card.
|
||||
|
||||
### Mainline modifiers (7)
|
||||
|
||||
Brakeman, Airbrakes (requires Brakeman), Helpers, Realignment ×2, Facing Point Locks ×2.
|
||||
|
||||
### Action cards — played at other players (10)
|
||||
|
||||
Derail, Broken Coupler, Railroad Crossing, Per Diem Inventory, Demurrage Charge, Customer
|
||||
Complaints, Vandalism, Hotbox, Outlawed.
|
||||
|
||||
**We have no player-interaction mechanic whatsoever.** Several of these cause *point losses* based on
|
||||
board state — "lose one point per 2 empty cars on Secondary Tracks" — which is a scoring channel our
|
||||
model does not have. In a Competitive game these are presumably a large part of the tension.
|
||||
|
||||
### Proposal
|
||||
|
||||
Treat these as four separate work packages, in this order: **Enhancements** (they change core
|
||||
movement and switching), **Mainline modifiers**, **Action cards** (needed for multiplayer to mean
|
||||
anything), **Space-use** (lowest mechanical weight).
|
||||
|
||||
---
|
||||
|
||||
## 8. What this does to the decided gaps
|
||||
|
||||
| Gap | Status now | Why |
|
||||
| --- | --- | --- |
|
||||
| 1 Phase order | **stands** | nothing in the new files contradicts it |
|
||||
| 2 Collisions automatic | **amended** | ABS Signals makes them avoidable on a card |
|
||||
| 3 Office upgrades | **stands**, counts confirmed | A/D and porters match exactly |
|
||||
| 4a Deck composition | **superseded** | 52 → 115, track removed from deck |
|
||||
| 4b Piece counts | **needs re-derivation** | Mainline speeds change train turnaround entirely |
|
||||
| 4c Consist criteria | **superseded** | consists are by category, with per-train rules |
|
||||
| 5 Extra/Timetabled ties | **moot** | Extras are X13–X22; the tie cannot occur |
|
||||
| 6 Victory conditions | **needs re-derivation** | new revenue sources and point losses |
|
||||
| 7 Terminology | **stands** | |
|
||||
| 8 Office junction stubs | **needs checking against the art** | `tracks.png` shows the through-line at the card's top edge, not the middle |
|
||||
| 9 Make-up round repeats | **stands** | |
|
||||
| 10 Card faces | **superseded** | this is the real card list |
|
||||
| 11 Track orientation | **superseded** | handedness is printed; both hands are supplied |
|
||||
| 12 Balance targets | **invalidated, re-measure** | measured against a placeholder ruleset |
|
||||
| 13 Deck scaling | **largely dissolves** | track is per-player by construction |
|
||||
|
||||
Superseded decisions should be **marked, not deleted** — the reasoning that produced them is how we
|
||||
know what questions the design answers.
|
||||
|
||||
---
|
||||
|
||||
## 9. Code impact
|
||||
|
||||
| Component | Impact | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1 Card catalogue | **Rewrite** | pure data; largest single edit but lowest risk |
|
||||
| 2 State model | **Major** | new card kinds, per-player track supply, typed Mainline cards, train speed/rules |
|
||||
| 3 Track graph | **Major** | curves, printed handedness, per-card move cost |
|
||||
| 4 apply | **Major** | per-train operating rules, enhancements, action cards played at opponents |
|
||||
| 5 Phase driver | **Major** | speed model, passing, grades, yard sorting |
|
||||
| 6 legalActions | Moderate | follows from 4; the discipline of delegating to `check` protects it |
|
||||
| 7 RNG | None | |
|
||||
| 17 Bot | **Rewrite** | Small Yard changes the switching problem fundamentally |
|
||||
| 18 Harness | Minor | re-run everything |
|
||||
| 16 Replay | Moderate | new card types and mechanics to narrate |
|
||||
| 19 Tests | **Substantial** | most rules assertions need revisiting |
|
||||
|
||||
**The engine's architecture holds.** Nothing here breaks the `check`/`execute`/`reduce` split, the
|
||||
event log, or the pure-engine boundary. This is a large content and rules expansion inside a
|
||||
structure that was built to take it — which is the main thing worth saying about it.
|
||||
|
||||
---
|
||||
|
||||
## 9a. Answers from the designer
|
||||
|
||||
Recorded as they are given, so nothing is lost between sessions.
|
||||
|
||||
| # | Question | Answer |
|
||||
| --- | --- | --- |
|
||||
| 1 | What do Mainline 60 / 30 mean? | **Miles per hour, expressed as crossing time.** A 60 card takes **one Stage** to cross; a 30 card takes **two**. The printed cells are decoration. Realignment (Curves→Plains) therefore has real value, and 30-cards are genuine chokepoints. |
|
||||
| 2 | How does Fast/Slow affect crossing? | **Slow adds one Stage to every card.** Fast 60→1, 30→2; Slow 60→2, 30→3. The label therefore matters on every card. Consequence to watch: Slow trains occupy a Crew Tray roughly twice as long, so tray scarcity (§7) bites considerably harder than the placeholder measured. |
|
||||
| 3 | What does "Expedite" mean? | **The train departs the same Stage it arrives** — it does not spend a Stage standing at the Office. Still subject to the normal §8.1 clearance rules, so it can be held. Fits X17's "one turn at station *then* expedite", and partly offsets the Slow-train transit cost below. |
|
||||
| 4 | What does a lockout prevent? | **Building both in your district.** If you hold a Mine Tipple, a Power Plant card is unplayable in your Office Area, and vice versa. Each locked pair is a producer and the consumer of the same commodity, so this forces traffic to flow *between* districts — a deliberate multiplayer-interdependence mechanic. In solitaire it simply narrows the build options. |
|
||||
| 5 | Is a run-around a card? | **No — a pattern built from the track supply.** Opposite-hand turnouts joined by straights. Nothing to implement: reachability is computed from geometry, so a valid bypass already works. This explains the 8 turnouts per player, and makes §A.5's facing-point move a building achievement rather than a lucky draw. |
|
||||
| 6 | How are Space-use cards played? | **At another player's district**, consuming a grid cell they wanted. This explains Water Column ("remove any Watertower in *your* district" — one an opponent gave you) and Vandalism (exploits a Hobo Jungle you were saddled with). A second, milder attack channel alongside the Action cards. **In solitaire they have no target** — see the note below. |
|
||||
| 7 | Does a district grow both ways? | **Downward only** — the through-track sits at the card's top edge and everything diverges beneath it, matching `tracks.png`. A district is a Running Track with all Secondary Track hanging below. **Supersedes Gap 8's north-and-south stubs.** Consequences: buildable area halves; districts become long and shallow; §9's nine-spot Modifier neighbourhood needs rewording, since three of the nine spots are above the track and no longer exist. |
|
||||
| 8 | Any cap on trains in play? | **No — Crew Trays are the only limit.** All 12 timetabled trains may end up scheduled; Extras run on top ad hoc; a due train with no free crew is held (§7). Already implemented. This makes the Crew Tray count (`players + 3`) the single most important balance dial, and "held" a common visible state. |
|
||||
| 9 | What is the Second Section card? | **A card played on a train that is due out.** A second identical train runs immediately behind it — same number, rules and consist — and needs its own Crew Tray. It deliberately creates the following-train situation §8.1 makes the Superintendent rule on, so a player can force the game's central decision onto the table. Explains the green flags on the artwork. **Supersedes the "Sister trains" optional rule**, which should be removed from Appendix B. |
|
||||
|
||||
### Solitaire consequence of Q6
|
||||
|
||||
If Space-use cards are played at opponents, then in the **solitaire MVP they have no legal target** —
|
||||
12 of 115 cards (10.4%) become dead draws. Combined with the 10 Action cards, which are also
|
||||
opponent-directed, **22 of 115 cards (19%) do nothing in a one-player game.**
|
||||
|
||||
That is a real finding for the MVP, and there are only a few honest ways to handle it: remove them
|
||||
from a solitaire deck, allow them to be discarded freely, or accept that solitaire draws are a fifth
|
||||
dead. Worth deciding explicitly rather than by default. Not blocking — flagged for after the
|
||||
remaining questions.
|
||||
|
||||
### Consequence noted at the time of answering
|
||||
|
||||
Under Q1 + Q2, on a 3-player Division (4 Mainline cards, mixed terrain):
|
||||
|
||||
| | Mainline Stages | + Office stops | End-to-end |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Fast train | 6 | 3 | **9 Stages** |
|
||||
| Slow train | 10 | 3 | **13 Stages** |
|
||||
|
||||
A Day is 12 Stages, so **every Slow train is still on the road when the next Day begins**, holding
|
||||
its Crew Tray. With `players + 3` trays this will throttle freight hard. Expedite (Q3) relieves it
|
||||
somewhat for the trains that carry it — but none of the Slow freight trains do. Worth re-measuring
|
||||
once the speed model is implemented; the Crew Tray count is the obvious dial.
|
||||
|
||||
---
|
||||
|
||||
## 10. Questions only the designer can answer
|
||||
|
||||
1. **"Expedite"** — appears on seven trains and is never defined. Priority? No stopping? Extra speed?
|
||||
2. **Speed numbers.** Do 60 and 30 mean "cells per Stage", "Stages to cross", or literal mph against
|
||||
a 2-hour Stage? A Mainline card is "roughly a hundred miles" (§2.1), so 60 mph × 2 h ≈ one card
|
||||
per Stage and 30 ≈ two — but this should be confirmed, not inferred.
|
||||
3. **Fast vs Slow trains** — how do they interact with a card's speed? Does a Slow train take double
|
||||
on every card, or only where the card distinguishes (Hilly's P60/F30)?
|
||||
4. **Lockouts** (spreadsheet column E) — does one card prevent *building* the other, or prevent both
|
||||
*operating*?
|
||||
5. **Run-around** — we treat it as a card; it is not in the supply. Is it a pattern built from two
|
||||
turnouts?
|
||||
6. **Space-use cards** — played at yourself as a cost of drawing, or at opponents?
|
||||
7. **Where the Office's junction stubs are.** `tracks.png` draws the through-line along the top edge
|
||||
with curves diverging downward. Gap 8 assumed stubs above *and* below a centred track.
|
||||
8. **Are all 22 trains in play at once**, or is the timetable still 12 slots with Extras run on top?
|
||||
9. **Second Section** — is it a card played on a scheduled train, or its own train?
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended sequencing
|
||||
|
||||
The temptation is to rebuild everything at once. I would not.
|
||||
|
||||
1. **Transcribe the catalogue first** (`content.ts` from the spreadsheet and `Trains3.pdf`). Pure
|
||||
data, no rules risk, and it makes the true scope concrete.
|
||||
2. **Answer §10's questions**, particularly speed and Expedite — several later decisions hang on
|
||||
them.
|
||||
3. **Mainline card types and the speed model.** The largest mechanical gap and the one that
|
||||
invalidates the current balance numbers.
|
||||
4. **Track supply and curves**, including the sharp-curve double Move cost.
|
||||
5. **Industry retune to 1-out/1-loader plus the modifier system**, then re-run the harness. Expect
|
||||
very different numbers.
|
||||
6. **Enhancements**, starting with Small Yard and ABS Signals — both change core loops.
|
||||
7. **Action cards**, which is when multiplayer becomes a real game rather than parallel solitaire.
|
||||
8. **Space-use cards** last.
|
||||
|
||||
**Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder
|
||||
ruleset. Re-measure after step 5 and treat everything before that as void.
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
+146
-32
@@ -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
@@ -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
@@ -1,132 +1,89 @@
|
||||
/**
|
||||
* Component 1 — Card catalogue / static content.
|
||||
*
|
||||
* The printed values of every card. Source of truth: docs/rules/card-reference.md.
|
||||
* See architecture/components.md §2 A.1.
|
||||
* TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30):
|
||||
* docs/Deck cards2.xlsx — the complete card list and counts
|
||||
* docs/Trains3.pdf — all 22 train cards
|
||||
* docs/Mainline Cards.pdf — the ten Mainline card types
|
||||
* docs/tracks.png — card art
|
||||
*
|
||||
* This is DATA, not logic. Every number here is provisional and will be retuned repeatedly
|
||||
* against playtesting — keep tuning confined to this file so it never requires touching the
|
||||
* engine.
|
||||
* This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed
|
||||
* and which decisions it supersedes.
|
||||
*
|
||||
* This is DATA, not logic. Keep tuning confined here so it never requires touching the engine.
|
||||
*
|
||||
* BEHAVIOUR NOT YET IMPLEMENTED, pending answers from the designer (implications.md §10): the
|
||||
* meaning of Mainline speeds and Fast/Slow, the "Expedite" keyword, lockout semantics, and how
|
||||
* space-use cards are played. The DATA for all of these is transcribed below and marked.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §2.2. A coloured car is loaded; a white car is empty. */
|
||||
export type CarType = 'coach' | 'boxcar' | 'reefer' | 'hopper' | 'tank' | 'caboose';
|
||||
|
||||
export type Direction = 'east' | 'west';
|
||||
|
||||
/** §9 — some freight facilities load only, some unload only, some both. */
|
||||
/** §9 — an industry loads only, unloads only, or does both. */
|
||||
export type FlowDirection = 'outbound' | 'inbound' | 'both';
|
||||
|
||||
export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal';
|
||||
|
||||
/** The six industries in the design. Note: no "oilRefinery" — it is `refinery`. */
|
||||
export type FreightKind =
|
||||
| 'freightHouse'
|
||||
| 'mineTipple'
|
||||
| 'produceShed'
|
||||
| 'grocersWarehouse'
|
||||
| 'oilRefinery'
|
||||
| 'powerPlant';
|
||||
| 'refinery'
|
||||
| 'powerPlant'
|
||||
| 'packingSheds'
|
||||
| 'grocersWarehouse';
|
||||
|
||||
export type ModifierKind =
|
||||
| 'teamTrack'
|
||||
| 'loadingDock'
|
||||
| 'storageShed'
|
||||
| 'extraPlatform'
|
||||
| 'sectionGang';
|
||||
|
||||
export type TrackGeometry = 'turnout' | 'straight' | 'runAround';
|
||||
export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout';
|
||||
export type Hand = 'left' | 'right' | 'none';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freight facilities — card-reference.md §2
|
||||
// Track — a PER-PLAYER supply, not deck cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FreightProfile = {
|
||||
kind: FreightKind;
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
hand: Hand;
|
||||
name: string;
|
||||
carType: CarType;
|
||||
flow: FlowDirection;
|
||||
laborers: number;
|
||||
/** Green box capacity; 0 when the facility does not load. */
|
||||
outboundCapacity: number;
|
||||
/** Red box capacity; 0 when the facility does not unload. */
|
||||
inboundCapacity: number;
|
||||
/** Cars that fit on the industry track. Never above 4 (§9.3). */
|
||||
industryTrackLength: number;
|
||||
/** Copies in the deck. Identical pairs — Gap 10b. */
|
||||
copies: number;
|
||||
perPlayer: number;
|
||||
/** Column J of the sheet, "Train can stop here". Turnouts are blank. */
|
||||
isOperationalRail: boolean;
|
||||
/** Sharp curves: "Any move across this track is doubled (two moves)". */
|
||||
moveCost: number;
|
||||
};
|
||||
|
||||
export const FREIGHT_PROFILES: readonly FreightProfile[] = [
|
||||
{
|
||||
kind: 'mineTipple',
|
||||
name: 'Mine Tipple',
|
||||
carType: 'hopper',
|
||||
flow: 'outbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 3,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'produceShed',
|
||||
name: 'Produce Shed',
|
||||
carType: 'reefer',
|
||||
flow: 'outbound',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'grocersWarehouse',
|
||||
name: "Grocer's Warehouse",
|
||||
carType: 'boxcar',
|
||||
flow: 'both',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'oilRefinery',
|
||||
name: 'Oil Refinery',
|
||||
carType: 'tank',
|
||||
flow: 'both',
|
||||
laborers: 3,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'powerPlant',
|
||||
name: 'Power Plant',
|
||||
carType: 'hopper',
|
||||
flow: 'inbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 0,
|
||||
inboundCapacity: 3,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
/**
|
||||
* 26 pieces per player, 104 for four players. Track is NOT in the Home Office deck — this is the
|
||||
* single biggest structural change from the placeholder, and it means the draw is entirely about
|
||||
* industry, trains and enhancements.
|
||||
*
|
||||
* Handedness is PRINTED, not chosen on placement. That supersedes Gap 11.
|
||||
*/
|
||||
export const TRACK_SUPPLY: readonly TrackProfile[] = [
|
||||
{ geometry: 'straight', hand: 'none', name: 'Straight track', perPlayer: 8, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
|
||||
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
|
||||
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/**
|
||||
* "Freight House" (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
||||
* facility permitting both directions. Gap 10d.
|
||||
*/
|
||||
export function isFreightHouse(profile: FreightProfile): boolean {
|
||||
return profile.flow === 'both';
|
||||
}
|
||||
export const TRACK_PER_PLAYER = TRACK_SUPPLY.reduce((n, t) => n + t.perPlayer, 0); // 26
|
||||
|
||||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||||
export const WHISTLE_POST_SUPPLY = 4;
|
||||
export const LIMITS_SUPPLY = 8;
|
||||
|
||||
/** Legacy alias kept so existing call sites resolve; prefer TRACK_SUPPLY. */
|
||||
export const TRACK_PROFILES = TRACK_SUPPLY;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — card-reference.md §4
|
||||
// Offices — Depot 4, Station 2, Terminal 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
@@ -136,65 +93,25 @@ export type OfficeProfile = {
|
||||
isPassengerFacility: boolean;
|
||||
adTracks: number;
|
||||
porters: number;
|
||||
greenSlots: number;
|
||||
redSlots: number;
|
||||
/** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */
|
||||
/** Green slots. The design gives slots EQUAL to porters, not one more. */
|
||||
passengerOut: number;
|
||||
/** Red slots. */
|
||||
passengerIn: number;
|
||||
copiesInDeck: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gap 8: all four tiers carry IDENTICAL track geometry — a through track plus a plain junction
|
||||
* stub above and below. They differ only in the three flags and the counts below, which is what
|
||||
* makes an upgrade a drop-in replacement that never disturbs a connection.
|
||||
* A/D tracks and Porter counts are exactly as designed. The slot counts are corrected downward:
|
||||
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{
|
||||
tier: 'whistlePost',
|
||||
name: 'Whistle Post',
|
||||
isControlPoint: false,
|
||||
isPassengerFacility: false,
|
||||
adTracks: 1,
|
||||
porters: 0,
|
||||
greenSlots: 0,
|
||||
redSlots: 0,
|
||||
copiesInDeck: 0,
|
||||
},
|
||||
{
|
||||
tier: 'depot',
|
||||
name: 'Depot',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 2,
|
||||
porters: 1,
|
||||
greenSlots: 2,
|
||||
redSlots: 2,
|
||||
copiesInDeck: 4,
|
||||
},
|
||||
{
|
||||
tier: 'station',
|
||||
name: 'Station',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 3,
|
||||
porters: 2,
|
||||
greenSlots: 3,
|
||||
redSlots: 3,
|
||||
copiesInDeck: 3,
|
||||
},
|
||||
{
|
||||
tier: 'terminal',
|
||||
name: 'Terminal',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 4,
|
||||
porters: 3,
|
||||
greenSlots: 4,
|
||||
redSlots: 4,
|
||||
copiesInDeck: 2,
|
||||
},
|
||||
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
|
||||
];
|
||||
|
||||
/** Upgrade order is strict — no skipping (Gap 3b). */
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
|
||||
export function officeProfile(tier: OfficeTier): OfficeProfile {
|
||||
@@ -203,163 +120,371 @@ export function officeProfile(tier: OfficeTier): OfficeProfile {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The next tier up, or null at Terminal. Strict sequence, no skipping. */
|
||||
export function nextOfficeTier(tier: OfficeTier): OfficeTier | null {
|
||||
const i = OFFICE_ORDER.indexOf(tier);
|
||||
return i >= 0 && i + 1 < OFFICE_ORDER.length ? OFFICE_ORDER[i + 1]! : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — card-reference.md §5
|
||||
// Industries — one car out, one loader. Capacity grows via modifiers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type IndustryProfile = {
|
||||
kind: FreightKind;
|
||||
name: string;
|
||||
carTypes: readonly CarType[];
|
||||
flow: FlowDirection;
|
||||
/** Base green-box capacity. Every industry starts at ONE. */
|
||||
baseOut: number;
|
||||
/** Base red-box capacity. */
|
||||
baseIn: number;
|
||||
/** "Loaders" in the design; the rules PDF calls them Laborers. One to start. */
|
||||
baseLoaders: number;
|
||||
/** Column E — industries that may not coexist with this one. Semantics pending (§10 Q4). */
|
||||
lockouts: readonly FreightKind[];
|
||||
copies: number;
|
||||
};
|
||||
|
||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
|
||||
|
||||
/** §9.3 — the collective term for an industry that both loads and unloads. */
|
||||
export function isFreightHouse(p: IndustryProfile): boolean {
|
||||
return p.flow === 'both';
|
||||
}
|
||||
|
||||
export function industryProfile(kind: FreightKind): IndustryProfile {
|
||||
const f = INDUSTRY_PROFILES.find((p) => p.kind === kind);
|
||||
if (!f) throw new Error(`unknown industry: ${kind}`);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — 17 industry + 6 passenger, each tied to specific hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModifierKind =
|
||||
| 'waitingArea' | 'restaurant' | 'hotel'
|
||||
| 'truckDock' | 'railroadExpressAgency' | 'forklifts'
|
||||
| 'prepPlant' | 'coalPiles' | 'conveyorBelts'
|
||||
| 'pipelines' | 'oilDepot' | 'viscosityBreakers'
|
||||
| 'transmissionLines' | 'rotaryDumps' | 'steamTurbines'
|
||||
| 'iceHouse' | 'localSmallGroceries';
|
||||
|
||||
export type ModifierProfile = {
|
||||
kind: ModifierKind;
|
||||
name: string;
|
||||
effect: string;
|
||||
appliesTo: 'freight' | 'passenger' | 'either';
|
||||
/** Which hosts it may sit beside. 'office' means any Passenger Facility. */
|
||||
hosts: readonly (FreightKind | 'office')[];
|
||||
addOut: number;
|
||||
addIn: number;
|
||||
addLoaders: number;
|
||||
addPorters: number;
|
||||
copies: number;
|
||||
};
|
||||
|
||||
export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
|
||||
{
|
||||
kind: 'teamTrack',
|
||||
name: 'Team Track',
|
||||
effect: '+1 car on the industry track',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'loadingDock',
|
||||
name: 'Loading Dock',
|
||||
effect: '+1 green Outbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'storageShed',
|
||||
name: 'Storage Shed',
|
||||
effect: '+1 red Inbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'extraPlatform',
|
||||
name: 'Extra Platform',
|
||||
effect: '+1 green and +1 red slot',
|
||||
appliesTo: 'passenger',
|
||||
},
|
||||
{
|
||||
kind: 'sectionGang',
|
||||
name: 'Section Gang',
|
||||
effect: '+1 Laborer or +1 Porter',
|
||||
appliesTo: 'either',
|
||||
},
|
||||
// Passenger — "Add 1 Psgn out, 1 porter"
|
||||
{ kind: 'waitingArea', name: 'Waiting area', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 3 },
|
||||
{ kind: 'restaurant', name: 'Restaurant', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 2 },
|
||||
{ kind: 'hotel', name: 'Hotel', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 1 },
|
||||
// Freight House / Packing Sheds / Grocer's
|
||||
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
|
||||
{ kind: 'railroadExpressAgency', name: 'Railroad Express Agency', hosts: ['freightHouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'forklifts', name: 'Forklifts', hosts: ['freightHouse', 'packingSheds'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
|
||||
// Mine Tipple
|
||||
{ kind: 'prepPlant', name: 'Prep Plant', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'coalPiles', name: 'Coal Piles', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'conveyorBelts', name: 'Conveyor Belts', hosts: ['mineTipple'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
// Refinery
|
||||
{ kind: 'pipelines', name: 'Pipelines', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'oilDepot', name: 'Oil Depot', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'viscosityBreakers', name: 'Viscosity breakers', hosts: ['refinery'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
// Power Plant — inbound, so these add loaders rather than outbound capacity
|
||||
{ kind: 'transmissionLines', name: 'Transmission lines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'rotaryDumps', name: 'Rotary Dumps', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'steamTurbines', name: 'Steam Turbines', hosts: ['powerPlant'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
// Produce
|
||||
{ kind: 'iceHouse', name: 'Ice House', hosts: ['packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
|
||||
{ kind: 'localSmallGroceries', name: 'Local small groceries', hosts: ['grocersWarehouse'], addOut: 0, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
];
|
||||
|
||||
export function modifierProfile(kind: ModifierKind): ModifierProfile {
|
||||
const m = MODIFIER_PROFILES.find((p) => p.kind === kind);
|
||||
if (!m) throw new Error(`unknown modifier: ${kind}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains — card-reference.md §3
|
||||
// Trains — 22 cards, named, with speed class and individual rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrainSpeed = 'fast' | 'slow';
|
||||
|
||||
/**
|
||||
* Consists are specified by CATEGORY, not by car type: "Freight (2)" means any two freight cars.
|
||||
* `freightTypes` narrows it where a card does (X14 is reefers only).
|
||||
*/
|
||||
export type ConsistSpec = {
|
||||
/** Revenue cars, excluding the caboose. */
|
||||
count: number;
|
||||
allowedTypes: readonly CarType[];
|
||||
requiresCaboose: boolean;
|
||||
freight: number;
|
||||
coach: number;
|
||||
caboose: number;
|
||||
freightTypes?: readonly CarType[];
|
||||
/** X13 Appleseed: empties only. */
|
||||
emptiesOnly?: boolean;
|
||||
};
|
||||
|
||||
/** Operating rules printed on the card. Behaviour pending §10 Q1 for `expedite`. */
|
||||
export type TrainRules = {
|
||||
noSwitching?: boolean;
|
||||
terminalsOnly?: boolean;
|
||||
expedite?: boolean;
|
||||
/** Train 7/8: "Coach must remain on station track if switching." */
|
||||
coachStaysOnStationTrack?: boolean;
|
||||
/** Train 3/4: may drop or pick up one freight car at every location. */
|
||||
oneFreightPerLocation?: boolean;
|
||||
noPassengerWork?: boolean;
|
||||
/** X13: may drop empties but not pick anything up. X22: may only pick up empties. */
|
||||
dropOnly?: boolean;
|
||||
pickUpEmptiesOnly?: boolean;
|
||||
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
||||
stopEarnsPoint?: boolean;
|
||||
stopThenExpedite?: boolean;
|
||||
/** The Second Section card copies the next scheduled train. */
|
||||
copiesNextScheduled?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type TrainProfile = {
|
||||
/** 1..12. For an Extra this is the number following the "X" (§2.3). */
|
||||
number: number;
|
||||
isExtra: boolean;
|
||||
className: string;
|
||||
/** Extras are head-on: the player choosing determines direction (§2.3). */
|
||||
name: string;
|
||||
speed: TrainSpeed;
|
||||
direction: Direction | 'playerChoice';
|
||||
consist: ConsistSpec;
|
||||
rules: TrainRules;
|
||||
};
|
||||
|
||||
const ALL_CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank'];
|
||||
|
||||
/**
|
||||
* Odd numbers run westbound, even eastbound (§2.3). Pairs are sister trains under the optional
|
||||
* rule. Seniority runs passenger-first: low numbers are the varnish, high the drags and locals.
|
||||
*
|
||||
* NOTE the four-slot Crew Tray limit INCLUDES the caboose (§A.4), which is why no caboose train
|
||||
* carries more than three revenue cars.
|
||||
*/
|
||||
function timetabledPair(
|
||||
odd: number,
|
||||
className: string,
|
||||
consist: ConsistSpec,
|
||||
): readonly TrainProfile[] {
|
||||
/** Odd numbers run westbound, even eastbound (§2.3). Pairs share a class. */
|
||||
function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec, rules: TrainRules): TrainProfile[] {
|
||||
return [
|
||||
{ number: odd, isExtra: false, className, direction: 'west', consist },
|
||||
{ number: odd + 1, isExtra: false, className, direction: 'east', consist },
|
||||
{ number: odd, isExtra: false, name, speed, direction: 'west', consist, rules },
|
||||
{ number: odd + 1, isExtra: false, name, speed, direction: 'east', consist, rules },
|
||||
];
|
||||
}
|
||||
|
||||
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
||||
...timetabledPair(1, 'Limited', {
|
||||
count: 4,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(3, 'Mail-Express', {
|
||||
count: 3,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(5, 'Manifest Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ['boxcar', 'reefer'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(7, 'Coal Drag', {
|
||||
count: 3,
|
||||
allowedTypes: ['hopper'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(9, 'Oil Train', {
|
||||
count: 3,
|
||||
allowedTypes: ['tank'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(11, 'Way Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ALL_CAR_TYPES,
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, caboose: 0 },
|
||||
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
||||
...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 },
|
||||
{ oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }),
|
||||
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||
{ noSwitching: true, expedite: true }),
|
||||
...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 },
|
||||
{ coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }),
|
||||
...pair(9, 'Heavy Freight', 'slow', { freight: 3, coach: 0, caboose: 1 }, {}),
|
||||
...pair(11, 'Drag Freight', 'slow', { freight: 2, coach: 0, caboose: 1 }, {}),
|
||||
];
|
||||
|
||||
/** Gap 4a: Extras carry high numbers, i.e. low seniority. They yield to every scheduled train. */
|
||||
export const EXTRA_TRAINS: readonly TrainProfile[] = [9, 10, 11, 12].map((n) => ({
|
||||
number: n,
|
||||
isExtra: true,
|
||||
className: 'Extra',
|
||||
direction: 'playerChoice' as const,
|
||||
consist: { count: 3, allowedTypes: ALL_CAR_TYPES, requiresCaboose: true },
|
||||
}));
|
||||
/**
|
||||
* Extras are X13–X22 — ALL junior to every timetabled train, so Gap 5's tie between an Extra and a
|
||||
* Timetabled train of the same number can no longer occur.
|
||||
*/
|
||||
export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
{ number: 13, isExtra: true, name: 'Appleseed Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1, emptiesOnly: true }, rules: { dropOnly: true, note: 'May drop MTs but not pick up anything.' } },
|
||||
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
|
||||
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
|
||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
|
||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } },
|
||||
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
|
||||
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
|
||||
];
|
||||
|
||||
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
|
||||
|
||||
export function trainProfile(number: number, isExtra: boolean): TrainProfile | null {
|
||||
return ALL_TRAINS.find((t) => t.number === number && t.isExtra === isExtra) ?? null;
|
||||
}
|
||||
|
||||
/** Total cars a consist calls for. The Crew Tray limit still applies (§A.4). */
|
||||
export function consistSize(c: ConsistSpec): number {
|
||||
return c.freight + c.coach + c.caboose;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track — card-reference.md §6
|
||||
// Mainline cards — ten types, with speeds and named entry points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
export type MainlineKind =
|
||||
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
|
||||
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard';
|
||||
|
||||
/**
|
||||
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
|
||||
* prints "G" with the player setting orientation.
|
||||
*
|
||||
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
|
||||
* the answer can be applied without re-reading the cards.
|
||||
*/
|
||||
export type MainlineSpeed =
|
||||
| { kind: 'uniform'; value: number }
|
||||
| { kind: 'byTrainType'; passenger: number; freight: number }
|
||||
| { kind: 'grade' };
|
||||
|
||||
export type MainlineProfile = {
|
||||
kind: MainlineKind;
|
||||
name: string;
|
||||
/** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */
|
||||
isOperationalRail: boolean;
|
||||
copies: number;
|
||||
speed: MainlineSpeed;
|
||||
/** Double Track and Uncontrolled Siding: "Trains may pass". */
|
||||
trainsMayPass: boolean;
|
||||
/** Yard: "Sort cars in new order". */
|
||||
sortsCars: boolean;
|
||||
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
||||
entryPoints: readonly string[];
|
||||
};
|
||||
|
||||
export const TRACK_PROFILES: readonly TrackProfile[] = [
|
||||
{ geometry: 'turnout', name: 'Turnout', isOperationalRail: false, copies: 6 },
|
||||
{ geometry: 'straight', name: 'Straight', isOperationalRail: true, copies: 3 },
|
||||
{ geometry: 'runAround', name: 'Run-around', isOperationalRail: true, copies: 3 },
|
||||
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
|
||||
{ kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
|
||||
{ kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
|
||||
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
|
||||
{ kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* How many Stages a train needs to cross a Mainline card.
|
||||
*
|
||||
* Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage,
|
||||
* a 30 card takes two. The cells drawn on the cards are decoration.
|
||||
* Q2 — a Slow train adds one Stage to every card.
|
||||
*
|
||||
* Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any
|
||||
* coach is "passenger" for this purpose.
|
||||
*/
|
||||
export function crossingStages(
|
||||
kind: MainlineKind,
|
||||
trainSpeed: TrainSpeed,
|
||||
carriesPassengers: boolean,
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
|
||||
let mph: number;
|
||||
switch (profile.speed.kind) {
|
||||
case 'uniform':
|
||||
mph = profile.speed.value;
|
||||
break;
|
||||
case 'byTrainType':
|
||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||
break;
|
||||
case 'grade':
|
||||
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
|
||||
// improve it. Treated as a 30 until those are implemented.
|
||||
mph = 30;
|
||||
break;
|
||||
}
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
return base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
}
|
||||
|
||||
/** `Realignment` converts one Mainline card into another. */
|
||||
export const REALIGNMENTS: readonly { from: MainlineKind; to: MainlineKind }[] = [
|
||||
{ from: 'plains', to: 'doubleTrack' },
|
||||
{ from: 'curves', to: 'plains' },
|
||||
{ from: 'uncontrolledSiding', to: 'doubleTrack' },
|
||||
{ from: 'trestle', to: 'uncontrolledSiding' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock supply — card-reference.md §8
|
||||
// The four new card categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SimpleCard = { key: string; name: string; copies: number; placement: string; effect: string };
|
||||
|
||||
/** "Burns tablespace" — occupies a grid cell and does nothing useful. Semantics pending §10 Q6. */
|
||||
export const SPACE_USE_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'beanHouse', name: 'Bean house', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
{ key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' },
|
||||
{ key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' },
|
||||
{ key: 'sectionHouse', name: 'Section House', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
{ key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
|
||||
{ key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
{ key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
];
|
||||
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
|
||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.' },
|
||||
{ key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
|
||||
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
|
||||
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
||||
{ key: 'absSignals', name: 'ABS Signals', copies: 2, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
|
||||
];
|
||||
|
||||
export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'brakeman', name: 'Brakeman', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill.' },
|
||||
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
|
||||
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
|
||||
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Q9 — the Second Section card. Played on a train that is DUE OUT; a second identical train runs
|
||||
* immediately behind it, needing its own Crew Tray. It deliberately creates the following-train
|
||||
* situation §8.1 makes the Superintendent rule on. Supersedes the "Sister trains" optional rule.
|
||||
*/
|
||||
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
|
||||
|
||||
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||
{ key: 'poling', name: 'Poling', copies: 1, placement: 'any time', effect: 'TBD in the source.' },
|
||||
];
|
||||
|
||||
/** Played AT other players. We have no player-interaction mechanic yet. */
|
||||
export const ACTION_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'derail', name: 'Derail', copies: 2, placement: 'a moving train in the Local Phase', effect: 'That train must stop for the remainder of the turn.' },
|
||||
{ key: 'brokenCoupler', name: 'Broken coupler', copies: 1, placement: 'a moving train in the Mainline Phase', effect: 'That train must stop and not move.' },
|
||||
{ key: 'railroadCrossing', name: 'Railroad crossing', copies: 1, placement: 'any Secondary Track Straight', effect: 'May not be used as a stop point for switching. May not become an Industry.' },
|
||||
{ key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' },
|
||||
{ key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' },
|
||||
{ key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' },
|
||||
{ key: 'vandalism', name: 'Vandalism', copies: 1, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
|
||||
{ key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' },
|
||||
{ key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock, scale, and victory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
|
||||
/** NOT in the recovered files — still the provisional figure. */
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
@@ -369,54 +494,36 @@ export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
||||
];
|
||||
|
||||
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce(
|
||||
(n, s) => n + s.loaded + s.empty,
|
||||
0,
|
||||
);
|
||||
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce((n, s) => n + s.loaded + s.empty, 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixed supplies and scale — card-reference.md §8, rules §12.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §4.3 — one Mainline card between each adjacent pair, and one beyond each end. */
|
||||
export function mainlineCardCount(players: number): number {
|
||||
return players + 1;
|
||||
}
|
||||
|
||||
/** Gap 4b — scarcity is an explicit mechanic (§7). */
|
||||
/** Provisional; not in the recovered files. */
|
||||
export function crewTrayCount(players: number): number {
|
||||
return players + 3;
|
||||
}
|
||||
|
||||
/** Each Mainline card is divided into two regions (Gap 4b). */
|
||||
export const REGIONS_PER_MAINLINE_CARD = 2;
|
||||
|
||||
export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
|
||||
export const STAGES_PER_DAY = 12;
|
||||
|
||||
/** §5 — the Fedora passes every three Stages; shift changes at Stages 3, 6, 9, 12. */
|
||||
export const STAGES_PER_SHIFT = 3;
|
||||
|
||||
/** §6.2 — hand limit, before the optional Red Flag. */
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/** §A.4 — a Crew Tray may hold at most four Rolling Stock, cabooses included. */
|
||||
export const MAX_CONSIST = 4;
|
||||
|
||||
/** §6.1 — Moves per Local Operations Phase; five during night Stages under Reduced Visibility. */
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
|
||||
/** §9.3 — Green -> MEN -> AT -> WORK -> car. */
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
export const COLLISION_PENALTY = 5;
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Victory — rules §3.2, revised by Gap 10e
|
||||
// ---------------------------------------------------------------------------
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
export type GameLength = 'short' | 'standard' | 'campaign';
|
||||
|
||||
export type LengthProfile = { length: GameLength; target: number; days: number };
|
||||
|
||||
/** Provisional and now unvalidated — the balance run they came from used a placeholder ruleset. */
|
||||
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||
{ length: 'short', target: 10, days: 3 },
|
||||
{ length: 'standard', target: 20, days: 5 },
|
||||
@@ -429,37 +536,38 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** §10 — a collision costs the party at fault 5 Revenue. */
|
||||
export const COLLISION_PENALTY = 5;
|
||||
|
||||
/** §3.4 — Competitive only: three collisions in one Day and everyone loses. */
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
|
||||
/** §3.5 — Competitive timed games: all players' Revenue combined must reach this. */
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — card-reference.md §1
|
||||
// Deck composition — 115 cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DECK_SIZE = 52;
|
||||
|
||||
/**
|
||||
* The Home Office deck is a SINGLE deck containing every card type (§2.6, Gap 4a). The three
|
||||
* Department slots are face-up market slots fed from it, not decks with their own contents.
|
||||
* Cards that can only be played AT another player (Q6). In a solitaire game they have no legal
|
||||
* target, so they are removed from the deck rather than sitting in hand as 19% dead draws.
|
||||
*/
|
||||
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
|
||||
|
||||
export function isOpponentOnly(category: string): boolean {
|
||||
return OPPONENT_ONLY_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
export function deckComposition(): { category: string; count: number }[] {
|
||||
const office = OFFICE_PROFILES.reduce((n, p) => n + p.copiesInDeck, 0);
|
||||
const freight = FREIGHT_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
const track = TRACK_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
|
||||
return [
|
||||
{ category: 'timetabledTrain', count: TIMETABLED_TRAINS.length },
|
||||
{ category: 'extraTrain', count: EXTRA_TRAINS.length },
|
||||
{ category: 'office', count: office },
|
||||
{ category: 'freightFacility', count: freight },
|
||||
{ category: 'modifier', count: MODIFIER_PROFILES.length },
|
||||
{ category: 'track', count: track },
|
||||
{ category: 'office', count: OFFICE_PROFILES.reduce((n, o) => n + o.copiesInDeck, 0) },
|
||||
{ category: 'industry', count: sum(INDUSTRY_PROFILES) },
|
||||
{ category: 'modifier', count: sum(MODIFIER_PROFILES) },
|
||||
{ category: 'train', count: ALL_TRAINS.length },
|
||||
{ category: 'spaceUse', count: sum(SPACE_USE_CARDS) },
|
||||
{ category: 'enhancement', count: sum(ENHANCEMENT_CARDS) },
|
||||
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
|
||||
{ category: 'maneuver', count: sum(MANEUVER_CARDS) },
|
||||
{ category: 'action', count: sum(ACTION_CARDS) },
|
||||
];
|
||||
}
|
||||
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => ({'&':'&','<':'<','>':'>'}[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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user