/** * 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, officeCoord: GridCoord): OfficeArea { const grid = new Map(); 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 { 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 = { '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(['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 = { '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', ); }); });