/** * 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, enhancementRule, industryProfile, mainlineModifierRule, mainlineProfile, modifierProfile, nextOfficeTier, officeProfile, } from './content.ts'; import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts'; import type { GameEvent } from './events.ts'; import type { Intent, RejectionCode } from './intents.ts'; import type { CardId, Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard, TrayId, } from './state.ts'; import { createRng } from './rng.ts'; import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; import type { Occupancy, Port } from './track.ts'; import { canDropCarsAt, canPlaceAt, facilityVariants, 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); 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. */ export function facilityCarType(f: Facility): CarType | null { if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null; return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[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 { 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. */ export function canStartLoad(f: Facility): boolean { 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; return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded); } /** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */ 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; return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded); } function trainAtOfficeWith( s: GameState, player: PlayerIndex, pred: (c: RollingStock) => boolean, ): boolean { const area = areaOf(s, player); return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false); } // --------------------------------------------------------------------------- // 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 from = trayCoord(s, i.trayId); if (!from) return 'ILLEGAL_MOVE'; const dests = destinationsFor(s, player, i.trayId, from, i.reverse); return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col) ? null : 'ILLEGAL_MOVE'; } 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'; if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY'; const here = trayCoord(s, i.trayId); if (!here) return 'CANNOT_DROP_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 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] ? 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); } 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 'track.lay': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN'; if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN'; const key = `${i.geometry}:${i.hand}`; if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD'; const proto = protoTrackCard(i.geometry, i.hand, i.variant); if (!proto) return 'NO_PLACEMENT'; return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED'; } 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 rolls into an ADJACENT industry — the engine never enters, which is the whole point // of the move and why it beats a normal spot. const adjacent = Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1; if (!adjacent) 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 commodity. if (facilityCarType(f) !== 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.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'; 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'; } return null; } 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'; // §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 : 'NO_TRAIN_AT_OFFICE'; 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 : 'NO_TRAIN_AT_OFFICE'; 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'; // The load is placed on WORK, the last box, so that box must be free. 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, ): RejectionCode | null { const card = s.cards.get(cardId); if (!card) return 'NO_SUCH_CARD'; const area = areaOf(s, player); 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'; return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED'; } case 'freightFacility': { if (!placement) return 'NO_PLACEMENT'; // Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and // the consumer of the same commodity, so this forces traffic to flow between districts. 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'; // §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby // spots) or it does nothing at all, so placing it anywhere else is not a legal play. return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED'; } case 'enhancement': { 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'; default: // Train cards go to the timetable; the phase driver schedules them. 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); const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing; return reachableDestinations( { area: areaOf(s, player), occupancy: occupancyFor(s, player, trayId), consistSize: tray.consist.length, self: trayId, }, from, exit, ); } // --------------------------------------------------------------------------- // 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)!; const events: GameEvent[] = [ { type: 'trayMoved', trayId: i.trayId, from, to: i.to, movesRemaining: s.turn.movesRemaining - 1, }, ]; if (dest.couples.length > 0) { events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples }); } 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. const stock = tray.consist.slice(tray.consist.length - i.count); return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }]; } 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': return [{ type: 'phaseEnded', player, phase: 'localOps' }]; case 'draw.fromHomeOffice': return [ { type: 'cardDrawn', player, source: 'homeOffice', cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!, }, ]; case 'draw.fromDepartment': { const events: GameEvent[] = [ { type: 'cardDrawn', player, source: 'department', slot: i.slot, cardId: s.decks.departments[i.slot]!, }, ]; // §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in // the empty spot." Without this the three face-up slots empty out permanently and the market // silently disappears from the game. const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1]; if (refill) { events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill }); } 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) { events.push({ 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 'track.lay': { const key = `${i.geometry}:${i.hand}`; return [ { type: 'trackLaid', player, geometry: i.geometry, hand: i.hand, at: i.placement, variant: i.variant ?? 0, remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1, }, ]; } 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: f.menAtWork[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 = f.menAtWork[i.box]!; const next = load.dir === 'out' ? i.box + 1 : i.box - 1; if (next >= f.menAtWork.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 }; 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); tray.consist.push(...e.stock); // Every card along the path is cleared; the event carries what was taken. for (const card of area.grid.values()) { if (card.standing.length > 0) card.standing = []; if (card.facility) card.facility.industryTrack.cars = []; } break; } case 'consistSorted': { const tray = s.trays.get(e.trayId)!; tray.consist = e.after.map((c) => ({ ...c })); // "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); tray.consist.splice(tray.consist.length - e.stock.length, e.stock.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); break; } case 'departmentRefilled': s.decks.homeOffice.pop(); s.decks.departments[e.slot] = e.cardId; 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] = null; 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 'trackLaid': { const area = areaOf(s, e.player); area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining); s.turn.laidThisTurn = true; const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant); if (built) { area.grid.set(coordKey(e.at), built); extendLimitsIfNeeded(area, e.at); } break; } case 'officeUpgraded': { // Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track. const area = areaOf(s, e.player); area.tier = e.to; const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard?.facility) { const p = officeProfile(e.to); officeCard.facility.porters = p.porters; officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn }; officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility }; } break; } case 'cardDiscarded': { const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId); s.decks.hands.set(e.player, hand); s.decks.departments[e.toSlot] = 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); 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 = f.menAtWork.findIndex((l) => l !== null); if (idx >= 0) f.menAtWork[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': { const rule = enhancementRule(e.key); if (rule?.placement === 'mainlineCard') { const node = s.division.nodes[e.at.col]; // ABS Signals: trains on this card stop short rather than rear-ending each other. if (node?.kind === 'mainline') node.absSignals = true; } else { 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); 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) { f.inboundBox.push(tray.consist[ci]!); tray.consist[ci] = { type: 'coach', loaded: false }; 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); f.menAtWork[0] = { type: e.carType, dir: 'out' }; f.usedThisStage.laborers += 1; break; } case 'loadAdvanced': { const f = facilityAt(s, e.player, e.at)!; f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!; f.menAtWork[e.fromBox] = null; f.usedThisStage.laborers += 1; break; } case 'unloadCompleted': { const f = facilityAt(s, e.player, e.at)!; f.menAtWork[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)!; f.menAtWork[f.menAtWork.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) f.industryTrack.cars[ci] = { type: e.carType, loaded: false }; f.menAtWork[f.menAtWork.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 (Gap 11). 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 }, variant: number | undefined, ): TrackCard | null { const base = { standing: [], facility: null, modifiers: [], enhancements: [] }; if (kind.kind === 'track') { const geometry = kind.geometry as TrackGeometry; const options = variantsFor(geometry); const v = options[variant ?? 0]; if (!v) return null; return { geometry: { kind: 'track', geometry, ...(v.axis ? { axis: v.axis } : {}), ...(v.turnout ? { turnout: v.turnout } : {}), ...(v.bypass ? { bypass: v.bypass } : {}), }, baseOperationalRail: geometry !== 'turnout', ...base, }; } const options = facilityVariants(); const v = options[variant ?? 0]; if (!v) return null; return { geometry: { kind: 'facility', facility: kind.facility as never, ...(v.axis ? { axis: v.axis } : {}), }, 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. */ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null { // Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six. if (coord.row > area.runningRow) return 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 }; if (c.row > area.runningRow) continue; if (area.grid.get(coordKey(c))?.facility) 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'; // ABS Signals goes on a Mainline card; `placement.col` names which one. if (rule.placement === 'mainlineCard') { const node = s.division.nodes[placement.col]; return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED'; } 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; const isStraight = card.geometry.kind === 'track' && card.geometry.geometry === 'straight'; switch (rule.placement) { case 'runningTrackStraight': return onRunning && isStraight ? null : 'NOT_CONNECTED'; case 'secondaryTrackStraight': return !onRunning && isStraight ? null : 'NOT_CONNECTED'; case 'onCard': return null; default: return 'NOT_CONNECTED'; } } /** 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); } /** A track piece from the player's own supply, at the chosen rotation. */ function protoTrackCard( geometry: TrackGeometry, hand: Hand, variant: number | undefined, ): TrackCard | null { const options = variantsFor(geometry); const v = options[variant ?? 0]; if (!v) return null; return { geometry: { kind: 'track', geometry, ...(v.axis ? { axis: v.axis } : {}), ...(v.turnout ? { turnout: v.turnout } : {}), ...(hand !== 'none' ? { hand } : {}), }, baseOperationalRail: geometry !== 'turnout', standing: [], facility: null, modifiers: [], enhancements: [], }; } /** * Q4 — would building `kind` here conflict with something already in the district? * 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 (wanted.lockouts.includes(present)) return true; if (industryProfile(present).lockouts.includes(kind)) return true; } return false; } /** 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); if (!target) return; const f = area.grid.get(coordKey(target))?.facility; if (!f) return; const m = modifierProfile(modifier); f.capacity.outbound += m.addOut; f.capacity.inbound += m.addIn; f.laborers += m.addLoaders; f.porters += m.addPorters; if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn; } /** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */ 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 }; if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 }; } // --------------------------------------------------------------------------- // 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 };