From 982952fd6c74fcc58e4dea0d568f056627f13c39 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 4 Aug 2026 04:59:23 -0400 Subject: [PATCH] Draw freight on the card, and teach the bot to build a railroad --- CHANGELOG.md | 284 +++++++++++++++++++++ TODO.md | 90 ++++++- docs/design/board-first-pass.html | 25 +- src/engine/advance.ts | 20 ++ src/engine/apply.ts | 22 +- src/sim/board-svg.ts | 51 +++- src/sim/bot.ts | 399 +++++++++++++++++++++++++----- src/sim/view.ts | 14 +- test/apply.test.ts | 44 +++- test/sim.test.ts | 333 ++++++++++++++++++++++++- test/web.test.ts | 95 +++++++ 11 files changed, 1288 insertions(+), 89 deletions(-) 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]
West DPno limit — trains queueYardT2 (1)0 of 1 freeABS SignalsTerminal4 of 4 freeDouble Track2 of 2 freeWhistle Post1 of 1 freeDouble TrackT6 (1)1 of 2 freeWhistle Post1 of 1 freeTunnel1 of 1 freeDepotT9T30 of 2 freeDouble TrackT7 (2)1 of 2 freeEast DPno limit — trains queue
5 players · seed 82 · Day 2 Stage 11 · 13 sections · 4 train(s) on the Division · revenue [1, 0, -10, 1, 2]
West DPTX22no limit — trains queueHillyTX20 (1)0 of 1 freeStation3 of 3 freeUncontrolled SidingT12 (2)1 of 2 freeWhistle PostT110 of 1 freePlains1 of 1 freeWhistle Post1 of 1 freeUncontrolled Siding2 of 2 freeDepot2 of 2 freeHeavy Gradeclimbs E ▲1 of 1 freeBrakeman · AirbrakesDepot2 of 2 freeYard1 of 1 freeEast DPno limit — trains queue

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
Depot0,0Freight House0,-1turnout0,1Limits0,2Rotary Dumps-1,0Freight House0,-2Limits0,-3RUNNING TRACK
-
seed 430 · solitaire · a few turns in: the Running Track starts to grow · 16 cards · 5 facilities · Office is a Depot
Depot0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3Limits0,4straight-1,4Limits0,-4straight-1,5straight-1,6RUNNING TRACK
-
seed 430 · solitaire · mid-game, first industries down · 32 cards · 5 facilities · Office is a Terminal
Terminal0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4turnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5Limits0,7Limits0,-7curve-1,-6RUNNING TRACK
-
seed 430 · solitaire · the winning game, fully grown · 37 cards · 7 facilities · Office is a Terminal
Terminal0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4cabempturnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5turnout0,7curve0,-7curve-1,-6Freight House0,8Limits0,-8curve0,9Mine Tipple0,10Limits0,11RUNNING TRACK
-
seed 202 · solitaire · a long Running Track with industries standing on it · 34 cards · 4 facilities · Office is a Station
Station0,0OverpassFreight House0,-1empempturnout0,1Freight House0,2empPower Plant-1,0empempempcurve-1,1Packing Sheds0,-2straight-1,2turnout0,-3straight-1,3straight-1,4Ice House-1,-1straight-1,5Forklifts-2,0straight-1,6straight-1,7straight-1,8straight-1,9turnout0,3curve-1,10turnout0,4turnout0,-4curve-1,-3cabcurve-1,-2turnout0,-5curve-1,-4cabturnout0,5turnout0,6curve0,-6Viscosity Breakers-2,1curve-1,-5curve0,7Limits0,-7Limits0,8RUNNING TRACK
-
seed 111 · solitaire · a district hanging below the Running Track · 25 cards · 2 facilities · Office is a Depot
Depot0,0T7Grocer's Warehouse0,-1empturnout0,1turnout0,2Refinery-1,0curve-1,1straight-1,2Yard OfficeT10straight-1,3Pipelines-1,-1straight-1,4straight-1,5straight-1,6straight-1,7turnout0,-2straight-1,8straight-1,9curve-1,10turnout0,-3Hotel-2,0curve-1,-2loaturnout0,3turnout0,4Limits0,-4curve-1,-3Limits0,5RUNNING TRACK
-
seed 80 · 3 players · three-player game, player 1 · 22 cards · 1 facility · Office is a Terminal
Terminal0,0turnout0,-1turnout0,1Power Plant0,2curve-1,1straight-1,2straight-1,3straight-1,4Ice House-1,0T11straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9Transmission Lines-1,-1curve-1,10turnout0,-2turnout0,-3turnout0,3curve-1,-2cabLimits0,4Limits0,-4RUNNING TRACK
-
seed 81 · 4 players · four-player game, player 1 · 19 cards · 2 facilities · Office is a Terminal
Terminal0,0T9Refinery0,-1turnout0,1Limits0,2Grocer's Warehouse-1,0curve-1,1turnout0,-2straight-1,2straight-1,3Restaurant-1,-1straight-1,4straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9curve-1,10Limits0,-3curve-1,-2RUNNING TRACK
\ No newline at end of file +
seed 430 · solitaire · the opening position — Limits, Whistle Post, Limits · 7 cards · 2 facilities · Office is a Depot
Depot0,0›MAW›Freight House0,-1›MAW›turnout0,1Limits0,2Rotary Dumps-1,0Freight House0,-2›MAW›Limits0,-3RUNNING TRACK
+
seed 430 · solitaire · a few turns in: the Running Track starts to grow · 16 cards · 5 facilities · Office is a Depot
Depot0,0›MAW›Freight House0,-1›MAW›turnout0,1Freight House0,2›MAW›Rotary Dumps-1,0Freight House0,-2›MAW›Refinery0,-3›MAW›curve-1,1Packing Sheds0,3›MAW›straight-1,2straight-1,3Limits0,4straight-1,4Limits0,-4straight-1,5straight-1,6RUNNING TRACK
+
seed 430 · solitaire · mid-game, first industries down · 32 cards · 5 facilities · Office is a Terminal
Terminal0,0›MAW›Freight House0,-1›MAW›turnout0,1Freight House0,2›MAW›Rotary Dumps-1,0Freight House0,-2›MAW›Refinery0,-3›MAW›curve-1,1Packing Sheds0,3›MAW›straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4turnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5Limits0,7Limits0,-7curve-1,-6RUNNING TRACK
+
seed 430 · solitaire · the winning game, fully grown · 36 cards · 6 facilities · Office is a Terminal
Terminal0,0›MAW›Freight House0,-1›MAW›turnout0,1Freight House0,2›MAW›empRotary Dumps-1,0Freight House0,-2›MAW›Refinery0,-3›MAW›curve-1,1T7Packing Sheds0,3›MAW›straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4turnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5turnout0,7curve0,-7curve-1,-6Freight House0,8›MAW›Limits0,-8curve0,9Limits0,10RUNNING TRACK
+
seed 202 · solitaire · a long Running Track with industries standing on it · 34 cards · 4 facilities · Office is a Station
Station0,0›MAW›OverpassFreight House0,-1›MAW›empempturnout0,1Freight House0,2›MAW›empPower Plant-1,0›MAW›empempempcurve-1,1Packing Sheds0,-2›MAW›straight-1,2turnout0,-3straight-1,3straight-1,4Ice House-1,-1straight-1,5Forklifts-2,0straight-1,6straight-1,7straight-1,8straight-1,9turnout0,3curve-1,10turnout0,4turnout0,-4curve-1,-3cabcurve-1,-2turnout0,-5curve-1,-4cabturnout0,5turnout0,6curve0,-6Viscosity Breakers-2,1curve-1,-5curve0,7Limits0,-7Limits0,8RUNNING TRACK
+
seed 111 · solitaire · a district hanging below the Running Track · 25 cards · 2 facilities · Office is a Depot
Depot0,0›MAW›T7Grocer's Warehouse0,-1›MAW›empturnout0,1turnout0,2Refinery-1,0›MAW›curve-1,1straight-1,2Yard OfficeT10straight-1,3Pipelines-1,-1straight-1,4straight-1,5straight-1,6straight-1,7turnout0,-2straight-1,8straight-1,9curve-1,10turnout0,-3Hotel-2,0curve-1,-2loaturnout0,3turnout0,4Limits0,-4curve-1,-3Limits0,5RUNNING TRACK
+
seed 80 · 3 players · three-player game, player 1 · 22 cards · 1 facility · Office is a Terminal
Terminal0,0›MAW›turnout0,-1turnout0,1Power Plant0,2›MAW›curve-1,1straight-1,2straight-1,3straight-1,4Ice House-1,0T11straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9Transmission Lines-1,-1curve-1,10turnout0,-2turnout0,-3turnout0,3curve-1,-2cabLimits0,4Limits0,-4RUNNING TRACK
+
seed 81 · 4 players · four-player game, player 1 · 19 cards · 2 facilities · Office is a Terminal
Terminal0,0›MAW›T9Refinery0,-1›MAW›turnout0,1Limits0,2Grocer's Warehouse-1,0›MAW›curve-1,1turnout0,-2straight-1,2straight-1,3Restaurant-1,-1straight-1,4straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9curve-1,10Limits0,-3curve-1,-2RUNNING TRACK
\ 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);