570 lines
22 KiB
TypeScript
570 lines
22 KiB
TypeScript
/**
|
|
* Build order step 2 — "Move legality provable against Appendix A's worked switching examples."
|
|
* See architecture/components.md §4.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type {
|
|
GameState,
|
|
GridCoord,
|
|
OfficeArea,
|
|
RollingStock,
|
|
TrackCard,
|
|
TurnoutOrientation,
|
|
} from '../src/engine/state.ts';
|
|
import { coordKey } from '../src/engine/state.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
|
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
|
import {
|
|
allReachable,
|
|
canDropCarsAt,
|
|
canPlaceAt,
|
|
exitsFrom,
|
|
hasPort,
|
|
neighbour,
|
|
opposite,
|
|
reachableDestinations,
|
|
connectionsFor,
|
|
variantsFor,
|
|
} from '../src/engine/track.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
|
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
|
baseOperationalRail: true,
|
|
standing,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
|
geometry: o
|
|
? { kind: 'track', geometry: 'turnout', turnout: o }
|
|
: { kind: 'track', geometry: 'turnout' },
|
|
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const officeCard = (): TrackCard => ({
|
|
geometry: { kind: 'office' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const lockedFacility = (): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
|
facility: {
|
|
kind: 'freight',
|
|
subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [],
|
|
inboundBox: [],
|
|
capacity: { outbound: 3, inbound: 0 },
|
|
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
|
industryTrack: { length: 4, cars: [] },
|
|
laborers: 3,
|
|
porters: 0,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
|
|
|
function straightCard(): TrackCard {
|
|
return {
|
|
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
|
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
};
|
|
}
|
|
|
|
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
|
|
function gameWith(area: OfficeArea): GameState {
|
|
const s = createGame({
|
|
id: 'g', seed: 5,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['p'],
|
|
});
|
|
s.officeAreas.set(0, area);
|
|
return s;
|
|
}
|
|
|
|
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
|
|
const grid = new Map<string, TrackCard>();
|
|
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
|
|
return {
|
|
owner: 0,
|
|
tier: 'whistlePost',
|
|
grid,
|
|
officeCoord,
|
|
runningRow: officeCoord.row,
|
|
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
|
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
|
adOccupancy: [],
|
|
heldAtLimits: [],
|
|
dispatchUsedToday: [],
|
|
trackSupply: new Map(),
|
|
};
|
|
}
|
|
|
|
const emptyOccupancy: Occupancy = { trayAt: () => null, freeAdTracks: () => 1 };
|
|
|
|
function ctxFor(area: OfficeArea, over: Partial<MoveContext> = {}): MoveContext {
|
|
return { area, occupancy: emptyOccupancy, consistSize: 0, self: 'tray0', ...over };
|
|
}
|
|
|
|
const at = (r: number, c: number): GridCoord => ({ row: r, col: c });
|
|
const has = (dests: { coord: GridCoord }[], r: number, c: number): boolean =>
|
|
dests.some((d) => d.coord.row === r && d.coord.col === c);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ports and geometry', () => {
|
|
it('pairs opposite ports and grid neighbours consistently', () => {
|
|
for (const p of ['n', 's', 'e', 'w'] as Port[]) {
|
|
assert.equal(opposite(opposite(p)), p);
|
|
const there = neighbour(at(0, 0), p);
|
|
const back = neighbour(there, opposite(p));
|
|
assert.deepEqual(back, at(0, 0));
|
|
}
|
|
});
|
|
|
|
it('joins a turnout stem to both legs but never the legs to each other', () => {
|
|
// §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
|
|
// reaches only the stem. This is an ABSENT edge, not a one-way edge.
|
|
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
|
assert.deepEqual(exitsFrom(t, 'e').sort(), ['s', 'w']);
|
|
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
|
|
assert.deepEqual(exitsFrom(t, 's'), ['e']);
|
|
});
|
|
|
|
it('lets a train traverse a turnout in both directions', () => {
|
|
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
|
assert.ok(exitsFrom(t, 'e').includes('s'), 'stem to diverging leg');
|
|
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
|
|
});
|
|
|
|
it('gives the Office card a junction stub BELOW only', () => {
|
|
// Q7 — the card art draws the through-track along the top edge with everything diverging
|
|
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
|
|
const o = officeCard();
|
|
for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
|
|
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
|
|
assert.ok(exitsFrom(o, 's').includes('e'));
|
|
assert.ok(exitsFrom(o, 's').includes('w'));
|
|
});
|
|
|
|
it('joins a curve from one end of the through track downward', () => {
|
|
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
|
|
// handedness decides which end of the through track it leaves from.
|
|
const r: TrackCard = {
|
|
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Move legality', () => {
|
|
// row 0: [D] [E office ] [F] <- Running Track
|
|
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
|
|
const basicArea = (): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
[coordKey(at(-1, 0))]: nsStraight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('travels any distance in a straight line', () => {
|
|
// §2.4 — "regardless of distance".
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
|
|
assert.ok(has(dests, 0, 0), 'reaches the office');
|
|
assert.ok(has(dests, 0, -1), 'reaches past it');
|
|
});
|
|
|
|
it('branches onto Secondary Track through the Office stub', () => {
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
|
|
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
|
|
});
|
|
|
|
it('never reverses within a single Move', () => {
|
|
// Leaving east from the west end cannot double back to the west end.
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
|
|
assert.ok(!has(dests, 0, -1), 'a Move may not return to its own start');
|
|
});
|
|
|
|
it('separates forward and reverse into different Moves', () => {
|
|
const area = basicArea();
|
|
const both = allReachable(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(has(both.forward, 0, 1));
|
|
assert.ok(has(both.reverse, 0, -1));
|
|
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
|
|
});
|
|
|
|
it('will not stop on a turnout', () => {
|
|
// row 0: [start] [turnout ]
|
|
// row -1: [C n-s straight]
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(-1, 1))]: nsStraight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
|
|
assert.ok(has(dests, -1, 1), 'but a train may pass through it');
|
|
});
|
|
|
|
it('will not stop on a facility whose track is locked by a load', () => {
|
|
// §9.3 — while any load sits on MEN|AT|WORK the industry track is not Operational Rail.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: lockedFacility(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'locked industry track must not accept a train');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('coupling', () => {
|
|
const withCars = (n: number): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(Array.from({ length: n }, car)),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('couples cars met along the way, mandatorily', () => {
|
|
// §A.4 — "you MUST pick them up. You may not go around them."
|
|
const dests = reachableDestinations(ctxFor(withCars(2)), at(0, 0), 'e');
|
|
const beyond = dests.find((d) => d.coord.col === 2);
|
|
assert.ok(beyond, 'should be able to pass the occupied card');
|
|
assert.equal(beyond.couples.length, 2, 'cars must be collected, not stepped over');
|
|
});
|
|
|
|
it('forbids a Move that would overfill the tray', () => {
|
|
// §A.4 — max four Rolling Stock, cabooses included. Overfilling makes the move illegal
|
|
// rather than picking up fewer cars.
|
|
const ctx = ctxFor(withCars(3), { consistSize: 3 });
|
|
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
|
assert.equal(dests.length, 0, '3 in tray + 3 standing exceeds 4');
|
|
});
|
|
|
|
it('permits a Move that exactly fills the tray', () => {
|
|
const ctx = ctxFor(withCars(2), { consistSize: 2 });
|
|
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
|
assert.ok(dests.length > 0, '2 + 2 = 4 is legal');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('occupancy', () => {
|
|
const twoTrainArea = (): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5), // office elsewhere, so col 1 is ordinary track
|
|
);
|
|
|
|
it('blocks a card another train occupies', () => {
|
|
// §A.4 — two trains may not share a card or move through each other.
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 1,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(twoTrainArea(), { occupancy }), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'occupied card');
|
|
assert.ok(!has(dests, 0, 2), 'and no passing through it');
|
|
});
|
|
|
|
it('allows passing through the Office while A/D tracks remain free', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: officeCard(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 1),
|
|
);
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 1,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
|
assert.ok(has(dests, 0, 2), 'the Office is the exception to the blocking rule (§A.4)');
|
|
});
|
|
|
|
it('blocks the Office once every A/D track is taken', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: officeCard(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 1),
|
|
);
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 0,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 2));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('placement and drop-off', () => {
|
|
it('lets the first Facility attach to the Office stub', () => {
|
|
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
|
|
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
|
|
});
|
|
|
|
it('refuses a card whose ports do not meet the neighbour it touches', () => {
|
|
// Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
|
|
// so it cannot attach to the stub. Orientation is not cosmetic.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canPlaceAt(area, at(-1, 0), straight()), 'e-w straight cannot meet a n-s stub');
|
|
});
|
|
|
|
it('refuses a card that connects to nothing', () => {
|
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
|
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
|
|
});
|
|
|
|
it('refuses to place on an occupied cell', () => {
|
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
|
assert.ok(!canPlaceAt(area, at(0, 0), straight()));
|
|
});
|
|
|
|
it('forbids dropping cars at the Office', () => {
|
|
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 0)), 'Office track');
|
|
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
|
|
});
|
|
|
|
it('forbids dropping cars on a locked industry track', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: lockedFacility(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 1)));
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
});
|
|
|
|
it('joins exactly two adjacent edges, with no through track', () => {
|
|
// The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling
|
|
// a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one.
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
const links = connectionsFor(arcCard(arc));
|
|
assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`);
|
|
assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`);
|
|
}
|
|
});
|
|
|
|
it('is no longer a duplicate of a turnout', () => {
|
|
const norm = (c: readonly (readonly string[])[]): string =>
|
|
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: [],
|
|
};
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
|
`a ${arc} curve still has the same connections as a turnout`);
|
|
}
|
|
});
|
|
|
|
it('can reach NORTH, which nothing but an n-s straight could before', () => {
|
|
// This is the whole point. With every leg diverging south, a district could only ever be a
|
|
// vertical column — no siding, no parallel track, no way back up to the Running Track.
|
|
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
|
|
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
|
|
);
|
|
assert.ok(reachesNorth, 'a curve still cannot reach north');
|
|
});
|
|
|
|
it('offers all four rotations when placed', () => {
|
|
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
|
|
// so the printed hand governs supply, not what can be built.
|
|
for (const g of ['curved', 'sharpCurved'] as const) {
|
|
const arcs = variantsFor(g).map((v) => v.arc).sort();
|
|
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
|
|
}
|
|
});
|
|
|
|
it('a sharp curve differs from a curve only in the Moves it costs', () => {
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
assert.deepEqual(
|
|
connectionsFor(arcCard(arc, 'sharpCurved')),
|
|
connectionsFor(arcCard(arc, 'curved')),
|
|
'a sharp curve should be geometrically identical',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('lets a crew run a siding and rejoin the main', () => {
|
|
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
|
|
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
|
|
const grid: Record<string, TrackCard> = {
|
|
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'-1,-1': arcCard('ne'),
|
|
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'-1,1': arcCard('nw'),
|
|
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
};
|
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
|
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
|
|
|
|
const seen = new Set<string>(['0,-2']);
|
|
const queue = [{ row: 0, col: -2 }];
|
|
for (let i = 0; i < 40 && queue.length > 0; i++) {
|
|
const at = queue.shift()!;
|
|
for (const facing of ['e', 'w', 'n', 's'] as const) {
|
|
for (const d of reachableDestinations(ctx, at, facing)) {
|
|
const key = `${d.coord.row},${d.coord.col}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
queue.push(d.coord);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const cell of ['-1,-1', '-1,0', '-1,1']) {
|
|
assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`);
|
|
}
|
|
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
|
|
});
|
|
});
|
|
|
|
describe('coupling lifts only the cars the crew ran over', () => {
|
|
it('leaves cars standing elsewhere in the district alone', () => {
|
|
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
|
|
// deleted every car standing anywhere — including loads worked over several Stages onto an
|
|
// industry track the crew never went near.
|
|
const grid: Record<string, TrackCard> = {
|
|
'0,-1': straightCard(),
|
|
'0,0': straightCard(),
|
|
'0,1': straightCard(),
|
|
'0,2': straightCard(),
|
|
};
|
|
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
|
|
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
|
|
|
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
|
const s = gameWith(area);
|
|
s.trays.set('crew', {
|
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
|
|
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
s.turn.option = 'switch';
|
|
s.turn.movesRemaining = 6;
|
|
|
|
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
|
|
assert.ok(r.ok, 'the move should be legal');
|
|
assert.deepEqual(
|
|
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
|
|
['boxcar'],
|
|
'the crew should have picked up the car it ran over',
|
|
);
|
|
assert.equal(
|
|
areaOf(s, 0).grid.get('0,2')!.standing.length,
|
|
1,
|
|
'a car standing off the path was deleted by an unrelated coupling',
|
|
);
|
|
});
|
|
});
|