v0.4.7 — the switching game: track order, the cut on your own card, and four rules

Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.

TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD

Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.

  - Setting out is batch-invariant. Four cars at once, four singles and two pairs
    parked three different orders, one of them physically impossible. Successive
    cuts off the same end stack up towards the engine, so the insertion point is the
    train's own place in the row.
  - Approaching a cut from either end now mirrors. `couples` is built nearest-first
    along the direction of travel and reverses onto the nose, so the farthest car met
    ends up nose-most — which is what makes a run-around worth its Move.
  - A train no longer drives through its own cut. The walk began at the neighbour of
    the start square and never read the start card, so a crew could set cars out and
    pull straight away from them. Coupling is mandatory (§A.4) and your own square is
    no exception; the cut counts against the four-car limit. Setting out off the end
    you are not leaving by still works.

`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.

On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.

Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.

Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.

THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT

Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.

AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD

Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.

THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE

§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.

THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY

+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.

Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.

570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
This commit is contained in:
Jesse
2026-08-19 12:12:30 -04:00
parent 08339effba
commit 9f3b92d08e
44 changed files with 9739 additions and 4130 deletions
+332 -1
View File
@@ -19,7 +19,338 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## Unreleased
## 0.4.7 — 2026-08-19
Eight play reports and one design that had been written up and not built. The through-line is the
switching game: what a card can hold, which end of a train a cut comes off, which way a train meets
cars standing on the line, and what the board and the log say about all of it.
### Track order for standing cars, and the cut you left on your own card
**Reported, twice, and it turned out to be one bug wearing two faces:**
> "If I put cars off the nose on a given track, and my next move is go forward, I need to couple
> those cars right back on. If I drop cars off the back and I move back, then I will automatically
> recouple the cars onto the back of my train. Right after dropping my cars I need to be able to see
> if those cars are ahead or behind the train."
> "When dropping all 4 cars, order was reversed. It worked properly if we dropped cars individually.
> Also, when adding four cars, again the order was reversed."
**The single root cause.** `TrackCard.standing` was a bare array whose doc comment claimed "in track
order (§A.3)" and which in fact had **no defined orientation at all**. `CrewTray.consist` *is*
oriented — nose first, relative to `facing` — so every transfer between the two is a conversion that
nothing performed. §A.3 says what the orientation should be outright: cars "occupy the track, in the
same order they originally held, **left-to-right**". Left-to-right is west-to-east.
So `standing` (and an industry track through `carsOn`) now runs **west to east**, which is the
board's own orientation rather than whichever train last touched the card. Three consequences, each
of which was one of the reports:
- **Setting out is batch-invariant.** A drop costs no Moves, so one drop of four and four drops of
one are the same turn played two ways — and they parked three different orders, one of them
physically impossible (`2+2` gave `reefer tank boxcar hopper`). Successive cuts off the same end
stack up *towards* the engine, so the insertion point is the train's own place in the row, and all
three now park identically.
- **Approaching a cut from either end mirrors.** `couples` was accumulated in path order with no
reference to the direction of travel, so running onto a parked cut eastbound and westbound gave the
**identical** consist. It is built nearest-first along the direction of travel now, and reverses on
its way onto the nose — the farthest car met ends up nose-most, which is what makes a run-around
worth the Move it costs.
- **A train no longer drives through its own cut.** The movement walk began at the *neighbour* of the
start square and never read the start card at all, so a crew could set cars out and pull straight
away from them in either direction. Coupling is mandatory (§A.4) and your own square is no
exception: pulling out through the end the cut sits at picks it back up, and the cut counts against
the four-car limit. Setting out off the end you are *not* leaving by still works, which is the whole
reason the choice of end is a decision.
**`CrewTray.standingWest`** records where a train stands among the cars on its card — a train may set
out off both ends on one square, so which side a cut is on is not recoverable from the array alone.
It answers all three questions that needed it: which cut a departing train must couple, which cars are
ahead of the engine and which behind, and which side of the chip the board draws them on.
**On the board.** The cut used to be drawn as one strip along the bottom of the card whether a train
was there or not, so "are those cars ahead or behind" had no answer in the picture. The row is split
at the train now — west cars left, east cars right, the engine in the gap — and every car's tooltip
says *"standing AHEAD of the engine — it would couple onto the nose pulling forward"* or the reverse.
The history says which end a cut came off, and a move's button distinguishes *"takes your own empty
boxcar back off this card"* from cars found standing on the line.
**The printed-rule interaction, decided.** Trains 3/4 spend a per-location freight budget on setting
out, so recoupling would have been refused with `FREIGHT_WORKED_HERE` and a legal-looking drop would
have become silently one-way — same shape for X13's "drop but not pick up" and X22's "empties only".
Taking your own cut back on the square you are standing on is **undoing the drop**: exempt from those
restrictions, and the budget is refunded. The alternative — treat it as an ordinary pick-up — never
strands a train, since backing up stays legal, but it makes a legal-looking move a trap, which is
exactly what the "why can't I move" panel exists to prevent.
**Measured**, 200 paired seeds, developer bot: **-0.55 revenue** (SE 0.15, t = -3.63), 3 seeds better,
27 worse, 170 identical, with freight revenue 1.11 → 0.56. This is a real cost and it is the bot's,
not the rule's. The bot's trains run engine-first with every car behind, so at a stub industry it sets
a car out *between itself and the only way out* — and the correct play is §A.5's "facing point" move,
shoving the car in ahead of the engine and backing out, which is the same cross-turn planning
`TODO.md` already records as out of reach of any bot. What the bot could be taught, it was: moves that
drag its own cut back on are filtered out of its options before any heuristic sees them, which took
recoupling from **625 of 1,029 set-outs in 60 games to 101 of 677** — and all 101 that remain are the
stub-industry case above. Read the revenue as a bot measurement, not a balance one.
Twenty-one tests in `test/cut-ordering.test.ts`: batch-invariance off both ends at both facings, the
mirror property, the nearest-first coupling order in both directions, the round trip at every batch
size, §A.5's trailing-point step 1, the own cut counting against the four-car limit, and the freight
budget refunded for a 3/4 Express but still charged for a genuine pick-up. The published replays were
re-recorded twice — legality changed, so bot play changed.
### The Superintendent's ruling names the trains it is about
**Reported:** the Superintendent could not tell which train he was clearing without hovering the
button. The §8.1 clearance is the sharpest decision in the game and it was posed as "Superintendent —
rule on this train", over buttons reading "ALLOW" and "HOLD".
Which train is exactly what the ruling turns on, and it was the one thing not said. Two changes, and
the second is the one that was actually broken:
- **The heading asks the question.** The pending decision holds both trays, so it now reads *"may
Train 6 follow Train 4 onto the same Mainline card?"* instead of naming neither.
- **The train moved to the FRONT of each button.** They already carried the trains and the
consequences — but `actionButton` splits a label at the first em-dash and shows only the head, so
"ALLOW — Train 6 follows Train 4…" put the whole point behind a hover. They read
*"ALLOW Train 6 to follow Train 4 — onto the same Mainline card, closing up behind it"* and
*"HOLD Train 6 — it waits where it is, losing the Stage but keeping the line clear"*, so the
visible half of each button is now the half that decides it.
### An industry track holds four cars, like every other card
**Fixed:** a crew standing at an industry could set out fewer cars than it was carrying. Reported
from a playtest at undo 188 — *"we wanted to drop two cars, but were only allowed to drop one."*
The industry track was built `length: baseOut + baseIn` cars long, so its capacity for ROLLING STOCK
was silently its capacity for WORK. A Mine Tipple prints one green box and no red one, so it had
room for exactly one car and refused the second; the same arithmetic gave a Power Plant one and a
Freight House two. Nothing in the rules says this. An industry track is ordinary Operating Rail, and
what actually limits a set-out is the same thing that limits it anywhere else: a consist may not
exceed four cars, so four is all that can ever be shoved onto a card.
`industryTrack.length` is gone rather than corrected. Leaving a number there invites the next reader
to derive it from something, which is exactly how this happened. `spaceOn` is now one line for every
card — `MAX_CONSIST - carsOn(card).length` — and `carsOn` picks the industry track by asking whether
the facility is freight, which is what the length was standing in for.
**Also fixed, in the same place:** ordinary track was *unbounded*. It could be piled with five or
more cars, and once it was, no train could legally couple them — mandatory coupling would exceed
four — so the pile was unrecoverable. Both exceptions are gone; there is one rule now.
**Changed:** the siding graphic is off the industry cards. Every renderer drew one empty square per
unit of capacity, along the bottom of the card and labelled *siding* in the panels. It asserted two
things that are not true: that an industry card prints a siding, and that the siding is as long as
the box count. No industry card prints one. Industry cards now draw what is standing on them and
nothing more, exactly as a plain straight does, and the panel row is relabelled *spotted*.
A Modifier still adds its box and never adds room for a car. A Grocer's Warehouse receives into one
red box; set a Truck Dock beside it and it has two. Neither number has anything to do with how many
cars fit in front of it, which was four before the Truck Dock arrived and is four after.
**Test note:** `spots cars on industry tracks` went red on this change while measuring an
improvement. It summed cars left spotted across eight seeds, and across those eight the old engine
managed exactly ONE — a coin-flip standing in for the claim "structurally zero". With longer tracks
the bot leaves whole cuts instead of single cars, which lands on fewer seeds and delivers more cars
(22 against 19 over forty games). Widened to twenty seeds so it fails for the reason it names.
### The Truck Dock unloads, and brings nobody
**Changed:** the Truck Dock is now **+1 inbound slot, no Laborer**. It printed +1 outbound and +1
Laborer, which made it a longer-host-list copy of Forklifts — three of the seventeen Modifiers were
the same card with different names on them. A dock is where a truck backs up to take delivery, so it
adds the red box rather than the green one, and pays for the only inbound grant in the deck by
bringing no man to work it.
Two consequences, both intended and both visible before the card is played:
- **Beside Packing Sheds it now does nothing at all.** Packing Sheds is `flow: 'outbound'`, and
`usableGrant` drops a grant on a direction its host cannot use — the same rule that used to swallow
the Ice House's outbound slot at a Grocer's, running the other way. The hand tooltip reads
*"+1 in · goes beside Freight House or Packing Sheds or Grocer's Warehouse · Packing Sheds only
ships, so the inbound slot does nothing there"*, which is the whole decision stated while the card
is still in hand.
- **It is the first Modifier that grants no worker.** A facility's Laborer count no longer rises with
every card set beside it, so an unloading industry can now be capacity-rich and man-poor — which is
a queue at the industry track, not a bug.
Every tooltip, the panel's grant lines and the card reference are computed from `MODIFIER_PROFILES`,
so the one data change carries all of them; nothing prints the old numbers.
**Measured**, 200 paired seeds, developer bot: **-0.03 revenue**, 0 seeds better, 4 worse, 196
identical, with a Truck Dock standing at the end of 16 games either way. Neutral for the bot, which
places one in 8% of games and does not plan around unloading capacity. A human building toward a
Power Plant or a Grocer's is the player who feels this, and the harness cannot see that.
Both published replays that depended on the old grant went dead and were re-recorded
(`node src/sim/save-replay.ts 400 --top 3`). They went dead twice more before this release shipped —
the cut-ordering work above changed what is legal — so the three the site publishes are
`seed-2717217`, `seed-404869` and `seed-2701379`, all recorded against the final rules.
### Mainline cards say what they do
**Reported:** "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled Siding — I
have no idea the impact they have on game play."
Both are invisible without one: **Hilly** charges freight double what it charges passengers, and
**Uncontrolled Siding** is one of only two cards where a following train is not stuck behind a slower
one. The tip carried the card's name and its modifiers and nothing else.
Crossing times are **computed by `crossingStages`** rather than written out, so a tooltip cannot
drift from the rule it describes — including the Hilly split, which is decided by whether the train
carries a coach:
> **Hilly** — P60 / F30 — a train carrying ANY coach crosses as a 60 (1 Stage for a fast train), and
> a freight-only train as a 30 (2 Stages). A slow train adds one Stage either way. · One train at a
> time — anything following has to wait for it to clear.
> **Uncontrolled Siding** — 60 — 1 Stage for a fast train, 2 Stages for a slow one. · TRAINS MAY
> PASS — two trains may stand on this card at once, so a following train is not held behind a slower
> one. The Double Track and the Uncontrolled Siding are the only cards that allow it.
### An Extra starts where its number sends it
**Reported:** "Extras should start at Eastern or Western Division point based on their numbers. Even
trains run to the east (start at western DP), odd run to the west (start at eastern DP). They can
also start at a control point (any office except whistlepost) at player's choice."
Every Extra used to launch **eastbound from the West Division Point**, hardcoded, with the
simplification flagged in a comment — so half of them ran the wrong way and the Control Point option
did not exist. §2.3's "odd runs west, even runs east" now governs an Extra exactly as it governs a
timetabled train, and the New Train phase **stops for the decision** the same way it stops to have
cars placed. Measured over 60 deals: 32 Extras started at the West Division Point and 29 at the East,
where before it was 61 and 0.
A Control Point is any Office above a Whistle Post, so upgrading is what buys the option — `check`
refuses a Whistle Post, and an Extra starting at an Office takes an A/D track like any other arrival.
### A modifier's grant comes back when the Office can use it
**Reported:** "Restaurant attached to a whistle stop, then upgrade to depot — depot only shows one
green / one red box. I expected two, because Restaurant increases outbound by one."
Exactly right. `hosts: ['office']` includes a Whistle Post, which is **not** a Passenger Facility, so
`usableGrant` correctly dropped the +1 outbound when the card was played — and it was gone for good,
because the upgrade only ever applied the difference between two tiers and knew nothing about what
had been discarded. The porter landed, because porters have no direction gate, which is why the
Restaurant looked half-applied rather than suppressed.
`TrackCard.modifiers` already records which Modifiers served a facility, so what was dropped is
recoverable: when the Office becomes a Passenger Facility, each of them is granted the capacity it
always printed. Keyed on the **transition**, so a Depot → Station upgrade does not pay them twice.
### The Grocer's Warehouse ships as well as receives
**Reported:** "grocer's warehouse didn't get extra outbound slot for truck dock."
It could not — and the reason was in the card data, not the modifier code. `card-reference.md`:
| Facility | Car | Direction | Laborers | Out | In | Track |
| --- | --- | --- | ---: | ---: | ---: | ---: |
| Grocer's Warehouse | Boxcar | **Both** | 2 | 2 | 2 | 3 |
| Oil Refinery | Tank car | **Both** | 3 | 2 | 2 | 4 |
and in prose: *"'Freight House' is not a card. It is the collective term for a freight facility that
loads **and** unloads — the Grocer's Warehouse and the Oil Refinery."* The engine had the Grocer's
inbound-only and the Refinery outbound-only, so §9.3's "Passenger Facilities and Freight Houses
permit cars to move each direction" named **neither of them**, and every Modifier grant on the
missing direction was silently dropped — Truck Dock, Ice House and Forklifts at the Grocer's, and
every inbound grant at the Refinery.
`TODO.md` had recorded this in v0.4.2 as *"checked, and there is no bug"*, on the reasoning that a
Grocer's is inbound-only. **That premise was the bug**, and the note is corrected.
Base capacities stay at the engine's own scale — 1 per direction a facility allows — rather than the
card reference's 2/2. Every industry here is scaled down the same way, Mine Tipple included, so
raising one alone would be a balance change rather than a correction. Two things left for Jesse in
`TODO.md`: those numbers, and the fact that the engine deals a **Freight House card** (6 copies) that
the rules say is not a card at all.
### A defence goes out with the attack it answers
**Reported:** "just like the opponent directed cards are removed from the solitaire game, remove any
of the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column and
???). No need to have them in the deck when they can never be used."
The third is the **Overpass**, and there is a fourth: Facing Point Locks exists twice, once as an
Enhancement and once as a Mainline modifier. Seven cards in all, and every one of them answers a card
that is already held out of the deck:
| Card | Copies | Answers | Which is a… |
| --- | ---: | --- | --- |
| Facing Point Locks (Enhancement) | 2 | Derail | Action card |
| Facing Point Locks (Mainline modifier) | 2 | Derail | Action card |
| Water column | 2 | Watertower | Space-use card |
| Overpass | 1 | Railroad crossing | Action card |
The engine already knew two of them were dead — `ENHANCEMENT_RULES` marks Facing Point Locks and the
Water Column `dormantSolo`, and the Overpass is the one card with no code path at all — and dealt
them anyway. The pairing now lives **on the card**, as `SimpleCard.answers`, so it is visible where
the card is defined and they come back automatically the moment `opponentCardsInDeck` does.
The dealt deck drops 213 → **206**. The catalogue is unchanged, and so are the rules: the tests that
exercise Facing Point Locks and the Water Column now mint the card directly rather than fishing it
out of a deck that deliberately no longer contains it — a rule nobody exercises is a rule that rots,
and these fire the moment their attacker returns.
*Also:* a pending Extra's placement now gets its own heading naming the Extra and its card. It had
landed in the "Making up the train" group, which — with no tray being filled — had no train to name.
### Measured drift
- **Deals producing a completed unload: 12 in 40 → 6 in 40.** The Grocer's can ship now, so the bot
often loads there instead of unloading. Unloads themselves are unharmed — 30 completed across the
40 deals measured after the change — and the test that needed one widened its sample rather than
lowering its bar.
- **Moves per productive act: ~8 → 9.9**, with the crew doing *more* work (1.48 → 1.84 acts a game),
not less. Westbound Extras exist for the first time and the Grocer's gives more switching worth
doing; a crew shuttling for its own sake would show this ratio climbing while the work stood still.
- Both published replays were re-recorded: the rules genuinely moved.
- **Three reachability canaries were under-powered and are now sampled properly**, not relaxed. The
anomaly check ran 60 games while its rarest subject, Red Flags, fires in about 4 games in 200 — so
it reported "unreachable" on the luck of the draw, which is the opposite of what a canary is for;
it runs 200 now. The sound test pooled three deals while coupling happens in 39 games in 200, and
on this seed stride the first game that couples anything is index 13 — twelve seeds still contained
none, so it pools 24. The action-list width bound went 13 → 14, re-measured across five seeds in
all three opening deals.
### The Freight Agent may stage a load before the car is there
**Reported:** "A freight agent should be able to load an outbound green box prior to having the car
there that matches the load that he's putting in there. But in order for the laborers to move it
from the green box into the men at work track, they would need to have an empty car of the
appropriate type waiting there."
Correct on both halves, and the engine had the requirement one step too early. §6.3 lists what the
Freight Agent does — *"select one Rolling Stock from the Division Yard pile and place it onto a
Facility's green Outbound box"* — and asks for nothing on the industry track. The empty car belongs
to §9.3's **Load the car**: *"a load in the Green Loading Box and an empty car of the required type
on the industry's track"*. That is the Laborer action, the one that walks the load Green → MEN → AT →
WORK.
`freightAgent.stockOutbound` was rejecting with `NO_EMPTY_CAR_SPOTTED` when no matching empty was
spotted, which made the ordinary sequence illegal: you could not have the cargo waiting on the dock
while the car to ship it in was still being switched in. Now the gate lives only on
`laborer.startLoad`, where `startableLoad` already enforced it — including the type match and the
count against loads already staged or on the sign, so two loads can never walk toward one car.
**Nothing can jam as a result.** A load in a green box is *waiting*, not stuck; only a load on
MEN | AT | WORK locks the industry track (§9.3). The staged load simply sits there until a crew sets
an empty car out.
Also updated so nothing still says the old order:
- **Impediments** told players to "bring one in with a crew FIRST, then the Freight Agent can stage a
load onto it". They are not bound to that order — it now reports the empty siding as the second
errand rather than a reason to hold the Freight Agent back.
- **The bot** ranks a stock whose load a Laborer can start next Stage above one that must wait; it
falls back to staging ahead rather than wasting the option. Previously "first legal stock" was
enough because only ready facilities were offered. 40 games: revenue and trains unchanged, cards
played 16.8 → 16.9 — the bot barely exercises the new freedom, which is a human's to use.
Four tests: stocking over a bare industry track, stocking still refused with no matching loaded car
in the Division Yard, a staged load held in the box until an empty is spotted and started once it is,
and a spotted car of the wrong type not counting.
## 0.4.6 — 2026-08-16