/** * Component 4 — Intent validation and application. * * `applyIntent(state, actor, intent) -> events | rejection`. * See architecture/components.md §2 A.4. * * STRUCTURE. Every intent is a `check` + `execute` pair: * - `check` answers "is this legal right now?" and NEVER mutates. * - `execute` reads state and emits events; it never mutates either. * - `reduce` is the only thing that mutates, folding events into state. * * That keeps `state = fold(events)` true by construction, which is what makes replay and restart * recovery work. It also lets component 6 (legalActions) call these very same `check` functions, * so the two can never drift apart — see legal.ts. */ import { FREIGHT_PROFILES, HAND_LIMIT, LABORER_ACTIONS_PER_LOAD, MAX_CONSIST, REALIGNMENTS, consistSize, enhancementRule, industryProfile, mainlineModifierRule, mainlineProfile, modifierProfile, nextOfficeTier, officeProfile, trainProfile, } from './content.ts'; import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts'; import type { GameEvent } from './events.ts'; import type { Intent, RejectionCode } from './intents.ts'; import type { CardId, CrewTray, Facility, GameState, GridCoord, Load, OfficeArea, PlayerIndex, RollingStock, TrackArc, TrackCard, TrayId, TurnoutOrientation, } from './state.ts'; import { createRng } from './rng.ts'; import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; import type { MoveBlock, Occupancy, Port } from './track.ts'; import { canDropCarsAt, canPlaceAt, carriesThroughTrack, exploreMoves, facilityVariants, opposite, reachableDestinations, variantsFor, } from './track.ts'; export type ApplyResult = | { ok: true; events: GameEvent[] } | { ok: false; code: RejectionCode; message: string }; // --------------------------------------------------------------------------- // Lookup helpers // --------------------------------------------------------------------------- export function areaOf(s: GameState, player: PlayerIndex): OfficeArea { const a = s.officeAreas.get(player); if (!a) throw new Error(`no Office Area for player ${player}`); return a; } function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined { return area.grid.get(coordKey(c)); } function facilityAt(s: GameState, player: PlayerIndex, c: GridCoord): Facility | null { return cardAt(areaOf(s, player), c)?.facility ?? null; } function trayCoord(s: GameState, trayId: TrayId): GridCoord | null { const tray = s.trays.get(trayId); if (!tray || tray.position.at !== 'grid') return null; return tray.position.coord; } /** A tray sitting on the Office card occupies an A/D track (§2.1). */ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy { const area = areaOf(s, player); return { trayAt: (c) => { for (const [id, tray] of s.trays) { if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) { return id; } } return null; }, freeAdTracks: () => { const cap = officeProfile(area.tier).adTracks; return cap - area.adOccupancy.filter((t) => t !== self).length; }, }; } /** A tray's facing, expressed as the port it would leave by going forward. */ function facingPort(s: GameState, trayId: TrayId): Port { const tray = s.trays.get(trayId); if (tray?.facing) return tray.facing; return tray?.direction === 'west' ? 'w' : 'e'; } // --------------------------------------------------------------------------- // Shared predicates — used by BOTH check() below and legalActions() // --------------------------------------------------------------------------- export function isActor(s: GameState, player: PlayerIndex): boolean { return s.clock.currentActor === player; } export function inPhase(s: GameState, phase: GameState['clock']['phase']): boolean { return s.clock.phase === phase; } /** * §6 — "a player may do one of three things". You cannot do a thing that does not exist: a player * with no Facility has no Freight Agent operation available, and one with no Crew Tray in his area * has nothing to switch. Choosing such an option is illegal rather than a wasted turn. * * These deliberately inspect state directly rather than calling `check`, which would be circular. */ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolean { for (const card of areaOf(s, player).grid.values()) { const f = card.facility; if (!f) continue; if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true; if (f.inboundBox.length > 0) return true; if (f.menAtWork?.some((l) => l !== null)) return true; if (f.outboundBox.length > 0) return true; } return false; } /** * §9.1 — "Loading boxes are green, with the icon of the car type that can be loaded from there." * A facility handles exactly one commodity, so only that car type may be stocked into it. * * Without this check a Mine Tipple could be stocked with a coach. The load would then wait forever * for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK * strips the industry track of Operational Rail status (§9.3), the facility would jam permanently. */ /** * EVERY commodity a facility handles, not just the first one printed. * * Two of the six industries take two: a Power Plant burns coal OR oil (`['hopper','tank']`) and a * Grocer's Warehouse receives dry goods OR perishables (`['boxcar','reefer']`). This used to return * `carTypes[0]`, and because `freightAgent.stockOutbound` gates on it, the engine rejected the * second commodity as WRONG_CAR_TYPE — the sheet said a Power Plant takes tank cars and the code * said it did not. Measured effect: tank cars were dropped 0 times in 100 games. */ export function facilityCarTypes(f: Facility): readonly CarType[] { if (f.kind !== 'freight') return f.kind === 'passenger' ? ['coach'] : []; return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes ?? []; } /** The commodity to NAME a facility by, where one word is wanted. Legality must use the full set. */ export function facilityCarType(f: Facility): CarType | null { return facilityCarTypes(f)[0] ?? null; } export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean { for (const tray of s.trays.values()) { if (tray.position.at === 'grid' && tray.position.owner === player) return true; } return false; } /** §9.1 — Laborers and Porters may be used once each per Stage. */ export function laborersLeft(f: Facility): number { return f.laborers - f.usedThisStage.laborers; } export function portersLeft(f: Facility): number { return f.porters - f.usedThisStage.porters; } /** * Where a load may advance to along MEN | AT | WORK (§9.3). * * Direction depends on which way the load is travelling. **Outbound** runs Green -> MEN -> AT -> * WORK -> onto a spotted empty car. **Inbound** runs car -> WORK -> AT -> MEN -> red box. Getting * this wrong conflates loading with unloading, which is exactly the bug the statistics found: * `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point. */ export function canAdvanceLoad(f: Facility, box: number): boolean { // A Passenger Facility has no pipeline at all (§9.2 is porters, not MEN | AT | WORK), so freight // work is refused here on the shape of the facility rather than on it happening to have 0 Laborers. if (!f.menAtWork) return false; if (box < 0 || box >= f.menAtWork.length) return false; const load = f.menAtWork[box]; if (!load) return false; if (laborersLeft(f) < 1) return false; const next = load.dir === 'out' ? box + 1 : box - 1; if (next >= f.menAtWork.length) { // Off WORK and onto a spotted empty car of the right type. return f.industryTrack.cars.some((c) => !c.loaded && c.type === load.type); } if (next < 0) { // Off MEN and into the red Inbound box. return f.inboundBox.length < f.capacity.inbound; } return f.menAtWork[next] === null; } /** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */ /** * A turnout's diverging leg, as an arc. * * The stem is always an east or west edge and the leg always leaves north or south, so the arc is * just the two named together — `{stem:'w', diverge:'s'}` is `sw`. Naming the leg this way is what * lets the upgrade rule below be stated in GEOMETRY rather than in hands, so it is unaffected by * which printed row we call left. */ function divergingArc(t: TurnoutOrientation): TrackArc { return `${t.diverge}${t.stem}` as TrackArc; } /** * May this turnout be laid ON TOP of the card already on this square? * * Reported from playtesting: a district can only ever hang off a turnout, so a player who has laid * a straight along the main and then wants to branch there had no move at all — the piece had to * have been a turnout when it went down. A turnout may therefore UPGRADE: * * - a **straight**, at any of its orientations, because a turnout is a straight plus a leg; or * - a **curve of the same arc** as the turnout's own diverging leg, which is the same road with a * through track added beside it. * * Both are strict port SUPERSETS of what they replace — `{e,w}` for a straight, one arc for a curve * — so an upgrade can never sever a join a neighbour already relies on, and needs no connection test * of its own. The new leg is allowed to reach nothing at all; opening a direction is the point. * * Two things block it, and both are about the card being in use rather than about its shape: you * cannot swap the track out from under a standing car, and an Interlocking or Telegraph built on the * card would have to be lifted with it. The replaced card leaves play — board cards are never * salvaged (see the `cardPlayed` reducer), so a lifted one is simply gone, as it would be at a table. */ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCode | null { const t = proto.geometry.kind === 'track' ? proto.geometry.turnout : undefined; if (!t) return 'NOT_UPGRADEABLE_TRACK'; const g = existing.geometry; if (g.kind !== 'track') return 'NOT_UPGRADEABLE_TRACK'; if (g.geometry === 'curved' || g.geometry === 'sharpCurved') { // The ARC, not merely the diagonal: a `sw` curve and an `ne` one share a slope but leave by // opposite edges, so replacing one with the other would move the leg off its neighbour. if (g.arc !== divergingArc(t)) return 'NOT_UPGRADEABLE_TRACK'; } else if (g.geometry !== 'straight') { return 'NOT_UPGRADEABLE_TRACK'; } if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED'; if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED'; return null; } /** * The MEN | AT | WORK pipeline of a Freight Facility. * * `execute` and `reduce` run only after `check` has passed, and every freight-work check refuses a * Passenger Facility — so reaching here with one is a broken invariant, not a case to handle. Throwing * says that, where a `!` would quietly write into nothing and leave the fault to surface later. */ function workTrack(f: Facility): [Load | null, Load | null, Load | null] { if (!f.menAtWork) throw new Error('freight work attempted on a Passenger Facility'); return f.menAtWork; } export function canStartLoad(f: Facility): boolean { if (!f.menAtWork) return false; if (laborersLeft(f) < 1) return false; if (f.outboundBox.length === 0) return false; return f.menAtWork[0] === null; } /** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean { const f = facilityAt(s, player, at); if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false; // §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a // train these passengers can board however many empty coaches it is carrying. return trainAtOfficeWith( s, player, (c) => c.type === 'coach' && !c.loaded, (t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t), ); } /** * §9.2 — de-training needs an open red slot, a train carrying a loaded coach, AND AN EMPTY COACH IN * THE DIVISION YARD. * * "*Requirements: a white empty coach in the Division Yard and an unoccupied red Unloading slot. * Replace the blue coach on the train with the white one.*" The empty coach is where the passengers * were sitting — it has to come from somewhere, and the rule says where. * * That requirement was missing, and the reducer conjured the coach rather than taking it, so every * de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the * train. Measured at 1.29 cars a game created out of nothing across the two inbound paths. */ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean { const f = facilityAt(s, player, at); if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false; if (f.inboundBox.length >= f.capacity.inbound) return false; if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false; return trainAtOfficeWith( s, player, (c) => c.type === 'coach' && c.loaded, (t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t), ); } function trainAtOfficeWith( s: GameState, player: PlayerIndex, pred: (c: RollingStock) => boolean, trayOk: (t: CrewTray) => boolean = () => true, ): boolean { const area = areaOf(s, player); return area.adOccupancy.some((id) => { const t = s.trays.get(id); return !!t && trayOk(t) && t.consist.some(pred); }); } // --------------------------------------------------------------------------- // §7 — the operating rules printed on a train's own card // --------------------------------------------------------------------------- /** * What this tray's train card prints, or nothing at all. * * A local crew has no train number and therefore no printed rules — it is the player's own switcher * and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a * crew is unaffected by all of them. */ function rulesOf(tray: CrewTray): TrainRules { if (tray.trainNumber === null) return {}; return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {}; } /** Freight cars this train has already exchanged on this square this turn (trains 3/4). */ function freightWorkedKey(trayId: TrayId, at: GridCoord): string { return `${trayId}@${coordKey(at)}`; } const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose'; /** * May this train work these freight cars on this square? * * Trains 3/4 Express print "may drop or pick up one freight car at EVERY location", so the budget is * per square rather than per turn — it may work a car here, move on, and work another there. Both * setting out and picking up spend from the same one, because the card says "drop OR pick up". */ function freightBudgetLeft( s: GameState, tray: CrewTray, at: GridCoord, wanted: number, ): boolean { if (!rulesOf(tray).oneFreightPerLocation) return true; const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0; return already + wanted <= 1; } /** * Whether a train may do switching work at all (§7). * * Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and * Military trains. They run the Division; they do not shunt. This covers moving, setting out and * sorting alike, because all three are switching. */ function switchingRefusal(tray: CrewTray): RejectionCode | null { return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; } /** * Charge freight cars against this train's per-location budget (trains 3/4). * * Called from BOTH the coupling and the setting-out reducers, because the card says "drop OR pick up * one" — the two share a budget rather than getting one each. Only recorded for trains the rule * applies to, so the map stays empty for everything else. */ function spendFreightBudget( s: GameState, tray: CrewTray, at: GridCoord, stock: readonly RollingStock[], ): void { if (!rulesOf(tray).oneFreightPerLocation) return; const n = stock.filter(isFreight).length; if (n === 0) return; const key = freightWorkedKey(tray.id, at); s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n; } /** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */ function refusesPassengers(tray: CrewTray): boolean { return rulesOf(tray).noPassengerWork === true; } /** * Trains 1/2 Crack Limited — "stop at Terminals only". It runs into every Office and takes an A/D * track like anything else, but Porters only work it where it is booked to stop, so passengers can * neither board nor alight anywhere but a Terminal. */ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean { if (!rulesOf(tray).terminalsOnly) return false; const area = s.officeAreas.get(player); return !area || area.tier !== 'terminal'; } /** * WHY passenger work was refused — the printed rule if one is to blame, otherwise the general one. * * `canBoard`/`canDetrain` answer a single yes/no over every train at the Office, so when they say no * this works out whether a card is the reason. Without it a Military train standing at the platform * reported "no train at the Office", which is both wrong and unhelpful. */ function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode { const area = areaOf(s, player); const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t); if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL'; if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK'; return 'NO_TRAIN_AT_OFFICE'; } // --------------------------------------------------------------------------- // check — never mutates // --------------------------------------------------------------------------- export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null { if (s.status !== 'active') return 'WRONG_PHASE'; // The clearance ruling is the one intent that arrives out of turn order: it interrupts the // automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition). if (i.type === 'mainline.clearance') { if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION'; if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT'; return null; } if (!isActor(s, player)) return 'NOT_YOUR_TURN'; switch (i.type) { // -- Local Operations ----------------------------------------------------- case 'localOps.choose': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== null) return 'OPTION_ALREADY_CHOSEN'; // An option with no possible follow-up is not available at all (§6). if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY'; if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY'; return null; case 'switch.move': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); if (noSwitch) return noSwitch; const from = trayCoord(s, i.trayId); if (!from) return 'ILLEGAL_MOVE'; const dests = destinationsFor(s, player, i.trayId, from, i.reverse); const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col); if (!dest) return 'ILLEGAL_MOVE'; /** * COUPLING IS MANDATORY (§A.4), so a train forbidden to pick something up may not make the * MOVE that would pick it up. There is no "move but leave them"; the restriction has to bite * on the move or it cannot bite at all. */ const rules = rulesOf(tray); if (dest.couples.length > 0) { if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED'; if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY'; const freight = dest.couples.filter(isFreight).length; if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE'; } return null; } case 'switch.dropCars': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); if (noSwitch) return noSwitch; if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY'; const here = trayCoord(s, i.trayId); if (!here) return 'CANNOT_DROP_HERE'; /** * A CUT COMES OFF AN OUTER END, never out of the middle. * * The tray runs `[cars ahead of the engine] ENGINE [cars behind it]`. Setting out from the * nose takes from the front of that, and only the cars actually ahead of the engine; setting * out from the tail takes from the back, and only the cars behind it. Without this, a drop * could lift cars from beside the engine and leave the far end of the train still attached to * nothing — a cut no coupler could make. */ const ahead = tray.engineAt; const behind = tray.consist.length - tray.engineAt; if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY'; // The cut that would come off, so the printed rules can be asked about its contents. const cut = i.fromNose ? tray.consist.slice(0, i.count) : tray.consist.slice(tray.consist.length - i.count); const dropRules = rulesOf(tray); /** * Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is * never set out during switching at all. * * The intended reading was "set out only at the Office", but §A.4 makes that unimplementable: * `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail, * but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same * rule. What is left is the effect that matters: the Local may shunt its freight car around the * district, and may not abandon its coach at an industry or on a siding while it does. * Flagged in `TODO.md` in case the station track is meant to become a real place to leave one. */ if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) { return 'COACH_MUST_STAY'; } const droppedFreight = cut.filter(isFreight).length; if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) { return 'FREIGHT_WORKED_HERE'; } return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE'; } case 'switch.sortConsist': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const noSwitch = switchingRefusal(tray); if (noSwitch) return noSwitch; const here = trayCoord(s, i.trayId); if (!here) return 'ILLEGAL_MOVE'; // Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard". const card = areaOf(s, player).grid.get(coordKey(here)); if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED'; // The order must be a permutation of the current consist. if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER'; const seen = new Set(i.order); if (seen.size !== i.order.length) return 'CONSIST_ORDER'; if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER'; return null; } case 'switch.end': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN'; // -- Draw a card ---------------------------------------------------------- case 'draw.fromHomeOffice': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY'; return null; case 'draw.fromDepartment': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN'; if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY'; return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY'; case 'card.play': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; const hand = s.decks.hands.get(player) ?? []; if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node); } case 'card.discard': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; const hand = s.decks.hands.get(player) ?? []; if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND'; if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY'; return null; } case 'mainline.modify': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT'; const rule = mainlineModifierRule(card.kind.key); if (!rule) return 'NOT_IMPLEMENTED'; const node = s.division.nodes[i.node]; if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT'; const on = node.modifiers ?? []; if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN'; if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE'; if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED'; // "Not while a train is on it" — realigning under a moving train is exactly the situation the // restriction exists to prevent. if (node.transits.length > 0) return 'TRAIN_ON_CARD'; if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) { return 'NO_PLACEMENT'; } return null; } case 'maneuver.redFlags': { const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; // "A STOPPED train is prevented from being hit" — it protects a train that is standing on a // Mainline card, which is the only place a rear-ender can happen. if (tray.position.at !== 'mainline') return 'NO_PLACEMENT'; const node = s.division.nodes[tray.position.index]; if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT'; if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN'; return null; } case 'maneuver.flyingSwitch': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING'; const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; const here = trayCoord(s, i.trayId); if (!here) return 'CANNOT_DROP_HERE'; if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY'; // The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must // be somewhere the train could itself have run to — track-connected, not merely a neighbouring // square. An earlier version tested orthogonal adjacency, which was wrong in both directions: // it would have allowed a cut to cross to a cell with no rail between, while refusing a siding // two cards along the same track. It also made the card effectively unplayable — held for // 1,400 turns across 60 games and legal on 2. const reachable = [ ...destinationsFor(s, player, i.trayId, here, false), ...destinationsFor(s, player, i.trayId, here, true), ]; if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) { return 'NOT_CONNECTED'; } const fsArea = areaOf(s, player); const target = fsArea.grid.get(coordKey(i.to)); if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE'; return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE'; } case 'draw.end': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; // §6.2 — "the player must reduce his hand to no more than three cards". const hand = s.decks.hands.get(player) ?? []; const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT; return hand.length > limit ? 'HAND_LIMIT' : null; } // -- Freight Agent -------------------------------------------------------- case 'freightAgent.stockOutbound': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (!f.allows.outbound) return 'NO_SUCH_FACILITY'; if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL'; // §9.1 — the green box takes only this facility's commodities, of which it may have two. if (!facilityCarTypes(f).includes(i.carType)) return 'WRONG_CAR_TYPE'; if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) { return 'NO_SUITABLE_CAR'; } return null; } case 'freightAgent.clearInbound': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; return f.inboundBox[i.index] ? null : 'BOX_EMPTY'; } case 'freightAgent.end': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; // §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the // action unspent is a wasted Stage, which is the player's to waste — the alternative was // forcing an unjam that destroys a load. return s.turn.option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN'; case 'freightAgent.unjam': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY'; const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox; return box[i.index] ? null : 'BOX_EMPTY'; } // -- New Train ------------------------------------------------------------ case 'newTrain.placeCar': { if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; // §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a // train that is running, and loading one from the yard is teleporting cars onto it. if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP'; if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL'; if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) { return 'NO_SUITABLE_CAR'; } // §8.2 — "the train must be in the order listed on the train's card (engine on the front, // Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, // but not out of order." // // This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy // Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and // no caboose. Fewer is allowed; more, or of the wrong category, is not. return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR'; } case 'newTrain.secondSection': { if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; // Only on a train that is actually due out this Stage — a second section follows a first. if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY'; if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY'; return null; } case 'newTrain.passCar': { if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; const tray = s.trays.get(i.trayId); if (!tray) return 'NO_SUCH_TRAY'; // Same scope as placeCar: only the train being assembled has a make-up round to finish. if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP'; // §7 — "must make every effort to find a suitable car". A pass is only legal when none exists. return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null; } // -- Load / Unload -------------------------------------------------------- case 'porter.board': if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; return canBoard(s, player, i.at) ? null : passengerRefusal(s, player); case 'porter.detrain': if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY'; if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT'; return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player); case 'laborer.startLoad': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; return canStartLoad(f) ? null : 'BOX_EMPTY'; } case 'laborer.advanceLoad': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; return canAdvanceLoad(f, i.box) ? null : 'BOX_EMPTY'; } case 'laborer.beginUnload': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (!f.allows.inbound) return 'NO_SUCH_FACILITY'; if (laborersLeft(f) < 1) return 'RESOURCE_SPENT'; const car = f.industryTrack.cars[i.carIndex]; if (!car || !car.loaded) return 'WRONG_CAR_TYPE'; /** * §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE * DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*" * * The empty car requirement was not checked and the reducer conjured the car rather than * taking it, so every unload minted one. Unloading is meant to consume supply. */ if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR'; // The load is placed on WORK, the last box, so that box must be free — and a Passenger // Facility has no such box, so there is nothing to unload into. if (!f.menAtWork) return 'NO_SUCH_FACILITY'; return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL'; } case 'loadUnload.end': return inPhase(s, 'loadUnload') ? null : 'WRONG_PHASE'; case 'redFlag.play': return s.decks.redFlags.get(player) ? null : 'NO_SUCH_CARD'; default: return 'WRONG_PHASE'; } } /** Playing a card from hand (§6.2). Track, Facility and Office cards need a placement. */ function checkPlay( s: GameState, player: PlayerIndex, cardId: string, placement: GridCoord | undefined, variant: number | undefined, node?: number, ): RejectionCode | null { const card = s.cards.get(cardId); if (!card) return 'NO_SUCH_CARD'; const area = areaOf(s, player); // A Division node and an Office Area square are different boards. Naming both is not a placement // with extra detail, it is two contradictory answers to "where?". if (node !== undefined && placement) return 'NO_PLACEMENT'; switch (card.kind.kind) { case 'office': { // An upgrade replaces the Office in place (Gap 8), so a placement is meaningless — and // accepting one makes the event log claim the card was laid somewhere it was not. if (placement) return 'NO_PLACEMENT'; // Gap 3b — strict sequence, no skipping. const next = nextOfficeTier(area.tier); return next === card.kind.tier ? null : 'NOT_UPGRADEABLE'; } case 'track': if (!placement) return 'NO_PLACEMENT'; { const proto = protoCard(card.kind, variant); if (!proto) return 'NO_PLACEMENT'; /** * AN OCCUPIED SQUARE IS AN UPGRADE, not a placement. * * A turnout may be laid on top of a card already down — see `checkTurnoutUpgrade`. The one * occupied square that is NOT an upgrade is a Limits sign on the Running Track: that is the * growth point, and `canPlaceAt` moves it outward rather than building over it. */ const existing = area.grid.get(coordKey(placement)); const isMovableSign = existing?.geometry.kind === 'limits' && placement.row === area.runningRow && existing.standing.length === 0; if (existing && !isMovableSign) return checkTurnoutUpgrade(existing, proto); // Said separately from NOT_CONNECTED because it is a different mistake: the card would join // perfectly well, and would still leave the Running Track stopping dead at it. if (placement.row === area.runningRow && !carriesThroughTrack(proto)) { return 'BREAKS_RUNNING_TRACK'; } return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED'; } case 'freightFacility': { if (!placement) return 'NO_PLACEMENT'; /** * ON A STRAIGHT STUB, NEVER THE RUNNING TRACK. * * The sheet's "Placed" column reads the same for all six industries: **"Straight, Stub (not on * Running Track)"**. An industry has to hang off a siding, which is what makes a siding worth * building — the whole switching puzzle is getting a car from the main down to a spur and back. * * This was allowed, and merely warned about: an industry could sit on the Running Track and * the card text noted that a car left standing there would be hit by the next arrival. That is * a hazard, not a rule, and it let a player skip the district entirely and spot cars on the * main line. */ if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK'; // Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer // alongside the consumer of the same commodity. if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED'; const proto = protoCard(card.kind, variant); if (!proto) return 'NO_PLACEMENT'; return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED'; } case 'modifier': { if (!placement) return 'NO_PLACEMENT'; if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED'; // One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses. if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED'; // §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of // the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play. return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED'; } case 'enhancement': { // ABS Signals goes out on the Mainline, so it takes a Division node and no square. if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') { if (node === undefined) return 'NO_PLACEMENT'; const target = s.division.nodes[node]; return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED'; } if (!placement) return 'NO_PLACEMENT'; return checkEnhancementPlacement(s, area, card.kind.key, placement); } case 'mainlineModifier': // Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet, // with the same placement and the same effect. It is one card printed twice, so the mainline // copy uses the enhancement's grid placement rather than going onto a Mainline card. if (card.kind.key === 'facingPointLocksMainline') { if (!placement) return 'NO_PLACEMENT'; return checkEnhancementPlacement(s, area, 'facingPointLocks', placement); } // The rest are laid on a Mainline card, which is not a grid coordinate — see // `mainline.modify`. return 'WRONG_INTENT'; case 'maneuver': // Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in // the source", so there is nothing to implement. return 'WRONG_INTENT'; case 'spaceUse': case 'action': // Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and // §10. Rejecting is honest: silently accepting would make the card look playable while doing // nothing, which is exactly the bug that made Modifiers dead weight for weeks. return 'NOT_IMPLEMENTED'; case 'timetabledTrain': case 'extraTrain': // A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI // offer the same play at six different grid squares with six rotations apiece — all identical, // because the placement was then ignored. Same reasoning as the Office upgrade above: silently // discarding a placement makes the event log claim the card was laid somewhere it was not. return placement ? 'NO_PLACEMENT' : null; default: return null; } } function destinationsFor( s: GameState, player: PlayerIndex, trayId: TrayId, from: GridCoord, reverse: boolean, ) { const tray = s.trays.get(trayId)!; const facing = facingPort(s, trayId); // Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and // west, so a crew facing north or south reversed to 'e' — a port a north-south card does not // have — and could never back out of a district spur. Combined with a facing that was itself // derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track. const exit: Port = reverse ? opposite(facing) : facing; return reachableDestinations( { area: areaOf(s, player), occupancy: occupancyFor(s, player, trayId), consistSize: tray.consist.length, self: trayId, }, from, exit, ); } /** * WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE. * * Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when * looking at a card two squares away — a square reachable only by backing up is still reachable, and * a reason that applies in one direction should not be reported when the other direction works. * * Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules * that produced them. */ export function movesFor( s: GameState, player: PlayerIndex, trayId: TrayId, ): { to: GridCoord[]; blocked: MoveBlock[] } { const tray = s.trays.get(trayId); if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] }; const from = tray.position.coord; const ctx = { area: areaOf(s, player), occupancy: occupancyFor(s, player, trayId), consistSize: tray.consist.length, self: trayId, }; const facing = facingPort(s, trayId); const forward = exploreMoves(ctx, from, facing); const back = exploreMoves(ctx, from, opposite(facing)); const to = new Map(); for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord); const blocked = new Map(); for (const b of [...forward.blocked, ...back.blocked]) { if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b); } return { to: [...to.values()], blocked: [...blocked.values()] }; } // --------------------------------------------------------------------------- // execute — reads state, emits events, never mutates // --------------------------------------------------------------------------- function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { switch (i.type) { case 'localOps.choose': return [{ type: 'localOpsOptionChosen', player, option: i.option }]; case 'switch.move': { const from = trayCoord(s, i.trayId)!; const dests = destinationsFor(s, player, i.trayId, from, i.reverse); const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!; // The crew leaves by the port opposite the one it entered through, which is what it will be // facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns // onto a north-south spur can never move again. const events: GameEvent[] = [ { type: 'trayMoved', trayId: i.trayId, from, to: i.to, movesRemaining: s.turn.movesRemaining - 1, /** * A TRAIN THAT BACKS UP HAS NOT TURNED AROUND. * * `facing` is which way the ENGINE points, and this set it to the direction of travel on * every move — so one reverse move silently spun the train about. Everything then read * "forward" again, and a run-around became pointless: you could change ends for free by * backing up twice. * * Running forward the engine leads, so it points the way the train went: `opposite(entry)`. * Backing up it trails, still pointing the way it came, which is the port it arrived * through. Both hold around a curve, where the compass heading changes but the engine's * relationship to its train does not. */ facing: i.reverse ? dest.entry : opposite(dest.entry), }, ]; if (dest.couples.length > 0) { // Name the cards the cars came from. The reducer used to clear every card in the district // instead, so one coupling anywhere deleted every standing car and every industry track in // the Office Area — loads worked over several Stages vanished when a crew picked up a single // boxcar somewhere else entirely. const lifted = [ ...dest.path.map((step) => step.coord), i.to, ].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0); // §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto // its nose". Running forward the engine meets cars head-on and takes them in front; backing // up, they couple behind. Which end they land on is the whole point of a run-around: it // decides which car is next to come off. events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples, from: lifted, toNose: !i.reverse, }); } return events; } case 'switch.dropCars': { const tray = s.trays.get(i.trayId)!; const here = trayCoord(s, i.trayId)!; // §A.3 — cars come off in the order they are seated in the tray, from whichever end is being // set out. The nose is the end ahead of the engine. const stock = i.fromNose ? tray.consist.slice(0, i.count) : tray.consist.slice(tray.consist.length - i.count); return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }]; } case 'switch.sortConsist': { const tray = s.trays.get(i.trayId)!; const here = trayCoord(s, i.trayId)!; return [ { type: 'consistSorted', trayId: i.trayId, at: here, before: tray.consist.map((c) => ({ ...c })), after: i.order.map((n) => ({ ...tray.consist[n]! })), }, ]; } case 'switch.end': case 'draw.end': case 'freightAgent.end': return [{ type: 'phaseEnded', player, phase: 'localOps' }]; case 'draw.fromHomeOffice': { const events: GameEvent[] = [ { type: 'cardDrawn', player, source: 'homeOffice', cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!, }, ]; const sweep = reshuffleIfDepleted(s, 1); if (sweep) events.push(sweep); return events; } case 'draw.fromDepartment': { // §6.2 — "take the TOP face-up card". Never anything buried: a player who discarded onto this // pile chose to put a card out of reach as much as to offer one, and letting a rival dig // would take that decision away. const pile = s.decks.departments[i.slot]!; const events: GameEvent[] = [ { type: 'cardDrawn', player, source: 'department', slot: i.slot, cardId: pile[pile.length - 1]!, }, ]; // §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in the // empty spot." Only when taking the last card actually empties the pile; refilling on every // draw would grow the Departments without limit and drain the Home Office deck into them. const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1]; if (pile.length === 1 && refill) { events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill }); const sweep = reshuffleIfDepleted(s, 1); if (sweep) events.push(sweep); } return events; } case 'card.play': { const card = s.cards.get(i.cardId)!; const events: GameEvent[] = [ i.placement ? { type: 'cardPlayed', player, cardId: i.cardId, placement: i.placement, variant: i.variant ?? 0, } : { type: 'cardPlayed', player, cardId: i.cardId }, ]; if (card.kind.kind === 'office') { events.push({ type: 'officeUpgraded', player, from: areaOf(s, player).tier, to: card.kind.tier, }); } if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) { events.push( i.node !== undefined ? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node } : { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! }, ); } if (card.kind.kind === 'extraTrain') { // §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used // to do nothing at all, which made all four Extra cards dead weight in the deck. events.push({ type: 'extraQueued', player, trainNumber: card.kind.number }); } if (card.kind.kind === 'timetabledTrain') { // §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at // the bottom. Without this no train ever runs, so nothing can ever be earned. const rng = createRng(s.rngState); const roll = rng.d12(); const slot = findTimetableSlot(s, roll - 1); if (slot !== null) { events.push({ type: 'trainScheduled', player, trainNumber: card.kind.number, roll, slot, rngState: rng.getState(), }); } } return events; } case 'card.discard': return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }]; case 'mainline.modify': { const card = s.cards.get(i.cardId)!; const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : ''; const node = s.division.nodes[i.node]; const became = key === 'realignment' && node?.kind === 'mainline' ? REALIGNMENTS.find((r) => r.from === node.card)?.to : undefined; return [ { type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) }, ]; } case 'maneuver.redFlags': { const tray = s.trays.get(i.trayId)!; const index = tray.position.at === 'mainline' ? tray.position.index : -1; return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }]; } case 'maneuver.flyingSwitch': { const tray = s.trays.get(i.trayId)!; // §A.3 — cars come off the back, same as a normal drop. const stock = tray.consist.slice(tray.consist.length - i.count); return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }]; } case 'freightAgent.stockOutbound': return [ { type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } }, ]; case 'freightAgent.clearInbound': { const f = facilityAt(s, player, i.at)!; return [{ type: 'inboundCleared', player, at: i.at, stock: f.inboundBox[i.index]! }]; } case 'freightAgent.unjam': { const f = facilityAt(s, player, i.at)!; const stock: RollingStock = i.from === 'menAtWork' ? { type: workTrack(f)[i.index]!.type, loaded: true } : (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!; return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }]; } case 'newTrain.placeCar': return [ { type: 'carPlacedOnTrain', player, trayId: i.trayId, stock: { type: i.carType, loaded: i.loaded }, }, ]; case 'newTrain.passCar': return [{ type: 'carPassed', player, trayId: i.trayId }]; case 'newTrain.secondSection': return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }]; case 'mainline.clearance': return [ { type: 'clearanceGiven', trainId: s.clock.pendingDecision!.train, allow: i.allow, }, ]; case 'porter.board': return [ { type: 'passengersBoarded', player, at: i.at }, { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' }, ]; case 'porter.detrain': return [ { type: 'passengersDetrained', player, at: i.at }, { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' }, ]; case 'laborer.startLoad': { const f = facilityAt(s, player, i.at)!; return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }]; } case 'laborer.advanceLoad': { const f = facilityAt(s, player, i.at)!; const load = workTrack(f)[i.box]!; const next = load.dir === 'out' ? i.box + 1 : i.box - 1; if (next >= workTrack(f).length) { // Outbound complete: the load goes onto the spotted car (§9.3). return [ { type: 'loadCompleted', player, at: i.at, carType: load.type }, { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' }, ]; } if (next < 0) { // Inbound complete: the load reaches the red Unloading box (§9.3). return [ { type: 'unloadCompleted', player, at: i.at, carType: load.type }, { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' }, ]; } return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }]; } case 'laborer.beginUnload': { const f = facilityAt(s, player, i.at)!; return [ { type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type }, ]; } case 'loadUnload.end': return [{ type: 'phaseEnded', player, phase: 'loadUnload' }]; case 'redFlag.play': return [{ type: 'phaseEnded', player, phase: 'redFlag' }]; default: return []; } } function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number { return (s.players[player]?.revenue ?? 0) + delta; } /** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */ function findTimetableSlot(s: GameState, from: number): number | null { for (let i = 0; i < s.timetable.length; i++) { const slot = (from + i) % s.timetable.length; if (s.timetable[slot] === null) return slot; } return null; } // --------------------------------------------------------------------------- // reduce — the ONLY mutator. state = fold(events). // --------------------------------------------------------------------------- export function reduce(s: GameState, e: GameEvent): void { switch (e.type) { case 'localOpsOptionChosen': s.turn.option = e.option; break; case 'trayMoved': { const tray = s.trays.get(e.trayId)!; const owner = tray.position.at === 'grid' ? tray.position.owner : 0; tray.position = { at: 'grid', owner, coord: e.to }; if (e.facing) tray.facing = e.facing; s.turn.movesRemaining = e.movesRemaining; // An A/D track is held only while the train is actually standing at the Office (§2.1). // Leaving it out of sync means the Office looks permanently full and every arrival collides. const area = areaOf(s, owner); const atOffice = e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col; area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId); if (atOffice) area.adOccupancy.push(e.trayId); break; } case 'carsCoupled': { const tray = s.trays.get(e.trayId)!; const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); if (e.toNose) { // Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the // engine is no longer at the front and its index has to follow. It never did, so a crew that // shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's // make-up rule had nothing truthful to check. tray.consist.unshift(...e.stock); tray.engineAt += e.stock.length; } else { tray.consist.push(...e.stock); } // ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew // never went near. for (const coord of e.from) { const card = area.grid.get(coordKey(coord)); if (!card) continue; card.standing = []; if (card.facility) card.facility.industryTrack.cars = []; } spendFreightBudget(s, tray, e.at, e.stock); break; } case 'consistSorted': { const tray = s.trays.get(e.trayId)!; tray.consist = e.after.map((c) => ({ ...c })); // A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason // to use one: §8.2 will not let a train leave the Office with cars in front of its engine. tray.engineAt = 0; // "Spends one move in the yard" — the sort costs a Move. s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1); break; } case 'carsDropped': { const tray = s.trays.get(e.trayId)!; const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); if (e.fromNose) { // Off the front: everything ahead of the engine shortens, so the engine moves up by that // much. This is how a train that took cars onto its nose gets back to being made up. tray.consist.splice(0, e.stock.length); tray.engineAt = Math.max(0, tray.engineAt - e.stock.length); } else { tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length); } tray.engineAt = Math.min(tray.engineAt, tray.consist.length); const card = area.grid.get(coordKey(e.at)); // On a Facility card the industry track is where cars stand (§9.3). if (card) carsOn(card).push(...e.stock); spendFreightBudget(s, tray, e.at, e.stock); break; } case 'departmentRefilled': s.decks.homeOffice.pop(); s.decks.departments[e.slot]!.push(e.cardId); break; case 'deckReshuffled': { // Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards // turned face up as the Departments, the rest face down as the Home Office deck. The // Departments start one deep again, exactly as at setup. s.decks.salvageYard = []; s.decks.departments = [[], [], []]; const order = [...e.order]; for (const pile of s.decks.departments) { const card = order.pop(); if (card) pile.push(card); } // The END of the array is the top of the deck — `cardDrawn` pops from there. s.decks.homeOffice = order; s.rngState = e.rngState; break; } case 'cardDrawn': { const hand = s.decks.hands.get(e.player) ?? []; if (e.source === 'homeOffice') s.decks.homeOffice.pop(); else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop(); hand.push(e.cardId); s.decks.hands.set(e.player, hand); s.turn.drawnThisTurn = true; break; } case 'cardPlayed': { const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId); s.decks.hands.set(e.player, hand); const card = s.cards.get(e.cardId)!; const area = areaOf(s, e.player); if (e.placement && (card.kind.kind === 'track' || card.kind.kind === 'freightFacility')) { const built = protoCard(card.kind, e.variant); if (built) { if (card.kind.kind === 'freightFacility') built.facility = buildFreightFacility(card.kind.facility); area.grid.set(coordKey(e.placement), built); extendLimitsIfNeeded(area, e.placement); } } else if (e.placement && card.kind.kind === 'modifier') { // A Modifier stays on the board beside its Facility, and its effect is applied. It used to // be discarded straight to the Salvage Yard, so playing one did literally nothing. area.grid.set(coordKey(e.placement), { geometry: { kind: 'modifier', modifier: card.kind.modifier }, baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [], }); applyModifier(area, e.placement, card.kind.modifier); } else if (card.kind.kind !== 'office') { s.decks.salvageYard.push(e.cardId); } break; } case 'mainlineModified': { const node = s.division.nodes[e.node]; if (node?.kind === 'mainline') { if (e.became) node.card = e.became as MainlineKind; else node.modifiers = [...(node.modifiers ?? []), e.key]; } spendCard(s, e.player, e.cardId); break; } case 'redFlagsSet': { const node = s.division.nodes[e.node]; if (node?.kind === 'mainline') { node.redFlagged = [...(node.redFlagged ?? []), e.trayId]; } spendCard(s, e.player, e.cardId); break; } case 'flyingSwitch': { const tray = s.trays.get(e.trayId); const area = areaOf(s, e.player); const card = area.grid.get(coordKey(e.to)); if (tray && card) { tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length); const track = card.facility?.industryTrack; if (track) track.cars.push(...e.stock); else card.standing.push(...e.stock); } s.turn.movesRemaining -= 1; spendCard(s, e.player, e.cardId); break; } case 'officeUpgraded': { // Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track. const area = areaOf(s, e.player); const from = officeProfile(e.from); const to = officeProfile(e.to); area.tier = e.to; const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard?.facility) { /** * THE TIER IS A DELTA, NOT AN OVERWRITE. * * This wrote the new tier's printed numbers straight over the facility, which silently * deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the * Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away * with no message, after the card had been spent. Reported from a playtest where a * Restaurant's porter never appeared — it had appeared and then been erased. * * Applying the DIFFERENCE between the two tiers raises the Office by exactly what the * upgrade is worth and leaves anything standing beside it untouched. */ const f = officeCard.facility; f.porters += to.porters - from.porters; f.capacity = { outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut), inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn), }; // Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle // Post has no passenger boxes to add to. f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility }; } break; } case 'cardDiscarded': { const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId); s.decks.hands.set(e.player, hand); // ON TOP of the pile. Assigning here overwrote whatever was already face up on that // Department, quietly destroying a card from a closed deck — and it threw away the whole // point of choosing WHICH Department to discard onto. s.decks.departments[e.toSlot]!.push(e.cardId); break; } case 'stockToOutbound': { const f = facilityAt(s, e.player, e.at)!; const idx = s.yards.divisionYard.findIndex( (c) => c.type === e.stock.type && c.loaded === e.stock.loaded, ); if (idx >= 0) s.yards.divisionYard.splice(idx, 1); refillDivisionYardIfEmpty(s); f.outboundBox.push(e.stock); s.turn.freightAgentUsed = true; break; } case 'inboundCleared': { const f = facilityAt(s, e.player, e.at)!; const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); if (idx >= 0) f.inboundBox.splice(idx, 1); s.yards.classificationYard.push(e.stock); s.turn.freightAgentUsed = true; break; } case 'facilityUnjammed': { const f = facilityAt(s, e.player, e.at)!; if (e.from === 'menAtWork') { const idx = workTrack(f).findIndex((l) => l !== null); if (idx >= 0) workTrack(f)[idx] = null; } else { const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox; const idx = box.findIndex((c) => c.type === e.stock.type); if (idx >= 0) box.splice(idx, 1); } s.yards.classificationYard.push(e.stock); s.turn.freightAgentUsed = true; break; } case 'enhancementPlaced': { if (e.node !== undefined) { const node = s.division.nodes[e.node]; // ABS Signals: trains on this card stop short rather than rear-ending each other. if (node?.kind === 'mainline') node.absSignals = true; } else if (e.at) { const card = areaOf(s, e.player).grid.get(coordKey(e.at)); if (card) card.enhancements.push(e.key); } break; } case 'extraQueued': s.pendingExtras.push(e.trainNumber); break; case 'secondSectionOrdered': s.pendingSecondSections.push(e.trainNumber); break; case 'trainScheduled': s.timetable[e.slot] = e.trainNumber; s.rngState = e.rngState; s.decks.salvageYard.push(`train-${e.trainNumber}`); break; case 'carPlacedOnTrain': { const tray = s.trays.get(e.trayId)!; const idx = s.yards.divisionYard.findIndex( (c) => c.type === e.stock.type && c.loaded === e.stock.loaded, ); if (idx >= 0) s.yards.divisionYard.splice(idx, 1); refillDivisionYardIfEmpty(s); tray.consist.push(e.stock); break; } case 'passengersBoarded': { const f = facilityAt(s, e.player, e.at)!; const area = areaOf(s, e.player); const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded); const loaded = f.outboundBox.splice(idx, 1)[0]!; for (const id of area.adOccupancy) { const tray = s.trays.get(id); const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1; if (tray && ci >= 0) { s.yards.classificationYard.push(tray.consist[ci]!); tray.consist[ci] = loaded; break; } } f.usedThisStage.porters += 1; break; } case 'passengersDetrained': { const f = facilityAt(s, e.player, e.at)!; const area = areaOf(s, e.player); for (const id of area.adOccupancy) { const tray = s.trays.get(id); const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1; if (tray && ci >= 0) { // The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured, // which minted a coach on every de-training. const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded); const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: 'coach' as const, loaded: false }; refillDivisionYardIfEmpty(s); f.inboundBox.push(tray.consist[ci]!); tray.consist[ci] = empty; break; } } f.usedThisStage.porters += 1; break; } case 'loadStarted': { const f = facilityAt(s, e.player, e.at)!; const idx = f.outboundBox.findIndex((c) => c.type === e.carType); if (idx >= 0) f.outboundBox.splice(idx, 1); workTrack(f)[0] = { type: e.carType, dir: 'out' }; f.usedThisStage.laborers += 1; break; } case 'loadAdvanced': { const f = facilityAt(s, e.player, e.at)!; workTrack(f)[e.toBox] = workTrack(f)[e.fromBox]!; workTrack(f)[e.fromBox] = null; f.usedThisStage.laborers += 1; break; } case 'unloadCompleted': { const f = facilityAt(s, e.player, e.at)!; workTrack(f)[0] = null; f.inboundBox.push({ type: e.carType, loaded: true }); f.usedThisStage.laborers += 1; break; } case 'loadCompleted': { const f = facilityAt(s, e.player, e.at)!; workTrack(f)[workTrack(f).length - 1] = null; const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType); if (ci >= 0) { s.yards.classificationYard.push(f.industryTrack.cars[ci]!); f.industryTrack.cars[ci] = { type: e.carType, loaded: true }; } f.usedThisStage.laborers += 1; break; } case 'unloadBegan': { const f = facilityAt(s, e.player, e.at)!; const ci = f.industryTrack.cars.findIndex((c) => c.loaded); if (ci >= 0) { // §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what // minted a car on every unload, and it also skipped a requirement the rule states. const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded); const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: e.carType, loaded: false }; refillDivisionYardIfEmpty(s); f.industryTrack.cars[ci] = empty; } workTrack(f)[workTrack(f).length - 1] = { type: e.carType, dir: 'in' }; f.usedThisStage.laborers += 1; break; } case 'revenueChanged': { const p = s.players[e.player]; if (p) p.revenue = e.total; break; } case 'phaseEnded': // The actor has finished; the phase driver moves on to the next player. if (e.phase !== 'redFlag') s.turn.done = true; break; case 'clearanceGiven': s.clock.pendingDecision = null; // Recorded for the asking train to consume; otherwise the driver asks again forever. s.clock.clearanceRuling = { train: e.trainId, allow: e.allow }; break; default: break; } } /** * Builds the card a play would place, at the chosen orientation. Returns null when the variant * index is out of range, which `check` reports rather than silently defaulting — a wrong * orientation is a different card, not a detail. */ function protoCard( kind: { kind: string; geometry?: string; facility?: string; hand?: string }, variant: number | undefined, ): TrackCard | null { const base = { standing: [], facility: null, modifiers: [], enhancements: [] }; if (kind.kind === 'track') { const geometry = kind.geometry as TrackGeometry; // Track is a per-player supply rather than a deck card, so nothing reaches this branch today. // It still needs a hand: handedness IS the slope, and defaulting silently would lay a card on // the wrong diagonal. const hand = (kind.hand as Hand | undefined) ?? 'left'; const options = variantsFor(geometry, hand); const v = options[variant ?? 0]; if (!v) return null; return { geometry: { kind: 'track', geometry, ...(v.arc ? { arc: v.arc } : {}), ...(v.turnout ? { turnout: v.turnout } : {}), ...(v.bypass ? { bypass: v.bypass } : {}), ...(hand !== 'none' ? { hand } : {}), }, baseOperationalRail: geometry !== 'turnout', ...base, }; } // A Facility is plain east-west track, as every industry card on the printed sheet is, so there // is exactly one orientation and any index past it is out of range. if (!facilityVariants()[variant ?? 0]) return null; return { geometry: { kind: 'facility', facility: kind.facility as never }, baseOperationalRail: true, ...base, }; } /** * Instantiates a Freight Facility from its catalogue profile (card-reference.md §2). * * This was missing: placed facility cards were built with `facility: null`, making them inert — * they could never be stocked, worked, or scored from. Every freight card played was dead weight. */ function buildFreightFacility(kind: FreightKind): Facility { const p = FREIGHT_PROFILES.find((f) => f.kind === kind); if (!p) throw new Error(`unknown freight facility: ${kind}`); return { kind: 'freight', subtype: kind, allows: { outbound: p.flow === 'outbound' || p.flow === 'both', inbound: p.flow === 'inbound' || p.flow === 'both', }, outboundBox: [], inboundBox: [], // The design gives every industry ONE car out and ONE loader; capacity is grown by the // industry-specific modifier cards, not printed large on the industry itself. capacity: { outbound: p.baseOut, inbound: p.baseIn }, menAtWork: [null, null, null], industryTrack: { length: Math.max(1, p.baseOut + p.baseIn), cars: [] }, laborers: p.baseLoaders, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }; } /** * The Facility a Modifier at `coord` would serve — any of the nine nearby spots (§9). * * SIMPLIFICATION, deliberate and flagged: §9 says that when a Modifier touches two Facilities its * effect may be used on only one per Stage. This applies the effect permanently to the first * Facility found instead. Modelling the per-Stage choice needs an extra decision point in the * Load/Unload phase and is not worth it until the mechanic has been played. */ /** * The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it * is actually ALLOWED to attach to. * * Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger * Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any * square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and * `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which * could be a different one again. The player saw three legal spots for a card that has one. */ function adjacentFacilityCoord( area: OfficeArea, coord: GridCoord, modifier?: ModifierKind, ): GridCoord | null { // A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows // on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7 // guard here rejected the three above outright. const hosts = modifier ? modifierProfile(modifier).hosts : null; for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { if (dr === 0 && dc === 0) continue; const c = { row: coord.row + dr, col: coord.col + dc }; const f = area.grid.get(coordKey(c))?.facility; if (!f) continue; if (hosts && !hosts.includes(f.subtype)) continue; return c; } } return null; } /** * The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so * their placement and state are implemented and their effect is read at the point of attack: * * - **Facing Point Locks** — prevents `Derail` being played on you (Action card). * - **Water Column** — lets you remove a Watertower from your district (Space-use card). * - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card). * * In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are * queried here rather than being special-cased at each attack site. */ export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean { return [...area.grid.values()].some((c) => c.enhancements.includes(key)); } /** Facing Point Locks blocks a Derail played at this district. */ export function isProtectedFromDerail(area: OfficeArea): boolean { return hasDistrictEnhancement(area, 'facingPointLocks'); } /** A Water Column lets its owner clear a Watertower off their own grid. */ export function watertowersRemovable(area: OfficeArea): GridCoord[] { if (!hasDistrictEnhancement(area, 'waterColumn')) return []; const out: GridCoord[] = []; for (const [key, card] of area.grid) { if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue; const [row, col] = key.split(',').map(Number); out.push({ row: row!, col: col! }); } return out; } /** * Enhancements have per-card placement rules (implications.md §7): * - Interlocking, Water Column, Telegraph → a Running Track straight * - Yard Office, Small Yard → a Secondary Track straight * - Telephone / Radio → stacked on the card below them * - Facing Point Locks → needs an Interlocking in the district * - ABS Signals → a Mainline card, not the Office Area */ export function checkEnhancementPlacement( s: GameState, area: OfficeArea, key: string, placement: GridCoord, ): RejectionCode | null { const rule = enhancementRule(key); if (!rule) return 'NOT_IMPLEMENTED'; // A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers // it against `division.nodes` directly. This function is only ever asked about the Office Area. if (rule.placement === 'mainlineCard') return 'WRONG_INTENT'; const card = area.grid.get(coordKey(placement)); if (!card) return 'NOT_CONNECTED'; if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN'; if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) { return 'NOT_CONNECTED'; } if (rule.requiresInDistrict) { const present = [...area.grid.values()].some((c) => c.enhancements.includes(rule.requiresInDistrict!), ); if (!present) return 'NOT_CONNECTED'; } const onRunning = placement.row === area.runningRow; /** * A STRAIGHT-PLACED ENHANCEMENT REPLACES THE STRAIGHT, so it cannot be stacked. * * The printed placement is "any Running Track Straight": the card goes down IN PLACE OF the * straight, and what stands there afterwards is an Interlocking, not a straight carrying one. A * second such card has no straight left to replace. This was unchecked — an enhancement only adds * a string to `enhancements[]` and leaves the geometry alone, so a single straight could take * Interlocking and Telegraph and a Water Column all at once. * * The `onCard` chain is untouched and is NOT an exception to this: Telephone prints "on Telegraph" * and Radio "on Telephone", so those target a named card rather than a straight, which is exactly * why they still stack. `requiresOnSameCard` above is what enforces it. */ const isBareStraight = card.geometry.kind === 'track' && card.geometry.geometry === 'straight' && card.enhancements.length === 0; switch (rule.placement) { case 'runningTrackStraight': return onRunning && isBareStraight ? null : 'NOT_CONNECTED'; case 'secondaryTrackStraight': return !onRunning && isBareStraight ? null : 'NOT_CONNECTED'; case 'onCard': return null; default: return 'NOT_CONNECTED'; } } /** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */ function emptyCard(): TrackCard { return { geometry: { kind: 'limits' }, baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [], }; } /** Removes a played card from its owner's hand and sends it to the Salvage Yard. */ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void { s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId)); s.decks.salvageYard.push(cardId); } /** * §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards from * the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office deck and * Department slots." * * `taking` is how many cards the events already queued will pop off the deck, so this can be asked * BEFORE they are applied: a draw takes one, and refilling an emptied Department takes another. * * Returns null when the deck is not about to run out, or when there is nothing to sweep — a game * that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile. * Cards played onto the board are NOT recovered: they are on the table, which is where they belong. */ function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null { if (s.decks.homeOffice.length > taking) return null; const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()]; if (collected.length === 0) return null; const rng = createRng(s.rngState); return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() }; } /** * Q4 — would building `kind` here conflict with something already in the district? * * TWO RULES, both from the sheet's "Lockouts" column. * * 1. **No two of the same industry in one Office Area.** The sheet states it in the Freight House * row, which lists Freight House among its own lockouts; it is a general rule, so it is applied * to every kind here rather than repeated in all six catalogue entries. * 2. **Not a producer and the consumer of the same commodity.** Mine Tipple makes coal and the * Power Plant burns it; the Refinery makes oil and the Power Plant burns that too; Packing Sheds * fill reefers and the Grocer's Warehouse empties them. Build one end of a chain or the other, * never both — which is what pushes freight to run BETWEEN districts instead of circling inside * one. * * The relation is symmetric, so checking either direction is enough. */ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean { const wanted = industryProfile(kind); for (const card of area.grid.values()) { if (card.geometry.kind !== 'facility') continue; const present = card.geometry.facility; if (present === kind) return true; if (wanted.lockouts.includes(present)) return true; if (industryProfile(present).lockouts.includes(kind)) return true; } return false; } /** * Is a Modifier of this kind already standing in the district? * * Reported from playtesting: two Ice Houses could be built in one Office Area. Industries have been * barred from doubling up since Q4 (`isLockedOut` above), but a Modifier is a different card kind * and had no such check — every square adjacent to an eligible host was legal, however many copies * you held. One of a kind per Office Area, the same rule the industries follow. * * Enhancements are deliberately NOT covered. An Interlocking is a plant at one junction, so a second * on another straight is a different installation, and the Telegraph → Telephone → Radio chain is * already gated per card by `checkEnhancementPlacement`. * * Consequence worth expecting: modifiers printed in more than one copy (Truck Dock 2, Ice House 2, * Waiting Area 3) go partly dead in solitaire, where there is only one Office Area. That is correct * — the spare copies exist for other players' districts. */ function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean { for (const card of area.grid.values()) { if (card.geometry.kind === 'modifier' && card.geometry.modifier === kind) return true; } return false; } /** * How much of a Modifier's printed capacity its host can actually use. * * AN INDUSTRY'S PRINTED FLOW IS ABSOLUTE. A Grocer's Warehouse is `flow: 'inbound'`, so it has no * green boxes and `freightAgent.stockOutbound` refuses it — yet an Ice House beside it prints "+1 * outbound" and the capacity was being raised anyway, on a direction that can never be drawn or * stocked. Reported from playtesting as "the Ice House added the laborer but not the outbound slot": * the laborer landed because Laborers have no direction, and the slot did not because there was * nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped. * * It is not one card's quirk. The same trap catches **Truck Dock** and **Forklifts**, which also * print `addOut` and also list `grocersWarehouse` among their hosts, and **Waiting Area**, * **Restaurant** and **Hotel**, whose `hosts: ['office']` includes a Whistle Post — not a Passenger * Facility, so `allows.outbound` is false there too. * * The industry track grows by what was actually applied, not by what was printed: a slot that does * not exist must not lengthen the siding that would have served it. */ function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } { return { out: f.allows.outbound ? m.addOut : 0, in: f.allows.inbound ? m.addIn : 0, }; } /** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void { const target = adjacentFacilityCoord(area, coord, modifier); if (!target) return; const host = area.grid.get(coordKey(target)); const f = host?.facility; if (!host || !f) return; const m = modifierProfile(modifier); /** * Record WHICH facility this Modifier served. * * `TrackCard.modifiers` was initialised everywhere and appended nowhere, so the panel row listing * "Modifier cards standing beside this industry" was permanently empty — and, more to the point, * nothing downstream could tell which card had granted what. It is needed now to explain a grant * that the host's flow discards (see `usableGrant`). */ host.modifiers.push(modifier); const use = usableGrant(f, m); f.capacity.outbound += use.out; f.capacity.inbound += use.in; f.laborers += m.addLoaders; f.porters += m.addPorters; /** * The siding grows only where there IS one. A Passenger Facility has no industry track — passengers * board off the platform — so a Waiting Area, whose "+1" is a passenger slot rather than a car, * must not give the Office somewhere to spot a car. It did, and the board then drew the Depot a * siding square: the same phantom siding the renderer was just fixed for, arriving by another door. */ if (f.kind === 'freight' && (use.out > 0 || use.in > 0)) { f.industryTrack.length += use.out + use.in; } } /** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */ /** * §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it". * * The sign is a physical card, not just a recorded column. Moving only the coordinate left the * Limits card stranded mid-track: a district grew to * (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House … * with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic — * §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds * an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault. */ function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void { if (placed.row !== area.runningRow) return; if (placed.col <= area.limitsWest.col) { area.limitsWest = { row: placed.row, col: placed.col - 1 }; area.grid.set(coordKey(area.limitsWest), limitsCard()); } if (placed.col >= area.limitsEast.col) { area.limitsEast = { row: placed.row, col: placed.col + 1 }; area.grid.set(coordKey(area.limitsEast), limitsCard()); } } /** * §8.2 — may this car be coupled onto this train right now? * * "The train must be in the order listed on the train's card (engine on the front, Rolling Stock, * and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of * order." Fewer is allowed; more, or of the wrong category, is not. * * SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call * this. A second copy stalled the game outright: the phase believed a car could be added while * `check` rejected every option, so the Stage never completed. */ export function acceptsCar(tray: CrewTray, carType: CarType): boolean { const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); if (!profile) return true; const cat = (t: CarType): 'coach' | 'caboose' | 'freight' => t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight'; const adding = cat(carType); const already = tray.consist.filter((c) => cat(c.type) === adding).length; const allowed = adding === 'coach' ? profile.consist.coach : adding === 'caboose' ? profile.consist.caboose : profile.consist.freight; if (already >= allowed) return false; // The caboose rides last (§A.3), so nothing may be coupled behind one. if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false; // The card also narrows WHICH freight types it will take. const types = profile.consist.freightTypes; if (adding === 'freight' && types && !types.includes(carType)) return false; return true; } /** * §7 — IS THIS TRAY THE ONE BEING MADE UP? * * A train is made up where it is built, standing at a Division Point, and only until its consist * matches its card. Everything else with a Crew Tray — a train working your district, a train * halfway across the Division — is running, not being assembled. * * SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be: * `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard * would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on * a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games, * including Train 9 mid-crossing with three cars already aboard. */ export function isBeingMadeUp(tray: CrewTray): boolean { if (tray.trainNumber === null) return false; if (tray.position.at !== 'divisionPoint') return false; const profile = trainProfile(tray.trainNumber, tray.trainIsExtra); if (!profile) return false; return tray.consist.length < consistSize(profile.consist); } /** * The tray the New Train Phase is waiting on, or null. * * `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop * to ask for a car that cannot be supplied. */ export function trainNeedingCars(s: GameState): TrayId | null { for (const [id, tray] of s.trays) { if (!isBeingMadeUp(tray)) continue; // Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in // the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check` // uses: a separate copy of this test stalled the game, because the phase believed a car could // be added while `check` rejected every option, so the Stage never ended. if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id; } return null; } /** * §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE. * * Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight * back to the Division Yard. The Classification Yard empties only when the Division Yard is bare — * every car of every kind gone — and then all of it returns at once. * * Confirmed from the source after an earlier guess. The first implementation returned cars at the * DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up * continuously, where this lets it run down to nothing and refill in one go. That difference is the * whole of the supply pressure the game is meant to have. * * Called wherever a car leaves the Division Yard, so the refill happens the moment it empties * rather than at the next convenient tick. */ export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null { if (s.yards.divisionYard.length > 0) return null; if (s.yards.classificationYard.length === 0) return null; const count = s.yards.classificationYard.length; s.yards.divisionYard.push(...s.yards.classificationYard); s.yards.classificationYard = []; return { type: 'yardRefilled', count }; } /** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */ function limitsCard(): TrackCard { return { geometry: { kind: 'limits' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }; } // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult { const code = check(s, player, i); if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` }; const events = execute(s, player, i); for (const e of events) reduce(s, e); return { ok: true, events }; } export { isOperationalRail, destinationsFor };