the Roster Pass: every train at the Office visible
Builds "The Roster Pass" / "Two Trains, One Card" (station display for multiple trains at one Office). The Office is the one square where more than one train may legally stand at once (one per A/D track), and CellView.train had room for exactly one — a second train at a busy Station was counted in the old A/D pips and never drawn. CellView.train -> CellView.trains: TrainView[], seat-filtered and collecting every match rather than the first (fixes a latent cross-district leak in the process: trainOnCard never checked seat). The A/D pips are replaced with one roster chip per A/D track, always, free or occupied; clicking a chip sets selectedCrew, wiring the board and the action panel to the same value. standingWest moves from CrewTray to TrackCard: two trays sharing one Office card need one shared split, not one each, and there is no such thing as "west of one particular A/D track". No save migration — Save replays through the engine — and a stale value on an emptied card is inert because nothing reads a split with no train standing there. The Division map's Office cell is now sized by A/D capacity rather than occupancy, so it holds still as trains arrive and leave; chips lay into fixed slots instead of centre-spreading onto the Limits cards either side. 584 tests, 0 failures. Verified end-to-end against the built app and a direct render of a 4-train Terminal (screenshotted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
+13
-7
@@ -56,6 +56,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -445,6 +446,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -576,6 +578,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -757,6 +760,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -944,7 +948,7 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const area = areaOf(s, 0);
|
||||
const plain = (geometry: object): TrackCard => ({
|
||||
geometry: geometry as TrackCard['geometry'],
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const col = area.limitsEast.col;
|
||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||
@@ -1294,7 +1298,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
const area = areaOf(s, 0);
|
||||
const plain = (geometry: object): TrackCard => ({
|
||||
geometry: geometry as TrackCard['geometry'],
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const col = area.limitsEast.col;
|
||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||
@@ -1326,7 +1330,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
function build(s: GameState, kind: string, coord: GridCoord): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||||
geometry: { kind: 'facility', facility: kind as never },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1621,8 +1625,9 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
addCard(s, at(0, 0), straight());
|
||||
reduce(s, moved(id, 's'));
|
||||
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
|
||||
assert.ok(cell?.train, 'the train should be on the card');
|
||||
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
|
||||
const train = cell?.trains[0];
|
||||
assert.ok(train, 'the train should be on the card');
|
||||
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1646,6 +1651,7 @@ describe('a train that rounds a curve points where the curve took it', () => {
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -1782,11 +1788,11 @@ describe('the switching job a player actually does: put a car in a siding, take
|
||||
*/
|
||||
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -149,7 +150,14 @@ describe('a cut parks in the same order however many cars come off at once', ()
|
||||
|
||||
// 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');
|
||||
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
|
||||
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
|
||||
// west or east of the block of A/D tracks rather than of any one engine.
|
||||
assert.equal(
|
||||
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
|
||||
1,
|
||||
'the engine should be standing between the two cuts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const straight = (): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -313,6 +313,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -450,7 +450,7 @@ describe('the view shows one seat at a time', () => {
|
||||
for (let n = 0; n < p; n++) {
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
}
|
||||
s.decks.hands.set(p, source.slice(0, p + 1));
|
||||
@@ -527,7 +527,7 @@ describe('the view shows one seat at a time', () => {
|
||||
readyToLeave(s, 1, 'crew-1', 'east');
|
||||
|
||||
const withTray = (p: PlayerIndex): number =>
|
||||
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
|
||||
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length;
|
||||
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
|
||||
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
|
||||
});
|
||||
@@ -594,7 +594,7 @@ describe('scoring lands on the right seat', () => {
|
||||
const sizeBefore = areaAtSeat(s, 2).grid.size;
|
||||
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
|
||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
||||
|
||||
@@ -368,6 +368,34 @@ describe('the replay wire format is lossless (regression)', () => {
|
||||
assert.ok(checked > 100, `only ${checked} frames checked`);
|
||||
});
|
||||
|
||||
it("tolerates an older recording's singular train at the trains slot", () => {
|
||||
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
|
||||
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
|
||||
// anywhere in a packed row to key off.
|
||||
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
|
||||
const whats = ['a station'];
|
||||
const facs: unknown[] = [];
|
||||
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
|
||||
|
||||
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
|
||||
trains: unknown[];
|
||||
}[];
|
||||
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
|
||||
|
||||
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
|
||||
trains: unknown[];
|
||||
}[];
|
||||
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
|
||||
|
||||
const [withArray] = rehydrateCells(
|
||||
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
|
||||
cards,
|
||||
whats,
|
||||
facs,
|
||||
) as { trains: unknown[] }[];
|
||||
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
|
||||
});
|
||||
|
||||
it('writes each distinct card and description once, not once per frame', () => {
|
||||
const rec = record(880009, 'standard', 4000);
|
||||
const packed = compress(rec.frames);
|
||||
|
||||
+15
-6
@@ -48,6 +48,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -62,6 +63,7 @@ const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -73,6 +75,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
: { kind: 'track', geometry: 'turnout' },
|
||||
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -82,6 +85,7 @@ const officeCard = (): TrackCard => ({
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -91,6 +95,7 @@ const lockedFacility = (): TrackCard => ({
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
@@ -114,7 +119,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
function straightCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +215,7 @@ describe('ports and geometry', () => {
|
||||
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -629,7 +635,7 @@ describe('placement and drop-off', () => {
|
||||
describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry, arc },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('joins exactly two adjacent edges, with no through track', () => {
|
||||
@@ -647,7 +653,7 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
||||
const turnout: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
||||
@@ -690,11 +696,11 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
const ew = (): TrackCard => straightCard();
|
||||
const grid: Record<string, TrackCard> = {
|
||||
'0,-2': ew(),
|
||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||
'-1,-1': arcCard('ne'),
|
||||
'-1,0': ew(),
|
||||
'-1,1': arcCard('nw'),
|
||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||
'0,0': ew(),
|
||||
};
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
@@ -778,6 +784,7 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
@@ -919,7 +926,7 @@ describe('a turnout may be laid to face another, making a crossover', () => {
|
||||
/** A turnout stated the way the cards read: who may come in, and where they may go. */
|
||||
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
|
||||
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: false, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
|
||||
@@ -1027,6 +1034,7 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
@@ -1122,6 +1130,7 @@ describe('two routes to one square', () => {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
+138
-13
@@ -756,8 +756,8 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
|
||||
/** One card, alone, so the measurements are in card coordinates. */
|
||||
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
||||
const cell = {
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
|
||||
tray: null, cars: [], facility: null, links, what: '',
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
|
||||
};
|
||||
const svg = officeSvg([cell] as never, 0);
|
||||
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
||||
@@ -931,7 +931,7 @@ describe('the page explains itself', () => {
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||
// Said the way a player would ask it: if my train comes in from over there, where can it go?
|
||||
@@ -1960,10 +1960,11 @@ describe('the static build', () => {
|
||||
} as never);
|
||||
|
||||
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
|
||||
assert.ok(cell.train, 'the card carries no train');
|
||||
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
||||
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
|
||||
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
|
||||
const train = cell.trains[0];
|
||||
assert.ok(train, 'the card carries no train');
|
||||
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
||||
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
|
||||
assert.equal(train!.facing, 'e', 'which way it points is not carried');
|
||||
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
|
||||
@@ -2006,16 +2007,97 @@ describe('the static build', () => {
|
||||
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
|
||||
});
|
||||
|
||||
it('shows A/D tracks on the Office card, taken and total', () => {
|
||||
it('shows A/D tracks on the Office card, one chip per track', () => {
|
||||
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
||||
// decides whether the next arrival is an automatic collision (§8.3).
|
||||
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
|
||||
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||
assert.ok(cell.ad, 'the Office card carries no A/D count');
|
||||
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
|
||||
assert.equal(cell.ad!.used, 0);
|
||||
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
|
||||
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
|
||||
assert.equal(cell.trains.length, 0, 'no train is standing yet');
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
|
||||
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
|
||||
});
|
||||
|
||||
it('draws every train standing at a busy Office, not just one', () => {
|
||||
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
|
||||
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
|
||||
// at a Station was counted in the old A/D pips and never drawn at all.
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
|
||||
const at = area.officeCoord;
|
||||
const trainNumbers = [10, 12, 14, 16];
|
||||
for (const n of trainNumbers) {
|
||||
const id = `t${n}`;
|
||||
game.state.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
|
||||
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
|
||||
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
|
||||
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
|
||||
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
|
||||
for (const n of trainNumbers) {
|
||||
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
|
||||
const cell = {
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||
trains: [], adTracks: null,
|
||||
cars: ['boxcar', 'loaded hopper', 'reefer'],
|
||||
standingWest: 2, // stale — as if a train had once stood here and left
|
||||
facility: null, links: ['ew'], what: '',
|
||||
};
|
||||
const svg = officeSvg([cell as never], 0);
|
||||
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
|
||||
assert.equal(xs.length, 3, 'all three cars should be drawn');
|
||||
const sorted = [...xs].sort((a, b) => a - b);
|
||||
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
|
||||
// the row for an engine that is not there.
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads one split off the shared card, not one per train', () => {
|
||||
// The whole justification for moving `standingWest` onto the card rather than the tray
|
||||
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
|
||||
// about the same row of cars, because there is only one number for them to read.
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.tier = 'station'; // 3 A/D tracks
|
||||
const at = area.officeCoord;
|
||||
const card = area.grid.get(`${at.row},${at.col}`)!;
|
||||
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
|
||||
card.standingWest = 1;
|
||||
for (const n of [10, 12]) {
|
||||
const id = `t${n}`;
|
||||
game.state.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||
} as never);
|
||||
}
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
|
||||
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
|
||||
});
|
||||
|
||||
it('names the cars a drop would set out, and which end they come off', () => {
|
||||
@@ -2234,6 +2316,49 @@ describe('the Division map shows the whole route', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
|
||||
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
|
||||
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
|
||||
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
|
||||
const s = createEngineGame({
|
||||
id: 'div-chips', seed: 7,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const area = areaOf(s, 0);
|
||||
area.tier = 'terminal'; // 4 A/D tracks
|
||||
for (const n of [10, 12, 14, 16]) {
|
||||
const id = `t${n}`;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
|
||||
assert.ok(cells.length > 0, 'no Running Track cell drawn');
|
||||
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
|
||||
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
|
||||
|
||||
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
|
||||
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
|
||||
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
|
||||
for (const c of chips) {
|
||||
assert.ok(
|
||||
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
|
||||
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the Division as a line with two ends, never as a loop', () => {
|
||||
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
||||
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
|
||||
|
||||
Reference in New Issue
Block a user