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:
+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