adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+231
View File
@@ -10,6 +10,237 @@ The target is 20 Revenue over 5 Days.
## Unreleased
### Q13 — a train that catches the one ahead runs into it
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
judging the gap.
§10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS Signals
exists to stop trains rear-ending each other — but §8.3's trigger list never named one and nothing
was implemented, so granting clearance was FREE: both trains survived, no penalty, and ABS Signals
protected against nothing. Now a following train that closes on the one ahead runs into it, and one
that never closes is fine, so clearance is a bet on relative speed rather than a formality. §2.1
divides the card into two regions, and sharing one is what "caught up" means. ABS Signals does what
it prints instead: the follower stops short and holds.
**Not on a card that prints "trains may pass".** The first version fired 0.41 times a game while the
bot never once granted clearance, which is the tell — those were all Double Track and Uncontrolled
Siding, cards that hold two trains because they HAVE two roads. Catching up there means going past,
which is what the card is for.
With that corrected the mechanic is invisible to the current bot, because it always denies
clearance. That is the right shape, and the teeth are real:
| Superintendent | revenue | rear-enders | ABS holds |
| --- | --- | --- | --- |
| always denies (the bot) | 7.34 | 0.00/game | 0 |
| **always allows** | **−5.13** | **2.20/game** | 19 |
So the bot's always-deny policy — a deliberate choice made when clearance was free and the
arithmetic only guessed at — turns out to be correct, and is now correct for a measured reason.
Tested deterministically rather than through the bot: two trains built onto one single-track card
with the follower closing, which collides whichever order the phase processes them in, and the same
pair again with ABS Signals to prove it holds instead.
### The engine has a place in the train, and the yards are visible
**The engine had a position the game recorded and never used.** `engineFront` was a boolean, written
in three places and read in none — so every consist was drawn as an anonymous row of cars. It is now
`engineAt`, an index into the consist, because a Crew Tray is an engine plus its Rolling Stock and
the engine may be PULLING (ahead of everything), PUSHING (behind everything), or in the middle doing
both at once. A boolean cannot say the third thing. Consists are drawn with `ENG` where it sits.
The engine is deliberately NOT one of the `consist` entries: §8.2 counts the consist as Rolling
Stock, and the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
**Both yards are now on the page**, by car type and split loaded / empty, with the Division Yard
outlined the moment it goes bare. This matters more than it did: the Classification Yard returns to
service only when the Division Yard is empty, so the supply genuinely runs down, and a game that
never showed either yard gave no warning at all.
It shows the pressure immediately. In a finished game the Division Yard held 30 cars — hoppers,
tanks and cabooses — and **no boxcars, coaches or reefers at all**, while 19 of them sat in
Classification unable to come back, because the Division Yard was not bare.
Crew Tray scarcity was already implemented and is left alone: a train with no free tray is held
(`trainHeld`), which is §7.
### The Classification Yard rule, from the source — and my guess was worth 2.4 Revenue it should not have been
Answered: used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
straight back to the Division Yard, and **the Classification Yard empties only when the Division
Yard is bare** — then all of it returns at once.
That is a much harder rule than the one I invented. Returning cars at every DAY boundary keeps the
yard topped up continuously; this lets it run down to nothing and refill in one go, which is the
whole of the supply pressure the game is meant to have. Measured paired over 400 seeds:
my Day-boundary guess 9.67 the real rule 7.25
paired change -2.42 ± 0.49 (t -9.67) 235 of 400 seeds affected
So the +2.32 celebrated when the Classification Yard was first made readable was very largely an
artefact of getting the trigger wrong. The refill is now checked wherever a car leaves the Division
Yard, so it fires the moment the yard empties rather than at the next convenient tick.
**Poling is dealt zero copies** rather than deleted. Its effect is "TBD in the source", so there is
nothing to implement and a card that cannot be played is worse in a hand than absent from the deck.
The catalogue entry stays so the gap remains visible. Deck 140 → 139, solitaire 118 → 117.
**Heavy Grade orientation stays rolled from the seed**, and is now documented as temporary in the
code rather than only in TODO: the card says the player sets it, but it is dealt during setup and
setup has no decision point — `createGame` is a pure function of the seed, which is also what makes
a save portable.
**A counting question this raised, and could not answer.** A census of every holder of rolling stock
comes to 92 against the 80 dealt. That is not proof of duplication: `outboundBox`, `inboundBox` and
`menAtWork` all hold `RollingStock`, and stocking a green box takes a LOADED CAR out of the Division
Yard — so some of those objects are cargo in transit rather than cars, and nothing distinguishes
them. An accounting test was written and then withdrawn, because it could not tell the two apart.
Logged: until a load is its own type, "is any stock being created or destroyed?" is unanswerable.
### The freight figures were counting one half of freight
A measurement fix, not a game fix — but it is the instrument every balance decision is read from.
`rev.freightUnload` was assigned `eventCounts['unloadBegan']`: unloads STARTED, not Revenue EARNED,
and the two differ by every unload that never finished. `grossFreight` then used `freightLoad`
alone, so `freightShare` omitted the unload half outright. A completed load and a completed unload
each earn a point, on two distinct `revenueChanged` reasons.
The comment that stood there claimed "an unload scores through the same event as a load completion
in the reducer". It does not — `apply.ts` emits `freightUnload` separately. A comment asserting a
fact about code a few lines away, and wrong.
freight share of gross: 39% -> 49%
The harness now prints both halves. `strategyBuckets` counted "a scoring game" the same wrong way,
and so did the test guarding it — so a game that scored only by unloading was bucketed but not
counted, which is how the fix first showed up as a failure.
This matters backwards as well as forwards: the "freight is only 13-18% of gross" finding was read
from this number, and it is what drove the Gap 12 industry-density change. That decision was taken
against an instrument reading roughly 60% low.
### Turnouts that go nowhere — reported, confirmed, and mostly not the problem
Reported from two replays: of 8 turnouts off the Running Track, 2 formed a run-around, 2 served
industries and 4 went nowhere. Measured across 120 games, that holds exactly:
| what a turnout leads to | |
| --- | --- |
| part of a run-around | 30% |
| a stub, but serves an industry | 12% |
| a stub ending in bare track | 44% |
| **nothing below it at all** | **15%** |
**4.61 wasted turnouts a game.** Then three attempts to stop it, each measured paired over 400
seeds, and each WORSE than leaving it alone:
| attempt | paired change | |
| --- | --- | --- |
| no new way down while one leads nowhere | run-arounds → **0** | deadlocked: a run-around needs TWO ways down, and the second cannot be justified by what hangs off the first |
| first two free, gate the rest | **−0.84 ± 0.53** (t −3.08) | track spend collapsed 15.4 → 4.8 |
| forbid rail that butts an incompatible card | **−0.62 ± 0.57** (t −2.11) | |
The reason is that a turnout is not only a way DOWN. It is also a way UP, and both the east-west
extension and the closing arc are gated on one existing beyond them — so cutting the turnouts cuts
the places a siding can rejoin, and the sidings stop forming too. The apparent waste is optionality.
This also re-confirms, with proper statistics, a note left in the code by an earlier attempt.
### Rail that can never go anywhere
The one that did work, and only as a tie-breaker.
A port facing an EMPTY square is a promise: something may be built there later. A port butting an
OCCUPIED square whose card has no matching port is not — that square is taken, so the rail stops
dead and always will. Reported from seed 618682, where an arc came off a turnout with its far end
jammed into a curve that could not accept it. Measured: **28% of all pieces laid, 4.26 a game.**
Forbidding it cost 0.62 revenue a game. Applying it as a **tie-breaker on the distance score** —
never able to veto a piece, only to choose between two the heuristics rate equally — measured
**+0.43 ± 0.49 (t 1.74)**, with 108 seeds better against 67, and cut these from 28% of pieces to 7%.
Not significant on its own, but it is the only one of four attempts pointing the right way, and the
mechanism is sound.
**And it fixed the reported problem after all — sideways.** Re-running the turnout taxonomy:
| what a turnout leads to | before | after |
| --- | --- | --- |
| part of a run-around | 30% | **66%** |
| a stub, but serves an industry | 12% | 13% |
| a stub ending in bare track | **44%** | **2%** |
| nothing below it at all | 15% | 18% |
| **wasted per game** | **4.61** | **1.65** |
Bare stubs all but gone and run-arounds more than doubled, without ever refusing a turnout. Refusing
them directly had destroyed the run-arounds; declining to lay rail INTO a dead end leaves the bot
free to cut every turnout it likes and quietly stops it building the stubs. The remaining waste is
the last turnouts of a game, cut with no turns left to build beneath them.
Also added, and honest about it: a turnout is not cut when no arc remains to hang beneath it.
Measured at **0%** today — the bot lays the arc immediately after the turnout and never runs the
supply dry — so it is a guard against the supply changing rather than a fix for anything happening
now. Its test constructs the situation by draining the arcs.
### A source file git was hiding
Found while checking the above: **`src/web/replays.html` had never been committed.** `.gitignore`
carried `replay*.html` to catch the throwaway files generated at the repo root, and unanchored it
also matched a source page — the website's replay viewer. `git archive HEAD` confirms it: a fresh
clone does not contain that file, and `build-web.ts` copies it unconditionally, so the build would
have failed for anyone but this working copy.
The pattern is anchored to the root now (`/replay*.html`), which still ignores the generated files
and no longer ignores the source. A test asks GIT — not `.gitignore` — whether each source page
would survive a clone, because that is the actual question.
### Three places draw a game, and they had drifted
Reported from playtesting: the replay had lost its "extra slow" speed, and neither the sound nor the
auto-hide could be found. Both true, and the same cause — a game is drawn in THREE places and only
some of them had kept up:
| | speeds | sound | auto-hide |
| --- | --- | --- | --- |
| the playable page | — | yes | yes |
| the standalone replay file | 5 | yes | yes |
| **the website's replay viewer** | **3** | **no** | **no** |
The website viewer is its own implementation — it replays a save through the engine in the browser
rather than reading a rendered file — and it never got what the other two grew. It now has all five
speeds (extra slow through very fast), the sound, and the auto-hide, using the same shared `cuesFor`
and `playCue` as everywhere else.
Three tests hold them together from now on: the two viewers must offer the SAME set of speeds, all
three pages must carry the sound and auto-hide controls, and `replays.ts` may not ask for an element
its page does not have — the same total check the playable page already had, and the one that would
have caught this. Verified by removing a speed and a control and watching them fail.
### The published replays had stopped replaying
Both saves in `public/replays/` were dead. A save is a seed plus the intents, replayed through the
real engine — so it cannot describe a position the rules could not produce, and an intent that no
longer applies stops the replay rather than being forced. That is the safe direction, but it is
silent: `seed-202` got 42 intents into 360 before halting, and `seed-430` managed 4 of 338.
They were recorded before this run's rules work — Modifier hosts, the industry-to-car mapping, the
Classification Yard — so most of what they described is no longer legal. Replaced with three games
generated against the rules as they stand, each verified to replay every intent to the final Day:
| | |
| --- | --- |
| A winning run | 36 Revenue, seed 1038389 |
| A strong run | 32 Revenue, seed 618682 |
| Collisions | 10 Revenue and 27 smashes, seed 919604 |
The third is deliberately a bad game: a full Office is a collision, and it costs more than the
freight was worth.
This is the "save/restore is not version-aware" item in TODO doing exactly what it warns about. The
saves are cheap to regenerate, so the fix is not to freeze them — it is for a stale save to say so
instead of quietly ending early.
### The replay behaves like the game it is replaying
Sound and the district auto-hide were built for the playable page and the replay had neither, which