adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+99 -7
View File
@@ -16,12 +16,15 @@ import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
crossingStages,
mainlineModifierRule,
mainlineProfile,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameEvent } from '../src/engine/events.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { snapshot } from '../src/sim/view.ts';
@@ -210,11 +213,11 @@ describe('Red Flags protect a stopped train', () => {
const node = pinned(s, 1, 'plains');
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('behind', {
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes[0];
@@ -313,7 +316,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
area.grid.set(coordKey(at(-2, 0)), industry);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
// 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
@@ -405,12 +408,17 @@ describe('card coverage', () => {
}
});
it('Poling remains unimplemented, deliberately', () => {
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
// source says otherwise, a silent no-op would be worse than a rejection.
//
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
// the card returns the moment its rule is known.
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
assert.ok(poling, 'Poling should still be in the deck');
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
assert.match(poling!.effect, /TBD/i);
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
});
});
@@ -495,7 +503,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
@@ -525,7 +533,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'caboose', loaded: true }],
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
@@ -595,3 +603,87 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
}
});
});
describe('Q13 — a train that catches the one ahead runs into it', () => {
/**
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
* then region 1 — so it catches up whichever order the phase happens to process them in.
*/
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
const s = createGame({
id: 'rear', seed: 3,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['bot'],
});
const index = s.division.nodes.findIndex(
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
);
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
const node = s.division.nodes[index]!;
assert.ok(node.kind === 'mainline');
if (node.kind !== 'mainline') throw new Error('unreachable');
if (opts.absSignals) node.absSignals = true;
const make = (n: number, total: number, left: number): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east',
position: { at: 'mainline', index },
} as never);
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
return id;
};
make(9, 3, 2); // the leader
make(11, 2, 2); // the follower, closing
s.clock.phase = 'mainline';
const events: GameEvent[] = [];
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
return { s, events };
};
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
// survived, no penalty, and ABS Signals protected against nothing.
const { events } = twoTrains();
const smash = events.find((e) => e.type === 'trainsDestroyed');
assert.ok(smash, 'the follower ran into the leader and nothing happened');
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
assert.match(smash.reason, /ran into the train ahead/);
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
assert.ok(
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
'the collision cost nothing',
);
});
it('ABS Signals holds the follower short instead', () => {
// "Trains on this card will not rear-end each other; they stop short of a collision."
const { events } = twoTrains({ absSignals: true });
assert.ok(
!events.some((e) => e.type === 'trainsDestroyed'),
'ABS Signals did not prevent the collision',
);
assert.ok(
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
'nothing was held short of the train ahead',
);
});
it('leaves trains alone on a card that prints "trains may pass"', () => {
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
// times a game while the bot never once granted clearance — the tell that they were all
// passing cards.
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
});
});