Compare commits

...
2 Commits
Author SHA1 Message Date
Jesse f9c4d9fa92 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.
2026-08-20 23:26:39 -04:00
Jesse 3a5a909ec0 v0.4.9a — box art opens full size, a third splash card, and a scoped footer claim
Splash page only: the box art thumbnail opens a full-size lightbox on click, a
new "Play multiplayer" (coming soon) card balances the bottom row to three
cards, and the footer's "runs entirely in your browser" claim is now scoped
to solitaire.
2026-08-20 07:40:58 -04:00
11 changed files with 329 additions and 36 deletions
+99
View File
@@ -19,6 +19,105 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.4.9d — 2026-08-21
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
0.5.0 line: a display bug that made a switching train look like it had vanished, an engine bug that
silently ignored which car the player chose to unload, and a UI bug that could render a legal
decision with zero buttons — indistinguishable from a hang.
### A switching train vanished from the board the moment it left the Office
**REPORTED:** Train 3 stood at the Office; dropping the loaded boxcar off the back at the Freight
House made the train "vanish off the face of the earth," regardless of which way it moved
afterward. The game state was never wrong — replaying the reported save
(`docs/station-master-seed116956197-day2.json`) through the engine directly showed `tray3` fully
intact, in the right place, with the right consist, for the rest of the game. Nothing was ever lost
from `state.trays`.
The bug was in `officeSvg` (`board-svg.ts`): `selectedTrain` — the value that decides whether a
card draws a train's crew badge (its label, engine arrow, and cars) — was assigned only inside the
`cell.adTracks !== null` branch, which is true for the Office card and nothing else. A train
standing on any other card, which is every card it stands on while actually being switched, got no
badge at all. It disappeared on the very first move off the Office, not only at the Freight House —
that was simply the first place a player was likely to pause and look. Traced to the Roster Pass
(37b1e5b), which rewrote `CellView.train` into `CellView.trains[]` to show every train sharing the
Office's A/D tracks and, in doing so, nested the general consist-drawing logic inside that
Office-only branch instead of leaving it to run for any card with a train on it.
**Fixed** by hoisting the `selectedTrain` assignment out of the `adTracks !== null` guard so it
runs for every card; the A/D roster-chip loop itself stays inside the guard, since only the Office
has more than one A/D track to draw chips for. New test: a train parked on an ordinary facility
card, `adTracks: null`, now has to draw its crew badge — it did not before this fix.
### Unloading always took the westmost car, whichever one was picked
**REPORTED:** three loaded cars stood at a Freight House; asking to unload the east (right-hand)
car unloaded the west one instead, every time, no matter which car was actually chosen.
`laborer.beginUnload` already carried the player's choice as `carIndex`, and `check()` validated
that specific car — but the event it produced, `unloadBegan`, carried only the car's `carType`, not
which one it was. The reducer that actually performs the swap re-derived the target itself with
`industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in track
order regardless of what was requested — so the player's choice was thrown away between `check` and
the reducer, and the westmost loaded car came off every time.
**Fixed** by adding `carIndex` to the `unloadBegan` event and using it directly in the reducer
(`src/engine/apply.ts`, `case 'unloadBegan'`), instead of re-deriving a car by scanning for
`loaded`. New test in `apply.test.ts`: three loaded cars of three different types, asking to unload
index 2 (east) now leaves indices 0 and 1 alone and empties exactly the one requested — it did not
before this fix.
### A legal decision could render with zero buttons — the game looked hung
**REPORTED:** working the Freight House mid-cycle (unloading three cars, then reloading them), the
game "seemed to have hung up. Cannot advance." No trains were on the Division or the Limits.
Replaying the reported save turned up no engine deadlock at any point — every step had a legal
action — but the exact final state (an Extra train due to start, no tray yet being made up) exposed
a UI bug: `newTrain.startExtra`'s action group is titled by `trainCardTitle`, which begins "Making
up Extra X22…" — the same prefix `renderActions()` (`main.ts`) used to strip from the action list on
the assumption it always belonged to the separate yard-chip car-placement panel. With no tray being
filled yet, that panel (`menu.makeUp`) is `null`, so nothing else rendered the Extra's "choose where
it starts" decision either — a legal, present, correctly-computed option with no button anywhere on
the page. Clicking nothing did nothing, which is indistinguishable from a hang.
**Fixed** by matching the exact title of the one group `menu.makeUp` actually covers
(`g.title !== menu.makeUp?.title`) instead of a title-prefix regex, so any other group that happens
to start "Making up …" — however it got that title — stays on screen. The existing softlock
regression test (`never strands the player with a legal move and no way to make it`) now mirrors the
real render filter instead of only checking the raw menu, so this class of bug fails that test
directly; a new deterministic test constructs the exact reported scenario (a pending Extra, no
active make-up) and asserts its group survives the filter.
590 tests, 0 failures.
---
## 0.4.9a — 2026-08-20
Four small tweaks to the splash page (`index.html`, `splash.ts`), no engine changes.
### Box art opens full size on click
The thumbnail beside the title was too small to read. Clicking (or Enter/Space on keyboard focus)
now opens it in a full-size lightbox overlay; click the overlay or press Escape to close.
### A third card balances the bottom row
The doors row was nested beside the box art, two cards wide, leaving the width under the box art
unused. Moved to a full-width row below the hero (three columns at roughly the same card width as
before) and added **Play multiplayer**, modeled on the Play Solitaire card but describing the
not-yet-built feature, with "Coming soon" in place of a link. Its text is shorter than Play
Solitaire's so the row height — and the page's vertical size — doesn't change.
### Footer's "runs in your browser" claim scoped to solitaire
Now reads "solitaire runs entirely in your browser" rather than an unqualified claim that would no
longer hold once multiplayer needs a server.
---
## 0.4.9 — 2026-08-19
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.9",
"version": "0.4.9d",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+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);
+45 -27
View File
@@ -12,22 +12,27 @@ body{margin:0;background:var(--bg);color:var(--fg);
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
main{width:100%;max-width:760px}
.hero{display:flex;gap:24px;align-items:flex-start}
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line)}
.hero-image{width:230px;flex:none;border-radius:9px;overflow:hidden;border:1px solid var(--line);cursor:zoom-in}
.hero-image img{display:block;width:100%;height:auto}
.hero-content{flex:1;min-width:0}
@media(max-width:640px){.hero{flex-direction:column}.hero-image{width:100%;max-width:280px}}
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
.tag{color:var(--dim);margin:0;font-size:14px}
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
.blurb{color:#c6ccd6;margin:16px 0 0;max-width:66ch}
.rule{height:1px;background:var(--line);margin:22px 0}
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.doors{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:26px}
@media(max-width:640px){.doors{grid-template-columns:1fr}}
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
.lightbox{position:fixed;inset:0;background:rgba(8,10,13,.92);display:flex;
align-items:center;justify-content:center;padding:32px;z-index:10;cursor:zoom-out}
.lightbox[hidden]{display:none}
.lightbox img{display:block;max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,.5)}
/* the rule between title and blurb is drawn as rail, not decorated */
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
.rail{stroke:#4a5361;stroke-width:1.6}
@@ -38,7 +43,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
<body>
<main>
<div class="hero">
<div class="hero-image">
<div class="hero-image" id="hero-image" tabindex="0" role="button" aria-label="View full-size box art">
<img src="./images/station-master-splash.jpg"
alt="Station Master — TimeTable &amp; Train Order Railroading, 1840&ndash;1950. Box art: two trains meet at a junction beside a train order office."
width="560" height="700">
@@ -60,35 +65,48 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
Your game data is saved in your browser &mdash; if you close the tab and reopen this site
without clearing your cache, your game is preserved and you can continue automatically.
During the game you can also explicitly save your progress for later replay.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
</div>
</div>
<div class="doors">
<div class="door disabled">
<h2>Play multiplayer</h2>
<p>Play with your friends across the internet. You each run your own office area within the
entire division. You can play a normal game, or choose co-op or cutthroat. This requires
a Station Master server to be running.</p>
<span class="go">Coming soon</span>
</div>
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
Your game data is saved in your browser &mdash; if you close the tab and reopen this site
without clearing your cache, your game is preserved and you can continue automatically.
During the game you can also explicitly save your progress for later replay.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Replay a saved game, step by step or advancing automatically through phases, Stages and
Days. Open one saved on the site, or a saved file that someone has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
<div class="rule"></div>
<footer>
<span>build <span id="build">__BUILD__</span></span>
<span>runs entirely in your browser &mdash; no server code required</span>
<span>solitaire runs entirely in your browser &mdash; no server code required</span>
</footer>
</main>
<div class="lightbox" id="lightbox" hidden>
<img src="./images/station-master-splash.jpg"
alt="Station Master — TimeTable &amp; Train Order Railroading, 1840&ndash;1950. Box art: two trains meet at a junction beside a train order office.">
</div>
<script type="module" src="./web/splash.js"></script>
</body>
</html>
+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>` +
+19
View File
@@ -7,3 +7,22 @@ if (ties) {
}
ties.innerHTML = out;
}
/** Clicking the box art opens it full size, since the thumbnail is too small to read. */
const heroImage = document.getElementById('hero-image');
const lightbox = document.getElementById('lightbox');
if (heroImage && lightbox) {
const open = () => { lightbox.hidden = false; };
const close = () => { lightbox.hidden = true; };
heroImage.addEventListener('click', open);
heroImage.addEventListener('keydown', (e) => {
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
open();
}
});
lightbox.addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e instanceof KeyboardEvent && e.key === 'Escape') close();
});
}
+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();
+1 -1
View File
@@ -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
View File
@@ -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