Fix parking trains, freight deadlock, and Whistle Post lock-in
This commit is contained in:
+28
-4
@@ -294,7 +294,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
@@ -311,7 +317,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
const classBefore = s.yards.classificationYard.length;
|
||||
|
||||
const mlx = s.division.nodes[1];
|
||||
if (mlx?.kind === 'mainline') mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
||||
if (mlx?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
mlx.card = 'plains';
|
||||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
s.trays.set('x', {
|
||||
id: 'x',
|
||||
trainNumber: 8,
|
||||
@@ -354,7 +366,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
// A second train at the Western Division Point wanting to follow it.
|
||||
s.trays.set('behind', {
|
||||
@@ -391,7 +409,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting',
|
||||
|
||||
@@ -189,7 +189,13 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return id;
|
||||
|
||||
+52
-15
@@ -71,31 +71,47 @@ describe('grade modifiers change crossing time', () => {
|
||||
});
|
||||
|
||||
it('Brakeman speeds the descent but not the climb', () => {
|
||||
// ASSUMPTION (§10 Q11): grades climb eastward, so westbound is downhill.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east'), 2);
|
||||
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
||||
// way is UPHILL. With up = east, a westbound train is descending.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
|
||||
});
|
||||
|
||||
it('follows the orientation the player chose, not a fixed compass direction', () => {
|
||||
// The same train on the same card, with the card turned around: Brakeman helps a westbound
|
||||
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
|
||||
});
|
||||
|
||||
it('Helpers speed the climb but not the descent', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
||||
});
|
||||
|
||||
it('Airbrakes stack with Brakeman on a slow train', () => {
|
||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west'), 3);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a train cross in no time', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves non-grade cards alone', () => {
|
||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west'), 1);
|
||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east'), 2);
|
||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,8 +281,10 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
/** A crew on a straight with a facility next door, mid-switch. */
|
||||
function setup(s: GameState) {
|
||||
const area = areaOf(s, 0);
|
||||
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
|
||||
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
|
||||
const spur: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
@@ -275,7 +293,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
};
|
||||
area.grid.set(coordKey(at(-1, 0)), spur);
|
||||
const industry: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
@@ -296,7 +314,11 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||
direction: 'east', position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
||||
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
|
||||
// track needs its actual port — without it the engine looks for an east exit the card has not
|
||||
// got, and the crew has no legal moves at all.
|
||||
direction: 'east', facing: 's',
|
||||
position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
@@ -340,13 +362,28 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
// (0,0) is the Office, adjacent to the crew but not a freight industry.
|
||||
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
||||
'CANNOT_DROP_HERE',
|
||||
);
|
||||
});
|
||||
|
||||
it('will not roll onto a card with no track connection', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const area = areaOf(s, 0);
|
||||
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
|
||||
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
|
||||
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
|
||||
area.grid.set(coordKey(at(-1, 1)), stranded);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
|
||||
'NOT_CONNECTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot cut more cars than it is hauling', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
|
||||
+25
-11
@@ -49,17 +49,19 @@ const newSolitaireGame = (seed = 1234) =>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes the 133-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus the 18 extra industry
|
||||
// cards added for Gap 12, taking industries from 9 to 27 (see INDUSTRY_PROFILES).
|
||||
assert.equal(DECK_SIZE, 133);
|
||||
it('composes the 140-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
|
||||
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
|
||||
// deliberate departures from the sheet and both are flagged provisional in content.ts.
|
||||
assert.equal(DECK_SIZE, 140);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches the design deck composition exactly', () => {
|
||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||
assert.deepEqual(byCategory, {
|
||||
office: 7,
|
||||
// 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
|
||||
office: 14,
|
||||
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||
industry: 27,
|
||||
modifier: 23,
|
||||
@@ -74,13 +76,13 @@ describe('card catalogue (component 1)', () => {
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||
// game they would be 22 of 133 draws (17%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 111);
|
||||
// game they would be 22 of 140 draws (16%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 118);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, 111);
|
||||
assert.equal(solo.length, 118);
|
||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||
// A competitive deck keeps them.
|
||||
assert.equal(buildDeck('competitive').length, 133);
|
||||
assert.equal(buildDeck('competitive').length, 140);
|
||||
});
|
||||
|
||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||
@@ -181,8 +183,20 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
||||
// Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
|
||||
// every tier must be at least as common as the one above it — otherwise players reach a rung
|
||||
// whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
|
||||
// and expected to move again; the pyramid shape is what must survive.
|
||||
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
|
||||
assert.deepEqual(inDeck, [4, 2, 1]);
|
||||
assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
|
||||
for (let i = 1; i < inDeck.length; i++) {
|
||||
assert.ok(
|
||||
inDeck[i]! <= inDeck[i - 1]!,
|
||||
`tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
|
||||
);
|
||||
}
|
||||
// The Whistle Post is the starting state, never a card.
|
||||
assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
|
||||
});
|
||||
|
||||
it('walks the upgrade sequence without skipping', () => {
|
||||
@@ -344,7 +358,7 @@ describe('game setup (component 2)', () => {
|
||||
...g.decks.salvageYard,
|
||||
...[...g.decks.hands.values()].flat(),
|
||||
];
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 111, not 133.
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 118, not 140.
|
||||
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
|
||||
@@ -287,3 +287,34 @@ describe('switching accomplishes something (regression)', () => {
|
||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measurement discipline', () => {
|
||||
it('the observer sees exactly the events the log records', () => {
|
||||
// REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
|
||||
// sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that
|
||||
// watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the
|
||||
// Office tier histogram from the same run plainly showed Depots, Stations and Terminals.
|
||||
//
|
||||
// A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts
|
||||
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
|
||||
// reason to hand-roll the drive loop again.
|
||||
const seen: string[] = [];
|
||||
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
|
||||
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
|
||||
|
||||
assert.ok(out.events.length > 0, 'a finished game must produce events');
|
||||
assert.deepEqual(seen, out.events.map((e) => e.type));
|
||||
});
|
||||
|
||||
it('records office upgrades where a probe can actually find them', () => {
|
||||
// The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed.
|
||||
// Asserted across several seeds because a single game may never draw a Depot card.
|
||||
let upgrades = 0;
|
||||
for (let seed = 0; seed < 25; seed++) {
|
||||
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
|
||||
const out = playGame(s, developerBot, pump);
|
||||
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
|
||||
}
|
||||
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user