adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user