/** * Component 3 — Track graph and movement. * * The Office Area grid as a traversable graph, and the rules governing a Move. * See architecture/components.md §2 A.3 and docs/rules/rules-v0.2.md Appendix A. * * THE CENTRAL SUBTLETY. §A.1 says a train entering a turnout from "A" may proceed to B or C, but * one entering through B or C may only proceed to A. That is NOT a one-way restriction — A→B and * B→A are both legal. What it means is that **B and C are not connected to each other**: the frog * offers no route between the two diverging legs. Modelling this as a directed graph would forbid * legal moves. It is an undirected graph over *port pairs*, and the constraint is which pairs * exist on each card. * * The second constraint is that a traversal may never leave a card through the port it entered by. * That is what "without changing direction" (§2.4) means in practice. */ import { MAX_CONSIST } from './content.ts'; import type { TrackGeometry } from './content.ts'; import type { GridCoord, OfficeArea, RollingStock, TrackCard, TrayId, TurnoutOrientation, } from './state.ts'; import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; // --------------------------------------------------------------------------- // Ports and geometry // --------------------------------------------------------------------------- /** The four edges of a card. East is the player's right, west their left (§2.4). */ export type Port = 'n' | 's' | 'e' | 'w'; export function opposite(p: Port): Port { switch (p) { case 'n': return 's'; case 's': return 'n'; case 'e': return 'w'; case 'w': return 'e'; } } /** The grid neighbour across a given edge. Row increases northward. */ export function neighbour(c: GridCoord, p: Port): GridCoord { switch (p) { case 'n': return { row: c.row + 1, col: c.col }; case 's': return { row: c.row - 1, col: c.col }; case 'e': return { row: c.row, col: c.col + 1 }; case 'w': return { row: c.row, col: c.col - 1 }; } } type PortPair = readonly [Port, Port]; /** * Which ports a card joins internally. * * - **straight / limits** — a plain through track. * - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist; * `w-n` deliberately does not. That absence is §A.1's rule. * - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around * standing cars. §A.5's facing-point move is impossible without one. * - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through * track. These are plain junctions, so unlike a turnout there is no missing pair. North and south * are not joined to each other — that would be crossing the running track. * - **facility** — a through track; the industry spur is the card's own spotting capacity rather * than a separate port. */ function connectionsFor(card: TrackCard): readonly PortPair[] { switch (card.geometry.kind) { // Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter. case 'modifier': case 'spaceUse': return []; case 'limits': return [['e', 'w']]; case 'facility': return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']]; // Q7 — the card art draws the through-track along the TOP edge with everything diverging // downward, so a district is a Running Track with all Secondary Track hanging beneath it. // This supersedes Gap 8's north-and-south stubs. case 'office': return [ ['e', 'w'], ['e', 's'], ['w', 's'], ]; case 'track': switch (card.geometry.geometry) { case 'straight': return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']]; case 'turnout': { const o = card.geometry.turnout ?? DEFAULT_TURNOUT; // stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1). return [ [o.stem, o.through], [o.stem, o.diverge], ]; } // A CURVE joins the through track to one diverging side — handedness is printed on the // card, not chosen (the design supplies both hands). A SHARP curve is geometrically the // same but costs two Moves to cross. // Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides // which end of the through track the leg leaves from, not whether it goes up or down. case 'curved': case 'sharpCurved': { const stem = card.geometry.hand === 'left' ? 'w' : 'e'; return [ ['e', 'w'], [stem, 's'], ]; } } } } /** A turnout with no stated orientation takes this one. */ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' }; // --------------------------------------------------------------------------- // Orientation (Gap 11) // --------------------------------------------------------------------------- /** * How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**: * a track card is generic and the player decides how to lay it. * * The measurement that settled it: with orientation fixed, an east-west straight has no north or * south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew * only sideways and downward — never upward — purely as an artifact of the default. That silently * undid Gap 8 and put Appendix A's switching puzzle out of reach. * * §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's * rotation is free. */ export type TrackVariant = { axis?: 'ew' | 'ns'; turnout?: TurnoutOrientation; bypass?: Port; }; /** * Q7 — everything diverges downward, so a turnout's leg is always south. Handedness (printed on * the card) decides which end of the through track it leaves from. */ const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [ { stem: 'e', through: 'w', diverge: 's' }, { stem: 'w', through: 'e', diverge: 's' }, ]; export function variantsFor(geometry: TrackGeometry): TrackVariant[] { switch (geometry) { case 'straight': return [{ axis: 'ew' }, { axis: 'ns' }]; case 'turnout': return TURNOUT_VARIANTS.map((t) => ({ turnout: t })); case 'curved': case 'sharpCurved': // Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left // to choose — one variant. return [{ axis: 'ew' }]; } } /** Facility and Office cards have fixed geometry; only plain track rotates. */ export function facilityVariants(): TrackVariant[] { return [{ axis: 'ew' }, { axis: 'ns' }]; } /** Ports reachable from `from` within this card, never including `from` itself. */ export function exitsFrom(card: TrackCard, from: Port): Port[] { const out: Port[] = []; for (const [a, b] of connectionsFor(card)) { if (a === from && b !== from) out.push(b); else if (b === from && a !== from) out.push(a); } return out; } export function hasPort(card: TrackCard, p: Port): boolean { return connectionsFor(card).some(([a, b]) => a === p || b === p); } // --------------------------------------------------------------------------- // Occupancy // --------------------------------------------------------------------------- export type Occupancy = { /** Which tray sits on a given card, if any. */ trayAt(coord: GridCoord): TrayId | null; /** Free A/D tracks at the Office card, for the pass-through allowance (§A.4). */ freeAdTracks(): number; }; // --------------------------------------------------------------------------- // Move reachability // --------------------------------------------------------------------------- export type MoveStep = { coord: GridCoord; entry: Port; exit: Port }; export type MoveDestination = { coord: GridCoord; /** The port the train arrived through; its new facing is the opposite. */ entry: Port; path: MoveStep[]; /** Standing cars coupled along the way, in the order encountered (§A.4). */ couples: RollingStock[]; }; export type MoveContext = { area: OfficeArea; occupancy: Occupancy; /** Cars already in the tray; coupling may not push the consist past four (§A.4). */ consistSize: number; /** The tray making the move, so it does not block itself. */ self: TrayId; }; function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined { return area.grid.get(coordKey(c)); } function sameCoord(a: GridCoord, b: GridCoord): boolean { return a.row === b.row && a.col === b.col; } /** * Every card a tray may finish a single Move on, starting from `start` and leaving through * `initialExit`. * * A Move travels any distance without changing direction (§2.4) and must finish on Operational * Rail (§A.1 — a turnout carries no wheel icon, so a train may pass through but not stop). Cars * met along the way are coupled automatically and mandatorily; you may not go around them (§A.4). * * Direction is expressed by which port the train first leaves through. Reversing is a separate * Move with the opposite initial exit, which is why §A.5's worked examples spend a Move on each * change of direction. */ export function reachableDestinations( ctx: MoveContext, start: GridCoord, initialExit: Port, ): MoveDestination[] { const { area, occupancy } = ctx; const startCard = cardAt(area, start); if (!startCard) return []; if (!hasPort(startCard, initialExit)) return []; const results: MoveDestination[] = []; const seen = new Set(); type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] }; const first = neighbour(start, initialExit); const queue: Frontier[] = [ { coord: first, entry: opposite(initialExit), path: [], couples: [] }, ]; while (queue.length > 0) { const node = queue.shift()!; const card = cardAt(area, node.coord); if (!card) continue; // Two trains may not share a card or move through each other (§A.4). The Office track is the // exception: while it has free A/D tracks you may enter and pass through. const occupant = occupancy.trayAt(node.coord); if (occupant !== null && occupant !== ctx.self) { const isOffice = sameCoord(node.coord, area.officeCoord); if (!isOffice || occupancy.freeAdTracks() <= 0) continue; } if (!hasPort(card, node.entry)) continue; // Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would // overfill the tray is illegal, not a move that picks up fewer cars. const couples = [...node.couples, ...carsOn(card)]; if (ctx.consistSize + couples.length > MAX_CONSIST) continue; const key = `${coordKey(node.coord)}|${node.entry}`; if (seen.has(key)) continue; seen.add(key); if (isOperationalRail(card) && !sameCoord(node.coord, start)) { results.push({ coord: node.coord, entry: node.entry, path: node.path, couples }); } for (const exit of exitsFrom(card, node.entry)) { const step: MoveStep = { coord: node.coord, entry: node.entry, exit }; queue.push({ coord: neighbour(node.coord, exit), entry: opposite(exit), path: [...node.path, step], couples, }); } } return results; } /** Both directions at once — what the UI highlights when a tray is selected. */ export function allReachable( ctx: MoveContext, start: GridCoord, facing: Port, ): { forward: MoveDestination[]; reverse: MoveDestination[] } { return { forward: reachableDestinations(ctx, start, facing), reverse: reachableDestinations(ctx, start, opposite(facing)), }; } // --------------------------------------------------------------------------- // Placement // --------------------------------------------------------------------------- /** * Gap 4a — a placed card must connect to existing track. Gap 8 gives the Office card junction * stubs above and below so this is satisfiable from the opening Stage. */ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean { const existing = cardAt(area, coord); // A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying // the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at // the table. Treating the sign as occupied forced players to build PAST it, stranding the sign // mid-track with everything beyond it nominally outside their own Limits. const isMovableSign = existing?.geometry.kind === 'limits' && coord.row === area.runningRow && existing.standing.length === 0; if (existing && !isMovableSign) return false; // §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so // the sign itself is the only growth point. Allowing a placement beyond it built track on the far // side of the sign and then planted a SECOND sign further out, leaving the board reading // limits · Whistle Post · limits · straight · limits // with a Limits card stranded mid-track. The district below the Running Track is unbounded. if (coord.row === area.runningRow) { if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false; } const ports: Port[] = ['n', 's', 'e', 'w']; for (const p of ports) { if (!hasPort(card, p)) continue; const neighbourCard = cardAt(area, neighbour(coord, p)); if (neighbourCard && hasPort(neighbourCard, opposite(p))) return true; } return false; } /** * §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there. * An industry track also has a finite length (§9.3), so it can be full. */ export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean { if (sameCoord(coord, area.officeCoord)) return false; const card = cardAt(area, coord); if (!card || !isOperationalRail(card)) return false; return spaceOn(card) >= count; }