/** * 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 every INTENT-driven change reducible by construction. Replay and restart recovery run * on the intents themselves (`protocol.md` §3), not on folding the log. 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, houseRules, runDirection, startingDivisionPoint, modifierProfile, nextOfficeTier, officeProfile, trainProfile, } from './content.ts'; import type { CarType, Direction, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts'; import type { GameEvent } from './events.ts'; import type { ExtraStart, Intent, RejectionCode } from './intents.ts'; import type { CardId, CrewTray, Facility, GameState, GridCoord, Load, OfficeArea, PlayerIndex, RollingStock, SeatIndex, TrackArc, TrackCard, TrayId, TurnoutOrientation, } from './state.ts'; import { tallyEvent } from './tally.ts'; import { createRng } from './rng.ts'; import { carsOn, coordKey, cutTowards, decisionActor, officeNodeFor, isOperationalRail, playerAtSeat, pooled, railFacingOf, seatOf, spaceOn, standingSides, trackOrder, turnOf, } from './state.ts'; import type { MoveBlock, MoveDestination, Occupancy, Port } from './track.ts'; import { canDropCarsAt, canPlaceAt, carriesThroughTrack, exitsFrom, exploreMoves, facilityVariants, opposite, reachableDestinations, rowEndAt, variantsFor, withinLimits, } from './track.ts'; export type ApplyResult = | { ok: true; events: GameEvent[] } | { ok: false; code: RejectionCode; message: string }; // --------------------------------------------------------------------------- // Lookup helpers // --------------------------------------------------------------------------- /** * The Office Area belonging to a PLAYER — that is, the one at the seat they currently occupy. * * Goes through `seatOf` rather than indexing directly, which is the whole point of the seat/player * split: today seating is the identity mapping so this is exactly what it always was, and under * Employee Rotation it follows the player to their new chair without a single caller changing. */ export function areaOf(s: GameState, player: PlayerIndex): OfficeArea { return areaAtSeat(s, seatOf(s, player)); } /** The Office Area at a POSITION on the Division, regardless of who is sitting there. */ export function areaAtSeat(s: GameState, seat: SeatIndex): OfficeArea { const a = s.officeAreas.get(seat); if (!a) throw new Error(`no Office Area at seat ${seat}`); 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). */ /** * Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy * question a switching move asks — a second copy would be free to drift into a different answer * about which cards are free. */ export 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; }, }; } /** * The port a train that came in through `entry` would carry on out by — read off the CARD. * * On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve * it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges. * * A card reached by a Move has exactly one exit from the port it was entered by — the only card with * three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller * holding a card the walk never validated, and matches the old behaviour rather than throwing. */ function farPort(card: TrackCard | undefined, entry: Port): Port { return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry); } /** * THE PORT A TRAIN BACKS OUT BY — the other end of the card it is standing on. * * Not `opposite(facing)`. A train stands on a two-port card (never a turnout: §A.1 forbids * finishing a Move on one), and backing up means leaving by whichever of those two ports it is not * facing. On a straight those coincide; on a curve they never do, because a curve joins ADJACENT * edges — a crew facing north on a north-west curve backs out through WEST, and `opposite('n')` is * a south port the card does not have. * * The consequence of getting this wrong is total: `exploreMoves` returns nothing at all from a port * the card lacks, so the crew simply cannot back up. It could round a curve and never come off it, * which is enough to make a siding unreachable and setting out a cut impossible. */ function reversePort(s: GameState, player: PlayerIndex, from: GridCoord, facing: Port): Port { return farPort(areaOf(s, player).grid.get(coordKey(from)), facing); } /** 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.seat === seatOf(s, 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'; /** * NOTHING IS ASKED ABOUT THE NEIGHBOURS, deliberately (Gitea#15). * * A turnout adds a 45° leg the card underneath did not have, and that leg may well point into an * occupied square with nothing to meet it. That is legal: RAR ruled (2026-08-26) that a rail may * stop dead against its neighbour, and an upgrade is no different from laying the piece there in * the first place. What must hold either way is that no train can cross the gap, which is * `exploreMoves`' business and is tested in `track.test.ts`. */ 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; } /** * The green-box load that may start down the sign, or null if none may. * * §9.3 — a load has to have somewhere to go before the Laborers touch it. It may not start the * MEN | AT | WORK moves until an empty car of the right type is standing on the industry's track, * "otherwise you are just dropping cargo onto the tracks — pointless waste". The final swap needs a * car of the LOAD's type, so a hopper load cannot be swapped onto a tank: strict matching, not "any * empty car". THIS IS THE ONLY PLACE THAT RULE APPLIES — stocking the green box is free of it * (§6.3), so cargo may wait on the dock for a car that has not been switched in yet. * * Not simply `outboundBox[0]`. A Power Plant takes hoppers AND tanks, so a green box holding a * hopper load and a tank load against one spotted empty tank must start the TANK — taking the first * entry regardless would report the whole facility as blocked while a perfectly legal load sat * beside it. * * Cars are COUNTED against claims rather than merely looked for, because green boxes and industry * tracks both grow past one slot with Modifiers (measured: capacity > 1 on 24.5% of freight * facilities and a track longer than one on 41%). Two loads walking the sign toward one spotted car * would strand the second on WORK, which is the jam this rule exists to prevent. */ export function startableLoad(f: Facility): number | null { const claims = new Map(); for (let i = 0; i < f.outboundBox.length; i++) { const type = f.outboundBox[i]!.type; // Each earlier entry of the same type has first claim on the spotted cars. const ahead = claims.get(type) ?? 0; const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length; const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length; if (spotted - working - ahead > 0) return i; claims.set(type, ahead + 1); } return null; } export function canStartLoad(f: Facility): boolean { if (!f.menAtWork) return false; if (laborersLeft(f) < 1) return false; if (f.outboundBox.length === 0) return false; if (f.menAtWork[0] !== null) return false; return startableLoad(f) !== 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, trayId?: TrayId): 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 passengerWork(s, player, 'board', trayId) !== null; } /** * §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, trayId?: TrayId): 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 passengerWork(s, player, 'detrain', trayId) !== null; } /** * A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means. */ export function isTrainCard(s: GameState, cardId: CardId): boolean { const kind = s.cards.get(cardId)?.kind.kind; return kind === 'timetabledTrain' || kind === 'extraTrain'; } /** * WHY THIS CARD CANNOT BE THROWN AWAY, or `null` if it can (§6.2, Gitea#9 superseding Gitea#6). * * The one place that answers the question, so `check`, the hand panel and the blocked "End Local * Operations" button all give the same reason rather than three hand-written approximations of it. * Gitea#6 made every train card unconditionally undiscardable; Gitea#9 narrows that: * * - a TIMETABLED train is discardable unless the `discardTimetabled` house rule is off. Jesse's * reasoning is about a long game whose timetable has filled up — "the stations are jammed and * the railroad doesn't need any more. You can toss it"; * - an EXTRA is never discardable. It never joins the timetable, so it cannot jam it, and the * rule it would otherwise dodge is the hand limit. * * Returns the sentence rather than a code because it is written for a player, and the two cases * fail for genuinely different reasons — "not in this game" and "not ever". */ export function keepReason(s: GameState, cardId: CardId): string | null { const kind = s.cards.get(cardId)?.kind.kind; if (kind === 'extraTrain') { return 'An Extra is never discarded. It runs once and ends in the Salvage Yard, so it can only ' + 'be played — hold it for as many Stages and Days as you like.'; } if (kind === 'timetabledTrain' && !houseRules(s.config).discardTimetabled) { return 'A train card is never discarded in this game. The only way it leaves your hand is onto ' + 'the timetable — hold it for as many Stages and Days as you like.'; } return null; } /** * WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer * all use, so a placement can never be checked against one square and made on another. * * §7 lets the player who played the card choose the start, and Jesse's ruling makes the start * decide the direction rather than the number (`runDirection`'s comment carries the supersession): * * - a DIVISION POINT runs the train away from itself — the west end runs east, the east end west. * `direction` on the intent is ignored rather than refused, because there is only one answer; * - an INTERCHANGE or a CONTROL POINT sits in the middle of the railroad, where both ways are real * runs, so the intent must say which. * * Which of those are on offer is the `extraStart` house rule. The two that belong to nobody — the * Division Points and the Interchange — are always available; an Office is a seat's own ground and * is gated, to `ownOffice` (the player who played the card) or `anyOffice`. * * Returns a refusal code rather than throwing, so `check` can hand it straight back. */ export function resolveExtraStart( s: GameState, player: PlayerIndex, i: { trainNumber: number; atSeat?: SeatIndex | null; start?: ExtraStart; direction?: Direction }, ): { at: ExtraStart; direction: Direction } | RejectionCode { /** * A save written before the choice existed. `atSeat` null meant the Division Point the NUMBER * sent the train to, a seat meant that Office, and both ran in the number's direction — so that * is what these replay as, whatever the rules say today. */ if (!i.start) { const direction = runDirection(i.trainNumber); if (i.atSeat === null || i.atSeat === undefined) { return { at: { kind: 'divisionPoint', side: startingDivisionPoint(i.trainNumber) }, direction }; } return { at: { kind: 'office', seat: i.atSeat }, direction }; } const start = i.start; if (start.kind === 'divisionPoint') { if (!s.division.nodes.some((n) => n.kind === 'divisionPoint' && n.side === start.side)) { return 'NO_SUCH_DIVISION_POINT'; } // Away from the end it is standing at. Nothing else is a run. return { at: start, direction: start.side === 'west' ? 'east' : 'west' }; } if (i.direction === undefined) return 'NO_DIRECTION_CHOSEN'; if (start.kind === 'mainline') { const node = s.division.nodes[start.node]; if (!node || node.kind !== 'mainline') return 'NO_SUCH_CARD'; // The Interchange is the one Mainline card an Extra may be made up on — it is the one with a // yard. `sortsCars` is what the card prints and the only thing that distinguishes it. if (!mainlineProfile(node.card).sortsCars) return 'NOT_AN_INTERCHANGE'; return { at: start, direction: i.direction }; } const rule = houseRules(s.config).extraStart; if (rule === 'divisionPointsOnly') return 'OFFICE_STARTS_NOT_ALLOWED'; if (rule === 'ownOffice' && start.seat !== seatOf(s, player)) return 'NOT_YOUR_OFFICE'; const area = s.officeAreas.get(start.seat); if (!area) return 'NO_SUCH_FACILITY'; // A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is // the whole reason upgrading buys a place for an Extra to start — and no setting of the house // rule lets one in. if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT'; return { at: start, direction: i.direction }; } /** * WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use. * * TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a * station, the select button does not work — regardless of which you pick, it is always one train, * not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the * Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it * found. The two were not even asking the same question: `check` skipped a train whose card refuses * passenger work and the reducer did not, so a Military train could be boarded as long as some other * train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the * one place that resolves it. * * And "passengers just boarded cannot be immediately unloaded": a coach carries the district that * filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may * alight from — they have to be carried to a different Office Area first. * * `trayId` absent means "any eligible train", which is what every intent recorded before this * existed meant, so an old save replays unchanged. */ function passengerWork( s: GameState, player: PlayerIndex, dir: 'board' | 'detrain', trayId?: TrayId, ): { trayId: TrayId; coachIndex: number } | null { const area = areaOf(s, player); const seat = seatOf(s, player); const wanted = (c: RollingStock): boolean => c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat); for (const id of area.adOccupancy) { if (trayId !== undefined && id !== trayId) continue; const tray = s.trays.get(id); if (!tray) continue; // §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. if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue; const coachIndex = tray.consist.findIndex(wanted); if (coachIndex >= 0) return { trayId: id, coachIndex }; } return null; } // --------------------------------------------------------------------------- // §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. */ /** Which district a tray is standing in; 0 when it is out on the Division. */ function trayySeat(tray: CrewTray): SeatIndex { return tray.position.at === 'grid' ? tray.position.seat : 0; } 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)}`; } /** Exported so the Blocked panel counts a freight car the same way the reducers do (Gitea#21). */ export const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose'; /** * IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says. * * `ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }` because §2.2's * "a coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT, * and a caboose has no white version — there is no such thing as an empty one to make up a train * from. Every other reading of `.loaded` in this file is already scoped to a coach or to a named * car type, so the flag's second meaning only ever escaped here. * * Reported as Gitea#8: X22 Pee-Dee, whose whole card is "may only pick up MTs", could not couple a * caboose at all — including the one it was made up with. Drop it and it was stranded, which made * the train unplayable rather than merely restricted. */ const carriesLoad = (c: RollingStock): boolean => c.loaded && 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, player: PlayerIndex, tray: CrewTray, at: GridCoord, wanted: number, ): boolean { if (!rulesOf(tray).oneFreightPerLocation) return true; const already = turnOf(s, player).freightWorked[freightWorkedKey(tray.id, at)] ?? 0; return already + wanted <= 1; } /** * Whether a train may set out or sort cars (§7). * * Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and * Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a * train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So * this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching` * itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a * move would make, not on the move itself. */ function switchingRefusal(tray: CrewTray): RejectionCode | null { return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; } /** * WHETHER TRAINS 3/4's PRINTED RULE IS WHAT IS STOPPING THIS CREW WHERE IT STANDS (Gitea#21). * * Exported for the Blocked panel, which needs to say so — and asks `freightBudgetLeft`, the same * predicate the reducer refuses on, rather than rebuilding the key for itself. `narrate.ts` cannot * then drift from the rule it is describing, which is the whole premise of that panel. */ export function freightRuleSpentHere(s: GameState, player: PlayerIndex, trayId: TrayId): boolean { const tray = s.trays.get(trayId); if (!tray || !rulesOf(tray).oneFreightPerLocation) return false; if (tray.position.at !== 'grid') return false; return !freightBudgetLeft(s, player, tray, tray.position.coord, 1); } /** * 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, player: PlayerIndex, tray: CrewTray, at: GridCoord, stock: readonly RollingStock[], ): void { if (!rulesOf(tray).oneFreightPerLocation) return; const n = stock.filter(isFreight).length; if (n === 0) return; const turn = turnOf(s, player); const key = freightWorkedKey(tray.id, at); turn.freightWorked[key] = (turn.freightWorked[key] ?? 0) + n; } /** * Give back what a set-out spent, when the train picks its own cut straight back up. * * The mirror of `spendFreightBudget` and deliberately its own function: "undo the drop" is a rule, * not an arithmetic detail, and it applies on the square the cars were LEFT on rather than the one * the train ends up at. */ function refundFreightBudget( s: GameState, player: PlayerIndex, tray: CrewTray, at: GridCoord, stock: readonly RollingStock[], ): void { if (!rulesOf(tray).oneFreightPerLocation) return; const n = stock.filter(isFreight).length; if (n === 0) return; const turn = turnOf(s, player); const key = freightWorkedKey(tray.id, at); turn.freightWorked[key] = Math.max(0, (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; return areaOf(s, player).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. */ export function passengerRefusal( s: GameState, player: PlayerIndex, at: GridCoord, dir: 'board' | 'detrain', trayId?: TrayId, ): RejectionCode { const area = areaOf(s, player); const trains = area.adOccupancy .filter((id) => trayId === undefined || id === trayId) .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'; if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE'; /** * EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded * coach". Told apart because the two read as opposite situations to a player: one is an empty * train, the other is a train full of passengers who have not been anywhere yet. */ if ( dir === 'detrain' && trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) && trains.every((t) => t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)), ) ) { return 'LOADED_IN_THIS_DISTRICT'; } /** * A TRAIN IS STANDING THERE, so say what is actually missing. * * This used to fall through to `NO_TRAIN_AT_OFFICE` — told to a player looking straight at a train * on their own A/D track, which reads as a broken game rather than a rule. Measured over 60 * solitaire games it fired 51 times with a coach train in front of the player: 27 with nobody * waiting to travel, 24 with passengers waiting and every coach already full. */ const f = facilityAt(s, player, at); if (!f || f.kind !== 'passenger') return 'NO_SUCH_FACILITY'; if (dir === 'board') { if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return 'NO_PASSENGERS_WAITING'; return 'NO_EMPTY_COACH'; } if (f.inboundBox.length >= f.capacity.inbound) return 'INBOUND_BOX_FULL'; if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return 'NO_EMPTY_COACH_IN_YARD'; return 'NO_LOADED_COACH'; } // --------------------------------------------------------------------------- // check — never mutates // --------------------------------------------------------------------------- export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null { /** * §3.3, EXTENDED PLAY (Gitea#11) — asked ABOVE the status guard, because the whole point of the * vote is that it is the one thing the rules will take from a game that has stopped. * * Out of turn like `mainline.clearance` below, and unlike it open to every seat at once: it is a * table decision rather than a ruling, so there is no actor to be. */ if (i.type === 'game.extend') { if (s.status !== 'awaitingExtension') return 'NOT_AWAITING_EXTENSION'; // The intent NAMES its voter so that a save can be replayed (`intents.ts`), which makes it a // claim until it is checked against the seat the caller authenticated. One seat may not vote // for another. if (i.player !== player) return 'NOT_YOUR_TURN'; if (s.extensionVotes[player] !== null) return 'ALREADY_VOTED'; return 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?.kind !== 'clearance') return 'NO_PENDING_DECISION'; if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT'; return null; } /** * §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase. * * Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the * card is checked again here because `check` is the authority and a hand can change between the * prompt being raised and answered. */ if (i.type === 'mainline.redFlag') { if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT'; if (decisionActor(s) !== player) return 'NOT_YOUR_TURN'; if (!i.flag) return null; const held = (s.decks.hands.get(player) ?? []).find((id) => { const c = s.cards.get(id); return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; }); if (!held) return 'NO_SUCH_CARD'; return null; } /** * §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase. * * Goes to the district's owner rather than the Superintendent, which is the whole reason * `pendingDecision` became a union. `decisionActor` is the single place that mapping lives. */ if (i.type === 'mainline.yardOffice') { if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER'; if (decisionActor(s) !== player) return 'NOT_YOUR_TURN'; 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 (turnOf(s, player).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 (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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); const dest = selectDestination(dests, i.to, i.via); 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); /** * THE TRAIN'S OWN CUT IS NOT A PICK-UP. Taking back the cars you just set out on the square you * are standing on undoes the drop; it is not fresh work, and none of the three restrictions * that gate picking cars up should bite on it. * * Left in and the rule reads absurdly: X13 prints "may drop but not pick up", so a legal drop * off the nose would leave the train forbidden to pull forward past its own cars for the rest * of the turn — a one-way move nothing warned about. Only the cars that were ALREADY there when * the crew arrived are a pick-up, so the own cut is subtracted before the tests run. */ const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length); if (fresh.length > 0) { /** * "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train * held at the Office may still need to clear onto Secondary Track ahead of other traffic — * §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of * those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent: * the restriction has to bite on the pick-up itself, the same reasoning as the comment above. */ if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED'; if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED'; if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY'; const freight = fresh.filter(isFreight).length; if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE'; } return null; } case 'switch.dropCars': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).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); const dropArea = areaOf(s, player); const atOffice = here.row === dropArea.officeCoord.row && here.col === dropArea.officeCoord.col; /** * Trains 7/8 Local — "coach must remain on station track if switching" — the coach may never * be set out anywhere ELSE while switching. §A.4 now carries an exception for the Office * square (v0.5.0, Jesse's call): any train may cut a coach loose there, which is exactly what * "station track" meant on the card all along. So the coach is refused everywhere except the * Office, rather than everywhere. */ if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach') && !atOffice) { return 'COACH_MUST_STAY'; } const droppedFreight = cut.filter(isFreight).length; if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) { return 'FREIGHT_WORKED_HERE'; } const coachesOnly = cut.every((c) => c.type === 'coach'); return canDropCarsAt(dropArea, here, i.count, coachesOnly) ? null : 'CANNOT_DROP_HERE'; } case 'switch.sortConsist': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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 turnOf(s, player).option === 'switch' ? null : 'OPTION_NOT_CHOSEN'; // -- Draw a card ---------------------------------------------------------- case 'draw.fromHomeOffice': if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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 (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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 (turnOf(s, player).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); } /** * §6.2 — WHICH TRAIN CARDS MAY BE THROWN AWAY (Gitea#9, superseding Gitea#6). * * `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the * `discardTimetabled` house rule is off, and an Extra never is. * * WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up, * they are more than able to" — a discard goes face-up on a Department pile, which is exactly * where a rival can draw it from, so the second half needed no machinery at all. * * The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a * player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end` * refuses while the hand is over the limit, and playing a train card is unconditionally legal * (`card.play`'s `timetabledTrain` case refuses only a board placement), so it can never lock. * * `legal.ts` enumerates candidates and filters them through here, so an undiscardable card * simply stops being offered; the bot needs no separate rule. */ 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'; if (keepReason(s, i.cardId) !== null) return 'TRAINS_ARE_NEVER_DISCARDED'; return null; } case 'mainline.modify': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).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 && node.card !== 'heavyGrade') 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. A train standing in the Interchange's yard counts: it is on // the card, and it is about to pull out onto the very rail being relaid. if (node.transits.length > 0 || (node.holding?.length ?? 0) > 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'; // §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no // placement to find: the district is yours, and the only question is which side. if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED'; return null; } case 'maneuver.flyingSwitch': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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 (turnOf(s, player).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 (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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'; } /** * STOCKING DOES NOT NEED A CAR SPOTTED — THE LABORERS DO. * * §6.3 asks for nothing but a car in the Division Yard and room in the box: the Freight Agent * "select[s] one Rolling Stock from the Division Yard pile and place[s] it onto a Facility's * green Outbound box". The empty-car requirement belongs one step later, to §9.3's "Load the * car" — "*a load in the Green Loading Box AND an empty car of the required type on the * industry's track*" — which is the action that walks the load down MEN | AT | WORK. * * The engine used to hoist that requirement forward onto stocking, and it made the ordinary * play illegal: an agent may perfectly well have the cargo waiting on the dock while the car * to ship it in is still being switched in. Nothing jams as a result — a load sitting in a * green box is waiting, not stuck, and only a load on MEN | AT | WORK locks the industry * track. `laborer.startLoad` holds the real gate (`startableLoad`), so a load can be staged * early but still cannot start down the sign until its car is standing there. */ return null; } case 'freightAgent.clearInbound': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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 turnOf(s, player).option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN'; case 'freightAgent.unjam': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN'; if (turnOf(s, player).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.startExtra': { if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE'; const pending = s.pendingExtras.find((x) => x.trainNumber === i.trainNumber); if (!pending) return 'NO_EXTRA_PENDING'; // §7 — "the player who played the card MAY place the Crew Tray": it is theirs to place, and // nobody else's to place for them. if (pending.player !== player) return 'NOT_YOUR_EXTRA'; if (s.freeTrays.length === 0) return 'NO_FREE_TRAY'; const where = resolveExtraStart(s, player, i); if (typeof where === 'string') return where; if (where.at.kind === 'divisionPoint') return null; if (where.at.kind === 'mainline') { // Nothing to refuse. The train is made up in the Interchange's yard, off the running line, // so however busy the card is this cannot be the collision §7 says it must not force. What // it may not do is get OUT — that is §8.1's question, asked at the Mainline Phase. return null; } const area = s.officeAreas.get(where.at.seat)!; // It still has to fit: an Extra starting here takes an A/D track like any other arrival. return area.adOccupancy.length >= officeProfile(area.tier).adTracks ? 'NO_FREE_AD_TRACK' : null; } 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, i.loaded, s.yards.divisionYard) ? 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 -------------------------------------------------------- /** * A WHISTLE POST HAS NO PORTERS AT ALL, which is not the same as having used them. * * The Office card carries a passenger facility at every tier so that an upgrade is a property * change rather than a card swap — but a Whistle Post's has `porters: 0`, so this fell into * `RESOURCE_SPENT`, "all Porters already used this Stage". A player who had used nothing was * told they had spent it all, when the answer was to upgrade the Office. 15 times in 60 games. */ case 'porter.board': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (f.porters < 1) return 'NO_PORTERS_HERE'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY'; return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId); } case 'porter.detrain': { if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE'; const f = facilityAt(s, player, i.at); if (!f) return 'NO_SUCH_FACILITY'; if (f.porters < 1) return 'NO_PORTERS_HERE'; if (portersLeft(f) < 1) return 'RESOURCE_SPENT'; if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY'; return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId); } 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'; if (!f.menAtWork) return 'NO_SUCH_FACILITY'; if (f.outboundBox.length === 0) return 'BOX_EMPTY'; if (f.menAtWork[0] !== null) return 'BOX_FULL'; // §9.3's "Load the car" requires "*an empty car of the required type on the industry's // track*", and THIS is where that is enforced — stocking the green box (§6.3) is deliberately // free of it. Cargo may be staged against a car that is still being switched in; it simply // cannot leave the box until the car is standing there. The check also has to be made at this // moment rather than earlier because a car can be coupled away after the cargo was staged: // running over an industry track couples whatever stands on it, mandatorily (§A.4). return startableLoad(f) !== null ? null : 'NO_EMPTY_CAR_SPOTTED'; } 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'; /** * A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e). * * Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately * unloaded." They could — a Freight House permits both directions, so the car its own Laborers * had just loaded was standing on its own track, loaded, with an empty of that type in the * yard, and every gate below said yes. Full Revenue at both ends for a load that never moved. * * The rule is district-wide and permanent, not "not at this facility" and not "not this * Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs * BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force. */ if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT'; /** * §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'; if (f.menAtWork[f.menAtWork.length - 1] !== null) return 'BOX_FULL'; /** * THE MIRROR OF THE LOAD RULE: a load coming IN needs somewhere to land too, and its * destination is the red Inbound box. This was checked only at the last step, so an unload * could be begun into a full box and walked W→A→M over three Stages before discovering it had * nowhere to go — jamming the industry, which is then locked and needs a Freight Agent turn to * clear. Measured: rare (2.2% of offers) but real, and seen jamming three times in 200 games. * * COUNTED, not just "is there a slot". Only the W box has to be free to begin, so once a load * moves W→A a second can start behind it — two loads walking toward one slot. Every red box in * play today holds exactly one car, which is precisely when that bites. */ const inFlight = f.menAtWork.filter((l) => l?.dir === 'in').length; return f.capacity.inbound - f.inboundBox.length > inFlight ? null : 'INBOUND_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'; } // Same reasoning: a siding reaching past your own sign JOINS perfectly well, and is refused // because it leaves your territory (§2.1). `canPlaceAt` enforces it too — this only names it. if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS'; 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'; // A Facility CARRIES TRACK — "placing a Facility places track" (§11.2) — so it is bounded by // the Limits exactly as a siding is. An industry outside them is how a district used to leave // its own territory. if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS'; // 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'; /** * NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both * halves. * * A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility * standing at the limit has three of its nine spots out there, and refusing them would make * the card unplayable exactly where the district ends. What it may NOT do is stand in the row * the Running Track grows along. Inside the Limits that row is always full, so this bites only * beyond the sign — which is the ground the main extends onto, and a Modifier parked there * would block your own sign from moving outward (§2.1) with nothing on screen to warn you. */ if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK'; // 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; } } /** * Among destinations at `to`, the one `via` names — or the first `legal.ts` enumerated there, if * `via` is absent or names no route on record. That fallback is what makes an old save (or any * caller that never learned about routing) replay exactly as before: the first-enumerated route is * the only one that ever existed until this feature did. */ export function selectDestination( dests: MoveDestination[], to: GridCoord, via: GridCoord | undefined, ): MoveDestination | undefined { const atTo = dests.filter((d) => d.coord.row === to.row && d.coord.col === to.col); if (via === undefined) return atTo[0]; const chosen = atTo.find((d) => d.path.some((step) => step.coord.row === via.row && step.coord.col === via.col)); return chosen ?? atTo[0]; } 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 ? reversePort(s, player, from, facing) : facing; return reachableDestinations( { area: areaOf(s, player), occupancy: occupancyFor(s, player, trayId), consistSize: tray.consist.length, self: trayId, }, from, exit, ); } /** * The cars this crew would take back off its OWN card by pulling out this way. * * Exported because three places need the same answer and none of them should re-derive the exit port * to get it: the move check exempts these cars from a train's pick-up restrictions, the move label * names them separately from cars found on the line, and the walk counts them against the four-car * limit. Empty for a train with nothing standing beside it, which is almost every move. */ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, reverse: boolean): RollingStock[] { const tray = s.trays.get(trayId); if (!tray || tray.position.at !== 'grid') return []; const here = tray.position.coord; const facing = facingPort(s, trayId); const exit: Port = reverse ? reversePort(s, player, here, facing) : facing; const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard(); return cutTowards(card, carsOn(card), rowEndAt(card, 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); // The card's other end, not the compass opposite — see `reversePort`. This is what the board // highlights, so getting it wrong hides half the crew's legal moves rather than merely refusing // one: the squares behind the train never light up at all. const back = exploreMoves(ctx, from, reversePort(s, player, from, 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) { /** * §3.3, EXTENDED PLAY (Gitea#11) — the vote, and what it settles. * * Decided HERE rather than in `reduce` because the answer depends on the votes as they stand * BEFORE this one lands, and `execute` is the half that still sees that. Three outcomes: * * - a refusal ends it immediately. Unanimity means one "no" is decisive, so nobody is made to * wait on a player who has already said no (Jesse's call, 2026-08-28); * - the last outstanding "yes" grants the Day — ONE Day, and the question is put again at the * end of it; * - anything else is just a vote recorded, and the table waits. */ case 'game.extend': { const vote: GameEvent = { type: 'extensionVoted', player, agree: i.agree }; if (!i.agree) return [vote, { type: 'playConcluded', declinedBy: player }]; const after = s.extensionVotes.map((v, p) => (p === player ? true : v)); return after.every((v) => v === true) ? [vote, { type: 'dayExtended', day: s.config.days + s.extraDays + 1 }] : [vote]; } case 'mainline.yardOffice': { const pending = s.clock.pendingDecision; const trainId = pending?.kind === 'yardOffice' ? pending.train : ''; return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }]; } 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 = selectDestination(dests, i.to, i.via)!; const tray = s.trays.get(i.trayId)!; // The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the // cut it recouples on the way out is the cut the walk counted. const facingNow = facingPort(s, i.trayId); const exitPort: Port = i.reverse ? reversePort(s, player, from, facingNow) : facingNow; // 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: turnOf(s, player).movesRemaining - 1, /** * A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT. * * `facing` is which way the ENGINE points, and this once set it to the direction of travel * on every move — so one reverse move silently spun the train about, and a run-around * became pointless: you could change ends for free by backing up twice. * * Backing up, the engine TRAILS, still pointing the way it came — out through the port the * train arrived by. That holds whatever the track does underneath, so it is `dest.entry` * and nothing else. * * Running forward, the engine LEADS, so it points out through the card's far end. That was * written `opposite(entry)`, which is the far end of a straight and of nothing else: a * curve is an arc between two ADJACENT edges, so entering a north-west curve through its * west port leaves the engine facing NORTH, not east. The wrong port was not merely * cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields * no destinations at all, so a crew that rounded a curve could only back out the way it * came. Reported as a consist drawn mirrored, which is the other half of the same bug: the * east-west sense the board draws is carried from `facing` (`railFacingOf`). * * `farPort` asks the CARD. A destination is never a turnout — a train may not finish a * Move on one (§A.1) — so there is exactly one way out of it. */ facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry), ...(i.via ? { via: i.via } : {}), }, ]; 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. * * `from` NOW BEGINS WITH THE SQUARE THE CREW LEFT. It could not before — the list was built * from `dest.path` plus the destination, neither of which can ever contain the start — so a * cut recoupled off your own card was added to the consist and left standing on the board at * the same time, one car becoming two. The cars that stay behind (a cut set out off the * OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the * reducer applying this event goes on to zero `standingWest` on the destination card, never * the origin, so nothing downstream needs this snapshot to have been taken any earlier. */ const grid = areaOf(s, player).grid; const startCard = grid.get(coordKey(from)) ?? emptyCard(); const startCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, exitPort)); const lifted = [ ...(startCut.length > 0 ? [from] : []), ...dest.path.map((step) => step.coord), i.to, ].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0); const sides = standingSides(startCard, carsOn(startCard)); // The OTHER end's cut, which stays behind — so it is the other end of the row, not the // other port. A 45° leg is an end of the row too (`rowEndAt`, Gitea#17). const stayed = rowEndAt(startCard, exitPort) === 'e' ? sides.west : sides.east; // §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, ...(startCut.length > 0 && stayed.length > 0 ? { leaves: { at: from, stock: stayed } } : {}), ...(startCut.length > 0 ? { recoupled: { at: from, stock: startCut } } : {}), }); } 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': return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }]; case 'mainline.redFlag': { const pending = s.clock.pendingDecision; const trainId = pending?.kind === 'redFlag' ? pending.train : ''; const seat = pending?.kind === 'redFlag' ? pending.seat : 0; const side = pending?.kind === 'redFlag' ? pending.from : 'east'; if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }]; const cardId = i.cardId ?? (s.decks.hands.get(player) ?? []).find((id) => { const c = s.cards.get(id); return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; })!; return [ { type: 'redFlagsSet', player, cardId, seat, side }, { type: 'redFlagRuled', player, trainId, flag: true }, ]; } 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.startExtra': { // `check` has already accepted this, so the resolve cannot fail here. const where = resolveExtraStart(s, player, i) as { at: ExtraStart; direction: Direction }; return [ { type: 'extraStarted', player, trainNumber: i.trainNumber, at: where.at, direction: where.direction }, ]; } 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, }, ]; /** * A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays * `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate * is named per COACH because a Porter handles exactly one coach per action. */ case 'porter.board': { // `check` has already established there is one; resolving it HERE, once, is what stops the // reducer from finding a different train than the one the rules were tested against. const work = passengerWork(s, player, 'board', i.trayId)!; return [ { type: 'passengersBoarded', player, at: i.at, ...work }, ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'), ]; } case 'porter.detrain': { const work = passengerWork(s, player, 'detrain', i.trayId)!; return [ { type: 'passengersDetrained', player, at: i.at, ...work }, ...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'), ]; } case 'laborer.startLoad': { const f = facilityAt(s, player, i.at)!; // The first load with a car to land on, which is not always the first in the box. return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[startableLoad(f)!]!.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; // Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end // pays `freightPerLoad` (`content.ts`). 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 }, ...earns(s, player, houseRules(s.config).revenue.freightPerLoad, '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 }, ...earns(s, player, houseRules(s.config).revenue.freightPerLoad, '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, carIndex: i.carIndex, }, ]; } 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; } /** * A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero. * * Zero is a real setting — it is how you switch one economy off to read the others — and a stream of * "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing * happened. The work still happens; it just does not pay. */ function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] { if (rate <= 0) return []; return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }]; } /** §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 on the INTENT path. // // `applyIntent` never touches state except through here, so everything a player does is reducible. // The phase driver (`advance.ts`) does not: it mutates and then describes, so folding the whole log // does NOT reconstruct a game. `protocol.md` §3 has the consequence — the intents are canonical. // --------------------------------------------------------------------------- export function reduce(s: GameState, e: GameEvent): void { switch (e.type) { // -- §3.3, extended play (Gitea#11) case 'extensionVoted': s.extensionVotes[e.player] = e.agree; break; /** * One more Day, and the votes are wiped: agreeing once does not agree to the rest of the game. * * `config.days` is deliberately untouched. It is what the OFFICIAL result was decided at * (`state.ts`'s `FinalReport`), so leaving it alone is what makes "the winner is decided at the * original game length" a fact about the code rather than a comment on it. `outcome` is left * alone too — it is the last evaluation, and it is what the results screen shows while the extra * Day is played. */ case 'dayExtended': s.extraDays += 1; s.extensionVotes = s.players.map(() => null); s.status = 'active'; break; case 'playConcluded': s.status = 'finished'; break; // §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for // the arriving train to consume, or the driver asks again for ever. case 'yardOfficeRuled': s.clock.pendingDecision = null; s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take }; break; case 'localOpsOptionChosen': turnOf(s, e.player).option = e.option; break; case 'trayMoved': { const tray = s.trays.get(e.trayId)!; // A tray moving stays in the district it was already in — the seat does not change. const seat = tray.position.at === 'grid' ? tray.position.seat : 0; tray.position = { at: 'grid', seat, coord: e.to }; if (e.facing) { tray.facing = e.facing; // The east-west sense only exists on east-west track, so it is CARRIED across north-south // track rather than recomputed there — see `railFacing` in state.ts. if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing; } // Only the player sitting in this district can be switching this tray, so the Moves come off // their turn. The event carries no player of its own. turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining; const area = areaAtSeat(s, seat); // A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the // destination has just been lifted into the tray. Zero the DESTINATION card's split — the // origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest` // on a card with no train standing there, so a stale value left behind is inert, not wrong. const destCard = area.grid.get(coordKey(e.to)); if (destCard) destCard.standingWest = 0; // 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 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 = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0); /** * `stock` ARRIVES NEAREST-FIRST — the order the engine MET the cars, accumulated along the * walk. A tray is ordered nose first, so only one of the two ends needs turning round. * * Behind the train, near-first is already right: the nearest car couples to the existing tail * and each one after it goes further back, which is the order the array is in. * * On the nose it is exactly backwards. The FARTHEST car ends up nose-most — the engine pushes * the first one it met ahead of it and picks up the next in front of that — so the cut reverses * on the way into the tray. Getting this wrong is invisible in a single move and shows up as a * run-around that changes nothing: approaching one parked cut from either end used to give the * identical consist, when the whole point is that the two mirror. */ 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].reverse()); 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 = []; } // The card the crew pulled OUT of may still be holding a cut set out off its other end, which // the sweep above has just emptied along with everything else. Put it back. if (e.leaves) { const card = area.grid.get(coordKey(e.leaves.at)); if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c }))); } // `trayMoved`, which always precedes this for the same move, already zeroed the destination // card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on // a card it has just emptied, and nothing of its own is left beside it there. /** * Charge only the cars that were ALREADY standing where the crew ran — the own cut at the * front of `stock` is a drop being undone, so it is refunded on the square it was left on * instead of being charged again at the far end. */ const owner = playerAtSeat(s, trayySeat(tray)); const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock; spendFreightBudget(s, owner, tray, e.at, taken); if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.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. const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray))); sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1); break; } case 'carsDropped': { const tray = s.trays.get(e.trayId)!; const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 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)); /** * WHERE THE CUT LANDS ON THE CARD — the fix for "when dropping all 4 cars, order was * reversed; it worked properly if we dropped cars individually". * * This was `carsOn(card).push(...stock)`, which is neither of the two things it needs to be. * The cut arrives in TRAY order (nose first) and the card is ordered WEST TO EAST, so it has * to be turned around for an east-facing train — `trackOrder`. And it has to go at the end of * the row the cut physically occupies, which is not always the end of the array: * * - a NOSE cut is set out ahead of the engine, on the `facing` side; * - a TAIL cut is set out behind it, on the other side. * * Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left * furthest away, and each one after it is left in the gap between the train and the last — * so the insertion point is the card's own `standingWest`, read against this train's side. That * is what makes the result batch-invariant: four cars at once, four singles, or two pairs all * park in the same order, which is the reported bug and the regression test. */ if (card) { const cars = carsOn(card); const facing = railFacingOf(tray); const onWestSide = e.fromNose ? facing === 'w' : facing === 'e'; const k = Math.max(0, Math.min(cars.length, card.standingWest)); const cut = trackOrder(e.stock, facing); cars.splice(k, 0, ...cut); // The train has not moved, so cars set out on its WEST side push the split along the row; // cars set out to the east leave it where it was. card.standingWest = onWestSide ? k + cut.length : k; } spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), 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); turnOf(s, e.player).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: [], standingWest: 0, 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 = officeNodeFor(s, e.seat); if (node) node.redFlag = e.side; spendCard(s, e.player, e.cardId); break; } /** * §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase * driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a * case here would be dead code that reads as the live one. */ case 'redFlagRuled': s.clock.pendingDecision = null; s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag }; 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); // `carsOn` is the one function that knows WHERE cars stand on a given card — an industry // track for a freight facility, the card itself for everything else. Written out longhand // here it was a second copy of that rule, and the copy was wrong for a Passenger Facility: // it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office // would have landed somewhere `carsOn` cannot see — cars on the board that no train can // couple and no walk is blocked by. `check` refuses a non-freight target, so this never // fired; a trap that needs another rule to stay unsprung is still a trap. carsOn(card).push(...e.stock); } turnOf(s, e.player).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. const wasPassenger = f.allows.outbound || f.allows.inbound; f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility }; /** * A GRANT SUPPRESSED AT A WHISTLE POST COMES BACK WHEN THE OFFICE CAN USE IT. * * Reported from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot * only shows one green / one red box. I expected two, because Restaurant increases outbound * by one." Exactly right, and here is why it happened: `hosts: ['office']` includes a Whistle * Post, which is NOT a Passenger Facility, so `usableGrant` dropped the +1 outbound at the * moment the card was played. The porter landed — porters have no direction gate — and the * slot was gone for good, because the upgrade only ever applied the difference between two * tiers and knew nothing about what had been discarded on the way. * * `TrackCard.modifiers` records which Modifiers served this facility, so what was dropped is * recoverable: when the Office becomes a Passenger Facility, every one of them is granted the * capacity it always printed. Keyed on the TRANSITION, so a Depot → Station upgrade does not * pay them a second time. */ if (!wasPassenger && to.isPassengerFacility) { for (const kind of officeCard.modifiers) { const m = modifierProfile(kind); f.capacity.outbound += m.addOut; f.capacity.inbound += m.addIn; } } } 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); turnOf(s, e.player).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); // `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts). s.yards.classificationYard.push(pooled(e.stock)); turnOf(s, e.player).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(pooled(e.stock)); turnOf(s, e.player).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': // §7 hands this train to the player who played the card, so the queue records them. s.pendingExtras.push({ trainNumber: e.trainNumber, player: e.player }); 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; } /** * THE THREE PLACES AN EXTRA MAY BE MADE UP (§7, Jesse's ruling) — and all three leave it * STANDING somewhere it must later highball out of, never already running. */ case 'extraStarted': { const trayId = s.freeTrays.pop()!; s.pendingExtras = s.pendingExtras.filter((x) => x.trainNumber !== e.trainNumber); const { direction } = e; const base = { id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [] as RollingStock[], direction, movesUsed: 0, // The queue is emptied here, before a single car goes on, so the tray carries the owner // from now on — §7 lets the player who played it load the consist as they choose. builtBy: e.player, }; // Which way the tray physically points, for the cards it will pick up. A Division Point start // does not set it: those trays have never carried a facing and `enterMainline` does not read // one, so writing it here would be inventing state the DP path has always done without. const facing = { facing: direction === 'west' ? ('w' as const) : ('e' as const), railFacing: direction === 'west' ? ('w' as const) : ('e' as const) }; if (e.at.kind === 'divisionPoint') { const side = e.at.side; s.trays.set(trayId, { ...base, position: { at: 'divisionPoint', side } }); const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side); if (dp?.kind === 'divisionPoint') dp.holding.push(trayId); break; } if (e.at.kind === 'mainline') { /** * IN THE INTERCHANGE'S YARD, NOT ON THE MAINLINE — the distinction §7 turns on. * * `holding` rather than `transits`, so the card can be nose to tail with traffic and this * placement still forces no collision. It joins the running line at a later Mainline Phase * through `evaluateClearance`, which is what gives the Superintendent the hold Jesse asked * for: an automatic one against a facing train, a ruling against a following one. */ const node = s.division.nodes[e.at.node]; s.trays.set(trayId, { ...base, ...facing, beingMadeUp: true, position: { at: 'mainline', index: e.at.node }, }); if (node?.kind === 'mainline') (node.holding ??= []).push(trayId); break; } // Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly // as though it had arrived there. const area = areaAtSeat(s, e.at.seat); s.trays.set(trayId, { ...base, ...facing, beingMadeUp: true, position: { at: 'grid', seat: e.at.seat, coord: area.officeCoord }, }); area.adOccupancy.push(trayId); break; } /** * THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks". * * This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains * standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and * why it could fill a coach on a train whose card refuses passenger work — `check` skipped such * a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork` * resolved for `check`, carried on the event rather than looked up again here. */ case 'passengersBoarded': { const f = facilityAt(s, e.player, e.at)!; const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded); const loaded = f.outboundBox.splice(idx, 1)[0]!; const tray = s.trays.get(e.trayId); if (tray && tray.consist[e.coachIndex]) { s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!)); /** * Stamped with the district that filled it — the chip turned upside down in the tray. These * passengers may not alight anywhere in this Office Area; the train has to carry them to a * different one. See `RollingStock.origin` in state.ts. */ tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) }; } f.usedThisStage.porters += 1; break; } case 'passengersDetrained': { const f = facilityAt(s, e.player, e.at)!; const tray = s.trays.get(e.trayId); if (tray && tray.consist[e.coachIndex]) { // 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. Throws now, for the reason in `unloadBegan`. const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded); if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard'); const empty = s.yards.divisionYard.splice(yi, 1)[0]!; refillDivisionYardIfEmpty(s); // The arriving coach goes into the red box carrying nothing: the journey it was stamped for // is over, and the box feeds straight back to a yard through the Freight Agent. f.inboundBox.push(pooled(tray.consist[e.coachIndex]!)); tray.consist[e.coachIndex] = empty; } 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(pooled(f.industryTrack.cars[ci]!)); /** * THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the * tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so * this load now has to leave the Office Area on a train before anyone can break it. See * `RollingStock.origin` in state.ts for the rule and why it is a seat. */ f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) }; } f.usedThisStage.laborers += 1; break; } case 'unloadBegan': { const f = facilityAt(s, e.player, e.at)!; /** * THE CAR THE PLAYER PICKED, not merely "a loaded one". * * REPORTED: with several loaded cars spotted on one industry track, unloading always took the * WESTMOST car regardless of which one was chosen. This used to re-derive the target with * `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first * loaded car in track order — no matter which `carIndex` the intent actually named; `check` * had already validated that specific car, and the choice was thrown away between there and * here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent. */ const ci = e.carIndex; 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. * * THROWS rather than falling back. `check` guarantees the car is there, so reaching this is * a broken invariant, and the old fallback quietly minted rolling stock instead of saying * so — which is exactly the shape of leak `TODO.md` spent a conservation audit chasing. */ const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded); if (yi < 0) throw new Error(`unloadBegan: no empty ${e.carType} in the Division Yard`); const empty = s.yards.divisionYard.splice(yi, 1)[0]!; 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') turnOf(s, e.player).done = true; break; case 'clearanceGiven': s.clock.pendingDecision = null; // Recorded for the asking train to consume; otherwise the driver asks again forever. s.clock.decisionAnswer = { kind: 'clearance', 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: [], standingWest: 0, 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: { 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: [], standingWest: 0, 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, and it cuts both ways. **Waiting Area**, **Restaurant** and **Hotel** * print `addOut` for `hosts: ['office']`, which includes a Whistle Post — not a Passenger Facility, * so `allows.outbound` is false there too. **Truck Dock** is the mirror image: it prints `addIn` and * lists **Packing Sheds**, which only ships, so its one grant is dropped there and the card does * nothing at all. Which host you set it beside is the whole decision, and the hand tooltip says so * before it is played. * * What is applied is what the host can actually use, not what the card printed. It affects the box * count only — see `applyModifier` on why no Modifier lengthens an industry's track. */ 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; /** * A MODIFIER ADDS A BOX, NEVER ROOM FOR A CAR. * * A Truck Dock beside a Grocer's Warehouse gives it a second RED box — somewhere for one more * arriving load to be cleared to — and changes nothing about how many cars may be set out there, * which was already four and stays four. This used to lengthen the industry track by the same * amount, which is where the phantom siding came from: capacity and rail are different things. */ } /** §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, /** * Whether the car being offered is loaded, and what the Division Yard still holds. * * Both optional so that a caller asking the SHAPE question — "does this card take a car of this * category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape * question of every car in the yard; `check` asks the full one about a specific car a player has * named. Omitting them skips the loading rules rather than guessing at them. */ loaded?: boolean, yard?: readonly RollingStock[], ): 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; if (loaded === undefined) return true; /** * X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY. * * `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both * `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with * loaded cars while its own card said it could not. Found while building Gitea#13, which is the * same rule pointing the other way, and fixed with it rather than left as the odd one out. * * A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no * such thing as an empty one — so applying this to the caboose would bar the Appleseed from the * caboose its own consist calls for. */ if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false; /** * MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement. * * "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only * while the yard can still supply a loaded car this train would accept; once it cannot, the empty * becomes legal and the train may also simply depart short. Asked of the yard rather than * remembered on the tray, because the yard is what the rule is about and it changes under the * train as other consists are built. * * The caboose is exempt for the same reason as above. */ if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) { const loadedAvailable = yard.some( (c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type), ); if (loadedAvailable) return false; } return true; } /** * §7 — IS THIS TRAY THE ONE BEING MADE UP? * * A train is made up where it is built 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. * * WHERE that is used to be the whole test: "standing at a Division Point". True while a Division * Point was the only place to build one, and wrong once an Extra could be started at a Control Point * or in an Interchange's yard (§7) — those trains were never offered a car and ran empty. The extra * clause is `beingMadeUp` (state.ts), a flag set on exactly those trays and cleared when they start * running, rather than a second positional rule: a train STANDING at an Office is usually one that * arrived, and must not be fillable from the yard. * * 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' && !tray.beingMadeUp) 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. * * ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer * the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but * loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would * both be told a car was available and then refused every one of them — the very stall this * comment was written about. */ if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) { 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: [], standingWest: 0, 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); // Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts` // for why it cannot simply live inside `reduce`. for (const e of events) tallyEvent(s, e); return { ok: true, events }; } export { isOperationalRail, destinationsFor };