v0.4.9d — three playtest bugs: a train that looked gone, an unload that ignored the pick, a hang that wasn't one

Patched directly onto 0.4.9a rather than the in-progress 0.5.0 line.

A switching train vanished from the board the moment it left the Office. `selectedTrain` in
`officeSvg` (board-svg.ts), which gates whether a card draws a train's crew badge, was only ever
assigned inside the `cell.adTracks !== null` branch — true for the Office card alone. A train
standing anywhere else, which is everywhere it stands while actually being switched, drew no badge
at all. Game state was never affected; confirmed against the reported save with the engine directly.
Fix hoists the assignment out of the guard so it runs for any card with a train on it.

Unloading always took the westmost car, whichever one was picked. `laborer.beginUnload` carried the
player's chosen `carIndex`, and `check()` validated that specific car, but the `unloadBegan` event
it produced carried only the car's type — the reducer that performs the swap re-derived the target
with `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
track order regardless of what was requested. Fix adds `carIndex` to the event and uses it directly.

A legal decision could render with zero buttons, which looked exactly like a hang. The "where does
this Extra start" decision is titled by `trainCardTitle`, beginning "Making up Extra X22…" — the
same prefix `renderActions()` stripped from the action list on the assumption it only ever belonged
to the separate yard-chip car-placement panel. With no tray yet being filled, that panel is null, so
nothing rendered the Extra's decision either: a legal, correctly-computed option with no button
anywhere on the page. Replaying the reported save found no engine deadlock at any step — the engine
always had a move. Fix matches the exact title of the one group the yard-chip panel covers instead
of a title-prefix regex.

New tests for all three: a train parked on an ordinary facility card must draw its crew badge; three
differently-typed loaded cars, unloading index 2, must leave indices 0 and 1 alone; the existing
softlock regression test now mirrors the real render filter instead of only the raw menu, plus a
deterministic test for the exact reported scenario. 590 tests, 0 failures.
This commit is contained in:
Jesse
2026-08-20 23:26:39 -04:00
parent 3a5a909ec0
commit f9c4d9fa92
9 changed files with 241 additions and 9 deletions
+18 -2
View File
@@ -1663,7 +1663,13 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'laborer.beginUnload': {
const f = facilityAt(s, player, i.at)!;
return [
{ type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type },
{
type: 'unloadBegan',
player,
at: i.at,
carType: f.industryTrack.cars[i.carIndex]!.type,
carIndex: i.carIndex,
},
];
}
@@ -2206,7 +2212,17 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'unloadBegan': {
const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
/**
* THE CAR THE PLAYER PICKED, not merely "a loaded one".
*
* REPORTED: with several loaded cars spotted on one industry track, unloading always took the
* WESTMOST car regardless of which one was chosen. This used to re-derive the target with
* `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first
* loaded car in track order — no matter which `carIndex` the intent actually named; `check`
* had already validated that specific car, and the choice was thrown away between there and
* here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent.
*/
const ci = e.carIndex;
if (ci >= 0) {
/**
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
+5 -1
View File
@@ -167,7 +167,11 @@ export type GameEvent =
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
/**
* `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment
* on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it.
*/
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
// -- consequences
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
+10 -2
View File
@@ -568,13 +568,21 @@ export function officeSvg(
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
* `data-crew`, so the play page can wire a click to switch which train that is.
*
* COMPUTED FOR EVERY CARD, NOT JUST THE OFFICE. This used to sit inside the `adTracks !== null`
* branch below, so it was only ever assigned on the Office card — a train standing anywhere else
* in the district (which is everywhere it stands while actually being switched) got no chip at
* all: no label, no engine arrow, and no cars drawn, though the game state never lost it. REPORTED:
* dropping a car at the Freight House made "the train vanish off the face of the earth" — it had
* simply left the one square this code drew a crew on. The Office is the only square that can hold
* more than one train, so `cell.trains[0]` is exactly right everywhere else too.
*/
let selectedTrain: CellView['trains'][number] | null = null;
const selectedTrain: CellView['trains'][number] | null =
cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
if (cell.adTracks !== null) {
const adTracks = cell.adTracks;
const gaps = Math.max(0, adTracks - 1);
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
for (let i = 0; i < adTracks; i++) {
const train = cell.trains[i] ?? null;
const cx = 6 + i * (chipW + 4);
+11 -1
View File
@@ -832,9 +832,19 @@ function renderActions(
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*
* EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the
* assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose
* where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra
* X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is
* null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight
* House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons,
* and nothing short of restarting looked like it would ever move again. Matching the exact
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
* however it is titled, on screen where a player can act on it.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +