v0.6.1 — five of six playtest bugs: one button per train, and a load that has to go somewhere

Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

The same change ships as v0.4.9e on the 0.4.9 line, branched from the v0.4.9d commit — the engine
files these fixes touch are identical across the two lines, so the patch applied cleanly both ways.

Also carries the two "Queued 2026-08-22, from playing on StartOS" TODO items that were staged
before this work started (Games in Progress readability, and getting back into a game after
losing a browser). They are notes, and items 9-12 below them are numbered against them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
This commit is contained in:
Jesse.Markowitz
2026-08-21 23:54:24 -04:00
co-authored by Claude Opus 5
parent 40f07b0710
commit 83a5450866
25 changed files with 2853 additions and 1811 deletions
+270 -3
View File
@@ -1286,9 +1286,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
* only the difference between two tiers and knew nothing about what had been discarded.
*
* This used to be written against an Ice House on a Grocer's Warehouse. That case no longer
* suppresses anything, because the Grocer's is a both-direction facility — which was the other
* half of the same report.
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
* and tests nothing about the upgrade path.
*/
const s = game();
const area = areaOf(s, 0);
@@ -1897,3 +1899,268 @@ describe('the switching job a player actually does: put a car in a siding, take
);
});
});
// ---------------------------------------------------------------------------
/**
* The v0.4.9d playtest, three reports with two causes.
*
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
* trains in a station: the select button does not work, regardless of which you pick it is always
* one train, not the other" — the porter intents carrying no tray.
*/
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
/** An Office that can work passengers, with someone waiting and Porters to hand. */
function platform(s: GameState): void {
const f = office(s);
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }];
s.yards.divisionYard.push({ type: 'coach', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** A tray standing on an A/D track at the Office. */
function atOffice(s: GameState, consist: CrewTray['consist']): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
direction: 'east', position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
});
areaOf(s, 0).adOccupancy.push(id);
return id;
}
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
function freightHouse(s: GameState, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: 'freightHouse' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'boxcar', loaded: true }],
inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
laborers: 9, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard);
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
}
/** Walk a staged load all the way onto the spotted car. */
function finishLoad(s: GameState, coord: GridCoord): void {
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
}
it('refuses to unload the boxcar the Freight House just loaded', () => {
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
finishLoad(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
assert.equal(
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
'LOADED_IN_THIS_DISTRICT',
);
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
assert.ok(
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
'the unload was still offered',
);
});
it('unloads a load that came from somewhere else', () => {
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
// traffic a district lives on.
const s = game();
const coord = at(-1, 0);
freightHouse(s, coord);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
});
it('refuses to detrain the passengers this Office just put aboard', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
const coach = s.trays.get(tray)!.consist[0]!;
assert.equal(coach.loaded, true, 'nobody boarded');
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
assert.equal(
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('detrains passengers who boarded somewhere else', () => {
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
});
it('takes the origin stamp off a coach that reaches the red box', () => {
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
// heads back to a yard from there; carrying the stamp on would poison the common supply.
const s = game();
platform(s);
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
});
});
describe('two trains in one station are told apart (v0.4.9e)', () => {
function twoAtOffice(s: GameState): [string, string] {
const area = areaOf(s, 0);
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
const ids: string[] = [];
for (let n = 0; n < 2; n++) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(id);
ids.push(id);
}
return [ids[0]!, ids[1]!];
}
it('offers boarding on each train, not once for the platform', () => {
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
// which you pick, it is always one train, not the other." There was one button, because the
// intent carried no train at all.
const s = game();
const [a, b] = twoAtOffice(s);
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
assert.deepEqual(
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
[a, b].sort(),
'both trains at the platform must be offered',
);
});
it('boards the train the player named, not the first on the A/D tracks', () => {
const s = game();
const [a, b] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
});
it('still works for an intent that names no train, so old saves replay', () => {
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
// record every save and every undo replays against.
const s = game();
const [a] = twoAtOffice(s);
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
});
});
/**
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
* drive right through them."
*
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
* engine can see always couple — which means cars a train can drive through are cars the engine does
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
* so if the case is found later it is somewhere none of them cover.
*/
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
const caboose = () => ({ type: 'caboose' as const, loaded: false });
/** A Freight House card, with cars spotted on its industry track. */
function industry(cars: TrackCard['standing']): TrackCard {
return {
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: [...cars] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [], enhancements: [],
} as TrackCard;
}
const empty = (s: GameState, ...coords: GridCoord[]): void => {
for (const c of coords) {
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
}
};
it('takes a cut standing on plain track on the way to the caboose', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
// where the two could have come apart.
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 2));
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
it("takes the train's own cut off the square it is standing on as well", () => {
const s = game();
addCard(s, at(0, 1), straight());
addCard(s, at(0, 0), straight([caboose()]));
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
empty(s, at(0, 1), at(0, 0));
});
});
+2 -2
View File
@@ -57,8 +57,8 @@ const SAMPLES: GameEvent[] = [
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
+19 -12
View File
@@ -183,23 +183,30 @@ describe('card catalogue (component 1)', () => {
}
});
it('identifies the both-direction industries the card reference names', () => {
it('names the Freight House and nothing else as the two-way industry', () => {
/**
* `card-reference.md`: "'Freight House' is not a card. It is the collective term for a freight
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table
* agrees: both are "Both", and only the Power Plant is inbound-only.
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
* answers to that.
*
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of
* them — and every Modifier grant on the missing direction was silently dropped, which is how
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported.
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
* "Both" column loses its only argument.
*
* `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly
* delete six cards over.
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
* and so does the modifier set — all three Refinery modifiers grant outbound.
*/
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']);
assert.deepEqual(houses.sort(), ['freightHouse']);
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
assert.equal(refinery.flow, 'outbound');
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
assert.equal(grocers.flow, 'inbound');
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
});
it('starts every industry at one car out and one loader', () => {
+10 -7
View File
@@ -476,8 +476,10 @@ describe('switching accomplishes something (regression)', () => {
* grew faster, which is traffic rather than aimlessness, and there are two new sources of it:
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
* at all (measured 32 west / 29 east across 60 deals, against every single one launching
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as
* receives, so there is more switching worth doing.
* eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
* well as received, which was more switching worth doing. That second source is gone again in
* v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still
* clears the floor, so the figure is left where it is rather than re-tuned to one release.
*
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
@@ -1072,12 +1074,13 @@ describe('the freight figures count both halves (regression)', () => {
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
// and whether the bot manages all three on a given deal is luck, not the thing under test.
/**
* FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness.
* FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness.
*
* The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so —
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while
* unloads themselves are unharmed (30 completed across the 40 measured after the change).
* The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
* well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
* 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
* sample is kept: the precondition it protects — that some deal in the batch actually completes
* an unload — is worth having whichever way the rule goes.
*
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
+99
View File
@@ -3623,6 +3623,105 @@ describe('two crews switching are told apart', () => {
});
});
describe('two trains at one platform are told apart (v0.4.9e)', () => {
/**
* REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does
* not work. Regardless of which you pick, it is always one train, not the other."
*
* `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however
* many trains were standing at it, and the reducer filled whichever tray came first out of
* `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is
* exactly what "the select button does not work" describes.
*
* Driven through `actionMenu` rather than `legalActions` because the second half of the failure was
* at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)"
* describes both trains.
*/
const twoAtPlatform = (): { game: Game; trays: string[] } => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!;
// A Station's worth of platform: Porters, slots, and two fares waiting.
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
const trays: string[] = [];
for (const trainNumber of [7, 9]) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord },
movesUsed: 0,
} as never);
area.adOccupancy.push(id);
trays.push(id);
}
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return { game, trays };
};
it('offers boarding once per train, with the train named on the button', () => {
const { game } = twoAtPlatform();
const labels = actionMenu(game)
.direct.flatMap((g) => g.actions)
.map((a) => a.label)
.filter((l) => /^board passengers/.test(l));
assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`);
});
it('boards the train whose button was pressed', () => {
const { game, trays } = twoAtPlatform();
const { options } = actionGroups(game);
const nine = options.findIndex(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1],
);
assert.ok(nine >= 0, 'no boarding option names the second train');
submit(game, options[nine]!);
assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them');
assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead');
});
it('will not detrain the passengers it has just put aboard', () => {
// The other half of the same playtest: "passenger stations — passengers just boarded cannot be
// immediately unloaded." They could, for a Porter action and full Revenue, without the train
// moving an inch.
const { game, trays } = twoAtPlatform();
const board = actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!;
submit(game, board);
const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain');
assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here');
assert.equal(
check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('says on the coach that it was loaded here', () => {
// The printed game turns the chip upside down in the tray; this is the screen's version of that.
const { game, trays } = twoAtPlatform();
submit(game, actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!);
const f = view(game);
const office = f.cells.find((c) => c.kind === 'office')!;
const train = office.trains.find((t) => t.trayId === trays[0]);
assert.ok(train, 'the boarded train is not on the Office card');
assert.ok(
train!.cars.some((c) => /loaded here/.test(c)),
`the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`,
);
});
});
describe('the Superintendent ruling names the train it is ruling on', () => {
/**
* REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should