v0.4.7 — the switching game: track order, the cut on your own card, and four rules
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
This commit is contained in:
+138
-33
@@ -8,7 +8,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
@@ -794,48 +794,153 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
|
||||
*
|
||||
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
|
||||
* normally next Stage" — and with ordinary traffic on the Division it happens a lot: driven by
|
||||
* the developer bot on the same seed, Train 6 was held and collected FOUR Local Operations turns
|
||||
* instead of none. So the log and the card must not promise that an Expedited train can never be
|
||||
* switched; they say "unless the Superintendent has to hold it", and this is why.
|
||||
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
|
||||
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
|
||||
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
|
||||
* meet depends on the deck, and the deck moves.
|
||||
*
|
||||
* So the log and the card must not promise that an Expedited train can never be switched.
|
||||
*/
|
||||
const s = game(7, { length: 'standard' });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const id = 'watched';
|
||||
|
||||
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
|
||||
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: true }] as never,
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
departsThisStage: true,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
|
||||
area.adOccupancy.push(id);
|
||||
|
||||
let localOps = 0;
|
||||
const seen = new Set<string>();
|
||||
let heldByClearance = false;
|
||||
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
if (s.trays.get(id)?.position.at === 'grid' && s.clock.phase === 'localOps') {
|
||||
const k = `${s.clock.day}|${s.clock.stage}`;
|
||||
if (!seen.has(k)) { seen.add(k); localOps += 1; }
|
||||
}
|
||||
const r = advance(s);
|
||||
if (r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id)) heldByClearance = true;
|
||||
if (s.status === 'finished' || s.clock.day > 3) break;
|
||||
if (r.needsInput) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!applyIntent(s, actor, developerBot.choose(s, actor, options)).ok) break;
|
||||
}
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
assert.ok(heldByClearance, 'the Expedited train was never asked for a clearance ruling');
|
||||
s.clock.phase = 'shiftChange';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
localOps > 0,
|
||||
'an Expedited train held for a ruling should get the Local Operations turns it was denied',
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||||
'the Expedited departure was not put to the Superintendent',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.position.at,
|
||||
'grid',
|
||||
'the Expedited train left despite needing a ruling nobody could give',
|
||||
);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
|
||||
'the log does not say why the Expedited train stayed',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.departsThisStage,
|
||||
false,
|
||||
'it should run as an ordinary train from now on, not retry the expedited departure',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('an Extra starts where its number sends it, or at a Control Point', () => {
|
||||
/**
|
||||
* REPORTED: "Extras should start at Eastern or Western Division point based on their numbers. Even
|
||||
* trains run to the east (start at western DP), odd run to the west (start at eastern DP). They
|
||||
* can also start at a control point (any office except whistlepost) at player's choice."
|
||||
*
|
||||
* Every Extra used to launch eastbound from the West Division Point, hardcoded, with the
|
||||
* simplification flagged in a comment — so half the Extras ran the wrong way and the Control Point
|
||||
* option did not exist at all.
|
||||
*/
|
||||
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
|
||||
const s = game(11);
|
||||
s.pendingExtras = [trainNumber];
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
if (tier) s.officeAreas.get(0)!.tier = tier;
|
||||
s.clock.phase = 'newTrain';
|
||||
advance(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
const started = (s: GameState): CrewTray => {
|
||||
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
|
||||
assert.ok(tray, 'the Extra never took a Crew Tray');
|
||||
return tray;
|
||||
};
|
||||
|
||||
it('stops for the decision instead of launching the train itself', () => {
|
||||
const s = pending(17);
|
||||
assert.equal(s.clock.phase, 'newTrain');
|
||||
assert.equal(s.trays.size, 0, 'the Extra was placed without anyone choosing where');
|
||||
assert.ok(
|
||||
legalActions(s, s.clock.currentActor ?? 0).some((i) => i.type === 'newTrain.startExtra'),
|
||||
'the placement was never offered',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an odd Extra west from the EASTERN Division Point', () => {
|
||||
// §2.3 — odd runs west. It therefore starts at the end it runs away from.
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'west');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
|
||||
});
|
||||
|
||||
it('sends an even Extra east from the WESTERN Division Point', () => {
|
||||
const s = pending(18);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'east');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'west');
|
||||
});
|
||||
|
||||
it('refuses a Whistle Post, which is not a Control Point', () => {
|
||||
const s = pending(18);
|
||||
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 }),
|
||||
'NOT_A_CONTROL_POINT',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
|
||||
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
|
||||
const s = pending(18, 'depot');
|
||||
const r = applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 });
|
||||
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
|
||||
const tray = started(s);
|
||||
const area = s.officeAreas.get(0)!;
|
||||
assert.equal(tray.position.at, 'grid');
|
||||
assert.deepEqual(
|
||||
tray.position.at === 'grid' ? tray.position.coord : null,
|
||||
area.officeCoord,
|
||||
'the Extra did not start on the Office card',
|
||||
);
|
||||
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
|
||||
assert.equal(tray.direction, 'east', 'an even Extra still runs east from wherever it starts');
|
||||
});
|
||||
|
||||
it('takes the Extra off the pending list once, whichever end it started from', () => {
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
assert.deepEqual(s.pendingExtras, []);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }),
|
||||
'NO_EXTRA_PENDING',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+176
-47
@@ -7,12 +7,12 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
|
||||
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } from '../src/engine/content.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, railFacingOf, turnOf } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, railFacingOf, spaceOn, turnOf } from '../src/engine/state.ts';
|
||||
import { cardDescription, snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -453,10 +453,9 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
// An empty hopper is SPOTTED. §9.3 refuses to stage cargo against no car — "otherwise you
|
||||
// are just dropping cargo onto the tracks" — so a fixture with a bare track is a facility
|
||||
// the Freight Agent cannot legally touch.
|
||||
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
|
||||
// An empty hopper is SPOTTED, which is what the LABORERS need (§9.3). The Freight Agent
|
||||
// does not: stocking works over a bare track too, and the tests below cover that.
|
||||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -478,6 +477,38 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
|
||||
});
|
||||
|
||||
it('stocks the green box before the car is spotted (§6.3)', () => {
|
||||
/**
|
||||
* REPORTED FROM PLAY: "A freight agent should be able to load an outbound green box prior to
|
||||
* having the car there that matches the load he's putting in there."
|
||||
*
|
||||
* §6.3 asks for nothing but a car in the Division Yard and room in the box. The empty-car
|
||||
* requirement is §9.3's, on the Laborer who moves the load out of the box — see the test below.
|
||||
* The engine used to hoist it forward onto stocking and so refused the ordinary play of having
|
||||
* the cargo waiting while the car is still being switched in.
|
||||
*/
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.industryTrack.cars = [];
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
|
||||
assert.ok(r.ok, 'staging cargo against a bare industry track is legal');
|
||||
assert.equal(f.outboundBox.length, 1);
|
||||
});
|
||||
|
||||
it('still refuses to stock with no matching loaded car in the Division Yard', () => {
|
||||
// The requirement §6.3 DOES make: the load has to come from somewhere.
|
||||
const s = game();
|
||||
const coord = withFacility(s);
|
||||
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'hopper' && c.loaded));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
|
||||
'NO_SUITABLE_CAR',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows only one Freight Agent operation per Stage', () => {
|
||||
// This is the bottleneck the whole economy rests on (card-reference.md §7).
|
||||
const s = game();
|
||||
@@ -553,7 +584,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||||
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
|
||||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -611,6 +642,46 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
|
||||
});
|
||||
|
||||
it('holds a staged load in the green box until a car is spotted (§9.3)', () => {
|
||||
/**
|
||||
* THE OTHER HALF OF THE STOCKING RULE. The Freight Agent may stage a load with nothing on the
|
||||
* industry track (§6.3), but "Load the car" requires "*a load in the Green Loading Box AND an
|
||||
* empty car of the required type on the industry's track*" — so the load simply waits in the
|
||||
* box, which is a wait and not a jam: only a load on MEN | AT | WORK locks the track.
|
||||
*/
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, null];
|
||||
f.outboundBox = [{ type: 'hopper', loaded: true }];
|
||||
f.industryTrack.cars = [];
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'laborer.startLoad', at: coord }),
|
||||
'NO_EMPTY_CAR_SPOTTED',
|
||||
'no car on the track — the Laborers cannot start it',
|
||||
);
|
||||
|
||||
// A crew sets an empty hopper out, and the same staged load may now start down the sign.
|
||||
f.industryTrack.cars = [{ type: 'hopper', loaded: false }];
|
||||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), null);
|
||||
const r = applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(f.outboundBox.length, 0, 'the load left the green box');
|
||||
assert.notEqual(f.menAtWork![0], null, 'and is on MEN');
|
||||
});
|
||||
|
||||
it('will not start a load onto a spotted car of the wrong type', () => {
|
||||
// Strict matching: a hopper load cannot be swapped onto a tank (§9.3).
|
||||
const s = game();
|
||||
const coord = facilityWithLoad(s);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.menAtWork = [null, null, null];
|
||||
f.outboundBox = [{ type: 'hopper', loaded: true }];
|
||||
f.industryTrack.cars = [{ type: 'tank', loaded: false }];
|
||||
assert.equal(check(s, 0, { type: 'laborer.startLoad', at: coord }), 'NO_EMPTY_CAR_SPOTTED');
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -694,7 +765,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -752,7 +823,7 @@ describe('an industry handles every commodity its profile names (regression)', (
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: p.baseOut, inbound: p.baseIn },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: p.baseLoaders, porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
} as never;
|
||||
@@ -792,7 +863,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -869,37 +940,40 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 1, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
/** Plays an Ice House next to the facility at `at`, and returns that facility. */
|
||||
const iceHouseBeside = (kind: string, out: boolean, into: boolean) => {
|
||||
/** Plays `modifier` next to the facility at `at`, and returns the facility and its card. */
|
||||
const modifierBeside = (modifier: string, kind: string, out: boolean, into: boolean) => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const at = { row: -1, col: 4 };
|
||||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const ice = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse',
|
||||
const card = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === modifier,
|
||||
);
|
||||
assert.ok(ice, 'no Ice House in the deck');
|
||||
s.decks.hands.set(0, [ice![0]]);
|
||||
assert.ok(card, `no ${modifier} in the deck`);
|
||||
s.decks.hands.set(0, [card![0]]);
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: ice![0], placement: { row: at.row, col: at.col + 1 },
|
||||
type: 'card.play', cardId: card![0], placement: { row: at.row, col: at.col + 1 },
|
||||
});
|
||||
assert.ok(r.ok, `the Ice House should be playable beside a ${kind}`);
|
||||
return area.grid.get(coordKey(at))!.facility!;
|
||||
assert.ok(r.ok, `the ${modifier} should be playable beside a ${kind}`);
|
||||
const host = area.grid.get(coordKey(at))!;
|
||||
return { f: host.facility!, card: host };
|
||||
};
|
||||
|
||||
const iceHouseBeside = (kind: string, out: boolean, into: boolean) =>
|
||||
modifierBeside('iceHouse', kind, out, into).f;
|
||||
|
||||
it('gives a Packing Sheds the outbound slot its card prints', () => {
|
||||
// The host ships, so every printed grant lands. This is the control for the case below.
|
||||
const f = iceHouseBeside('packingSheds', true, false);
|
||||
assert.equal(f.capacity.outbound, 2, 'the outbound slot did not arrive');
|
||||
assert.equal(f.laborers, 2, 'the laborer did not arrive');
|
||||
assert.equal(f.industryTrack.length, 2, 'the siding should lengthen with the slot it serves');
|
||||
});
|
||||
|
||||
it("drops the outbound slot on a Grocer's Warehouse, which only receives", () => {
|
||||
@@ -910,23 +984,47 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
assert.equal(f.laborers, 2, 'the laborer should still arrive — Laborers have no direction');
|
||||
assert.equal(f.capacity.outbound, 0, 'outbound capacity was credited to a facility that cannot ship');
|
||||
assert.equal(f.capacity.inbound, 1, 'the modifier must not silently move the grant to the other direction');
|
||||
assert.equal(f.industryTrack.length, 1, 'the siding lengthened for a slot that does not exist');
|
||||
});
|
||||
|
||||
it('does not give a Passenger Facility a siding by way of a Modifier', () => {
|
||||
it('gives a Truck Dock a second red box and not one more car length', () => {
|
||||
/**
|
||||
* The case as it was described: "if I have a Grocer's Warehouse that receives, it has one red
|
||||
* box. If it has a Truck Dock next to it, that should give it a second red box. There is no
|
||||
* change to the number of cars that are allowed to be dropped here."
|
||||
*
|
||||
* Both halves matter, and the second half is the one that used to be wrong: the Truck Dock's
|
||||
* inbound grant lengthened the industry track along with the box, because the track's length WAS
|
||||
* the box count. The boxes are how much work the industry can hold; the card holds four cars
|
||||
* either way.
|
||||
*/
|
||||
const { f, card } = modifierBeside('truckDock', 'grocersWarehouse', true, true);
|
||||
assert.equal(f.capacity.inbound, 2, 'the Truck Dock did not add the red box it prints');
|
||||
assert.equal(f.capacity.outbound, 1, 'the Truck Dock added a green box, which it does not print');
|
||||
|
||||
assert.equal(
|
||||
spaceOn(card as never), MAX_CONSIST,
|
||||
'a Modifier changed how many cars may be set out at the industry it stands beside',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not give a Passenger Facility an industry track by way of a Modifier', () => {
|
||||
// FOUND by rendering 12,000 frames of real games rather than a fixture: a Waiting Area beside the
|
||||
// Office lengthened `industryTrack`, and the board then drew the Depot a siding square to spot a
|
||||
// car on. The same phantom siding the renderer had just been fixed for, arriving by another door.
|
||||
// Office lengthened `industryTrack`, and the board then drew the Depot a square to spot a car on.
|
||||
// A Waiting Area's "+1" is a passenger slot; passengers board off the platform.
|
||||
//
|
||||
// The length field is gone now — an industry track holds four cars like any other card, so there
|
||||
// was never a number for a Modifier to raise. What is still worth pinning down is the property
|
||||
// that bug was really about: an Office keeps its cars in `standing` and has no industry track for
|
||||
// a Modifier to hand it, no matter what the Modifier prints.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
|
||||
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
|
||||
const office = area.grid.get(coordKey(area.officeCoord))!.facility!;
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||||
const office = officeCard.facility!;
|
||||
office.allows = { outbound: true, inbound: true };
|
||||
const before = office.industryTrack.length;
|
||||
|
||||
const waiting = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
|
||||
@@ -941,37 +1039,68 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted');
|
||||
assert.equal(office.porters, 1, 'the porter should still be granted');
|
||||
assert.equal(
|
||||
office.industryTrack.length, before,
|
||||
carsOn(officeCard), officeCard.standing,
|
||||
'a Modifier gave a Passenger Facility an industry track to spot cars on',
|
||||
);
|
||||
});
|
||||
|
||||
it('says which bonus was dropped, and why', () => {
|
||||
// The number genuinely does not move; what was missing was the reason.
|
||||
it('suppresses a grant the host cannot use — and gives it back when it can', () => {
|
||||
/**
|
||||
* REPORTED from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot only
|
||||
* shows one green / one red box. I expected two, because Restaurant increases outbound by one."
|
||||
*
|
||||
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1
|
||||
* outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right,
|
||||
* 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.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const at = { row: -1, col: 4 };
|
||||
area.grid.set(coordKey(at), withFacility('grocersWarehouse', false, true) as never);
|
||||
const office = area.grid.get(coordKey(area.officeCoord))!;
|
||||
assert.equal(office.facility!.allows.outbound, false, 'a Whistle Post is not a Passenger Facility');
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const ice = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'iceHouse',
|
||||
const restaurant = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
|
||||
)!;
|
||||
|
||||
// Before it is played, the card in hand warns which of its two printed hosts cannot use it.
|
||||
const what = cardDescription(s, ice[0]);
|
||||
assert.match(what, /only receives/, 'the hand card does not warn about the dead half');
|
||||
|
||||
s.decks.hands.set(0, [ice[0]]);
|
||||
s.decks.hands.set(0, [restaurant[0]]);
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: ice[0], placement: { row: at.row, col: at.col + 1 },
|
||||
}).ok);
|
||||
type: 'card.play',
|
||||
cardId: restaurant[0],
|
||||
placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col },
|
||||
}).ok, 'the Restaurant could not be played beside the Office');
|
||||
|
||||
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Grocer'));
|
||||
assert.ok(fv, "the Grocer's Warehouse is missing from the panel");
|
||||
const f = office.facility!;
|
||||
assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have');
|
||||
assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed');
|
||||
|
||||
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Whistle'));
|
||||
assert.ok(fv, 'the Office is missing from the panel');
|
||||
assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported');
|
||||
assert.match(fv!.suppressed[0]!, /Ice House/);
|
||||
// And the modifier is now recorded against the host it served, which never used to happen.
|
||||
assert.deepEqual(fv!.modifiers, ['Ice House'], 'the modifier is not listed against its host');
|
||||
assert.match(fv!.suppressed[0]!, /Restaurant/);
|
||||
assert.deepEqual(fv!.modifiers, ['Restaurant'], 'the modifier is not listed against its host');
|
||||
|
||||
// Upgrading makes it a Passenger Facility, and the slot the Restaurant always printed arrives.
|
||||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
|
||||
assert.equal(f.allows.outbound, true);
|
||||
assert.equal(
|
||||
f.capacity.outbound,
|
||||
officeProfile('depot').passengerOut + 1,
|
||||
"the Restaurant's slot did not come back when the Office could finally use it",
|
||||
);
|
||||
|
||||
// And it is paid ONCE: a second upgrade must not grant it again.
|
||||
const afterDepot = f.capacity.outbound;
|
||||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'depot', to: 'station' });
|
||||
assert.equal(
|
||||
f.capacity.outbound,
|
||||
afterDepot + (officeProfile('station').passengerOut - officeProfile('depot').passengerOut),
|
||||
'the Restaurant was paid a second time on the next upgrade',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* §A.3 — cars occupy the track "in the same order they originally held, left-to-right".
|
||||
*
|
||||
* Left-to-right is west-to-east, and `TrackCard.standing` had no defined orientation at all until
|
||||
* this release. Three reports came out of that one omission and each gets its own battery here:
|
||||
* a cut parked in a different order depending on how many cars were set out at once; a train that
|
||||
* got the identical consist whichever end it approached a cut from; and a train that drove straight
|
||||
* through the cars it had just set out.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
const game = (seed = 77): GameState => createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
const car = (type: string, loaded = false): RollingStock => ({ type, loaded }) as RollingStock;
|
||||
|
||||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
/** A row of plain straights, on row 1 — row 0 is the Running Track and holds the Office. */
|
||||
function row(s: GameState, n: number): void {
|
||||
for (let c = 0; c < n; c++) addCard(s, at(1, c), straight());
|
||||
}
|
||||
|
||||
function placeTray(
|
||||
s: GameState,
|
||||
coord: GridCoord,
|
||||
consist: RollingStock[],
|
||||
facing: 'e' | 'w',
|
||||
engineAt = 0,
|
||||
): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id,
|
||||
trainNumber: null,
|
||||
trainIsExtra: false,
|
||||
engineAt,
|
||||
consist,
|
||||
direction: facing === 'w' ? 'west' : 'east',
|
||||
facing,
|
||||
railFacing: facing,
|
||||
position: { at: 'grid', seat: 0, coord },
|
||||
movesUsed: 0,
|
||||
} as CrewTray);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Puts the game in the one phase switching intents are legal in. */
|
||||
function switching(s: GameState): void {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'switch';
|
||||
}
|
||||
|
||||
const types = (cars: readonly RollingStock[]): string[] => cars.map((c) => c.type);
|
||||
|
||||
const FOUR = (): RollingStock[] => [car('boxcar'), car('hopper'), car('reefer'), car('tank')];
|
||||
|
||||
const standingAt = (s: GameState, coord: GridCoord): string[] =>
|
||||
types(carsOn(areaOf(s, 0).grid.get(coordKey(coord))!));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a cut parks in the same order however many cars come off at once', () => {
|
||||
/**
|
||||
* Reported: "when dropping all 4 cars, order was reversed. It worked properly if we dropped cars
|
||||
* individually." Both halves are the same bug — `carsDropped` pushed onto the end of the array
|
||||
* whatever end the cut came off, so the answer depended on how the player chose to spend the drop.
|
||||
*
|
||||
* A drop costs no Moves, so one drop of four and four drops of one are the SAME turn played two
|
||||
* ways. They must park the same cars in the same order, and that is the whole property.
|
||||
*/
|
||||
function park(facing: 'e' | 'w', fromNose: boolean, batches: number[]): string[] {
|
||||
const s = game();
|
||||
row(s, 1);
|
||||
// The engine goes at whichever end lets the cut come off the one being tested: cars ahead of it
|
||||
// for a nose drop, behind it for a tail drop.
|
||||
const id = placeTray(s, at(1, 0), FOUR(), facing, fromNose ? 4 : 0);
|
||||
switching(s);
|
||||
for (const count of batches) {
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'switch.dropCars',
|
||||
trayId: id,
|
||||
count,
|
||||
...(fromNose ? { fromNose: true } : {}),
|
||||
});
|
||||
assert.ok(r.ok, `drop of ${count} refused: ${r.ok ? '' : r.code}`);
|
||||
}
|
||||
return standingAt(s, at(1, 0));
|
||||
}
|
||||
|
||||
for (const facing of ['e', 'w'] as const) {
|
||||
for (const fromNose of [false, true]) {
|
||||
const end = fromNose ? 'nose' : 'tail';
|
||||
it(`is batch-invariant off the ${end}, facing ${facing}`, () => {
|
||||
const all = park(facing, fromNose, [4]);
|
||||
assert.equal(all.length, 4, 'all four cars should be standing on the card');
|
||||
assert.deepEqual(park(facing, fromNose, [1, 1, 1, 1]), all, 'four singles parked a different order than one four');
|
||||
assert.deepEqual(park(facing, fromNose, [2, 2]), all, 'two pairs parked a different order again');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('parks west-to-east, so an east-facing train sets its tail cut out behind itself', () => {
|
||||
// Consist is nose first and the nose points east, so `[boxcar hopper reefer tank]` stands on the
|
||||
// ground as tank, reefer, hopper, boxcar reading west to east. The tail car is the westmost.
|
||||
assert.deepEqual(park('e', false, [4]), ['tank', 'reefer', 'hopper', 'boxcar']);
|
||||
// Facing west the tray already reads west-to-east, so it goes down as it stands.
|
||||
assert.deepEqual(park('w', false, [4]), ['boxcar', 'hopper', 'reefer', 'tank']);
|
||||
});
|
||||
|
||||
it('keeps the two cuts apart when a train sets out off both ends on one square', () => {
|
||||
// §A.3 lets a cut come off either outer end, so both can happen on one square. The engine ends
|
||||
// up standing between them, which is the thing `standingWest` exists to record.
|
||||
const s = game();
|
||||
row(s, 1);
|
||||
const id = placeTray(s, at(1, 0), FOUR(), 'e', 2);
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }).ok);
|
||||
|
||||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||||
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a parked cut mirrors depending on which end the train comes from', () => {
|
||||
/**
|
||||
* The consist a train ends up with must depend on which way it ran, or a run-around buys nothing.
|
||||
* `couples` was accumulated in path order with no reference to the direction of travel, so both
|
||||
* approaches produced the identical tray.
|
||||
*/
|
||||
function collect(from: 'west' | 'east'): string[] {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
// The cut stands on the middle square, boxcar west of hopper.
|
||||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||||
const start = from === 'west' ? at(1, 0) : at(1, 2);
|
||||
const to = from === 'west' ? at(1, 2) : at(1, 0);
|
||||
const id = placeTray(s, start, [], from === 'west' ? 'e' : 'w');
|
||||
switching(s);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse: false });
|
||||
assert.ok(r.ok, `the run was refused: ${r.ok ? '' : r.code}`);
|
||||
return types(s.trays.get(id)!.consist);
|
||||
}
|
||||
|
||||
it('gives mirrored consists, never the same one twice', () => {
|
||||
const eastbound = collect('west');
|
||||
const westbound = collect('east');
|
||||
assert.notDeepEqual(eastbound, westbound, 'approaching from either end gave the identical consist');
|
||||
assert.deepEqual(eastbound, [...westbound].reverse(), 'the two approaches are not mirror images');
|
||||
});
|
||||
|
||||
it('puts the car the engine met LAST on the nose, running forward', () => {
|
||||
// Running east onto boxcar-then-hopper, the engine pushes the boxcar ahead of it and picks the
|
||||
// hopper up in front of that. Nose first, the tray reads hopper, boxcar.
|
||||
assert.deepEqual(collect('west'), ['hopper', 'boxcar']);
|
||||
assert.deepEqual(collect('east'), ['boxcar', 'hopper']);
|
||||
});
|
||||
|
||||
it('couples behind the train nearest-car-first when backing up', () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||||
// Facing east at the east end, so backing up runs WEST into the cut: it meets the hopper first.
|
||||
const id = placeTray(s, at(1, 2), [car('caboose')], 'e');
|
||||
switching(s);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'hopper', 'boxcar']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('a train may not drive through the cut on its own card (§A.4)', () => {
|
||||
/**
|
||||
* Reported: "if I put cars off the nose on a given track, and my next move is go forward, I need
|
||||
* to couple those cars right back on." The walk started at the NEIGHBOUR of the start square and
|
||||
* never read the start card at all, so the cut was simply left behind.
|
||||
*
|
||||
* The rule is direction-sensitive, which is the point: only the cut at the end the train pulls out
|
||||
* through is in the way. That is what makes setting out off one particular end a decision.
|
||||
*/
|
||||
function runOut(fromNose: boolean, reverse: boolean): { consist: string[]; left: string[] } {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', fromNose ? 2 : 0);
|
||||
switching(s);
|
||||
assert.ok(
|
||||
applyIntent(s, 0, {
|
||||
type: 'switch.dropCars',
|
||||
trayId: id,
|
||||
count: 1,
|
||||
...(fromNose ? { fromNose: true } : {}),
|
||||
}).ok,
|
||||
);
|
||||
const to = reverse ? at(1, 0) : at(1, 2);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
|
||||
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
|
||||
return { consist: types(s.trays.get(id)!.consist), left: standingAt(s, at(1, 1)) };
|
||||
}
|
||||
|
||||
it('picks a nose cut back up when it pulls forward through it', () => {
|
||||
const out = runOut(true, false);
|
||||
assert.deepEqual(out.left, [], 'the cut was left standing on the card the train pulled out of');
|
||||
assert.equal(out.consist.length, 2, 'the train should have both cars back');
|
||||
});
|
||||
|
||||
it('leaves a nose cut standing when it backs away from it instead', () => {
|
||||
const out = runOut(true, true);
|
||||
assert.deepEqual(out.left, ['boxcar'], 'backing away from a nose cut should leave it where it is');
|
||||
assert.deepEqual(out.consist, ['hopper']);
|
||||
});
|
||||
|
||||
it('encodes §A.5’s trailing-point step 1 — drop the back cars, then pull away from them', () => {
|
||||
// "Drop the back two cars (blue and red) on Card F. Move train to Card B." The whole example
|
||||
// collapses if that first move drags them along again.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 2), FOUR(), 'w');
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: false });
|
||||
assert.ok(r.ok, `pulling away from the set-out cars was refused: ${r.ok ? '' : r.code}`);
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'hopper'], 'the train kept cars it had set out');
|
||||
assert.deepEqual(
|
||||
standingAt(s, at(1, 2)),
|
||||
['reefer', 'tank'],
|
||||
'the two cars set out should still be standing on the square the train left',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the own cut against the four-car limit', () => {
|
||||
// A full tray that sets out one car cannot simply pull forward through it again — that would be
|
||||
// five cars, and coupling is mandatory, so the MOVE is what has to be refused.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), FOUR(), 'e', 4);
|
||||
switching(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
// Refill the tray so the recoupled car would be a fifth.
|
||||
s.trays.get(id)!.consist = FOUR();
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }), 'ILLEGAL_MOVE');
|
||||
// Backing away from it is still legal — the cut is not at that end.
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the round trip comes back with the train it started with', () => {
|
||||
/**
|
||||
* Drop, run clear, back up, collect. This was already self-consistent before the fix — whatever
|
||||
* order got parked came back — and it has to STAY that way now that both halves have changed, at
|
||||
* both facings and every batch size.
|
||||
*/
|
||||
for (const facing of ['e', 'w'] as const) {
|
||||
for (const batches of [[4], [1, 1, 1, 1], [2, 2]]) {
|
||||
it(`survives ${batches.join('+')} cars set out, facing ${facing}`, () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const start = facing === 'e' ? at(1, 0) : at(1, 2);
|
||||
const away = facing === 'e' ? at(1, 2) : at(1, 0);
|
||||
const before = types(FOUR());
|
||||
const id = placeTray(s, start, FOUR(), facing);
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
for (const count of batches) {
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count }).ok);
|
||||
}
|
||||
assert.equal(s.trays.get(id)!.consist.length, 0, 'the whole train should be on the ground');
|
||||
|
||||
// Pull forward clear of the cut, then back up onto it again.
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: away, reverse: false }).ok);
|
||||
const back = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: start, reverse: true });
|
||||
assert.ok(back.ok, `backing onto the cut was refused: ${back.ok ? '' : back.code}`);
|
||||
|
||||
assert.deepEqual(types(s.trays.get(id)!.consist), before, 'the train came back in a different order');
|
||||
assert.deepEqual(standingAt(s, start), [], 'cars were left behind');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('taking your own cut back is undoing the drop, not a fresh pick-up', () => {
|
||||
/**
|
||||
* Jesse's call, of the two answers the plan put up. Trains 3/4 print "may drop OR pick up one
|
||||
* freight car at every location" and the two share one budget, so a drop off the nose would
|
||||
* otherwise leave the train forbidden to pull forward past its own cars — a legal-looking move
|
||||
* that is silently one-way. Same shape for X13's "drop but not pick up".
|
||||
*/
|
||||
it('refunds the 3/4 Express its per-location freight budget', () => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||||
s.trays.get(id)!.trainNumber = 3;
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false });
|
||||
assert.ok(r.ok, `the Express could not take its own car back: ${r.ok ? '' : r.code}`);
|
||||
assert.equal(s.trays.get(id)!.consist.length, 2, 'the car it set out should be back on the train');
|
||||
assert.deepEqual(
|
||||
Object.values(turnOf(s, 0).freightWorked).filter((n) => n > 0),
|
||||
[],
|
||||
'the drop it undid is still charged against the square it was made on',
|
||||
);
|
||||
});
|
||||
|
||||
it('still charges a genuine pick-up', () => {
|
||||
// The exemption is for YOUR OWN cut and nothing else: a car that was already standing there when
|
||||
// the crew arrived is an ordinary pick-up and the budget still bites.
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 2), straight([car('reefer')]));
|
||||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||||
s.trays.get(id)!.trainNumber = 3;
|
||||
switching(s);
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }).ok);
|
||||
assert.equal(
|
||||
turnOf(s, 0).freightWorked[`${id}@${coordKey(at(1, 2))}`],
|
||||
1,
|
||||
'only the car that was already standing there should be charged',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,15 @@ function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
/** Puts an enhancement card in hand and returns its id. */
|
||||
/**
|
||||
* An Enhancement in hand — minting one if the deck does not deal it.
|
||||
*
|
||||
* Facing Point Locks, the Water Column and the Overpass answer opponent-directed cards, which are
|
||||
* held out of every deck until they are implemented, so those three are no longer dealt either
|
||||
* (v0.4.7). Their RULES are still live and still worth testing: they fire the moment their attacker
|
||||
* returns, and a rule nobody exercises is a rule that rots. So the card is put in hand directly
|
||||
* rather than fished out of a deck that deliberately no longer contains it.
|
||||
*/
|
||||
function handEnhancement(s: GameState, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
|
||||
@@ -46,7 +54,10 @@ function handEnhancement(s: GameState, key: string): string {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no enhancement card: ${key}`);
|
||||
const id = `mint-${key}-${s.cards.size}`;
|
||||
s.cards.set(id, { id, kind: { kind: 'enhancement', key } } as never);
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Puts a specific track card in hand and returns its id. */
|
||||
|
||||
@@ -332,7 +332,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ const idleIndustry = () =>
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 1, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -504,7 +504,7 @@ describe('the view shows one seat at a time', () => {
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 1, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
|
||||
+11
-1
@@ -395,7 +395,17 @@ describe('the replay behaves like the game it is replaying', () => {
|
||||
// seed and passed on luck — the moment the opening deal changed, that seed's game happened to
|
||||
// contain no switching at all and four of the six cues vanished. What is under test is the
|
||||
// plumbing, not one game, so the frames are pooled.
|
||||
const recs = [5, 4321, 880009].map((seed) => record(seed, 'standard', 4000));
|
||||
/**
|
||||
* TWELVE SEEDS, not three — the same lesson as the comment above, learnt again at v0.4.7.
|
||||
* Coupling happens in about 39 games in 200, so three deals expect 0.6 of one: the assertion was
|
||||
* riding on luck exactly as the single seed had been, and the deck losing seven cards was enough
|
||||
* to tip it.
|
||||
*
|
||||
* TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that
|
||||
* couples anything is index 13, so a twelve-seed pool still contained none. The rate is what
|
||||
* matters, not the count — measured 39 in 200, with couplers at indices 13, 19, 22, 24, 28 …
|
||||
*/
|
||||
const recs = Array.from({ length: 24 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
|
||||
const withCues = recs.flatMap((rec) => rec.frames.filter((f) => (f.cues?.length ?? 0) > 0));
|
||||
assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
|
||||
|
||||
|
||||
+30
-4
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import { MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -104,7 +104,19 @@ describe('card catalogue (component 1)', () => {
|
||||
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
|
||||
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
|
||||
// categories NOT_IMPLEMENTED, so dealing them would make 22 of 235 draws (9%) reject outright.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 213);
|
||||
// 206, not 213: the 22 opponent-directed cards come out, and so do the SEVEN that exist only to
|
||||
// answer them — Facing Point Locks (both the Enhancement and the Mainline modifier, 2 each), two
|
||||
// Water Columns and one Overpass. A defence with nothing to defend against is the same dead draw
|
||||
// as the attack would be. `SimpleCard.answers` names the pairing, so they return together.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 206);
|
||||
assert.equal(DEFENCE_ONLY_COPIES, 7);
|
||||
for (const c of DEFENCE_ONLY_CARDS) {
|
||||
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
||||
assert.ok(
|
||||
!buildDeck().some((x) => (x.kind as { key?: string }).key === c.key),
|
||||
`${c.name} is still dealt`,
|
||||
);
|
||||
}
|
||||
for (const mode of ['solitaire', 'competitive'] as const) {
|
||||
const deck = buildDeck(mode);
|
||||
assert.equal(deck.length, SOLITAIRE_DECK_SIZE, `${mode} deck size`);
|
||||
@@ -169,9 +181,23 @@ describe('card catalogue (component 1)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('identifies the both-direction industry', () => {
|
||||
it('identifies the both-direction industries the card reference names', () => {
|
||||
/**
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
assert.deepEqual(houses.sort(), ['freightHouse']);
|
||||
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']);
|
||||
});
|
||||
|
||||
it('starts every industry at one car out and one loader', () => {
|
||||
|
||||
+48
-7
@@ -146,15 +146,22 @@ describe('the revenue chain works end to end (regression)', () => {
|
||||
// REGRESSION. Cars dropped at a facility went into the card's `standing` list, while §9.3's
|
||||
// loading rule looked at `industryTrack`. Across 50 games ZERO cars were ever spotted and
|
||||
// freight revenue was structurally zero. Win rate went 0% -> ~35% when these were unified.
|
||||
//
|
||||
// TWENTY SEEDS, NOT EIGHT. The claim is "structurally zero", and eight games was far too thin a
|
||||
// sample to stand for it: across the original eight, exactly ONE car was ever left spotted, so
|
||||
// the test passed on a single coin-flip. It went red on a change that measurably IMPROVED
|
||||
// spotting — once an industry track stopped being as short as its box count, the bot began
|
||||
// leaving whole cuts (2-5 cars) instead of single cars, which lands on fewer seeds but delivers
|
||||
// more cars in total (22 vs 19 over forty games). Widened so it fails for the reason it names.
|
||||
let spotted = 0;
|
||||
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
|
||||
for (const seed of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
|
||||
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump);
|
||||
for (const card of s.officeAreas.get(0)!.grid.values()) {
|
||||
if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
|
||||
}
|
||||
}
|
||||
assert.ok(spotted > 0, 'no car ever reached an industry track across eight games');
|
||||
assert.ok(spotted > 0, 'no car ever reached an industry track across twenty games');
|
||||
});
|
||||
|
||||
it('instantiates a real Facility when a freight card is placed', () => {
|
||||
@@ -315,11 +322,19 @@ describe('end-of-game statistics', () => {
|
||||
});
|
||||
|
||||
it('reports no anomalies — every rule in the engine is reachable', () => {
|
||||
/**
|
||||
* 200 GAMES, not 60. This is a reachability canary, and the rarest thing it watches for is Red
|
||||
* Flags: measured at 200 games it fires in 4 of them, about 2%, so a 60-game sample expects 1.2
|
||||
* and reports "unreachable" on the luck of the draw — which is the opposite of what a canary is
|
||||
* for. Coupling is the next rarest at 39 in 200. Both are genuinely reachable; the sample was
|
||||
* simply too small to say so, and it got smaller in effect when v0.4.7 took seven cards out of
|
||||
* the deck and moved which seeds reach which districts.
|
||||
*/
|
||||
// THE POINT OF THIS MODULE. Every serious bug so far was an unreachable rule: a pipeline with
|
||||
// no entry point, a facility that could not be worked, a car that could not be spotted. If an
|
||||
// expected event stops firing, something has become unreachable again.
|
||||
const report = simulate({
|
||||
games: 60, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
games: 200, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
/**
|
||||
* ONE NAMED EXEMPTION, not a blanket one.
|
||||
@@ -451,8 +466,22 @@ describe('switching accomplishes something (regression)', () => {
|
||||
* drift, and it is logged in `TODO.md` with the rest of it rather than quietly absorbed here.
|
||||
*/
|
||||
assert.ok(work > 1.2, `only ${work.toFixed(2)} productive acts a game — the crew is doing nothing`);
|
||||
/**
|
||||
* RAISED 9 -> 11, and the crew is doing MORE work rather than less.
|
||||
*
|
||||
* Measured at 400 games after v0.4.7: 18 Moves and 1.84 productive acts, a ratio of 9.9 — where
|
||||
* the work figure was 1.48 when the floor above was written. Both halves grew and the numerator
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
assert.ok(
|
||||
moves / work < 9,
|
||||
moves / work < 11,
|
||||
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
|
||||
`(${moves.toFixed(0)} Moves, ${work.toFixed(2)} productive acts)`,
|
||||
);
|
||||
@@ -734,7 +763,7 @@ describe('the bot does not throw away its own freight (regression)', () => {
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||
// Stranded on WORK with no spotted car to receive it — the definition of a jam.
|
||||
menAtWork: [null, null, { type: 'hopper', dir: 'out' }],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -1040,8 +1069,20 @@ describe('the freight figures count both halves (regression)', () => {
|
||||
// the precondition that makes the comparison mean anything. Three deals is a thin thread to hang
|
||||
// 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.
|
||||
// Measured across these twelve: 34 unloads earned, on 12 of 40 deals sampled more widely.
|
||||
for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433, 64352, 72271, 80190, 88109]) {
|
||||
/**
|
||||
* FORTY DEALS, up from twelve, and the reason is 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 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
|
||||
* anything. Widening the sample restores the precondition without weakening the assertion.
|
||||
*/
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const seed = 1000 + i * 7919;
|
||||
const s = createGame({
|
||||
id: `fu-${seed}`, seed,
|
||||
config: { ...config, length: 'standard' },
|
||||
|
||||
+60
-3
@@ -98,7 +98,7 @@ const lockedFacility = (): TrackCard => ({
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 3, inbound: 0 },
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
||||
industryTrack: { length: 4, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 3,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -565,6 +565,63 @@ describe('placement and drop-off', () => {
|
||||
);
|
||||
assert.ok(!canDropCarsAt(area, at(0, 1)));
|
||||
});
|
||||
|
||||
it('lets a crew set out as many cars at an industry as anywhere else (regression)', () => {
|
||||
/**
|
||||
* REPORTED from a playtest, at undo 188: "we wanted to drop two cars, but were only allowed to
|
||||
* drop one."
|
||||
*
|
||||
* An industry track used to be `length: baseOut + baseIn` cars long, so a Mine Tipple — one
|
||||
* green box, no red one — had room for exactly ONE car and refused the second. The box count is
|
||||
* how much WORK an industry can hold; it has nothing to do with how much rail is in front of it.
|
||||
* No industry card prints a siding of a fixed length, and an industry track is ordinary
|
||||
* Operating Rail: four cars fit, the same as on the plain straight beside it.
|
||||
*/
|
||||
const oneBoxIndustry = (): TrackCard => {
|
||||
const c = lockedFacility();
|
||||
// Unlocked — §9.3's lock is tested above and is not what this is about.
|
||||
c.facility!.menAtWork = [null, null, null];
|
||||
c.facility!.capacity = { outbound: 1, inbound: 0 };
|
||||
return c;
|
||||
};
|
||||
|
||||
const area = areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: officeCard(),
|
||||
[coordKey(at(0, 1))]: oneBoxIndustry(),
|
||||
[coordKey(at(0, 2))]: straight(),
|
||||
},
|
||||
at(0, 0),
|
||||
);
|
||||
|
||||
for (const count of [1, 2, 3, 4]) {
|
||||
assert.ok(
|
||||
canDropCarsAt(area, at(0, 1), count),
|
||||
`a one-box industry refused a cut of ${count} — the box count is not the siding length`,
|
||||
);
|
||||
assert.equal(
|
||||
canDropCarsAt(area, at(0, 1), count),
|
||||
canDropCarsAt(area, at(0, 2), count),
|
||||
`an industry and a plain straight disagreed about a cut of ${count}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Four is the ceiling everywhere, because a consist may not exceed four.
|
||||
assert.ok(!canDropCarsAt(area, at(0, 1), 5), 'a fifth car fitted on an industry track');
|
||||
assert.ok(!canDropCarsAt(area, at(0, 2), 5), 'a fifth car fitted on a plain straight');
|
||||
});
|
||||
|
||||
it('counts the cars already spotted at an industry against the room left', () => {
|
||||
const busy = lockedFacility();
|
||||
busy.facility!.menAtWork = [null, null, null];
|
||||
busy.facility!.industryTrack.cars = [car(), car(), car()];
|
||||
const area = areaFrom(
|
||||
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: busy },
|
||||
at(0, 0),
|
||||
);
|
||||
assert.ok(canDropCarsAt(area, at(0, 1), 1), 'the fourth car should still fit');
|
||||
assert.ok(!canDropCarsAt(area, at(0, 1), 2), 'a fifth car fitted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
@@ -724,7 +781,7 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: menAtWork as never,
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
@@ -974,7 +1031,7 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||
// A load on MEN locks the track (§9.3).
|
||||
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null] as never,
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('§7 — trains Porters may not work', () => {
|
||||
outboundBox: [coach(true)], inboundBox: [],
|
||||
capacity: { outbound: 2, inbound: 2 },
|
||||
menAtWork: null,
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 0, porters: 2, usedThisStage: { laborers: 0, porters: 0 },
|
||||
};
|
||||
s.trays.set('t', {
|
||||
|
||||
+84
-13
@@ -30,6 +30,7 @@ import {
|
||||
overHandLimit,
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
drain,
|
||||
handPlayable,
|
||||
currentActor,
|
||||
fromSave,
|
||||
@@ -340,7 +341,7 @@ describe('the board shows freight work happening', () => {
|
||||
outboundBox: [{ type: 'hopper', loaded: true }], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [{ type: 'hopper', loaded: false }] },
|
||||
industryTrack: { cars: [{ type: 'hopper', loaded: false }] },
|
||||
laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -384,7 +385,7 @@ describe('the board shows freight work happening', () => {
|
||||
kind: 'freight', subtype: kind,
|
||||
allows: { outbound: out, inbound: into },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||||
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
|
||||
menAtWork: [null, null, null], industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
});
|
||||
@@ -510,7 +511,7 @@ describe('the board shows freight work happening', () => {
|
||||
outboundBox: [{ type: 'coach', loaded: true }], inboundBox: [{ type: 'coach', loaded: true }],
|
||||
capacity: { outbound: n, inbound: n },
|
||||
menAtWork: null,
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 0, porters: n, usedThisStage: { laborers: 0, porters: 0 },
|
||||
};
|
||||
|
||||
@@ -544,7 +545,7 @@ describe('the board shows freight work happening', () => {
|
||||
allows: { outbound: false, inbound: true },
|
||||
outboundBox: [], inboundBox: [{ type: 'boxcar', loaded: true }],
|
||||
capacity: { outbound: 0, inbound: 1 },
|
||||
menAtWork: [null, null, null], industryTrack: { length: 1, cars: [] },
|
||||
menAtWork: [null, null, null], industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -566,7 +567,7 @@ describe('the board shows freight work happening', () => {
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, { type: 'hopper', dir: 'out' }, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -621,7 +622,7 @@ describe('the board shows freight work happening', () => {
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -686,7 +687,7 @@ describe('the board shows freight work happening', () => {
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
@@ -1065,7 +1066,7 @@ describe('the page explains itself', () => {
|
||||
kind: 'freight' as const, subtype: 'grocersWarehouse',
|
||||
allows: { outbound: false, inbound: true },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
|
||||
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
|
||||
menAtWork: [null, null, null], industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
};
|
||||
@@ -1761,11 +1762,12 @@ describe('the static build', () => {
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||
}
|
||||
// Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable
|
||||
// squares — the list getting longer for a good reason rather than the cross-products this test
|
||||
// was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three
|
||||
// opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`.
|
||||
assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`);
|
||||
// Raised 8 -> 10 -> 13 -> 14. The widest group is Switching every time, which is a crew's
|
||||
// reachable squares — the list getting longer for a good reason rather than the cross-products
|
||||
// this test was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all
|
||||
// three opening deals after v0.4.7: 14 under `sixRandom`, 13 under the other two. The deck is
|
||||
// seven cards smaller and Extras run both ways now, so which seeds reach which districts moved.
|
||||
assert.ok(worst <= 14, `the action list still reaches ${worst} buttons`);
|
||||
});
|
||||
|
||||
it('makes up ONE train at a time, and names that train', () => {
|
||||
@@ -2916,3 +2918,72 @@ describe('two crews switching are told apart', () => {
|
||||
assert.equal(f.moves[0]!.label, 'Train 12');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
* indicate what train number he is ruling on in the primary display — you should not have to look
|
||||
* at the tooltip."
|
||||
*
|
||||
* The page splits an action label at the first em-dash: the head goes on the BUTTON and the rest
|
||||
* becomes the hover. "ALLOW — Train 7 follows Train 4…" therefore put the train number in the one
|
||||
* place a player is not looking while making the sharpest decision in the game. Asserted on the
|
||||
* head alone, exactly as `actionButton` cuts it, so a label rewritten back into the tooltip fails.
|
||||
*/
|
||||
const facing = (): Game => {
|
||||
const game = newGame(4242);
|
||||
const s = game.state;
|
||||
// A train already occupying the first Mainline card, running east.
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain: Double Track and Uncontrolled Siding set `trainsMayPass`, which legitimately
|
||||
// removes the §8.1 bar this fixture exists to trip.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
// A second train at the Western Division Point wanting to follow it onto that card.
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 7, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
} as never);
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
drain(game);
|
||||
return game;
|
||||
};
|
||||
|
||||
/** What the page actually prints on the button — `actionButton` cuts at the first ' — '. */
|
||||
const head = (label: string): string => {
|
||||
const cut = label.indexOf(' — ');
|
||||
return cut > 0 ? label.slice(0, cut) : label;
|
||||
};
|
||||
|
||||
it('puts both train numbers on the ALLOW and HOLD buttons themselves', () => {
|
||||
const game = facing();
|
||||
assert.notEqual(game.state.clock.pendingDecision, null, 'the phase did not stop to ask');
|
||||
const group = actionMenu(game).direct.find((g) => /^Superintendent/.test(g.title));
|
||||
assert.ok(group, 'no Superintendent group in the action list');
|
||||
const heads = group.actions.map((a) => head(a.label));
|
||||
assert.equal(heads.length, 2, `expected ALLOW and HOLD, got ${JSON.stringify(heads)}`);
|
||||
assert.ok(
|
||||
heads.every((h) => /Train 7/.test(h)),
|
||||
`a ruling button does not name the train being ruled on: ${JSON.stringify(heads)}`,
|
||||
);
|
||||
assert.ok(
|
||||
heads.some((h) => /^ALLOW/.test(h) && /Train 4/.test(h)),
|
||||
`ALLOW does not name the train ahead: ${JSON.stringify(heads)}`,
|
||||
);
|
||||
assert.ok(heads.some((h) => /^HOLD/.test(h)), `no HOLD button: ${JSON.stringify(heads)}`);
|
||||
});
|
||||
|
||||
it('asks the question in the heading, naming both trains', () => {
|
||||
const title = actionMenu(facing()).direct.map((g) => g.title).find((t) => /^Superintendent/.test(t));
|
||||
assert.ok(title, 'no Superintendent heading');
|
||||
assert.match(title, /Train 7/);
|
||||
assert.match(title, /Train 4/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user