v0.4.9e — five of six playtest bugs: one button per train, and a load that has to go somewhere

Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
This commit is contained in:
Jesse.Markowitz
2026-08-21 23:55:08 -04:00
co-authored by Claude Opus 5
parent f9c4d9fa92
commit 7c35e002af
25 changed files with 2753 additions and 1812 deletions
+137
View File
@@ -19,6 +19,143 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.4.9e — 2026-08-22
Six bugs came back from a gameplay-testing session on 0.4.9d. Five are fixed here; the sixth could not
be reproduced and is written up in `TODO.md` with the two questions that would pin it down. Patched
onto the 0.4.9d line, and applied to the 0.5/0.6 line as the same change.
### Two trains at one platform answered to one button
**REPORTED:** "Operating two trains in a station: the select button does not work. Regardless of which
you pick, it is always one train, not the other."
It did not work because there was nothing for it to do. `porter.board` and `porter.detrain` carried
**no tray at all** — `{ type, at }` and nothing else — so there was one "board passengers at (0,0)"
button however many trains were standing at the platform, and the reducer walked `adOccupancy` and
filled the first empty coach it met. The roster chip the player clicked chose which crew the board
DREW and nothing else. Two independent things were wrong at once:
- `check` asked whether SOME train at the Office had an empty coach, skipping any whose card refuses
passenger work (`refusesPassengers`, `refusesThisOffice`). The reducer did not skip those. So with a
Military train and an ordinary one at the same platform, `check` said yes on behalf of the ordinary
one and the reducer boarded the Military.
- The action list collapses identical labels, and "board passengers at (0,0)" describes both trains —
the same trap that once ate a turnout's second rotation and a Department discard.
Both intents now carry an optional `trayId`, one function (`passengerWork`) resolves which train and
which coach for `check`, `execute` and the reducer alike, `legal.ts` enumerates one candidate per
train standing at the Office, and the label names it: *"board passengers at (0,0) onto Train 9"*.
`trayId` is OPTIONAL for the reason `switch.move`'s `via` is — intents are the canonical record every
save and undo replay against, and absent still means "the first eligible train".
The events carry `trayId` and `coachIndex` rather than leaving the reducer to find them again, which
is the lesson `unloadBegan`'s `carIndex` already taught: a reducer that re-derives the target is a
second implementation of the rule, and it disagreed with the first.
### A load could be made and broken without going anywhere
**REPORTED, twice over:** "Freight House: boxcars loaded cannot be immediately unloaded. In the game
we'll put the chip upside down in the tray to indicate." And: "Passenger stations: passengers just
boarded cannot be immediately unloaded."
They could. A Freight House permits both directions, so the boxcar its own Laborers had just loaded
was standing on its own industry track, loaded, with an empty of that type in the Division Yard and a
free red box — every gate said yes. Passengers were worse: `porter.board` filled a coach and
`porter.detrain` looked for "a loaded coach on a train at the Office", which is the coach that had
just been filled. Full Revenue at both ends of a movement that never happened, for one Porter action.
**Jesse's rule, and it is wider than the report:** freight or passengers loaded anywhere in an Office
Area may not be unloaded anywhere in that same Office Area — not at another facility, not in a later
Stage. A train has to carry them to a different district. So a load carries a stamp naming the SEAT
that made it (`RollingStock.origin`), and the stamp never expires; `laborer.beginUnload` and
`porter.detrain` refuse a car stamped with the district they are standing in, with a rejection code of
its own (`LOADED_IN_THIS_DISTRICT`) because "the car is loaded, the Laborer is free, the boxes are
clear, and the only thing wrong is where it came from" deserves better than "wrong car".
**A seat, not a player**, because Employee Rotation moves players between chairs and the district
stays with the chair. **Undefined, not −1**, for "no origin": the Division Yard opens with loaded cars
and loaded coaches out of the common supply, and those are exactly the inbound traffic a solitaire
district lives on — a sentinel inside `SeatIndex`'s own range is not a sentinel. And `pooled` strips
the stamp at every yard push, because the stamp belongs to the LOAD: a train can retire at a Division
Point with freight still aboard, and that car must not carry a district it left three Days ago into
whatever train is made up from it next.
**Measured: −0.60 ± 0.10 Revenue a game** (t = −6.1) over 400 paired deals — 78 deals worse, 3 better,
319 unchanged. That shape is the point. This is not a nerf spread across the game; it is a narrow
piece of free Revenue coming off the board, and on four deals in five the bot never took it.
The screen's version of the upside-down chip: a car or coach loaded by this district reads *"loaded
boxcar (loaded here)"* on the card, in the tray and in the facility panel.
### The Grocer's Warehouse shipped, and the Refinery received
**REPORTED:** "Grocer's warehouse should be receive only, does not ship anything out." And:
"Refinery: only ships out tanks, does not receive anything."
Both were `flow: 'both'` in `content.ts`, put there deliberately and for a reason that has since
collapsed. `card-reference.md` read: *"'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."* If that were
true, §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named
exactly those two, and they had to be two-way. But the engine has dealt a `freightHouse` card since
before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and the argument evaporates.
The card set says the same thing without needing the rules text. All three Refinery modifiers —
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
industry in the game with no card able to raise one of its two directions.
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
Warehouse · Inbound · 0 out / 1 in".
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
— which also means the only same-district load-and-unload the district rule above has to refuse is a
Freight House unloading its own work. The two fixes meet exactly where the report said they would.
A consequence worth naming rather than discovering: an **Ice House beside a Grocer's Warehouse is now
a dead card**, its +1 outbound dropped on a direction the host does not have. That is the design, not
an oversight — the Home Deck sheet says so outright, and names the Truck Dock's inbound grant beside
the outbound-only Packing Sheds as the other example. `suppressedGrants` already reports it on the
page. It does mean the v0.4.7 note in `TODO.md` that opened these two facilities up was half wrong,
and it is annotated there rather than deleted: the *machinery* it built (an industry's printed flow is
absolute; drop the grant, never open the direction) is exactly what makes this correction land.
### Not reproduced: cars left behind when backing up over them
**REPORTED:** "When I back up to collect standing cars and, further down the tracks, the caboose, I
get the caboose but the cars remain. I can later drive right through them."
Not found, and not for want of looking. Cars on plain track on the way; cars spotted at an INDUSTRY on
the way (Jesse's own guess at the shape); the train's own cut on the square it is pulling out of; a
stale `standingWest`; an industry locked by MEN AT WORK. Every one couples the lot, and the last
correctly blocks the whole route rather than letting the crew past. Three of them are now pinned in
`apply.test.ts` so the case, when it is found, is somewhere none of them cover.
The reason it is hard to make happen is structural: coupling is mandatory (§A.4) and `exploreMoves`
accumulates what it meets card by card, so a route that reaches the caboose has already met everything
between. `carsOn` is the single answer to "what is standing here", and the movement walk, the sweep in
`carsCoupled` and every renderer all ask it — so cars a train can drive through would have to be cars
that are on screen and not in `carsOn`, and there is no such place.
There was one way to MAKE such a place, and it is closed: `flyingSwitch`'s reducer wrote the cut
straight into `industryTrack`, which is not where `carsOn` looks on a Passenger Facility. `check`
refuses a non-freight target so it never fired, but a trap that needs another rule to stay unsprung is
still a trap; it goes through `carsOn` now.
`TODO.md` carries the two questions that would settle it: was there a second route to the caboose, and
what did the move button say it would couple. The label names every car, so "couples caboose" and
"couples 2 boxcars, caboose" are different bugs — the first is route selection, the second the sweep.
### Also
- **An unload never checks the facility's commodity** — found reading `laborer.beginUnload` for the
district rule, not from play. It gates on `allows.inbound`, a loaded car, a matching empty in the
yard and room in the red box, but never on `facilityCarTypes`, which `freightAgent.stockOutbound`
does check. So a Freight House will unload a hopper. Left alone and logged: the district rule now
refuses the one same-Office pairing that made it easy to reach, and the fix is a rule question about
what an industry will accept, not a one-line guard.
- **Both published replays that had gone dead were re-recorded** (`save-replay.ts 400 --top 3`). A save
is a save from a particular ruleset, so a rules change retires the files that no longer replay —
`harness.test.ts` catches it, which is what that test is for.
## 0.4.9d — 2026-08-21
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress