/** * 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. * * THE THIRD CONSTRAINT IS GEOMETRY, and it is not topological at all. The printed cards * (docs/tracks.png) run the through rail dead centre, east-west, on every card; there is no * north-south track anywhere. Everything that leaves through the north or south edge does so at 45° * through the MIDDLE of that edge, which means two vertically stacked cards line up only when their * legs lie on the same diagonal. Two ports meeting is therefore no longer enough to make a * connection: use `joins`, never a bare pair of `hasPort` calls. */ import { MAX_CONSIST } from './content.ts'; import type { Hand, TrackGeometry } from './content.ts'; import type { GridCoord, OfficeArea, RollingStock, Slope, TrackCard, TrayId, TrackArc, TurnoutOrientation, } from './state.ts'; import { carsOn, coordKey, cutTowards, isLockedByWork, 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 east-west through track. There is no north-south straight: the * printed sheet has none, and a vertical rail cannot meet a 45° leg at an edge. * - **turnout** — the east-west through track plus ONE 45° leg reaching north or south. `{stem, * through}` and `{stem, diverge}` exist; `{through, diverge}` deliberately does not. That absence * is §A.1's rule. * - **office / facility** — a plain through track. Every office and industry card on the printed * sheet (Depot, Station, Terminal, Refinery, Freighthouse, Coal Tipple, Manufacturing, Town) is a * straight east-west rail with no diverging leg at all; the industry spur is the card's own * spotting capacity rather than a separate port. A district therefore grows off a TURNOUT laid on * the Running Track, which is how a real railroad does it. */ /** * Exported so the board can DRAW what the engine believes. Deriving the rails from anything else * would let a picture disagree with the rules about whether two cards join — which is the one thing * a track diagram exists to settle. */ export 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': case 'facility': case 'office': return [['e', 'w']]; case 'track': switch (card.geometry.geometry) { case 'straight': return [['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 IS AN ARC between two adjacent edges — one connection, no through track. The * printed cards show exactly this (docs/tracks.png, rows 3-4): a run along the centre line * from the east or west edge to a frog, then a 45° leg out through the middle of the north * or south edge, with nothing running past it. * * A SHARP curve is the same shape and costs two Moves to cross. */ case 'curved': case 'sharpCurved': { const arc = card.geometry.arc ?? (card.geometry.hand === 'right' ? 'sw' : 'se'); return [[arc[0] as Port, arc[1] as Port]]; } } } } /** * A turnout with no stated orientation takes this one — the right-hand card at 0°, i.e. the * `ne_sw` slope. Only hand-less fixtures reach it; `variantsFor` always states an orientation. * * The ORIENTATION here is unchanged by the handedness fix; only the word for it moved, because * `ne_sw` is now the right hand. Fixtures that lay a bare turnout get the same physical card as * before, which is what keeps existing tests and saves describing the same board. */ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'w', through: 'e', diverge: 's' }; // --------------------------------------------------------------------------- // Slope — the 45° matching rule // --------------------------------------------------------------------------- /** * Which diagonal a port pair's 45° leg lies on, or `null` for the east-west through track. * * The pair IS the answer: a leg joining north to east and one joining south to west lie on the same * line, so both are `ne_sw`. Naming the slope after its two arcs makes the matching rule read * itself — `sw` above `ne` is continuous rail, `sw` above `nw` is a V. */ export function slopeOfPair(a: Port, b: Port): Slope | null { const pair = new Set([a, b]); if (!pair.has('n') && !pair.has('s')) return null; if (pair.has('n')) return pair.has('e') ? 'ne_sw' : 'nw_se'; return pair.has('w') ? 'ne_sw' : 'nw_se'; } /** * The slope of the leg leaving this card through `p`, or `null` if it has no such leg. * * A card has at most one leg per north/south edge — the printed cards give a turnout exactly one * and a curve exactly one — so there is never a second slope to choose between. `assertOneSlope` * in the tests holds that true for everything the supply can produce. */ export function slopeAt(card: TrackCard, p: Port): Slope | null { if (p !== 'n' && p !== 's') return null; for (const [a, b] of connectionsFor(card)) { if (a === p || b === p) return slopeOfPair(a, b); } return null; } /** * THE ADJACENCY TEST. Do these two cards actually join, across edge `p` of `a`? * * Two ports meeting is not enough. East and west ports sit at the same height on every card, so a * straight run is always continuous; but north and south are met at 45°, and a leg descending to * the right cannot be continued by one descending to the left. Every place that used to ask * `hasPort(a, p) && hasPort(b, opposite(p))` must ask this instead, or the board will draw — and * the engine will route trains through — a rail that bends back on itself at the card edge. */ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean { if (!hasPort(a, p) || !hasPort(b, opposite(p))) return false; if (p === 'e' || p === 'w') return true; return slopeAt(a, p) === slopeAt(b, opposite(p)); } /** * WHICH END OF THE WEST-TO-EAST ROW A PORT SITS AT. * * `TrackCard.standing` is ordered west to east (§A.3), so whether a train meets the row front to * back or back to front depends on which end it enters by — and a port is not always at one of * those two extremes. Every 45° leg leaves through the MIDDLE of its north or south edge, so its * end of the run is whichever end the arc does NOT reach: a `sw` curve's south leg is the EAST end * of the row, and an `se` curve's south leg is the WEST end. Same port, opposite answers, which is * why this has to ask the card rather than read the port. * * Gitea#17 is what both callers looked like without it. `exploreMoves` reversed the row for an 'e' * entry and for nothing else, so backing into a cut through a `sw` curve's south leg coupled it up * back to front — the caboose came out next to the engine, which §8.2 then calls badly made up. * `cutTowards` answered "you meet nothing" for a north or south exit, so a crew standing on a curve * pulled out through the leg and left the cars beside it standing, which §A.4 forbids. * * There is no north-south straight anywhere on the printed sheet (see the module comment), so a run * touching a 45° leg always has an east or west port at its other end and the answer is never * undefined. A TURNOUT is the one card whose row has three ends rather than two — and it is also * the one card no cut can ever stand on, since a train may not stop there (§A.1) and so never sets * anything out there. Its stem answers for it. */ export function rowEndAt(card: TrackCard, p: Port): 'e' | 'w' { if (p === 'e' || p === 'w') return p; for (const [a, b] of connectionsFor(card)) { const other = a === p ? b : b === p ? a : null; if (other === 'e') return 'w'; if (other === 'w') return 'e'; } // Not a card the printed sheet can produce. Reading the leg as the west end leaves the row in the // order it is stored rather than inventing a reversal on a card nothing knows the shape of. return 'w'; } // --------------------------------------------------------------------------- // Orientation (Gap 11) // --------------------------------------------------------------------------- /** * HOW A TRACK CARD MAY BE LAID: turned, but never flipped. * * A printed card has a back, so the only rotation that keeps the through rail east-west is 180°. * That gives every track card exactly TWO orientations, and it means the card's printed handedness * fixes which diagonal its 45° leg lies on for good — turning the card swaps the leg between north * and south, but never between diagonals. * * So handedness is not decoration and not merely a supply label: it is the slope, and a siding * needs one card of each hand (a right turnout to drop off the main, a left curve to climb back). * Which printed row we call "left" is fixed by the prototype and lives entirely in the two tables * below. * * WHICH ROW IS WHICH. A turnout is named for the side the diverging route leaves toward, seen by a * train entering at the points. Take `{stem:'e', through:'w', diverge:'s'}`: the train enters at the * east edge heading west, and facing west its left hand points south — so a leg going south is a * LEFT-hand turnout. Turn the card 180° and it enters heading east with the leg going north, which * is left again; that invariance under rotation is exactly why the hand can be a property of the * card. Both orientations lie on `nw_se`, so LEFT IS `nw_se`. * * A curve has no points of its own, so it takes its hand from the turnout whose leg it continues — * which is what makes "a run-around needs one card of each hand" true. Both tables therefore put * left on the SAME diagonal: a left turnout's leg lands on `nw_se` and only a left curve can carry * it onward. * * Both tables were inverted until playtesting caught it, so every label named the mirror card. The * geometry was never wrong — the board draws from `connectionsFor` — only the words. * * §A.1's constraint is preserved: a turnout's two legs still never join each other. */ export type TrackVariant = { arc?: TrackArc; turnout?: TurnoutOrientation; bypass?: Port; }; /** Left-hand curves lie on `nw_se`, right-hand on `ne_sw`; 0° sends the leg south, 180° north. */ const CURVE_VARIANTS: Record = { left: ['se', 'nw'], right: ['sw', 'ne'], none: ['se', 'nw'], }; /** Left-hand turnouts lie on `nw_se`, right-hand on `ne_sw` — the same diagonals as the curves. */ const TURNOUT_VARIANTS: Record = { left: [ { stem: 'e', through: 'w', diverge: 's' }, { stem: 'w', through: 'e', diverge: 'n' }, ], right: [ { stem: 'w', through: 'e', diverge: 's' }, { stem: 'e', through: 'w', diverge: 'n' }, ], none: [ { stem: 'e', through: 'w', diverge: 's' }, { stem: 'w', through: 'e', diverge: 'n' }, ], }; export function variantsFor(geometry: TrackGeometry, hand: Hand = 'none'): TrackVariant[] { switch (geometry) { case 'straight': // East-west, and turning it 180° gives the same card back. One orientation, not two. return [{}]; case 'turnout': return TURNOUT_VARIANTS[hand].map((t) => ({ turnout: t })); case 'curved': case 'sharpCurved': return CURVE_VARIANTS[hand].map((arc) => ({ arc })); } } /** Office and Facility cards are plain east-west track, so there is nothing to choose. */ export function facilityVariants(): TrackVariant[] { return [{}]; } /** 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[] { return exploreMoves(ctx, start, initialExit, false).destinations; } /** * A card the walk reached and refused, with the rule that refused it, in a player's words. * * `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would * overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train * runs through a turnout freely and simply may not finish a Move on one. They are drawn differently * and only the obstructions are worth listing in the "why nothing is moving" panel, where every * turnout in the district would otherwise appear. */ export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping'; export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string }; /** * THE SAME WALK, KEEPING ITS REJECTIONS. * * `reachableDestinations` answers "where may I go", and the UI could show that much — but a player * looking at a siding two cards away and no button for it is asking the opposite question, and the * answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in * certain conditions; make it clear what those conditions are". * * Both answers come out of ONE traversal on purpose. A second function that worked out why a square * was missing would be a second implementation of the movement rules, and the failure mode is a * reason that does not match the refusal — worse than no reason at all. * * A block is recorded for every card the walk actually touched and turned down, plus every card * beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed: * "there is no track between here and there" is not news. */ /** * How many frontier nodes a single `exploreMoves` walk may enqueue before it stops finding new * routes. A per-path visited set (below) guarantees every individual route terminates, but the * NUMBER of simple paths through a dense district is worst-case exponential — a real board never * gets close, but nothing stops a pathological one from being built. BFS order means the queue * fills shortest-route-first, so hitting the cap loses only the longest, least-likely-to-matter * routes; it never loses a shorter one in favour of a longer one. */ const MAX_ENUMERATED_FRONTIER = 4000; /** * The identity of a route's OUTCOME, for dedupe: which square it ends at, which side it entered * by, and exactly which cars it couples, each tagged with the card it came off. Two routes that * couple the same car TYPES from DIFFERENT cards are not the same outcome — sweeping the wrong * card clean is exactly the bug this key exists to avoid — so the origin card rides along with * every car, not just its type. */ function routeOutcomeKey(coord: GridCoord, entry: Port, couples: RollingStock[], origins: string[]): string { const cars = couples.map((c, idx) => `${origins[idx]}:${c.type}:${c.loaded ? 1 : 0}`).join(','); return `${coordKey(coord)}|${entry}|${cars}`; } export function exploreMoves( ctx: MoveContext, start: GridCoord, initialExit: Port, /** * False when only the destinations are wanted (`reachableDestinations`, every legality check): the * rejections are then not recorded at all. They never change a destination, and building them was * pure allocation on the hottest path in the engine. */ collectBlocks = true, ): { destinations: MoveDestination[]; blocked: MoveBlock[] } { const { area, occupancy } = ctx; const results: MoveDestination[] = []; const blocked: MoveBlock[] = []; const noted = new Set(); const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => { if (!collectBlocks) return; const k = coordKey(coord); if (noted.has(k)) return; noted.add(k); blocked.push({ coord, kind, why }); }; const startCard = cardAt(area, start); if (!startCard) return { destinations: results, blocked }; if (!hasPort(startCard, initialExit)) return { destinations: results, blocked }; const resultKeys = new Set(); type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[]; /** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */ origins: string[]; }; /** * Has THIS route already used `to`? Per-path, not global — see the doc comment on * `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to * find. * * Read off the route's own `path` instead of a Set copied at every step, which was a large share of * the walk's garbage. It answers exactly as that Set did: the start square, then every square * enqueued along the route AFTER the first hop, including this node's own — the first hop's square * was never added, and `path[0]` is that square, so the scan begins at 1. */ const onRoute = (node: Frontier, to: GridCoord): boolean => { if (sameCoord(to, start)) return true; if (node.path.length > 0 && sameCoord(to, node.coord)) return true; for (let k = 1; k < node.path.length; k++) { if (sameCoord(node.path[k]!.coord, to)) return true; } return false; }; // The very first hop is checked here because `start`'s card is not itself enqueued; every later // hop is checked at the push site below, where both sides of the edge are in hand. const first = neighbour(start, initialExit); const firstCard = cardAt(area, first); if (!firstCard || !joins(startCard, initialExit, firstCard)) { if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal'); return { destinations: results, blocked }; } /** * THE CUT ON YOUR OWN CARD, which the walk used to ignore entirely. * * Reported from play: "if I put cars off the nose on a given track, and my next move is go * forward, I need to couple those cars right back on". Exactly so — and the engine let the train * drive away and leave them. `cutTowards` takes only the cars between the engine and the end it is * leaving by, so setting out off the nose and then BACKING away is still free, which is the whole * point of setting out off one particular end. * * Ordered nearest-first like every other card's, so it simply seeds the accumulator. */ const ownCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, initialExit)); const startKey = coordKey(start); const queue: Frontier[] = [ { coord: first, entry: opposite(initialExit), path: [], couples: ownCut, origins: ownCut.map(() => startKey), }, ]; let enumerated = 1; // FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order. let head = 0; while (head < queue.length) { const node = queue[head++]!; 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) { block( node.coord, 'occupied', isOffice ? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)' : 'another train is standing here — two trains may not share a card, or move through each other (§A.4)', ); continue; } } if (!hasPort(card, node.entry)) continue; // §9.3 — a locked industry may not be occupied OR MOVED ON. Losing Operational Rail status only // stops a train FINISHING here; a turnout is not Operational Rail either and trains run through // one all day. Without this a crew rolled straight over a locked industry, and coupled the cars // spotted on it on the way past — the two things the safety lockout exists to prevent. if (isLockedByWork(card)) { block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)'); 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. * * NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering * at the row's EAST end meets them back to front and the row has to be reversed. Without this * the same parked cut produced an identical consist whichever way it was approached, when the * two must mirror — which is the difference between a run-around being worth a Move and being * pointless. * * `rowEndAt` rather than `node.entry === 'e'`: a 45° leg is an end of the row too, and which * end it is depends on the card's arc (Gitea#17). */ const met = rowEndAt(card, node.entry) === 'e' ? [...carsOn(card)].reverse() : carsOn(card); const couples = [...node.couples, ...met]; const nodeKey = coordKey(node.coord); const origins = [...node.origins, ...met.map(() => nodeKey)]; if (ctx.consistSize + couples.length > MAX_CONSIST) { const own = ownCut.length > 0 ? `, ${ownCut.length} of them the cut standing at the end of your OWN card that this move pulls out through` : ''; block( node.coord, 'tooManyCars', `too many cars — running here couples ${couples.length} standing car(s)${own} onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`, ); continue; } if (isOperationalRail(card) && !sameCoord(node.coord, start)) { // Dedupe on OUTCOME, not on reaching the square: two routes that end here having coupled // different cars (or the same cars off different cards) are a real choice, and both are // offered. Two routes that end here having coupled identically are not a choice — one is // noise doubling the button list and the bot's branching factor for nothing. const rkey = routeOutcomeKey(node.coord, node.entry, couples, origins); if (!resultKeys.has(rkey)) { resultKeys.add(rkey); results.push({ coord: node.coord, entry: node.entry, path: node.path, couples }); } } else if (!sameCoord(node.coord, start)) { // Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon // (§A.1). Trains run through one all day; they just cannot finish a Move on it. block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move'); } if (enumerated >= MAX_ENUMERATED_FRONTIER) continue; for (const exit of exitsFrom(card, node.entry)) { // Slope is a property of the EDGE, not of either card, so it can only be tested with both in // hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself. const to = neighbour(node.coord, exit); const next = cardAt(area, to); if (!next || !joins(card, exit, next)) { if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal'); continue; } // A card twice on ONE route is a loop, not a longer route (§2.4 travels without changing // direction; it never says without repeating ground, but a train cannot occupy the same // track twice at once either). Per-path, not global — a DIFFERENT route may legitimately // pass through a card this one already used. if (onRoute(node, to)) continue; if (enumerated >= MAX_ENUMERATED_FRONTIER) break; enumerated++; const step: MoveStep = { coord: node.coord, entry: node.entry, exit }; queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins }); } } if (!collectBlocks) return { destinations: results, blocked }; // A card that turned out to be reachable after all is not a blocker: the walk may meet a square // from a bad angle first and a good one later. const reached = new Set(results.map((r) => coordKey(r.coord))); return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) }; } /** 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 // --------------------------------------------------------------------------- /** * Does this card carry the through route — an east-west road straight across it? * * The Running Track is the road trains run on, Limits to Limits, and every card standing in it has * to pass traffic along the row. A curve does not: it has one road, from an east or west edge round * to a 45° leg, and dropping one into the running row DEAD-ENDS the main there. Straights, turnouts, * Limits signs, the Office and industries all carry it. */ export function carriesThroughTrack(card: TrackCard): boolean { return connectionsFor(card).some( ([a, b]) => (a === 'e' && b === 'w') || (a === 'w' && b === 'e'), ); } /** * Gap 4a — a placed card must connect to existing track, and "connect" means `joins`: ports meeting * AND, on a north or south edge, 45° legs on the same diagonal. * * The opening Office Area is three east-west cards, so the first piece of a district is necessarily * a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no * office or industry card on the printed sheet does. */ /** * §2.1 — IS THIS SQUARE INSIDE THE DISTRICT AT ALL? * * The Limits sign denotes "the limit of your control area", and §3 defines Secondary Track as "all * tracks IN YOUR LIMITS that are not the Running Track" — so the bound belongs to the district, not * to the one row the sign happens to stand in. This used to guard the Running Track alone, on the * reasoning that the rows above and below were unbounded; the consequence was a siding snaking east * past your own sign, taking industries with it, and a player switching cars on track outside the * territory §8.1 and §10 reason about. Reported by Jesse: sidings must not be built outside the * Limits. * * INCLUSIVE OF THE SIGN'S OWN COLUMN. The sign stands ON the boundary rather than beyond it, and the * opening district is signs at ±1 around the Office — so the strict reading would leave exactly one * buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier * neighbourhood, are usable from the first Stage. * * MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that * exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a * Facility, so a Facility standing at the limit has three of its nine outside them and bounding the * card would make it unplayable exactly where a district ends. Play showed the cost of that the * other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which * reads at the table as building outside your own territory, and §8.1 and §10 both reason about * what is inside a player's Limits. * * THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than * argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free, * legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward * as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district. * `check` bars them from the Running Track ROW as well, which is the ground the main grows onto. */ export function withinLimits(area: OfficeArea, coord: GridCoord): boolean { return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col; } 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. if (!withinLimits(area, coord)) return false; // And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main // stops dead at it — the bot did exactly this, turning the west end of its own Running Track // into a stub and cutting the Office off from the Limits. if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false; /** * ONE NEIGHBOUR MUST JOIN. THE OTHERS NEED NOT — AND THIS RULE HAS BEEN BOTH WAYS (Gitea#15). * * A card may be laid with an exit facing a card that has nothing to meet it. The rail stops dead * at that edge, and that is legal. * * The issue was filed the other way round — "if a card is placed in that space, it MUST connect" — * against a right-hand curve laid with its north leg against an Ice House and the turnout below it * pointing at its portless south edge. **RAR reversed it on review (2026-08-26): placing it is * fine, and a stub like that is useful — a siding to park cars on.** * * WHAT MATTERS INSTEAD IS THAT NOTHING CAN DRIVE ACROSS THE GAP, so the real requirement is on * MOVEMENT rather than on placement: two cards touching are not connected, and `exploreMoves` must * refuse the hop. It does — every step is gated on `joins`, never on a bare pair of `hasPort` * calls — and `track.test.ts` pins the reported geometry against exactly that. * * SO DO NOT ADD A PER-EDGE CHECK HERE. One was written and taken out again when the ruling * arrived. What survives is the weaker rule that was always here: the piece must touch the network * SOMEWHERE, which is what stops orphaned track being laid in an empty corner of the board. */ const ports: Port[] = ['n', 's', 'e', 'w']; for (const p of ports) { const neighbourCard = cardAt(area, neighbour(coord, p)); if (neighbourCard && joins(card, p, neighbourCard)) return true; } return false; } /** * §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there — WITH ONE * EXCEPTION (Jesse's call, v0.5.0): any train may set out one or more coaches at the Office. It is * the one car type a passenger platform is meant to hold, and it is what makes the ENGINE-boxcar- * coach Local arrangement workable — the coach drops here while the engine and freight car switch * freely. Freight and cabooses stay banned at the Office for every train, no exception. * * An industry track also has a finite length (§9.3), so it can be full. */ export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1, coachesOnly = false): boolean { const card = cardAt(area, coord); if (sameCoord(coord, area.officeCoord)) { return coachesOnly && !!card && isOperationalRail(card) && spaceOn(card) >= count; } if (!card || !isOperationalRail(card)) return false; return spaceOn(card) >= count; }