Draw freight on the card, and teach the bot to build a railroad

This commit is contained in:
Jesse
2026-08-04 04:59:23 -04:00
parent e23d81eade
commit 982952fd6c
11 changed files with 1288 additions and 89 deletions
+284
View File
@@ -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
+81 -9
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+333 -60
View File
@@ -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 &&
/**
* 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 === 'curved' || c.geometry.geometry === 'sharpCurved') &&
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
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,11 +1081,15 @@ 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;
// 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
// de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train —
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+95
View File
@@ -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);