Fix parking trains, freight deadlock, and Whistle Post lock-in

This commit is contained in:
Jesse
2026-07-31 15:09:16 -04:00
parent b085d5a0bb
commit d261ad7af8
15 changed files with 577 additions and 121 deletions
+163 -8
View File
@@ -76,14 +76,171 @@ Crossing time never falls below one Stage — a train cannot cross in no time.
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
an effect; a silent no-op would be worse than a rejection.
> **Open question (Q11).** The sheet says Brakeman and Airbrakes give "faster passage downhill" and
> Helpers "faster passage uphill", but never says **which way a Heavy Grade climbs**. Modelled as
> climbing **eastward** — eastbound is uphill, westbound downhill. If grades instead carry their own
> direction, this becomes a property of the Mainline node rather than a constant.
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
climb, so turning the card around swaps which trains each card helps.
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
should become a real player choice when setup gains an interactive phase.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
### Parking trains — two engine bugs and a bot defect
Trains reached an Office and never left: **668 arrivals against 515 departures**. A train may only
highball from the Office square itself (§8.1, Gap 2b); from anywhere else `moveTrain` returns
`held`, permanently. 77 of 93 leftover crews were sitting somewhere they could never depart from.
Three separate causes, found by following the stranded crews:
1. **Reverse was hardcoded to east/west.** `destinationsFor` computed the backing-out port as
`facing === 'e' ? 'w' : 'e'`. A crew facing *south* reversed to *east* — a port a north-south
card does not have — so it could never back out of a district spur. Now `opposite(facing)`.
2. **Facing was derived from `direction`, which only carries east and west.** A crew standing on a
north-south spur was given an exit port its card did not have, leaving it with no legal moves at
all. `CrewTray.facing` now records the actual port, updated on every move from the port the crew
entered through.
3. **The bot never went home,** and its "no useful work" fallback took an *arbitrary* legal move —
the same shuttling bug as before, re-entered by a different door. It now heads for the Office
whenever nothing productive remains, and a crew stranded away from the Office can claim a whole
Local Operations turn to walk back.
Flying Switch was also mis-modelled: it tested **orthogonal adjacency** when the cut *rolls along
the track*, which both allowed a cut to cross to a cell with no rail between and refused a siding
two cards along the same track. It made the card nearly unplayable — held for 1,400 turns across 60
games and legal on 2. Now the target must be track-connected.
| | before | after |
| --- | --- | --- |
| arrivals / departures | 668 / 515 (77%) | **795 / 793 (99.7%)** |
| leftover crews stranded off the Office | 77 of 93 | 24 of 53 |
| collisions per game | 0.2 | **0.7** |
| revenue per player | 1.0 | **1.1** |
**Revenue got worse, and that is the honest result.** Parked trains were not colliding. With trains
circulating properly, every collision is now `no free A/D track` — 35 of 35 across 60 games — and
collisions cost **3.3** of the 1.1 net. The previous figure was flattered by a bug.
### A/D congestion is not congestion — it is the Whistle Post
Chasing it found no congestion at all. The Office is **empty at 88% of Stage starts** (occupancy 0
at 3,136 of 3,600) and mean dwell on an A/D track is **1.4 Stages**. Collisions are bursts, and they
are concentrated in one place:
- **25 of 26 collisions happen at Whistle Post**, which has **one** A/D track, so any second arrival
is an automatic collision (§8.3, Gap 2a). One collision occurred at Depot; none above it.
- **No collision was ever recorded in a district holding an Interlocking.** The enhancement is a
complete cure — it just arrives too late and too rarely.
- **25 of 100 games never escape Whistle Post at all**, and those are exactly the games that never
drew a Depot card. Mean first upgrade is Stage **9.3 of 60**.
The damage is concentrated, not spread:
| | games | mean revenue | worst |
| --- | --- | --- | --- |
| never upgraded past Whistle Post | 25 | **6.0** | 54 |
| upgraded at least once | 75 | **0.4** | +9 (best) |
That is what makes the median **+1.0** while the mean is **1.0**: a quarter of games are ruined by
a draw that never comes. The bot now plays an Office upgrade ahead of everything else and prefers
Interlocking over other Enhancements; both were previously unranked. Neither moves the mean, because
the constraint is **draw luck, not policy** — escaping Whistle Post requires one of **4 Depot cards
in 133**.
### Q12 answered — office density doubled
**Players always start at a Whistle Post**; that is settled and not up for change. The lever is
therefore card density. Office `copiesInDeck` is **doubled**: Depot 4→8, Station 2→4, Terminal 1→2.
Offices go 7 → 14 and the deck 133 → **140** (solitaire 111 → **118**).
Station and Terminal were doubled alongside Depot rather than lifting Depot alone, because upgrades
are strictly sequential (Gap 3b): the ladder has to stay a pyramid or players reach a rung whose
next card is scarcer than the one that got them there. The test now asserts that **property**
instead of the literal counts, so it survives the next density change.
| | 7 offices / 133 | 14 offices / 140 |
| --- | --- | --- |
| revenue per player | 1.0 | **0.7** |
| games never leaving Whistle Post | 25 | **13** |
| mean revenue, games that did upgrade | 0.4 | **1.9** |
| collisions per game | 0.7 | **0.3** |
| lost to collisions | 3.3 | **1.6** |
| worst game | 48 | 28 |
All 37 remaining collisions are still at Whistle Post, which confirms the diagnosis rather than
undermining it. Note the games that *still* never upgrade got worse (6.0 → 10.5): they are now a
smaller, unluckier group, so the mean is both more extreme and noisier. **Still 0 wins.**
> **PROVISIONAL — re-evaluate later.** These counts were chosen to remove a 25% chance of an
> unwinnable opening deal, not taken from the recovered design, and the instrument is blunt: it
> lifts the whole office ladder and dilutes every other category slightly. Revisit once the victory
> target is settled and freight carries its intended share. The better answer may turn out to be
> fewer Terminals, a cheaper first upgrade, or more A/D capacity at the Whistle Post itself.
### Measuring, after getting it wrong
A throwaway probe reported "60 of 60 games never upgraded past Whistle Post" while the Office-tier
histogram from the same run plainly showed Depots, Stations and Terminals. Events reach the log from
**two** sources — the phase driver (`pump`) and player intents (`applyIntent`) — and the probe
watched only the first. Office upgrades arrive through the second.
The cause was hand-rolling the drive loop. `playGame` already tallied both sources correctly; it was
re-implemented because it returns the event log but not the *state at the moment an event fired*,
which is what questions like "what tier was the Office at this collision" need.
`playGame` now takes an optional `PlayObserver``(event, state) => void`, called from the single
point both sources funnel through. Two tests guard it: one asserts the observer sees **exactly** the
events the log records, one asserts office upgrades are visible in the log across 25 games.
Reintroducing the original mistake fails both.
**A wrong measurement is worse than a missing one, because it gets believed and acted on.**
### Freight throughput — a self-inflicted deadlock
Freight was 10% of gross revenue with 27 industries on the board, so the Gap 12 tripling had bought
facilities but not freight. The pipeline said why: **415 loads started, 413 unjams, 13 completions**
across 60 games — a **3%** completion rate, with the Freight Agent spending more than half its
actions clearing jams it had caused.
A load leaves MEN|AT|WORK only onto a **spotted empty car of its own type** (§9.3). Starting one with
no car spotted parks it on WORK — and a load on WORK **locks the industry track**, which blocks the
very car that would clear it. The bot started a load whenever the intent was legal, so it jammed its
own facilities and then spent its turns unjamming them.
Two fixes, both in the bot; the engine was right throughout.
- **Only start a load that can finish.** `loadCanFinish` requires the receiving car to be spotted
already. Worth recording that the first attempt kept a last-resort `startLoad` "rather than idle a
Laborer" and measured *identical* to having no gate at all — the fallback was taken every time. An
idle Laborer is strictly better than a jam, which costs an action to clear and locks the track
meanwhile.
- **Weigh the whole consist, not just the back car.** 744 switch turns produced 135 Moves and 741
immediate ends: a crew holding wanted cars behind one unwanted car concluded there was nothing to
do. §A.3 makes the back car the only *droppable* one; it does not make it the only one worth
travelling for. The crew now sets out dead weight on a plain spur **before** travelling — ordering
that matters, because doing it after produced the shuttling loop for the third time.
| | before | after |
| --- | --- | --- |
| loads started / completed | 415 / 13 (**3%**) | 44 / 38 (**86%**) |
| unjams | 413 | 281 |
| cars dropped | 151 | 197 |
| cars coupled | 14 | 25 |
| unloads completed | 40 | 74 |
| **revenue per player** | 0.7 | **2.2** |
| cards played | 12.4 | 16.9 |
| trains scheduled | 2.0 | 2.7 |
| passenger revenue | 1.3 | 2.7 |
Freeing the Laborers freed the whole turn economy — passenger revenue doubled without a single
passenger rule changing. **Still 0 wins against a target of 20**, and freight is still only 13% of
gross, so the loop runs now but does not yet carry the game.
### Track was unplayable (found while wiring up Enhancements)
Moving track out of the deck into a per-player supply (§12.2) left **no way to play it**. Districts
@@ -99,10 +256,8 @@ the grid.
Fixed by adding a `track.lay` intent drawing from `OfficeArea.trackSupply`, and by giving
Enhancements the occupied cells as candidates. Enhancement placements went 21 → 98 per 40 games.
> **Open question (Q10).** The design moves track to a per-player supply but never says *when* a
> piece may be laid. It is currently treated as a play available during the "draw a card" option,
> limited to **one piece a turn** — which is how track behaved when it *was* a card. This is an
> assumption, flagged in `intents.ts`, and needs confirming.
**Q10, answered.** Track is laid during the "draw a card" option, **one piece a turn** — which is
how track behaved when it *was* a card.
### Measurement after the answers