v0.4.9e — 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
This commit is contained in:
co-authored by
Claude Opus 5
parent
f9c4d9fa92
commit
7c35e002af
+270
-3
@@ -1284,9 +1284,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);
|
||||
@@ -1895,3 +1897,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
@@ -52,8 +52,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
@@ -181,23 +181,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
@@ -474,8 +474,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.
|
||||
@@ -1070,12 +1072,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
|
||||
|
||||
@@ -3193,6 +3193,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
|
||||
|
||||
Reference in New Issue
Block a user