diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41655f4..3050c9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,290 @@ The target is 20 Revenue over 5 Days.
## Unreleased
+### Rolling stock was leaving the game
+
+Four things were tried against bot revenue. **One of them was worth more than everything else in
+this changelog combined, and it is the one that had been ranked third and predicted not to matter.**
+
+**The Classification Yard was write-only.** Seven places pushed cars into it — retired trains,
+collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained one way
+out of the game: 30 cars dead by the end of a game, **37% of the 80 dealt at setup**. Gap 2c says
+engines and cabooses return to the Division Yard and everything else to Classification; the
+recovered rules never say how Classification empties.
+
+**ASSUMPTION, flagged rather than derived:** sorting cars for redistribution is what a
+classification yard is for, and a Day is its natural cycle, so they now return at the Day boundary.
+This is a rules decision that wants confirming against the source.
+
+Paired over 400 seeds: **+2.32 ± 0.52, t = 8.79**, 194 seeds better against 44. Revenue 6.70 → 9.02.
+
+Why it was mis-ranked is worth recording. The Division Yard does not run *dry* in five Days — 16.6
+loaded freight cars remain, empty in 2 games of 100 — so it was reasoned that supply could not be
+binding. The aggregate was never the point: what starves freight is not having the RIGHT commodity
+at the moment a green box needs stocking, and returning classified cars keeps the mix alive.
+
+### Three things that did not work, kept because the measurement is the result
+
+Measured paired over the same 400 seeds, which is the only way to see effects this size.
+
+**Capping the draw option: worth nothing.** 62% of all Local Operations actions went to drawing and
+12.6 of 29 cards drawn were discarded, so a draw into a full hand converts straight into a discard.
+Refusing it: **-0.10 ± 0.13**, and 379 of 400 seeds byte-identical. The branch almost never fires.
+
+**Pairing the two halves of a load: worth nothing.** Sampling every outbound industry at every
+loadUnload phase, 76% of the time it had NEITHER a stocked green box NOR a spotted car, and only 5%
+of Stages had a single workable facility. Letting the bot switch for a stocked box without waiting
+for a train at the Office changed nothing measurable — folded into the -0.10 above. The diagnosis
+was right and the prescription did not address it: there is usually nothing to switch.
+
+**Waking a dead branch made things worse.** `chooseLocalOption` tested
+`options.some(i => i.type === 'mainline.modify')`, but that intent requires `s.turn.option === 'draw'`
+and the test runs while the option is still null — `legalActions` had already filtered it out, so the
+branch could never fire and never had. Rewriting it to check the hand cost **-0.64 ± 0.32
+(t = -3.90)**, 104 seeds worse against 35. Its comment claimed the value compounds like a train
+card's; it does not. The branch is now deleted, with the measurement in its place.
+
+**The Enhancements other than Interlocking are worth exactly nothing** — and cost nothing either:
+**-0.01 ± 0.41 (t = -0.04)** when the bot is forbidden to place any of them. Left as they are; there
+is nothing to gain by restricting them. Note the first attempt at this experiment showed 0/400 seeds
+changed, which was the experiment failing rather than the answer: a `card.play` fallback with no
+placement filter was still placing them.
+
+| | before | after |
+| --- | --- | --- |
+| revenue (200 games) | 6.7 | **8.7** |
+| wins | 6.5% | **12%** |
+| freight loads | 2.8 | 3.2 |
+| cars dead in the Classification Yard | 28.4 | **~0** |
+
+### A Running Track with nowhere to put an Enhancement
+
+Every penalty in the game has one cause. Across 100 games, all 48 were `collision: no free A/D
+track` — 2.70 revenue a game, **27% of gross**, concentrated in about a fifth of games and
+responsible for the −47 tail.
+
+The rules already answer it. Interlocking prints "may stop an inbound train on the Limit Track", and
+`advance.ts:621` holds the train at the Limits instead of colliding. **It had never once been
+placed.** Nor had any train ever been held at the Limits.
+
+The reason was structural and nothing to do with Interlocking. The bot builds minimal two-arc
+run-arounds — an `ne` arc meets an `nw` arc directly — so it never needed a straight and laid none:
+**0.00 straights on the Running Track across 100 games.** Interlocking, Water Column and Telegraph
+all require one; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
+chain off Telegraph. Thirteen of the eighteen Enhancement cards that go on the board were
+unplayable. They were drawn 3.78 a game and placed 0.64.
+
+`bestTrackLay` now scores one straight onto the Running Track. Enhancements placed went 0.64 → 3.01
+across nine types where only Overpass had ever appeared, and Interlocking now reaches the board in
+about a quarter of games.
+
+**On whether it pays — measured properly, and the answer is qualified.** Revenue per game has a
+standard deviation of ~9, so a 100-game run carries roughly ±1.0 of noise. Run against the same 400
+seeds, paired:
+
+| | without | with |
+| --- | --- | --- |
+| revenue | 5.99 | **6.70** |
+| collisions cost | 2.70 | **1.91** |
+| worst single game | −47 | **−24** |
+| most collisions in a game | 10 | 6 |
+| wins | 5.3% | 6.5% |
+
+Paired per-seed the change is **+0.70 ± 0.74 (95% CI), t = 1.87** — not significant on its own. And
+154 seeds improved against **165 that got worse**: the mean gain comes from removing catastrophes,
+not from making a typical game better. What justifies keeping it is that the mechanism is measured
+directly and accounts for the whole effect — collision cost falls 0.79, and revenue rises 0.70.
+
+**A correction to the numbers already in this file.** The per-100-game revenue figures reported for
+the earlier changes carry the same ±1.0 noise, so the individual steps (5.0 → 6.0 → 6.5) were stated
+more precisely than the sample supports. The cumulative move from 3.2 to ~6.7 is far larger than the
+noise and stands; the individual increments should be read as indicative only.
+
+### The bot was throwing away its own freight
+
+**Routing turned out not to be the problem, which is why it was worth measuring first.** Of 1650
+drops across 100 games, 940 (57%) landed on a facility that wanted the car and **zero** landed on
+one that did not. The crew makes 9.4 correctly-targeted drops a game; the one-move-only destination
+test costs nothing measurable. "70% of waiting loads have nothing spotted" was a misleading signal —
+the cars arrive.
+
+Following the freight from the other end found the leak. Loads were being **destroyed**:
+
+ stockToOutbound 9.45/game
+ loadStarted 2.71/game
+ facilityUnjammed 3.10/game from outbound <- healthy waiting loads, discarded
+
+`facilityUnjammed` from `outbound` splices the load out of the green box and pushes it to the
+classification yard. That load cost a Local Operations action to stock, so discarding it is strictly
+negative — and the bot did it more often than it started a load.
+
+**Two fallbacks meeting, and the root cause is a familiar one: two rules for one act.**
+`canStockProductively` decided the Freight Agent option was worth taking if a matching empty car was
+spotted. The engine's `stockOutbound` additionally requires a **loaded car of that commodity in the
+Division Yard** — which the predicate never checked. So the option was chosen believing a box could
+be stocked when none could; the follow-through then found nothing stuck, nothing to clear and
+nothing stockable, and fell through to "clear whatever is stuck" with nothing stuck.
+
+Fixed by making the predicate ask the same question the engine does, by no longer using Freight
+Agent as the idle default (switching at worst moves the crew toward the Office, which a train must
+reach to depart at all, §8.1), and by ordering the last-resort unjam by what it costs to lose —
+MEN|AT|WORK first, then the red box whose Revenue is already banked, and the green box last.
+
+| | cars fixed | freight kept |
+| --- | --- | --- |
+| loads discarded from a green box | 3.10 | **0.00** |
+| revenue | 6.0 | **6.5** |
+| wins | 5/100 | **8/100** |
+| trains scheduled | 3.0 | 3.4 |
+| cards played | 16.6 | 19.3 |
+
+**The gain is development, not freight.** Loads started held at 2.70 and freight revenue at 2.6 —
+the recovered Local Operations actions went into drawing and switching rather than into the freight
+chain. Green-box stocking fell 9.45 → 6.34 because the bot no longer stocks boxes it cannot serve.
+The regression test asserts outbound unjams stay at zero *and* that genuine MEN|AT|WORK jams are
+still cleared, so gutting the fallback would not pass it.
+
+### Half the industries never asked for a car
+
+Freight had not moved through two rounds of fixing the district, and this is why: **three of the six
+industries were invisible when the bot chose what to put on a train.**
+
+`wantedCars` consulted a hand-written `industry -> car type` switch that had drifted from the sheet.
+It named `produceShed` and `oilRefinery` — neither is an industry — and omitted `freightHouse`,
+`refinery` and `packingSheds`, which are. An industry it could not name returned null and was
+skipped entirely, so it never requested a car. The Refinery is the only source of tank traffic, so
+tank cars boarded a train 0.07 times a game and were **dropped by a crew zero times in 100 games**,
+while 23 of 79 waiting loads sat at an industry that wanted one.
+
+It now derives the commodities from `INDUSTRY_PROFILES`, which is the sheet. The switch is deleted
+rather than corrected — a second copy of the mapping is the bug, not the values in it.
+
+**A second, narrower collapse.** `facilityCarType` returned `carTypes[0]`, so the second commodity
+of a two-commodity industry was unreachable: a Power Plant burns coal OR oil, a Grocer's Warehouse
+receives dry goods OR perishables. `facilityCarTypes` (plural) now returns the full set, and the
+bot's spotting and switching checks accept any of them.
+
+Worth stating precisely: the engine's `WRONG_CAR_TYPE` gate was corrected to use the full set too,
+but that changes nothing today — both two-commodity industries are inbound-only, so
+`freightAgent.stockOutbound` rejects them before the commodity is examined. That fix is latent. The
+measured gain is entirely the bot side.
+
+| | facilities fixed | cars fixed |
+| --- | --- | --- |
+| revenue | 5.0 | **6.0** |
+| wins | 1/100 | **5/100** |
+| freight revenue | 1.3 | **2.6** |
+| freight share of gross | 25% | **37%** |
+| loads completed | 1.27 | **2.60** |
+| tank cars dropped | **0.00** | **0.62** |
+| reefers dropped | 0.04 | 0.67 |
+
+Both regression tests were checked against the bug they guard: restoring the stale map fails the
+tank assertion, and collapsing `carTypes` to its first entry fails the profile assertion.
+
+**Still 70% of waiting loads have nothing spotted at all.** The commodity mix is right now; the
+volume reaching the industry tracks is not. That is a routing question — which facility the crew
+takes a car to — rather than a car-choice one.
+
+### Putting the industries on the siding
+
+The run-arounds were being built and the industries were somewhere else — facilities sitting on one
+stayed at **0.00 a game** even at 91 districts in 100 with a closed loop. The cause was blunt:
+facility placement was `options.find(i => i.placement !== undefined)`, the first legal square the
+generator happened to list, unscored, while track laying had sixty lines of scoring beside it.
+
+**Facilities are now scored**, on the thing that decides whether a crew can serve them at all: a
+placement in line with a siding still being built becomes part of the loop itself (its own through
+track is a segment), so the crew reaches it from either end and can pass its standing cars (§A.5).
+Merely touching reachable track is worth less; the Running Track is a penalty, because a car left
+standing there is hit by the next arrival (§11.2).
+
+**A second bug surfaced immediately, and it is the interesting one.** Scoring facilities onto the
+siding row sent run-arounds *down*, 91 games in 100 to 36 — the industry took the square and the
+loop stopped closing around it. `runsAcross` tested the card's KIND ("a track straight running
+east-west"), so an industry standing in the line read as a dead end and the run refused to extend
+through it. It now asks the card's PORTS instead. A Facility carries its own rails (§11.2), and so
+do the Office and a Limits sign; what matters is whether a port faces this way.
+
+The reachability walk is now shared between track laying and facility placement rather than written
+twice — two copies would eventually disagree about whether a district connects, which is the one
+thing both decisions rest on.
+
+| | before sidings | sidings fixed | facilities fixed |
+| --- | --- | --- | --- |
+| facilities on a run-around | 0.00 | 0.00 | **1.08** |
+| games with a run-around | 0/100 | 91/100 | 71/100 |
+| revenue | 3.2 | 4.1 | **5.0** |
+| collisions | — | — | 0.4 (was 0.6) |
+| track pieces spent | 19.5 | 16.1 | 13.6 |
+
+Run-arounds fall from 91 to 71 because facilities now compete for the siding squares — which is the
+trade being made deliberately: a loop with an industry on it is worth more than an empty one.
+
+**Freight did not follow.** Loads completed 1.42 → 1.27 and freight's share of gross 31% → 25%; the
+revenue gain is passengers and fewer collisions. Of facilities holding a load, 77% still have
+nothing spotted at all. The industries are now reachable and the right cars still are not arriving —
+which is the car-selection problem in TODO, untouched by any of this.
+
+### The bot was building stubs, not sidings
+
+**Measured first: 0 run-arounds in 100 games.** A run-around is the engine's own definition of a
+useful siding (`track.ts`) — double-ended, both ends reaching the main, and §A.5's facing-point move
+is impossible without one. Every district the bot built was dead-end stubs, 3.86 of them a game,
+plus 2.89 cards below the main that reached nothing at all. Before trusting a zero the detector was
+handed a run-around built on purpose and found it from both ends.
+
+**Three bugs, all the same shape: scoring on local form without checking it reaches anything.**
+
+- The +12 rule was commented "close the loop back up to the main: the run-around is complete" and
+ only tested that a neighbour ran east-west — never that the card above had a south port to join.
+ The siding terminated in an arc pointing north into empty space. It now requires a way up above,
+ asked of the engine's `hasPort` so the Office counts too; `divergesSouth` had looked for a turnout
+ and missed the one way down that is on every board. An arc's facing also decides what it meets —
+ `nw` joins west, `ne` joins east — so closing from the wrong side connected nothing.
+- The east-west extension had no stopping condition, so the run went on past the last column it
+ could rejoin at, in 96 of 100 games. The loop then missed by one card.
+- **`bestTrackLay` never declined.** This was the one that actually mattered, and the first two
+ fixes barely moved the overshoot without it: the function returned its best-scoring option
+ unconditionally, so once the useful squares were taken it kept laying track because track was
+ legal. Bonuses are now tracked apart from the distance score, and a piece that earns none is not
+ laid — the Stage falls through to playing a card instead.
+
+Anchors also have to be reachable from the main now. A stranded east-west straight made both its
+neighbours look like legal extensions, so a fragment joined to nothing grew in both directions.
+
+| | before | after |
+| --- | --- | --- |
+| games with a run-around | **0/100** | **91/100** |
+| track laid east of the last way up | 96/100 | **0/100** |
+| track pieces spent | 19.5 | 16.1 |
+| revenue | 3.2 | **4.1** |
+| freight revenue | 2.6 | **3.8** |
+| cars dropped | 11.6 | 20.9 |
+| loads completed | 0.99 | 1.42 |
+
+Two regression tests, both walking the district with the engine's own `exitsFrom` so they cannot
+credit a connection §A.1 forbids: one asserts run-arounds get closed, the other that no track is
+spent east of the last column with a way up.
+
+**Still open.** Facilities sitting *on* a run-around: 0.00. The loops get built and the industries
+are not on them, so the run-around is not yet paying for itself in freight — which is the next
+thread, not a finished one.
+
+### Freight, drawn where the work happens
+
+**The load pipeline moved onto the card.** A load crosses green → `MEN | AT | WORK` → a spotted car,
+and that journey *is* freight. It was drawn only in the side panel, so a Laborer action — the whole
+of the freight game — changed nothing on the card the player was looking at. The squares now sit
+under the track, which is where the printed cards put them and why they are printed at all. The
+tooltip names the same thing in words: which square the load is on, and what the next Laborer action
+does with it.
+
+**A collision found while placing them.** `overpass` and `facingPointLocks` are placed `onCard`, so
+they can land on a facility, and the enhancement label's baseline ran straight through the new
+squares. The label moved into the gap between the crew tray and the pipeline; a test now asserts the
+two do not overlap rather than trusting the two constants to stay apart.
+
### Teaching the bot to use a siding
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
diff --git a/TODO.md b/TODO.md
index 9dbf499..b10b397 100644
--- a/TODO.md
+++ b/TODO.md
@@ -9,11 +9,44 @@ Ordered within each section by how much it is currently costing us.
## Next
-- [ ] **Find out why switching work does not become Revenue.** The crew now drops 11 cars a game and
- couples 8 (was 3.3 and ~0.4), at about 4 Moves per productive act — real railroading. Revenue
- did not move (3.9 → 3.2). Either the freight it is now shuffling is not the freight the
- industries want, or the loads finish too late in the Day to be worked, or the Revenue is going
- somewhere and being lost again. Measure the freight chain end to end before changing anything.
+- [ ] **Measure with error bars from now on.** Revenue has a standard deviation of ~9, so a
+ 100-game run carries about ±1.0 of noise — every single-change revenue claim in the changelog
+ before the Interlocking work is inside that. Use paired per-seed comparison (the harness deals
+ the same seeds either way) and 400+ games before calling a heuristic change good or bad. The
+ first attempt at the Running Track straight was read as a 0.6 REGRESSION on 100 games and is
+ a 0.7 improvement on 400.
+- [ ] **Confirm the Classification Yard rule against the source.** Cars now return to the Division
+ Yard at the Day boundary — an ASSUMPTION, not a recovered rule. Gap 2c says everything but
+ cabooses goes to Classification and never says how it empties. It is worth +2.32 revenue a
+ game, so if the real rule differs the balance numbers move with it.
+- [x] ~~**Enhancements are placed but mostly do nothing.**~~ Measured: forbidding every Enhancement
+ except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
+ pay nor cost. Left alone. Unlocking the Running Track straight put
+ nine kinds on the board (telegraph 0.73, waterColumn 0.57 …), but only Interlocking has a
+ measured effect. The Telegraph/Telephone/Radio chain adds to the other train's number when
+ dispatching facing trains, which may be worth nothing in solitaire; Water Column removes a
+ Watertower; Facing Point Locks prevents Derail, which is multiplayer-only. Worth measuring
+ what each is actually worth before the bot spends actions on them.
+- [ ] **The marginal Local Operations action is worth ~0, and that is the real ceiling.** Three
+ separate attempts to spend the 60 actions better — capping the draw, pairing the two halves of
+ a load, restricting Enhancements — each measured within noise of zero over 400 paired seeds.
+ 76% of the time an outbound industry has neither a stocked green box nor a spotted car, and
+ only 5% of Stages have a single workable facility anywhere, yet redirecting actions at that
+ does nothing. Something upstream limits how much work exists to do at all; find out what
+ before spending more effort on the option mix.
+- [ ] **Freight was stuck at ~2.7 loads a game and three fixes have not moved it.** Sidings,
+ facility placement, car selection and the discarded-load leak all raised revenue (3.2 → 6.5)
+ without raising `loadStarted` past 2.7. The chain is not leaking and the cars are arriving
+ correctly (57% of drops land on a facility that wants them, 0% on one that does not). The
+ binding constraint is now upstream of routing: 60 Local Operations actions a game, and a load
+ needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
+ Measure how many Stages have all three before changing any heuristic — the answer may be that
+ the economy, not the bot, is what caps freight.
+- [ ] **`stats.ts` undercounts freight.** `rev.freightUnload` is assigned `eventCounts['unloadBegan']`
+ — unloads *begun*, not revenue *earned* — and `grossFreight` uses `freightLoad` alone, so
+ `freightShare` omits unload revenue entirely. The comment justifying it ("an unload scores
+ through the same event as a load completion") is wrong: `apply.ts:947` emits a distinct
+ `freightUnload` reason. This is why freight was recorded at 13–18% of gross.
---
@@ -45,13 +78,16 @@ target is settled and freight carries its intended share.
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
capacity at the Whistle Post itself.
-- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio; freight still only
- 13–18% of gross revenue.
+- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio. The "freight is
+ only 13–18% of gross" figure that motivated this was partly a measurement bug (see the
+ `stats.ts` item) and partly the car-selection bug; freight now runs at 37%. Worth re-deciding
+ whether 27 is still the right number now that the industries are actually served.
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
changes.
-- [ ] **The victory target itself** (20 over 5 Days). Still only 1 win in 100. Worth revisiting once
- the bot exploits sidings, since that is a known unclaimed gain.
+- [ ] **The victory target itself** (20 over 5 Days). 5 wins in 100, up from 1, and the bot now does
+ exploit sidings — so that unclaimed gain has been claimed and the target is still missed by a
+ wide margin (mean 6.0 against 20). This is the next real balance question.
---
@@ -84,6 +120,42 @@ target is settled and freight carries its intended share.
## Done, kept for the reasoning
+- [x] **Put rolling stock back into circulation.** The Classification Yard was write-only — seven
+ writers, no readers — so 37% of all rolling stock left the game by Day 5. Returning it at the
+ Day boundary is **+2.32 ± 0.52 (t = 8.79)**, the largest single change measured on this bot,
+ and it was ranked THIRD and predicted not to matter because the Division Yard never runs dry.
+ The aggregate was the wrong measure; having the right commodity at the right moment is what
+ counts.
+- [x] **Make Enhancements reachable at all.** The bot never laid a straight on the Running Track
+ (0.00 in 100 games) because two-arc run-arounds do not need one — so 13 of the 18 Enhancement
+ cards had nowhere to go, including Interlocking, the only cure for the only penalty in the
+ game (`no free A/D track`, 27% of gross). One scored straight fixed it: enhancements placed
+ 0.64 → 3.01, collision cost 2.70 → 1.91, worst game −47 → −24. Revenue +0.70 ± 0.74 paired
+ over 400 seeds — real but not significant alone; the variance reduction is the clearer win.
+- [x] **Stop the bot discarding its own freight.** `canStockProductively` did not check the Division
+ Yard while the engine's `stockOutbound` does, so Freight Agent was chosen when nothing could be
+ stocked and the follow-through fell through to an unjam that threw a waiting load out of the
+ green box — 3.10 a game against 2.71 started. Now 0.00. Revenue 6.0 → 6.5, wins 5 → 8 in 100.
+ Also confirmed **routing was never the problem**: 0% of drops land on a facility that does not
+ want the car.
+- [x] **Why switching work did not become Revenue.** Answered: it was the freight the crew shuffled,
+ not the shuffling. The chain never leaked — 95% of started loads finished — it was barely
+ entered, because a load needs a matching empty car spotted and half the industries never asked
+ for one. Three fixes later (sidings, facility placement, car selection) revenue is 3.2 → 6.0
+ and freight 26% → 37% of gross.
+- [x] **Fix car selection.** Three of six industries were invisible to `wantedCars` — a hand-written
+ industry→car map naming two industries that do not exist and omitting three that do — so tank
+ cars were dropped **0 times in 100 games**. Derived from `INDUSTRY_PROFILES` now, and the
+ second commodity of the two-commodity industries is reachable. Revenue 5.0 → 6.0, freight
+ share 25% → 37%, wins 1 → 5 in 100.
+- [x] **Put the industries on the run-around.** Facility placement was unscored — the first legal
+ square — so 0.00 facilities a game sat on a loop; now 1.08. The instructive part was the
+ second bug: scoring facilities onto the siding row dropped run-arounds 91→36, because the
+ anchor test asked a card's KIND rather than its PORTS and an industry in the line read as a
+ dead end. Revenue 4.1 → 5.0. Freight did **not** follow, which is the item above.
+- [x] **Make the bot build sidings that are sidings.** 0 run-arounds in 100 games → 91. Three bugs,
+ all scoring on local shape without checking it reached anything; the decisive one was that
+ `bestTrackLay` never declined a piece, so it spent the track supply on whatever was legal.
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
decides which car is droppable; the bot runs around rather than setting out, when the drop can
follow. Switching activity transformed, revenue unchanged.
diff --git a/docs/design/board-first-pass.html b/docs/design/board-first-pass.html
index d7fe2e4..0afb947 100644
--- a/docs/design/board-first-pass.html
+++ b/docs/design/board-first-pass.html
@@ -28,6 +28,15 @@ section{background:#1a1f26;border:1px solid #2c333d;border-radius:8px;padding:14
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
+.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
+.bs-wb.bs-green{stroke:#3f7a52}
+.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
+.bs-wb.bs-maw{stroke:#8a6d1f}
+.bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)}
+.bs-wb.bs-red{stroke:#8a5a5a}
+.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
+.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
+.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
@@ -55,11 +64,11 @@ g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3
4 players · seed 81 · Day 2 Stage 9 · 11 sections · 5 train(s) on the Division · revenue [3, -2, -3, -8]
5 players · seed 82 · Day 2 Stage 11 · 13 sections · 4 train(s) on the Division · revenue [1, 0, -10, 1, 2]
OFFICE AREAS — rails drawn, so a join is visible
-
seed 430 · solitaire · the opening position — Limits, Whistle Post, Limits · 7 cards · 2 facilities · Office is a Depot
-
seed 430 · solitaire · a few turns in: the Running Track starts to grow · 16 cards · 5 facilities · Office is a Depot
-
seed 430 · solitaire · mid-game, first industries down · 32 cards · 5 facilities · Office is a Terminal
-
seed 430 · solitaire · the winning game, fully grown · 37 cards · 7 facilities · Office is a Terminal
-
seed 202 · solitaire · a long Running Track with industries standing on it · 34 cards · 4 facilities · Office is a Station
-
seed 111 · solitaire · a district hanging below the Running Track · 25 cards · 2 facilities · Office is a Depot
-
seed 80 · 3 players · three-player game, player 1 · 22 cards · 1 facility · Office is a Terminal
-
seed 81 · 4 players · four-player game, player 1 · 19 cards · 2 facilities · Office is a Terminal
\ No newline at end of file
+
seed 430 · solitaire · the opening position — Limits, Whistle Post, Limits · 7 cards · 2 facilities · Office is a Depot
+
seed 430 · solitaire · a few turns in: the Running Track starts to grow · 16 cards · 5 facilities · Office is a Depot
+
seed 430 · solitaire · mid-game, first industries down · 32 cards · 5 facilities · Office is a Terminal
+
seed 430 · solitaire · the winning game, fully grown · 36 cards · 6 facilities · Office is a Terminal
+
seed 202 · solitaire · a long Running Track with industries standing on it · 34 cards · 4 facilities · Office is a Station
+
seed 111 · solitaire · a district hanging below the Running Track · 25 cards · 2 facilities · Office is a Depot
+
seed 80 · 3 players · three-player game, player 1 · 22 cards · 1 facility · Office is a Terminal
+
seed 81 · 4 players · four-player game, player 1 · 19 cards · 2 facilities · Office is a Terminal
\ No newline at end of file
diff --git a/src/engine/advance.ts b/src/engine/advance.ts
index f6417f7..c285d16 100644
--- a/src/engine/advance.ts
+++ b/src/engine/advance.ts
@@ -755,6 +755,26 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
if (s.clock.stage >= STAGES_PER_DAY) {
// Telegraph/Telephone/Radio are each usable once a Day.
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
+
+ /**
+ * THE CLASSIFICATION YARD GOES BACK INTO SERVICE OVERNIGHT.
+ *
+ * ASSUMPTION, flagged rather than derived: Gap 2c says engines and cabooses return to the
+ * Division Yard and everything else to Classification, and the recovered rules never say how
+ * Classification empties. It was write-only — seven places pushed into it and nothing ever read
+ * it — so rolling stock drained one way and left the game. Measured at 30 cars a game dead by
+ * the end, 37% of the 80 dealt at setup.
+ *
+ * Sorting cars for redistribution is what a classification yard is FOR, and a Day is its
+ * natural cycle, so they return at the Day boundary. This does not bind a 5-Day game — the
+ * Division Yard still held 16.6 loaded freight cars at the end and ran dry in 2 games of 100 —
+ * but a campaign-length game would have starved.
+ */
+ if (s.yards.classificationYard.length > 0) {
+ s.yards.divisionYard.push(...s.yards.classificationYard);
+ s.yards.classificationYard = [];
+ }
+
s.clock.day += 1;
s.clock.stage = 1;
s.collisionsToday = 0;
diff --git a/src/engine/apply.ts b/src/engine/apply.ts
index f2c5566..3a90c20 100644
--- a/src/engine/apply.ts
+++ b/src/engine/apply.ts
@@ -149,9 +149,23 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea
* for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK
* strips the industry track of Operational Rail status (§9.3), the facility would jam permanently.
*/
+/**
+ * EVERY commodity a facility handles, not just the first one printed.
+ *
+ * Two of the six industries take two: a Power Plant burns coal OR oil (`['hopper','tank']`) and a
+ * Grocer's Warehouse receives dry goods OR perishables (`['boxcar','reefer']`). This used to return
+ * `carTypes[0]`, and because `freightAgent.stockOutbound` gates on it, the engine rejected the
+ * second commodity as WRONG_CAR_TYPE — the sheet said a Power Plant takes tank cars and the code
+ * said it did not. Measured effect: tank cars were dropped 0 times in 100 games.
+ */
+export function facilityCarTypes(f: Facility): readonly CarType[] {
+ if (f.kind !== 'freight') return f.kind === 'passenger' ? ['coach'] : [];
+ return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes ?? [];
+}
+
+/** The commodity to NAME a facility by, where one word is wanted. Legality must use the full set. */
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)?.carTypes[0] ?? null;
+ return facilityCarTypes(f)[0] ?? null;
}
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
@@ -433,8 +447,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!f) return 'NO_SUCH_FACILITY';
if (!f.allows.outbound) return 'NO_SUCH_FACILITY';
if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL';
- // §9.1 — the green box takes only this facility's commodity.
- if (facilityCarType(f) !== i.carType) return 'WRONG_CAR_TYPE';
+ // §9.1 — the green box takes only this facility's commodities, of which it may have two.
+ if (!facilityCarTypes(f).includes(i.carType)) return 'WRONG_CAR_TYPE';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
return 'NO_SUITABLE_CAR';
}
diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts
index a787e6d..55129ac 100644
--- a/src/sim/board-svg.ts
+++ b/src/sim/board-svg.ts
@@ -194,6 +194,44 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
out += `${esc(cell.label)}`;
out += `${cell.row},${cell.col}`;
+ /**
+ * THE LOAD PIPELINE, on the card itself.
+ *
+ * A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of
+ * freight. It was drawn only in the side panel, so advancing a load changed nothing on the card
+ * the player was looking at. The printed cards put these squares under the track for exactly
+ * this reason.
+ */
+ const f = cell.facility;
+ if (f) {
+ const cellW = 13;
+ const box = (bx: number, by: number, filled: boolean, cls: string, label?: string): string =>
+ `` +
+ (label ? `${esc(label)}` : '');
+
+ let bx = 7;
+ const by = H - 42;
+ // Green: what is waiting to be worked.
+ for (let i = 0; i < Math.max(1, f.greenCap); i++) {
+ out += box(bx, by, i < f.green.length, 'bs-green');
+ bx += cellW + 2;
+ }
+ out += `›`;
+ bx += 8;
+ // MEN | AT | WORK — a load sits in one of these while it is being worked.
+ for (let i = 0; i < f.maw.length; i++) {
+ out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
+ bx += cellW + 2;
+ }
+ out += `›`;
+ bx += 8;
+ // Red: what has come off an arriving car and is waiting to be cleared.
+ for (let i = 0; i < Math.max(1, f.redCap); i++) {
+ out += box(bx, by, i < f.red.length, 'bs-red');
+ bx += cellW + 2;
+ }
+ }
+
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
@@ -205,7 +243,9 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
}
if (cell.enhancements.length > 0) {
- out += `${esc(cell.enhancements.join(' · '))}`;
+ // Above the load pipeline, not on it: an `onCard` enhancement can land on a facility, and at
+ // the old baseline the label printed straight through the green/MEN|AT|WORK/red squares.
+ out += `${esc(cell.enhancements.join(' · '))}`;
}
if (cell.tray) {
out += `` +
@@ -274,6 +314,15 @@ export const BOARD_CSS = `
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
+.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
+.bs-wb.bs-green{stroke:#3f7a52}
+.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
+.bs-wb.bs-maw{stroke:#8a6d1f}
+.bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)}
+.bs-wb.bs-red{stroke:#8a5a5a}
+.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
+.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
+.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
diff --git a/src/sim/bot.ts b/src/sim/bot.ts
index 5a2cb90..91fed0a 100644
--- a/src/sim/bot.ts
+++ b/src/sim/bot.ts
@@ -19,14 +19,15 @@
* complete at all.
*/
-import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
+import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, 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 { variantsFor } from '../engine/track.ts';
+import { exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
+import type { Port } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts';
-import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
+import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = {
name: string;
@@ -169,13 +170,18 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return because('a train card is in hand and its value compounds every Day', can('draw')!);
}
- // A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
- // 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
- // compounds — but only if it can be laid right now, so check for a legal target rather than for
- // the card sitting in hand.
- if (can('draw') && options.some((i) => i.type === 'mainline.modify')) {
- return because('a Mainline modifier can be laid — every later crossing pays less', can('draw')!);
- }
+ /**
+ * NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
+ *
+ * There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
+ * requires `s.turn.option === 'draw'` and this runs while the option is still null, so
+ * `legalActions` had already filtered it out — the branch could never fire and never had.
+ *
+ * Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
+ * paired over 400 seeds (t = -3.90, 104 seeds worse against 35 better). Its comment claimed the
+ * value compounds like a train card's; it does not. Spending a Local Operations action on a
+ * Mainline modifier costs more than the crossings it saves. Left unwritten deliberately.
+ */
// 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
@@ -206,7 +212,10 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
- return because('no option has a clear purpose this turn', can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!);
+ // Freight Agent is deliberately NOT the idle default. Reaching its follow-through with nothing
+ // productive to do is what fed the unjam fallback that discarded loads; switching, at worst,
+ // moves the crew toward the Office, which a train must reach to depart at all (§8.1).
+ return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
@@ -248,13 +257,27 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
if (!f || !f.allows.outbound) continue;
if (f.outboundBox.length >= f.capacity.outbound) continue;
+ /**
+ * The load has to COME from somewhere. `freightAgent.stockOutbound` requires a LOADED car of
+ * that commodity in the Division Yard, and this predicate did not check for one — so the bot
+ * chose the Freight Agent option believing it could stock, found no legal stock, and fell
+ * through to the unjam fallback that discarded a green box. Two rules for the same act, and
+ * only one of them was true.
+ */
+ const inYard = (type: string): boolean =>
+ s.yards.divisionYard.some((c) => c.type === type && c.loaded);
+
// 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;
+ if (f.kind === 'passenger') {
+ if (inYard('coach')) return true;
+ continue;
+ }
- const want = facilityCarType(f);
- if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
+ const want = facilityCarTypes(f);
+ if (!f.industryTrack.cars.some((c) => !c.loaded && want.includes(c.type))) continue;
+ if (want.some((t) => inYard(t))) return true;
}
return false;
}
@@ -287,6 +310,160 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
* worth more than a turnout the bot has no plan for. Ties break toward the Office: a compact
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
*/
+/**
+ * Walk the Secondary Track from the Running Track, using the ENGINE's own `exitsFrom`.
+ *
+ * Shared deliberately. Track laying and facility placement both need to know what a crew can
+ * actually reach, and two copies of this walk would eventually disagree about whether a district
+ * connects — which is the one thing both decisions rest on. A turnout's legs never join each other
+ * (§A.1), so this traverses in (cell, entry port) space rather than treating a cell as one node.
+ */
+function descendFrom(
+ area: OfficeArea,
+ col: number,
+): { rejoins: Set; seen: Set } {
+ const main = area.runningRow;
+ const rejoins = new Set();
+ const seen = new Set();
+ const start = area.grid.get(`${main - 1},${col}`);
+ if (!start || !hasPort(start, 'n')) return { rejoins, seen };
+
+ const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
+ const visited = new Set();
+ while (stack.length > 0) {
+ const cur = stack.pop()!;
+ const id = `${cur.row},${cur.col}:${cur.entry}`;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ const card = area.grid.get(`${cur.row},${cur.col}`);
+ if (!card) continue;
+ seen.add(`${cur.row},${cur.col}`);
+ for (const exit of exitsFrom(card, cur.entry)) {
+ const n = neighbour({ row: cur.row, col: cur.col }, exit);
+ const next = area.grid.get(`${n.row},${n.col}`);
+ if (!next || !hasPort(next, opposite(exit))) continue;
+ if (n.row >= main) {
+ if (n.col !== col) rejoins.add(n.col);
+ continue;
+ }
+ stack.push({ row: n.row, col: n.col, entry: opposite(exit) });
+ }
+ }
+ return { rejoins, seen };
+}
+
+/** Every way down off the main, by column. */
+function waysDown(area: OfficeArea): number[] {
+ const out: number[] = [];
+ for (const [k, c] of area.grid.entries()) {
+ const [r, col] = k.split(',').map(Number);
+ if (r !== area.runningRow || !hasPort(c, 's')) continue;
+ const under = area.grid.get(`${area.runningRow - 1},${col}`);
+ if (under && hasPort(under, 'n')) out.push(col!);
+ }
+ return out;
+}
+
+/** Below-main squares a crew can reach from the Running Track at all — siding or stub. */
+function reachableBelowMain(area: OfficeArea): Set {
+ const reached = new Set();
+ for (const col of waysDown(area)) {
+ for (const k of descendFrom(area, col).seen) reached.add(k);
+ }
+ return reached;
+}
+
+/**
+ * Below-main squares that lie on a RUN-AROUND — a siding reaching the main at both ends, so a crew
+ * can pass its own standing cars (§A.5). A facility here can be worked from either direction; one
+ * on a stub can only be reached by shoving in and backing out.
+ */
+function runAroundCells(area: OfficeArea): Set {
+ const onLoop = new Set();
+ for (const col of waysDown(area)) {
+ const { rejoins, seen } = descendFrom(area, col);
+ if (rejoins.size === 0) continue;
+ for (const k of seen) onLoop.add(k);
+ }
+ return onLoop;
+}
+
+/**
+ * WHERE AN INDUSTRY GOES.
+ *
+ * This was `options.find(i => i.placement !== undefined)` — the first legal placement the generator
+ * happened to list, entirely unscored, while track laying got sixty lines of scoring. The measured
+ * result: once the bot started closing a run-around in 91 games of 100, facilities sitting ON one
+ * stayed at 0.00 a game. The loops got built and the industries were put somewhere else.
+ *
+ * A facility is worked by spotting a matching car on its industry track (§9.3), so where it sits
+ * decides whether that is possible at all:
+ *
+ * - IN LINE with the siding, a facility becomes part of the run-around itself — its own through
+ * track is a segment of the loop — so a crew reaches it from either end and can pass its
+ * standing cars (§A.5). This is the placement worth paying for.
+ * - Merely touching reachable track still works, by shoving in and backing out.
+ * - On the Running Track a car left standing is hit by the next arrival (§11.2), so an industry
+ * there is one the crew dare not serve.
+ */
+function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
+ const area = s.officeAreas.get(player);
+ if (!area) return null;
+
+ const onLoop = runAroundCells(area);
+ const reachable = reachableBelowMain(area);
+ const sidingRow = area.runningRow - 1;
+
+ let best: Intent | null = null;
+ let bestScore = -Infinity;
+
+ for (const i of options) {
+ if (i.type !== 'card.play' || i.placement === undefined) continue;
+ if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue;
+
+ const { row, col } = i.placement;
+ const axis = facilityVariants()[i.variant ?? 0]?.axis;
+ if (!axis) continue;
+ const ports: Port[] = axis === 'ns' ? ['n', 's'] : ['e', 'w'];
+
+ // Closer to the Office is fewer Moves to reach, same as track scoring.
+ let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
+
+ if (row === area.runningRow) {
+ score -= 20;
+ } else {
+ let meetsLoop = false;
+ let meetsReachable = false;
+ for (const p of ports) {
+ const n = neighbour({ row, col }, p);
+ const nc = area.grid.get(`${n.row},${n.col}`);
+ if (!nc || !hasPort(nc, opposite(p))) continue;
+ const k = `${n.row},${n.col}`;
+ if (onLoop.has(k)) meetsLoop = true;
+ if (reachable.has(k) || n.row >= area.runningRow) meetsReachable = true;
+ }
+
+ // A facility cannot be inserted into a loop that is already closed — one card per square — so
+ // the placement that lands ON a run-around is the one laid IN LINE with a siding still being
+ // built, east-west on the siding row. That is scored highest deliberately.
+ const inLineWithSiding =
+ row === sidingRow &&
+ axis === 'ew' &&
+ (reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
+
+ if (inLineWithSiding) score += 26;
+ else if (meetsLoop) score += 20;
+ else if (meetsReachable) score += 12;
+ }
+
+ if (score > bestScore) {
+ bestScore = score;
+ best = i;
+ }
+ }
+ return best;
+}
+
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
@@ -298,16 +475,51 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
const divergesSouth = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
- /** A card on the siding row that runs east-west — the siding itself. */
- const runsAcross = (c: TrackCard | undefined): boolean =>
- !!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
+ /**
+ * Can the siding continue THROUGH this neighbour, into the square being scored?
+ *
+ * Asked by the neighbour's PORTS, not its card kind. Testing for "a track straight running
+ * east-west" meant an industry standing in the line read as a dead end — and once facilities
+ * started being placed on the siding on purpose, the run stopped extending past the first one and
+ * run-arounds fell from 91 games in 100 to 36. A Facility carries its own rails (§11.2); so does
+ * an Office; so does a Limits sign. What matters is whether a port faces this way.
+ */
+ const carriesInto = (c: TrackCard | undefined, toward: Port): boolean =>
+ !!c && hasPort(c, toward);
- /** An arc that reaches up to the Running Track. */
- const reachesUp = (c: TrackCard | undefined): boolean =>
- !!c &&
- c.geometry.kind === 'track' &&
- (c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
- (c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
+ /**
+ * A main-line card a siding can actually climb back onto — asked of the ENGINE, not guessed from
+ * the card kind. Turnouts diverge south, and so does the Office (its `e-s`/`w-s` pairs), so
+ * testing for a turnout alone missed a way up that is on every board.
+ */
+ const wayUp = (c: TrackCard | undefined): boolean => !!c && hasPort(c, 's');
+
+ /** Main-line columns a siding could rejoin at. Without one of these beyond it, a run goes nowhere. */
+ const waysUp = [...area.grid.entries()]
+ .filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && wayUp(c))
+ .map(([k]) => Number(k.split(',')[1]));
+
+ const reachableBelow = reachableBelowMain(area);
+
+ /**
+ * Straights already on the Running Track — the only thing an Enhancement can attach to.
+ *
+ * Interlocking, Water Column and Telegraph all print "any Running Track Straight", and Telephone
+ * and Radio chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw`
+ * directly), so it never needed a straight and never laid one: 0.00 on the Running Track across
+ * 100 games, and with it 13 of the 18 enhancement cards that go on the board were structurally
+ * unplayable. Enhancements were drawn 3.78 a game and placed 0.64.
+ *
+ * Interlocking is the expensive one. It is the designed answer to the ONLY penalty in the game —
+ * "no free A/D track", which cost 2.40 revenue a game, 27% of gross — and it had never once been
+ * placed, so no train was ever held at the Limits instead of colliding.
+ */
+ const mainStraights = [...area.grid.entries()].filter(
+ ([k, c]) =>
+ Number(k.split(',')[0]) === area.runningRow &&
+ c.geometry.kind === 'track' &&
+ c.geometry.geometry === 'straight',
+ ).length;
const arcOf = (i: Extract): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
@@ -326,6 +538,10 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
+ // Bonuses are tracked apart from the distance score so the bot can tell a piece that BUILDS
+ // something from one that is merely legal. Without that distinction it always returned its
+ // best-scoring option, and spent all 26 pieces on whatever was placeable.
+ let bonus = 0;
/**
* BUILD A SIDING, not a stub.
@@ -336,7 +552,17 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
* one asked for it.
*/
- if (row === area.runningRow && i.geometry === 'turnout') {
+ if (
+ row === area.runningRow &&
+ i.geometry === 'straight' &&
+ variantsFor('straight')[i.variant ?? 0]?.axis === 'ew' &&
+ mainStraights < 1
+ ) {
+ // Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go
+ // without competing further with the ways down. Scored below a first turnout, which is still
+ // the most valuable single piece on the board, and below closing a run-around.
+ bonus += 13;
+ } else if (row === area.runningRow && i.geometry === 'turnout') {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
@@ -344,31 +570,66 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
// Measured: capping this to one or two ways down cost more than the spare turnouts did
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
// crew can actually reach, which matters more than a tidy Running Track.
- score += turnouts.length === 0 ? 14 : 3;
+ bonus += turnouts.length === 0 ? 14 : 3;
} else if (row === below) {
const arc = arcOf(i);
- const turnoutAbove = divergesSouth(at(area.runningRow, col));
- if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
- // Turn along beneath the turnout — this is what a bare n-s stub could never do.
- score += 13;
+ // What this square is anchored to, and therefore which way a run through it would extend. The
+ // anchor must be reachable from the main: extending a stranded fragment builds nothing.
+ const anchoredAt = (c: number): boolean =>
+ reachableBelow.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w');
+ const anchorWest = anchoredAt(col - 1);
+ const anchorEast = anchoredAt(col + 1);
+
+ if (arc === 'ne' || arc === 'nw') {
+ /**
+ * AN ARC IS ONLY WORTH LAYING WHERE IT CAN REACH THE MAIN.
+ *
+ * Both of these used to score on shape alone: "close the loop back up to the main" was
+ * awarded to any arc beside an east-west run, whether or not there was anything above it to
+ * join. Measured, that produced 0 run-arounds in 100 games — the siding terminated in an arc
+ * pointing north into empty space, past the east end of the Running Track, every game.
+ *
+ * An arc also has exactly two ports, so which way it faces decides what it meets: `nw` joins
+ * north and WEST, `ne` joins north and EAST. Scoring them alike let the bot close a run from
+ * the wrong side, which connects nothing.
+ */
+ if (wayUp(at(area.runningRow, col))) {
+ const meetsRun = (arc === 'nw' && anchorWest) || (arc === 'ne' && anchorEast);
+ // Closing an existing run beats opening a new one: that is the whole difference between a
+ // run-around and one more stub.
+ bonus += meetsRun ? 15 : 13;
+ }
+ // An arc with nothing above it earns no bonus but is not forbidden — reaching a facility
+ // two rows down is still worth doing when there is no loop to close.
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
- // Extend the siding beside the main, but only from something that already turned.
- if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
- runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
- score += 11;
+ /**
+ * Extend the siding beside the main, but only from something that already turned AND only
+ * while there is still somewhere to climb back up. Unbounded, this rule ran the siding east
+ * past the last usable column in 96 of 100 games — the loop then missed by one card.
+ */
+ const canStillClose =
+ (anchorWest && waysUp.some((c) => c > col)) || (anchorEast && waysUp.some((c) => c < col));
+ if ((anchorWest || anchorEast) && canStillClose) {
+ bonus += 11;
}
- } else if (arc && (arc === 'ne' || arc === 'nw') &&
- (runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
- // Close the loop back up to the main: the run-around is complete.
- score += 12;
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
- if (score > bestScore) {
- bestScore = score;
+ /**
+ * DECLINE A PIECE THAT BUILDS NOTHING.
+ *
+ * This used to return its best option unconditionally, so once the useful squares were taken it
+ * kept laying track simply because track was legal — 21 pieces a game, ending in straights and
+ * arcs east of the last column with a way up, joined to nothing. Returning null instead lets the
+ * turn fall through to playing a card, which is what the Stage is worth more spent on.
+ */
+ if (bonus <= 0) continue;
+
+ if (score + bonus > bestScore) {
+ bestScore = score + bonus;
best = i;
}
}
@@ -583,7 +844,12 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const track = bestTrackLay(s, player, options);
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
- // Then real development: a card actually laid into the grid.
+ // Then real development: a card actually laid into the grid. Freight facilities are scored —
+ // where an industry sits decides whether a crew can ever serve it — and everything else
+ // placeable falls through to the first legal square.
+ const facility = bestFacilityPlay(s, player, options);
+ if (facility) return because('put the industry where a crew can actually work it', facility);
+
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
@@ -770,7 +1036,27 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
- return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
+ /**
+ * NEVER UNJAM A HEALTHY BOX.
+ *
+ * This used to fall through to `pickFirst(options, 'freightAgent.unjam')` — "clear whatever
+ * is stuck" — with nothing stuck. `facilityUnjammed` from `outbound` splices the load out of
+ * the green box and pushes it to the classification yard, so the fallback destroyed a load
+ * that had cost a Local Operations action to stock. Measured at 3.10 a game against 2.71
+ * loads STARTED: the bot threw away more freight than it worked.
+ *
+ * §6.3's unjam is for a load stranded on MEN|AT|WORK that cannot advance. A green box holding
+ * a load waiting for a car is not jammed, it is waiting.
+ */
+ // The option has no "end" action, so something must be done. Order by what it costs to lose:
+ // a load stranded on MEN|AT|WORK is already blocking the pipeline, a red box holds a load
+ // whose Revenue is banked, and the green box is the only one where discarding destroys work
+ // that has not been paid for yet. It is last deliberately.
+ const byCost = (['menAtWork', 'inbound', 'outbound'] as const).map((from) =>
+ options.find((i) => i.type === 'freightAgent.unjam' && i.from === from),
+ );
+ const fallback = byCost.find((i) => i !== undefined);
+ return because('nothing productive at any facility — clearing the least costly box', fallback ?? options[0]!);
}
default:
@@ -795,10 +1081,14 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
if (!f || f.kind !== 'freight') continue;
const spotted = f.industryTrack.cars.length;
if (spotted >= f.industryTrack.length) continue;
- const type = carTypeOf(card);
- if (!type) continue;
- if (f.allows.outbound) out.push({ type, loaded: false });
- if (f.allows.inbound) out.push({ type, loaded: true });
+ // Every commodity the industry handles, asked of its profile. A hand-written switch here had
+ // drifted from the sheet: it named two industries that do not exist (`produceShed`,
+ // `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
+ // half the industries in the deck never asked for a car at all.
+ for (const type of facilityCarTypes(f)) {
+ if (f.allows.outbound) out.push({ type, loaded: false });
+ if (f.allows.inbound) out.push({ type, loaded: true });
+ }
}
// 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
@@ -813,23 +1103,6 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
return out;
}
-function carTypeOf(card: { geometry: { kind: string; facility?: string } }): string | null {
- if (card.geometry.kind !== 'facility') return null;
- switch (card.geometry.facility) {
- case 'mineTipple':
- case 'powerPlant':
- return 'hopper';
- case 'produceShed':
- return 'reefer';
- case 'grocersWarehouse':
- return 'boxcar';
- case 'oilRefinery':
- return 'tank';
- default:
- return null;
- }
-}
-
/**
* Would spotting this car here do any work?
*
@@ -840,7 +1113,7 @@ function carTypeOf(card: { geometry: { kind: string; facility?: string } }): str
function facilityWants(f: Facility, car: RollingStock): boolean {
if (f.kind !== 'freight') return false;
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
- if (car.type !== facilityCarType(f)) return false;
+ if (!facilityCarTypes(f).includes(car.type)) return false;
if (!car.loaded && f.allows.outbound) return true;
if (car.loaded && f.allows.inbound) return true;
return false;
diff --git a/src/sim/view.ts b/src/sim/view.ts
index 6ce02d0..9cecf0e 100644
--- a/src/sim/view.ts
+++ b/src/sim/view.ts
@@ -634,6 +634,18 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
const f = card.facility;
if (!f) return 'a facility';
if (f.kind === 'passenger') return 'passengers board and detrain here';
+ // Say where the work has actually got to — the squares on the card show it, this names it.
+ const inWork = f.menAtWork.findIndex((l) => l !== null);
+ const progress =
+ inWork >= 0
+ ? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
+ inWork === f.menAtWork.length - 1
+ ? 'one more Laborer action and it goes onto a spotted car'
+ : 'each Laborer action moves it one square right'
+ }`
+ : f.outboundBox.length > 0
+ ? ' · a load waits in the green box for a Laborer to start it'
+ : '';
const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
@@ -642,7 +654,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
const where = onRunning
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
: '';
- return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`;
+ return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
}
case 'track':
switch (g.geometry) {
diff --git a/test/apply.test.ts b/test/apply.test.ts
index 0b2c7a6..0bf1eb5 100644
--- a/test/apply.test.ts
+++ b/test/apply.test.ts
@@ -6,8 +6,8 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
-import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
-import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
+import { applyIntent, check, areaOf, facilityCarTypes } from '../src/engine/apply.ts';
+import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
@@ -595,3 +595,43 @@ describe('legalActions shares its rules with apply (component 6)', () => {
assert.equal(legalActions(s, 0).length, 0);
});
});
+
+describe('an industry handles every commodity its profile names (regression)', () => {
+ it('derives car types from the sheet rather than a hand-written map', () => {
+ // REGRESSION, and the reason tank cars were dropped ZERO times in 100 games.
+ //
+ // Two maps of industry -> car type existed. The engine's returned `carTypes[0]`, so the second
+ // commodity of a two-commodity industry was unreachable — a Power Plant burns coal OR oil and
+ // a Grocer's Warehouse receives dry goods OR perishables. The bot kept its own switch beside
+ // it, which had drifted further: it named `produceShed` and `oilRefinery`, neither of which is
+ // an industry, and omitted `freightHouse`, `refinery` and `packingSheds`, which are. Half the
+ // industries in the deck therefore never asked for a car at all.
+ //
+ // Asserting equality with the profile is the point: it fails if anyone writes a third map.
+ for (const p of INDUSTRY_PROFILES) {
+ const f = {
+ kind: 'freight',
+ subtype: p.kind,
+ allows: { outbound: p.baseOut > 0, inbound: p.baseIn > 0 },
+ outboundBox: [], inboundBox: [],
+ capacity: { outbound: p.baseOut, inbound: p.baseIn },
+ menAtWork: [null, null, null],
+ industryTrack: { length: 2, cars: [] },
+ laborers: p.baseLoaders, porters: 0,
+ usedThisStage: { laborers: 0, porters: 0 },
+ } as never;
+ assert.deepEqual(
+ [...facilityCarTypes(f)],
+ [...p.carTypes],
+ `${p.kind} handles ${p.carTypes.join('/')} on the sheet`,
+ );
+ }
+ });
+
+ it('names both commodities of the two-commodity industries', () => {
+ // Guards the specific collapse: `carTypes[0]` alone passes the test above only if someone also
+ // rewrites the profiles, but silently fails this one.
+ const twoCommodity = INDUSTRY_PROFILES.filter((p) => p.carTypes.length > 1).map((p) => p.kind);
+ assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
+ });
+});
diff --git a/test/sim.test.ts b/test/sim.test.ts
index 2de0af1..d86b247 100644
--- a/test/sim.test.ts
+++ b/test/sim.test.ts
@@ -8,7 +8,9 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
-import type { GameConfig, GameState } from '../src/engine/state.ts';
+import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
+import { exitsFrom, hasPort, neighbour, opposite } from '../src/engine/track.ts';
+import type { Port } from '../src/engine/track.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
import { record } from '../src/sim/replay.ts';
@@ -301,6 +303,335 @@ describe('switching accomplishes something (regression)', () => {
});
});
+describe('the bot builds sidings that are actually sidings (regression)', () => {
+ /**
+ * Walk south off one Running Track column and report which OTHER main-line columns the walk can
+ * climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the
+ * rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
+ * of the traversal would quietly invent that join.
+ */
+ const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set; seen: Set } => {
+ const main = area.runningRow;
+ const rejoins = new Set();
+ const seen = new Set();
+ const start = area.grid.get(`${main - 1},${col}`);
+ if (!start || !hasPort(start, 'n')) return { rejoins, seen };
+
+ const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
+ const visited = new Set();
+ while (stack.length > 0) {
+ const at = stack.pop()!;
+ const id = `${at.row},${at.col}:${at.entry}`;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ const card = area.grid.get(`${at.row},${at.col}`);
+ if (!card) continue;
+ seen.add(`${at.row},${at.col}`);
+ for (const exit of exitsFrom(card, at.entry)) {
+ const n = neighbour({ row: at.row, col: at.col }, exit);
+ const next = area.grid.get(`${n.row},${n.col}`);
+ if (!next || !hasPort(next, opposite(exit))) continue;
+ if (n.row >= main) {
+ if (n.col !== col) rejoins.add(n.col);
+ continue;
+ }
+ stack.push({ row: n.row, col: n.col, entry: opposite(exit) });
+ }
+ }
+ return { rejoins, seen };
+ };
+
+ const districts = (games: number): OfficeArea[] => {
+ const out: OfficeArea[] = [];
+ for (let i = 0; i < games; i++) {
+ const s = createGame({
+ id: `sd-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ playGame(s, developerBot, pump);
+ const area = s.officeAreas.get(0);
+ if (area) out.push(area);
+ }
+ return out;
+ };
+
+ it('closes run-arounds instead of building only dead-end stubs', () => {
+ // REGRESSION. `bestTrackLay` scored an arc +12 with the comment "close the loop back up to the
+ // main: the run-around is complete", but only checked that a neighbour ran east-west — never
+ // that anything above it had a south port to join. Measured: 0 run-arounds in 100 games. Every
+ // district terminated its siding in an arc pointing north into empty space.
+ const areas = districts(60);
+ const closed = areas.filter((area) => {
+ for (const [k, card] of area.grid) {
+ const [r, c] = k.split(',').map(Number);
+ if (r !== area.runningRow || !hasPort(card, 's')) continue;
+ if (rejoinsFrom(area, c!).rejoins.size > 0) return true;
+ }
+ return false;
+ }).length;
+
+ // Measured at 91/100 after the fix, against 0/100 before it. Half is a floor with room for the
+ // deal to be unkind, not a restatement of the current number.
+ assert.ok(
+ closed >= areas.length / 2,
+ `only ${closed}/${areas.length} districts contain a run-around — sidings are dead-end stubs, ` +
+ 'so a crew cannot run around its own train (§A.5)',
+ );
+ });
+
+ it('puts industries ON the run-around, not merely near one', () => {
+ // REGRESSION. Facility placement was `options.find(i => i.placement !== undefined)` — the first
+ // legal square the generator listed, unscored, while track laying had sixty lines of scoring.
+ // Once the bot started closing run-arounds in 91 games of 100, facilities sitting on one stayed
+ // at 0.00 a game: the loops were built and the industries put somewhere else.
+ //
+ // A facility on a run-around can be reached from either end and its standing cars passed
+ // (§A.5); on a stub the crew must shove in and back out.
+ const areas = districts(60);
+ let onLoop = 0;
+ for (const area of areas) {
+ const loop = new Set();
+ for (const [k, card] of area.grid) {
+ const [r, c] = k.split(',').map(Number);
+ if (r !== area.runningRow || !hasPort(card, 's')) continue;
+ const walk = rejoinsFrom(area, c!);
+ if (walk.rejoins.size === 0) continue;
+ for (const cell of walk.seen) {
+ if (area.grid.get(cell)?.geometry.kind === 'facility') loop.add(cell);
+ }
+ }
+ onLoop += loop.size;
+ }
+
+ // Measured at 1.08 facilities a game on a run-around, against 0.00 before. A third of the games
+ // managing it at all is a floor, not a restatement.
+ assert.ok(
+ onLoop >= areas.length / 3,
+ `only ${onLoop} facilities across ${areas.length} districts sit on a run-around — the loops ` +
+ 'are built and the industries are somewhere else, so the run-around buys no freight',
+ );
+ });
+
+ it('moves the commodities the district needs, not just boxcars', () => {
+ // REGRESSION. `wantedCars` decided what to put on a train from a hand-written industry -> car
+ // map that had drifted from the sheet, so three of the six industries never asked for anything.
+ // Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a
+ // crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were
+ // sitting at an industry that wanted a tank.
+ const dropped = new Set();
+ for (let i = 0; i < 30; i++) {
+ const s = createGame({
+ id: `cs-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ const r = playGame(s, developerBot, pump);
+ for (const e of r.events) {
+ if (e.type !== 'carsDropped') continue;
+ for (const c of e.stock) dropped.add(c.type);
+ }
+ }
+
+ // Tank is the strict test: it is the one commodity no industry shares, so it can only appear if
+ // the Refinery is asking for it.
+ assert.ok(dropped.has('tank'), `no tank car was ever dropped — types seen: ${[...dropped].join(', ')}`);
+ assert.ok(dropped.has('reefer'), `no reefer was ever dropped — types seen: ${[...dropped].join(', ')}`);
+ });
+
+ it('spends no TRACK east of the last column it could rejoin at', () => {
+ // REGRESSION, the other half of the same bug. Nothing capped the east-west extension and
+ // `bestTrackLay` never declined a piece, so once the useful squares were taken it kept laying
+ // whatever was legal — the siding ran past the end of the Running Track and its closing arc had
+ // nothing above it. The loop missed by one card in 96 of 100 games.
+ //
+ // TRACK specifically: a Modifier out there burns tablespace by design and a Facility is placed
+ // for its own reasons. The defect is spending one of 26 track pieces on a loop that cannot
+ // close, so that is what this counts.
+ const areas = districts(60);
+ let overshot = 0;
+ for (const area of areas) {
+ const main = area.runningRow;
+ let lastWayUp = -Infinity;
+ let lastTrack = -Infinity;
+ for (const [k, card] of area.grid) {
+ const [r, c] = k.split(',').map(Number);
+ if (r === main && hasPort(card, 's')) lastWayUp = Math.max(lastWayUp, c!);
+ if (r === main - 1 && card.geometry.kind === 'track') lastTrack = Math.max(lastTrack, c!);
+ }
+ if (lastTrack > lastWayUp) overshot++;
+ }
+
+ assert.equal(
+ overshot,
+ 0,
+ `${overshot}/${areas.length} districts lay track east of the last column with a way up, ` +
+ 'which spends the track supply on a loop that cannot close',
+ );
+ });
+});
+
+describe('the bot does not throw away its own freight (regression)', () => {
+ it('never unjams a green box that is merely waiting', () => {
+ // REGRESSION. `facilityUnjammed` from `outbound` splices the load out of the green box and
+ // pushes it to the classification yard — that load cost a Local Operations action to stock, so
+ // discarding it is strictly negative. The bot reached it through two fallbacks meeting:
+ //
+ // 1. `canStockProductively` did not check the Division Yard, while the engine's own
+ // `stockOutbound` requires a LOADED car of that commodity to be sitting there. Two rules
+ // for one act, and only one was true — so the Freight Agent option was chosen believing a
+ // box could be stocked when none could.
+ // 2. Having chosen it, the follow-through found nothing stuck, nothing to clear and nothing
+ // stockable, and fell through to "clear whatever is stuck" with nothing stuck.
+ //
+ // Measured at 3.10 discarded loads a game against 2.71 STARTED: the bot destroyed more freight
+ // than it worked. §6.3's unjam is for a load stranded on MEN|AT|WORK; a green box holding a
+ // load waiting for a car is not jammed, it is waiting.
+ let outbound = 0;
+ let menAtWork = 0;
+ let started = 0;
+ const games = 40;
+ for (let i = 0; i < games; i++) {
+ const s = createGame({
+ id: `uj-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ const r = playGame(s, developerBot, pump);
+ for (const e of r.events) {
+ if (e.type === 'loadStarted') started++;
+ if (e.type !== 'facilityUnjammed') continue;
+ if (e.from === 'outbound') outbound++;
+ if (e.from === 'menAtWork') menAtWork++;
+ }
+ }
+
+ assert.equal(
+ outbound,
+ 0,
+ `${outbound} loads were unjammed out of a green box across ${games} games ` +
+ `(${started} loads were started) — the bot is discarding freight it paid to stock`,
+ );
+ // The intended use must survive: gutting the fallback entirely would also pass the assertion
+ // above while leaving genuine jams to block the pipeline forever.
+ assert.ok(menAtWork > 0, 'no MEN|AT|WORK jam was ever cleared — §6.3 unjam has become dead code');
+ });
+});
+
+describe('Enhancements can reach the board at all (regression)', () => {
+ it('keeps a straight on the Running Track for an Enhancement to attach to', () => {
+ // REGRESSION, structural. Interlocking, Water Column and Telegraph all print "any Running Track
+ // Straight"; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio
+ // chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw` directly),
+ // so it never needed a straight and laid NONE — 0.00 on the Running Track across 100 games.
+ // Thirteen of the eighteen Enhancement cards that go on the board were therefore unplayable:
+ // drawn 3.78 a game, placed 0.64.
+ let withStraight = 0;
+ const games = 40;
+ for (let i = 0; i < games; i++) {
+ const s = createGame({
+ id: `st-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ playGame(s, developerBot, pump);
+ const area = s.officeAreas.get(0);
+ if (!area) continue;
+ for (const [k, card] of area.grid) {
+ const row = Number(k.split(',')[0]);
+ if (row !== area.runningRow) continue;
+ if (card.geometry.kind === 'track' && card.geometry.geometry === 'straight') {
+ withStraight++;
+ break;
+ }
+ }
+ }
+ assert.ok(
+ withStraight >= games / 2,
+ `only ${withStraight}/${games} districts have a straight on the Running Track — an ` +
+ 'Enhancement that prints "any Running Track Straight" has nowhere to go',
+ );
+ });
+
+ it('gets Interlocking down, which is the only cure for the only penalty', () => {
+ // "No free A/D track" is the sole cause of every penalty in the game — 2.70 revenue a game,
+ // 27% of gross. Interlocking is the designed answer (advance.ts:621): it holds the train at the
+ // Limits instead of colliding. It had never once been placed.
+ //
+ // Measured at 15 games in 60 after the fix, against 0 in 100 before. A quarter of that is a
+ // floor that tolerates an unkind deal without restating the current number.
+ let placed = 0;
+ const games = 60;
+ for (let i = 0; i < games; i++) {
+ const s = createGame({
+ id: `il-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ playGame(s, developerBot, pump);
+ const area = s.officeAreas.get(0);
+ if (!area) continue;
+ for (const card of area.grid.values()) {
+ if (card.enhancements.includes('interlocking')) {
+ placed++;
+ break;
+ }
+ }
+ }
+ assert.ok(
+ placed >= games / 8,
+ `Interlocking reached the board in only ${placed}/${games} games — the district has no ` +
+ 'answer to a full Office but a collision',
+ );
+ });
+});
+
+describe('rolling stock stays in circulation (regression)', () => {
+ it('returns the Classification Yard to service overnight', () => {
+ // REGRESSION, and the largest single revenue change measured on this bot: +2.32 ± 0.52 paired
+ // over 400 seeds (t = 8.79).
+ //
+ // The Classification Yard was WRITE-ONLY. Seven places pushed cars into it — retired trains,
+ // collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained
+ // one way out of the game: 30 cars a game dead by the end, 37% of the 80 dealt at setup.
+ //
+ // It was not obvious this mattered. The Division Yard does not run DRY in five Days (16.6
+ // loaded freight cars left, empty in 2 games of 100), which is why this was ranked third and
+ // predicted not to affect revenue. The aggregate was never the point: what starves freight is
+ // not having the RIGHT commodity when a green box needs stocking, and returning classified
+ // cars keeps the mix alive.
+ const games = 30;
+ let deadAtEnd = 0;
+ let everReturned = 0;
+ for (let i = 0; i < games; i++) {
+ const s = createGame({
+ id: `cy-${i}`,
+ seed: 1000 + i * 7919,
+ config: { ...config, length: 'standard' },
+ playerNames: ['bot'],
+ });
+ const r = playGame(s, developerBot, pump);
+ deadAtEnd += s.yards.classificationYard.length;
+ // Cars must have passed THROUGH it, or the test proves nothing about the return path.
+ if (r.events.some((e) => e.type === 'trainCompleted')) everReturned++;
+ }
+
+ assert.ok(everReturned > 0, 'no train ever completed — nothing reached the Classification Yard');
+ // A Day's worth of arrivals may still be sitting there when the game ends; a whole game's worth
+ // may not. Before the fix this was ~30 a game.
+ assert.ok(
+ deadAtEnd / games < 15,
+ `${(deadAtEnd / games).toFixed(1)} cars a game are still stranded in the Classification ` +
+ 'Yard at the end — rolling stock is leaving the game and never coming back',
+ );
+ });
+});
+
describe('measurement discipline', () => {
it('the observer sees exactly the events the log records', () => {
// REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
diff --git a/test/web.test.ts b/test/web.test.ts
index 5f1009c..7b41b24 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
+import { officeSvg } from '../src/sim/board-svg.ts';
import {
actionGroups,
actionMenu,
@@ -188,6 +189,100 @@ describe('the action menu presents choices the way they are made', () => {
});
});
+describe('the board shows freight work happening', () => {
+ it('moves the load across MEN | AT | WORK on the card itself', () => {
+ // Advancing a load changed only the side panel, so the card being worked showed nothing. The
+ // load crossing green -> MEN|AT|WORK -> a spotted car IS freight; the printed cards put these
+ // squares under the track for exactly this reason.
+ const game = newGame(5);
+ const area = game.state.officeAreas.get(0)!;
+ area.grid.set('-1,0', {
+ geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
+ baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
+ facility: {
+ kind: 'freight', subtype: 'mineTipple',
+ allows: { outbound: true, inbound: false },
+ outboundBox: [{ type: 'hopper', loaded: true }], inboundBox: [],
+ capacity: { outbound: 1, inbound: 0 },
+ menAtWork: [null, null, null],
+ industryTrack: { length: 2, cars: [{ type: 'hopper', loaded: false }] },
+ laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
+ },
+ } as never);
+
+ const filled = (): string[] => {
+ const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
+ const svg = officeSvg([cell], 0);
+ return [...svg.matchAll(/class="bs-wb (bs-\w+)( bs-occ)?"/g)].map(
+ (m) => m[1]!.replace('bs-', '') + (m[2] ? '=FULL' : ''),
+ );
+ };
+
+ assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
+
+ game.state.clock.phase = 'loadUnload';
+ game.state.clock.currentActor = 0;
+ assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
+ assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
+
+ assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
+ assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
+
+ assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
+ assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
+ });
+
+ it('names where the work has got to, in the tooltip', () => {
+ const game = newGame(5);
+ const area = game.state.officeAreas.get(0)!;
+ area.grid.set('-1,0', {
+ geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
+ baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
+ facility: {
+ kind: 'freight', subtype: 'mineTipple',
+ allows: { outbound: true, inbound: false },
+ outboundBox: [], inboundBox: [],
+ capacity: { outbound: 1, inbound: 0 },
+ menAtWork: [null, { type: 'hopper', dir: 'out' }, null],
+ industryTrack: { length: 2, cars: [] },
+ laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
+ },
+ } as never);
+ const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
+ assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
+ });
+
+ it('keeps the enhancement label clear of the pipeline squares', () => {
+ // `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
+ // old baseline the label printed straight through the green/MEN|AT|WORK/red row.
+ const game = newGame(5);
+ const area = game.state.officeAreas.get(0)!;
+ area.grid.set('-1,0', {
+ geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
+ baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
+ facility: {
+ kind: 'freight', subtype: 'mineTipple',
+ allows: { outbound: true, inbound: false },
+ outboundBox: [], inboundBox: [],
+ capacity: { outbound: 1, inbound: 0 },
+ menAtWork: [null, null, null],
+ industryTrack: { length: 2, cars: [] },
+ laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
+ },
+ } as never);
+ const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
+ const svg = officeSvg([cell], 0);
+
+ const enhY = Number(/class="bs-enh" x="\d+" y="(\d+)"/.exec(svg)?.[1]);
+ const boxTop = Math.min(
+ ...[...svg.matchAll(/class="bs-wb [^"]*" x="\d+" y="(\d+)"/g)].map((m) => Number(m[1])),
+ );
+ assert.ok(Number.isFinite(enhY) && Number.isFinite(boxTop), 'label or squares are not drawn');
+ // 9px text sits roughly 9px above its baseline, so the baseline itself must clear the squares.
+ assert.ok(enhY <= boxTop, `enhancement label at y=${enhY} prints over the squares at y=${boxTop}`);
+ });
+});
+
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);