Draw freight on the card, and teach the bot to build a railroad
This commit is contained in:
+284
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
+18
-4
@@ -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';
|
||||
}
|
||||
|
||||
+50
-1
@@ -194,6 +194,44 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
|
||||
/**
|
||||
* 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 =>
|
||||
`<rect class="bs-wb ${cls}${filled ? ' bs-occ' : ''}" x="${bx}" y="${by}" width="${cellW}" height="12" rx="2"/>` +
|
||||
(label ? `<text class="bs-wl" x="${bx + cellW / 2}" y="${by + 9}" text-anchor="middle">${esc(label)}</text>` : '');
|
||||
|
||||
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 += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
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 += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
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 += `<text class="bs-enh" x="6" y="${H - 30}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
// 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 += `<text class="bs-enh" x="6" y="${H - 46}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.tray) {
|
||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
||||
@@ -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}
|
||||
|
||||
+336
-63
@@ -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<number>; seen: Set<string> } {
|
||||
const main = area.runningRow;
|
||||
const rejoins = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
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<string>();
|
||||
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<string> {
|
||||
const reached = new Set<string>();
|
||||
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<string> {
|
||||
const onLoop = new Set<string>();
|
||||
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<Intent, { type: 'track.lay' }>): 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;
|
||||
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+42
-2
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
+332
-1
@@ -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<number>; seen: Set<string> } => {
|
||||
const main = area.runningRow;
|
||||
const rejoins = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user