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
+55
View File
@@ -685,6 +685,61 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
});
it('unloads the car the player actually picked, not always the westmost one', () => {
// REPORTED FROM PLAY: three loaded cars stood at a Freight House; asking to unload the EAST
// (index 2) car unloaded the WEST one instead, every time, regardless of which car was picked.
// The reducer for `unloadBegan` used to re-derive the target with
// `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
// track order no matter which `carIndex` the intent actually named.
const s = game();
const coord = at(-1, 0);
addCard(s, coord, {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: {
cars: [
{ type: 'boxcar', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'tank', loaded: true },
],
},
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
// An empty replacement of every type standing on the track, so `check` never refuses for a
// reason unrelated to what this test is about.
s.yards.divisionYard.push(
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: false },
{ type: 'tank', loaded: false },
);
const r = applyIntent(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 2 });
assert.ok(r.ok, 'unloading the east (index 2) car should be legal');
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.equal(f.industryTrack.cars[0]!.type, 'boxcar', 'the west car must be untouched');
assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the west car must still be loaded');
assert.equal(f.industryTrack.cars[1]!.type, 'hopper', 'the middle car must be untouched');
assert.equal(f.industryTrack.cars[1]!.loaded, true, 'the middle car must still be loaded');
assert.equal(f.industryTrack.cars[2]!.type, 'tank', 'the east car — the one picked — must still be there');
assert.equal(f.industryTrack.cars[2]!.loaded, false, 'the east car is the one that should be emptied');
});
it('takes four Laborer actions in total for one point', () => {
// The asymmetry the whole economy is calibrated against (card-reference.md §2).
const s = game();