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:
@@ -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();
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
||||
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper', carIndex: 0 },
|
||||
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
|
||||
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
|
||||
];
|
||||
|
||||
+65
-1
@@ -1787,7 +1787,19 @@ describe('the static build', () => {
|
||||
// click-through happens to visit.
|
||||
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
|
||||
const out = new Set<number>();
|
||||
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
|
||||
/**
|
||||
* THE SAME EXCLUSION `renderActions()` (main.ts) APPLIES, not the raw menu.
|
||||
*
|
||||
* `menu.direct` alone is what the ENGINE offers; `renderActions()` then drops any group whose
|
||||
* title matches the one `menu.makeUp` already covers, so a group counted here but filtered
|
||||
* there is exactly the gap that stranded a pending Extra with `menu.makeUp` null and its own
|
||||
* "Making up Extra X22…" title caught by the OLD broader regex this once was. Mirroring the
|
||||
* real filter is what makes this test the one that would have caught that.
|
||||
*/
|
||||
const onScreen = menu.direct.filter(
|
||||
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||
);
|
||||
for (const g of onScreen) for (const a of g.actions) out.add(a.index);
|
||||
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
|
||||
for (const h of menu.hand) {
|
||||
if (h.playNow !== null) out.add(h.playNow);
|
||||
@@ -1817,6 +1829,37 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('offers "where does this Extra start" on screen, not just in the menu', () => {
|
||||
// REPORTED: the game "hung" mid-Freight-House-work with nothing to click. `newTrain.startExtra`
|
||||
// was legal and present in `menu.direct` — the engine was never stuck — but its group is titled
|
||||
// by `trainCardTitle`, which begins "Making up Extra X22…" exactly like the yard-chip panel's
|
||||
// OWN group. `renderActions()` used to drop every group titled that way, on the assumption only
|
||||
// the yard-chip panel used the prefix; with no tray yet being filled (`menu.makeUp` is null,
|
||||
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
|
||||
// it vanished from the page — a legal decision with zero buttons.
|
||||
const game = newGame(430);
|
||||
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
game.state.clock.phase = 'newTrain';
|
||||
game.state.clock.currentActor = 0;
|
||||
assert.equal(currentActor(game), 0, 'a decision should be waiting');
|
||||
|
||||
const menu = actionMenu(game);
|
||||
assert.equal(menu.makeUp, null, 'no tray is being filled — the Extra has not started yet');
|
||||
const extraGroup = menu.direct.find((g) => g.actions.some((a) => menu.options[a.index]?.type === 'newTrain.startExtra'));
|
||||
assert.ok(extraGroup, 'newTrain.startExtra should appear in some menu.direct group');
|
||||
assert.match(extraGroup!.title, /^Making up /, 'this is exactly the title shape that used to collide');
|
||||
|
||||
// The actual filter `renderActions()` applies (main.ts) — reproduced here for the reason given
|
||||
// on `reachable()` above: there is no DOM harness in this suite to call the real renderer.
|
||||
const onScreen = menu.direct.filter(
|
||||
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
|
||||
);
|
||||
assert.ok(
|
||||
onScreen.includes(extraGroup!),
|
||||
'the Extra\'s "choose where it starts" group was filtered out along with the yard-chip panel',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders every part of the menu it is given', () => {
|
||||
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
|
||||
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
|
||||
@@ -2054,6 +2097,27 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the crew chip on a train standing away from the Office', () => {
|
||||
// REPORTED: dropping a loaded boxcar off the back of Train 3 at the Freight House made "the
|
||||
// train vanish off the face of the earth". The game state was never wrong — `selectedTrain` was
|
||||
// only ever assigned inside the `cell.adTracks !== null` branch, so a train standing on any card
|
||||
// OTHER than the Office (which is every card it stands on while actually being switched) got no
|
||||
// badge at all: no label, no engine arrow, no cars. This is the Freight House from that report —
|
||||
// an ordinary facility card, `adTracks: null`, one train parked on it mid-switch.
|
||||
const cell = {
|
||||
row: 1, col: 0, kind: 'facility', label: 'Freight House', running: false,
|
||||
enhancements: [], enhancementsWhat: [], adTracks: null,
|
||||
trains: [{
|
||||
trayId: 'tray3', label: 'T3', cars: ['empty boxcar'], engineAt: 0, facing: 'w' as const,
|
||||
what: 'Train 3 "Express"',
|
||||
}],
|
||||
cars: [], standingWest: 0, facility: null, links: ['ew'], what: '',
|
||||
};
|
||||
const svg = officeSvg([cell as never], 0);
|
||||
assert.match(svg, /class="bs-crew"/, 'the crew badge is not drawn off the Office square');
|
||||
assert.match(svg, />T3</, 'the train\'s own label is missing from the board');
|
||||
});
|
||||
|
||||
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||
|
||||
Reference in New Issue
Block a user