/** * Component 5 — The phase driver. * * `advance(state) -> { events, needsInput }`. Everything the game does WITHOUT a player acting: * Mainline movement, highball evaluation, collisions, train make-up, the Stage and Day clock, * Superintendent rotation, and victory checks. * * See architecture/components.md §2 A.5. This lives inside the engine rather than the session * layer so that all rules knowledge stays in one pure deterministic place — which is also what * makes the balance harness a plain loop over `advance` and `applyIntent`, with no server. * * USAGE: * while (true) { * const r = advance(state); * if (r.needsInput || state.status === 'finished') break; * } */ import { COLLISION_PENALTY, EXPEDITE_FAULT_PENALTY, MAINLINE_PROFILES, enhancementRule, crossingStages, trainProfile, startRegion, MOVES_PER_LOCAL_OPS, MOVES_PER_LOCAL_OPS_NIGHT, STAGES_PER_DAY, STAGES_PER_SHIFT, houseRules, officeProfile, mainlineProfile, } from './content.ts'; import type { Direction, MainlineEntry, MainlineKind } from './content.ts'; import type { GameEvent } from './events.ts'; // `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and // the legality test cannot disagree about which train is being assembled. import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts'; import { reachableDestinations } from './track.ts'; import { tallyEvent } from './tally.ts'; export type AdvanceResult = { events: GameEvent[]; /** True when the game is waiting on a player. The caller should stop pumping. */ needsInput: boolean; }; const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]); /** * IS EVERY CAR ON THIS TRAIN LOADED? (Gitea#13) * * "You only get credit for a circus or campaign train (one point per stop) if you have it fully * loaded. Not much of a circus if all the cars are empty." * * A COACH COUNTS AS LOADED WHEN IT IS OCCUPIED, which is what makes this the right test for the * Campaign Train: X17 carries one coach and no freight, so "fully loaded" is precisely "the * candidate is aboard" (Jesse's ruling, 2026-08-29). The engine already models an occupied coach * as `loaded`, so no second notion is introduced here. * * A CABOOSE IS EXEMPT, and it costs nothing to say so: every caboose in `ROLLING_STOCK_SUPPLY` is * minted `loaded: true` — there is no empty one — so including it would change no outcome today. * It is excluded anyway because a caboose is crew space rather than payload, and a supply table * that grew an empty caboose should not silently start voiding circus points. * * AN EMPTY TRAIN IS NOT FULLY LOADED. A Circus that departed short and carries nothing at all earns * nothing: `every` on an empty list is vacuously true, which would pay the emptiest train of the * lot, so the length is tested first. */ function fullyLoaded(tray: CrewTray): boolean { const payload = tray.consist.filter((c) => c.type !== 'caboose'); return payload.length > 0 && payload.every((c) => c.loaded); } function movesForStage(s: GameState): number { return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage) ? MOVES_PER_LOCAL_OPS_NIGHT : MOVES_PER_LOCAL_OPS; } // --------------------------------------------------------------------------- // Division navigation // --------------------------------------------------------------------------- function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number { return s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat); } const step = (d: Direction): number => (d === 'east' ? 1 : -1); // --------------------------------------------------------------------------- // advance // --------------------------------------------------------------------------- /** * The phase driver, plus the two things that have to happen around EVERY batch of events it * produces. `advanceInner` below is the driver itself, unchanged. * * ORDER IS THE WHOLE POINT of this wrapper, and it is the one subtle thing in Gitea#16. * `checkVictory` runs deep inside the driver, so if the official result froze a copy of the Tally * from in there it would freeze it BEFORE this batch's events had been counted — and the batch that * ends a game is exactly the one carrying the last Day's work. So the Tally is folded first and the * result frozen second, both out here where the whole batch is in hand. * * Safe because both endings `return` the moment they fire: no scoring event is emitted after a game * has ended within a single batch, so "everything in this batch" and "everything up to the ending" * are the same set of events. `test/tally.test.ts` pins that. */ export function advance(s: GameState): AdvanceResult { const r = advanceInner(s); for (const e of r.events) tallyEvent(s, e); freezeOfficial(s); return r; } /** * THE OFFICIAL RESULT, written once (Gitea#11). * * "The winner is based upon the original game length" — so the first ending is the real one and * every later evaluation is informational. Idempotent by construction: it does nothing once * `official` is set, which is what stops an extended Day, or a §3.4 breach during one, from * rewriting a recorded win. */ function freezeOfficial(s: GameState): void { if (s.official !== null || s.outcome === null) return; s.official = { day: s.config.days, outcome: { ...s.outcome }, revenues: s.players.map((p) => p.revenue), collisionsTotal: s.collisionsTotal, tally: cloneTally(s.tally), }; } function advanceInner(s: GameState): AdvanceResult { const events: GameEvent[] = []; if (s.status === 'finished') return { events, needsInput: false }; /** * §3.3 (Gitea#11) — the timetable has run out and the table is being asked whether to play one * more Day. Nothing runs itself while that question is open, so this is `needsInput` rather than * an ending: `pump` stops here, the server keeps the game in memory, and the only intent the * rules will take is `game.extend`. */ if (s.status === 'awaitingExtension') return { events, needsInput: true }; // The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1). if (s.clock.pendingDecision !== null) return { events, needsInput: true }; switch (s.clock.phase) { case 'localOps': return playerPhase(s, events, 'localOps'); case 'newTrain': return newTrainPhase(s, events); case 'mainline': return mainlinePhase(s, events); case 'loadUnload': return playerPhase(s, events, 'loadUnload'); case 'shiftChange': return shiftChange(s, events); } } // --------------------------------------------------------------------------- // Player-driven phases (Local Ops, Load/Unload) // --------------------------------------------------------------------------- function playerPhase( s: GameState, events: GameEvent[], phase: 'localOps' | 'loadUnload', ): AdvanceResult { /** * THE CURSOR STILL WALKS ONE PLAYER AT A TIME. * * Turn state is now per-player, but only the player at `actorOffset` is ever asked to act, so this * behaves exactly as it did when there was a single `TurnState` — which is the point: the model * change is neutral, and letting local work happen off-cursor is a later change to `isActor` * alone (`docs/architecture/multiplayer.md` D19). */ const actor = actorAt(s, s.clock.actorOffset); const turn = turnOf(s, actor); // A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3). if (phase === 'localOps' && turn.option === 'freightAgent' && turn.freightAgentUsed) { turn.done = true; } // Spending the last Move ends a switching turn without needing an explicit end (§6.1). if (phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining === 0) { turn.done = true; } if (!turn.done) { s.clock.currentActor = actor; // SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking. // This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) — // but a rules gap that stranded a player would otherwise hang the game rather than fail // visibly, and a hung game is far harder to diagnose than a forfeited turn. if (legalActions(s, s.clock.currentActor).length === 0) { turn.done = true; } else { return { events, needsInput: true }; } } s.clock.actorOffset += 1; if (s.clock.actorOffset >= s.players.length) { return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false }; } s.clock.currentActor = actorAt(s, s.clock.actorOffset); events.push({ type: 'actorChanged', player: s.clock.currentActor }); return { events, needsInput: false }; } function actorAt(s: GameState, offset: number): PlayerIndex { return playerLeftOf(s, s.clock.superintendent, offset); } function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] { switch (p) { case 'localOps': return 'newTrain'; case 'newTrain': return 'mainline'; case 'mainline': return 'loadUnload'; case 'loadUnload': return 'shiftChange'; default: return 'localOps'; } } function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] { s.clock.phase = phase; s.clock.actorOffset = 0; // Every player gets a turn at phase entry, not one at a time as the cursor reaches them. With the // cursor still walking sequentially this is indistinguishable from the old behaviour. s.turns = freshTurns(s.players.length, movesForStage(s)); s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0); return [ { type: 'phaseBegan', phase }, { type: 'actorChanged', player: s.clock.currentActor }, ]; } // --------------------------------------------------------------------------- // New Train Phase (§7) // --------------------------------------------------------------------------- function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult { // Make up any Timetabled Train due this Stage, if a Crew Tray is free (§7). const due = s.timetable[s.clock.stage - 1]; if (due !== null && due !== undefined && !trainRunning(s, due, false)) { if (s.freeTrays.length === 0) { // "Held train" — no crew available. It waits; the Stage moves on (§7). events.push({ type: 'trainHeld', trainNumber: due, reason: 'no free Crew Tray' }); return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false }; } const profile = trainProfile(due, false); if (profile) { const trayId = s.freeTrays.pop()!; const direction: Direction = profile.direction === 'east' ? 'east' : 'west'; // An eastbound train starts at the Western Division Point and runs east. const side: Direction = direction === 'east' ? 'west' : 'east'; const tray: CrewTray = { id: trayId, trainNumber: due, trainIsExtra: false, engineAt: 0, consist: [], direction, position: { at: 'divisionPoint', side }, movesUsed: 0, }; s.trays.set(trayId, tray); const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side); if (dp && dp.kind === 'divisionPoint') dp.holding.push(trayId); events.push({ type: 'trainMadeUp', trainNumber: due, isExtra: false, at: side === 'west' ? 'West Division Point' : 'East Division Point', direction, }); } } // Q9 — a second section runs immediately behind its first, with the same number, rules and // consist. It needs its own Crew Tray, and because it follows a train of the same number into // the same Subdivision it will usually force the §8.1 clearance decision. if (s.pendingSecondSections.length > 0 && s.freeTrays.length > 0) { const number = s.pendingSecondSections.shift()!; const profile = trainProfile(number, false); if (profile) { const trayId = s.freeTrays.pop()!; const direction: Direction = profile.direction === 'east' ? 'east' : 'west'; const side: Direction = direction === 'east' ? 'west' : 'east'; s.trays.set(trayId, { id: trayId, trainNumber: number, trainIsExtra: false, engineAt: 0, consist: [], direction, position: { at: 'divisionPoint', side }, movesUsed: 0, }); const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side); if (dp?.kind === 'divisionPoint') dp.holding.push(trayId); events.push({ type: 'trainMadeUp', trainNumber: number, isExtra: false, at: side === 'west' ? 'West Division Point' : 'East Division Point', direction, }); } } /** * §7 — "if there is a played Extra Train card and an available Crew Tray, the player who played * the card may place the Crew Tray in either division point for immediate departure." * * THIS USED TO LAUNCH EVERY EXTRA EASTBOUND FROM THE WEST DIVISION POINT, with the simplification * flagged in a comment. Jesse's rule: an Extra runs the way its NUMBER says, exactly as a * timetabled train does — odd runs west, even runs east (§2.3) — so it starts at the Division * Point it runs FROM. It may instead start at any Control Point, which is any Office above a * Whistle Post, at the player's choice. * * That is a decision, so the phase stops for it the same way it stops to have cars placed. The * chooser is the acting player; §7 says the player who PLAYED the card, which is the same person * in solitaire and needs `pendingExtras` to carry a player before it is not. */ const extra = s.pendingExtras[0]; if (extra !== undefined && s.freeTrays.length > 0) { // §7 — the player who PLAYED the card places it. `pendingExtras` carries them (2026-08-23); // before that it was a bare train number and this asked whoever the acting order was on, which // is the same person in solitaire and the wrong one at every table. Reported by Jesse from a // two-player game. if (s.clock.currentActor !== extra.player) events.push({ type: 'actorChanged', player: extra.player }); s.clock.currentActor = extra.player; return { events, needsInput: true }; } /** * Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains, * cycling Superintendent-then-left one car at a time (§7). * * `tray.consist.length` IS the round position: a freshly made-up tray always starts with * `consist: []`, and each `newTrain.placeCar` appends exactly one car to it (`apply.ts`'s * `carPlacedOnTrain` reducer), so it counts placements toward THIS tray without any new state — * and resets to 0 naturally for the next train made up, which a phase-wide `actorOffset` cannot * do. Reproduces the rulebook's worked example exactly: 2 players, 4-coach Limited → seats * 0, 1, 0, 1. */ const filling = trainNeedingCars(s); if (filling) { const tray = s.trays.get(filling)!; /** * TWO DIFFERENT RULES, and §7 states them a paragraph apart. A TIMETABLED train's consist is * built by the table — "starting with the Superintendent and working left, each player may place * ONE car" — while an EXTRA is loaded by the player who played it, "as he chooses". So an Extra * does not enter the round at all; it belongs to `builtBy` until it is full. */ const nextActor = tray.trainIsExtra && tray.builtBy !== undefined ? tray.builtBy : actorAt(s, tray.consist.length % s.players.length); if (s.clock.currentActor !== nextActor) events.push({ type: 'actorChanged', player: nextActor }); s.clock.currentActor = nextActor; return { events, needsInput: true }; } return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false }; } function trainRunning(s: GameState, number: number, isExtra: boolean): boolean { for (const t of s.trays.values()) { if (t.trainNumber === number && t.trainIsExtra === isExtra) return true; } return false; } // --------------------------------------------------------------------------- // Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling // --------------------------------------------------------------------------- function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult { s.clock.currentActor = null; // §8 — trains move in numeric order, lowest first. Timetabled outranks an Extra of the same // number (Gap 5): sort by (number, isExtra) ascending. const order = [...s.trays.entries()] // §8 — "Any train holding at a player's Office ... or a Division Point must attempt to move." // Trains standing on an A/D track are included: they are exactly the ones due to highball. .filter(([, t]) => t.trainNumber !== null) .sort(([, a], [, b]) => { const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0); return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra); }); /** * Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins * was not kept ready to highball, wherever in the district it has been left — switched onto * Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase * moves anything, and charged every Phase it is still caught away: the fault is in leaving it * there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is * unaffected — this only bites when the train is not even in the queue to leave. */ for (const [, tray] of order) { if (!isExpedited(tray) || tray.position.at !== 'grid') continue; const area = areaAtSeat(s, tray.position.seat); const { coord } = tray.position; if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue; const owner = playerAtSeat(s, tray.position.seat); const p = s.players[owner]; if (!p) continue; p.revenue -= EXPEDITE_FAULT_PENALTY; events.push({ type: 'expediteFault', player: owner, trainNumber: tray.trainNumber ?? 0, where: `(${coord.col},${coord.row})`, }); events.push({ type: 'revenueChanged', player: owner, delta: -EXPEDITE_FAULT_PENALTY, total: p.revenue, reason: 'expedited train left off the station', }); } for (const [id, tray] of order) { if (s.movedThisPhase.has(id)) continue; const where = tray.position; const moved = moveTrain(s, id, tray, events); /** * X18 CIRCUS / X17 CAMPAIGN — a Stage spent set up in somebody's Office Area earns a point. * * The flag was declared on the profile and read NOWHERE, so the one card in the deck that paid * for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a * full Stage and no point arrived. * * ONCE PER OFFICE AREA (Gitea#13, Jesse 2026-08-29): "once per stop in an office area. In a * multiplayer game, each player could score if the circus stops in their area." So a Circus * touring three districts is paid three times and one parked in the same district all game is * paid once, which `stopPointSeats` records per seat. * * ONLY IN AN OFFICE AREA. It used to pay for standing on the Mainline or at a Division Point * too, and misattributed both: `playerAtSeat` needs a seat, and off the grid there is none, so * the fallback handed the point to PLAYER 0 wherever the train happened to be. Scoping the rule * to Office Areas is what Jesse's ruling says and it removes that bug rather than patching it. * * FULLY LOADED, or nothing. "Not much of a circus if all the cars are empty" — see * `fullyLoaded` below for what that means for a train whose only car is a coach. * * "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where * it was. A train that is still in the district when the phase runs has not moved either, which * is the circus setting up on a siding rather than crossing the Division. */ if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) { const stillThere = tray.position.at === where.at && (tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) && (tray.position.at !== 'grid' || where.at !== 'grid' || (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); // Bound as one value so the grid case narrows: `tray.position` is a union, and testing a // `seat` extracted from it does not tell the compiler which member it came from. const at = tray.position.at === 'grid' ? tray.position : null; const alreadyPaidHere = at !== null && (tray.stopPointSeats ?? []).includes(at.seat); if (stillThere && at !== null && !alreadyPaidHere && fullyLoaded(tray)) { tray.stopPointSeats = [...(tray.stopPointSeats ?? []), at.seat]; // The point goes to whoever is SITTING in the district it stopped in. const owner = playerAtSeat(s, at.seat); const label = `(${at.coord.col},${at.coord.row})`; events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label }); const p = s.players[owner]; if (p) { p.revenue += 1; events.push({ type: 'revenueChanged', player: owner, delta: 1, total: p.revenue, reason: 'set up in the district — a Stage spent standing still, fully loaded', }); } } } if (moved === 'needsClearance') return { events, needsInput: true }; s.movedThisPhase.add(id); } s.movedThisPhase = new Set(); return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false }; } type MoveOutcome = 'moved' | 'held' | 'needsClearance'; /** * §8.2 — why this train is not fit to run, or null if it is. * * "The engine in front, cars behind, and if there is a caboose, the caboose at the rear." * * DIRECTION-FREE, deliberately. The engine may be at either end of the tray — pulling or pushing — * and which of those counts as "in front" depends on which way the train is pointed, which the tray * does not reliably record for a crew that has been shunting round a siding. What a train may NOT be * is broken-backed: the engine buried among its own cars, with some ahead of it and some behind. The * caboose then has to ride at the far end from the engine, which is the rear whichever way it runs. * * This is reachable purely through switching. A train arrives made up, and only comes apart because * the player took cars onto the nose or picked up a cut in a run-around. */ function badlyMadeUp(tray: CrewTray): string | null { const n = tray.consist.length; if (n === 0) return null; const pulling = tray.engineAt === 0; const pushing = tray.engineAt === n; if (!pulling && !pushing) { return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`; } const caboose = tray.consist.findIndex((c) => c.type === 'caboose'); if (caboose === -1) return null; // The rear is the end away from the engine. const rear = pulling ? n - 1 : 0; return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train'; } /** * WHICH REGION OF A MAINLINE CARD A TRAIN IS STANDING IN (Gitea#3). * * A card is `regions` boxes wide and a train advances one per Stage, so what it has LEFT to run says * where it is: enter with `regions` still to go and you are at the beginning; enter with one to go * and you are in the last box. * * This used to be derived from a single global `REGIONS_PER_MAINLINE_CARD = 2`, with an entry term * that put a one-Stage train in region 1 of a two-region card — a fast train did not traverse a fast * card, it appeared at the far half of it. Cards carry their own region count now, so the position * is simply the count minus what is left. */ export function regionOfTransit(card: MainlineKind, stagesRemaining: number): number { const regions = mainlineProfile(card).regions; return Math.min(regions - 1, Math.max(0, regions - stagesRemaining)); } /** The entry a train would make onto this card, before occupancy is taken into account. */ function entryFor( node: Extract, tray: CrewTray, startsAtBack = false, ): MainlineEntry { const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); return { trainSpeed: profile?.speed ?? 'slow', direction: tray.direction, gradeUp: node.gradeUp ?? 'east', modifiers: node.modifiers ?? [], ...(startsAtBack ? { startsAtBack: true } : {}), }; } /** * THE UNCONTROLLED SIDING RULE (Gitea#3): "if a train already exists when you arrive, you go in the * second stage back — you are in the siding and are one behind the other train. This prevents a * collision, since you are not in same exact location." * * So arriving at an occupied siding is not a collision and not a hold; it is a different, slower * entry. Anywhere else this returns false and the ordinary start applies. */ function takesTheSiding(node: Extract): boolean { return node.card === 'uncontrolledSiding' && node.transits.length > 0; } /** * IS MOVING ONTO THIS CARD A COLLISION? (Gitea#3) * * A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train * granted clearance arrives in the same region as the train ahead the moment it enters. There was no * test for that at all: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage * crossing never reaches, so entering behind another train on a Plains was silently free. * * ABS is the card that answers it, in RAR's words: "This is played on a mainline card to prevent * collisions. If a collision would normally occur, the train moving onto the card is instead held * back." Held, not waved through — it tries again next Stage. * * The Uncontrolled Siding never conflicts on entry, because `takesTheSiding` has already moved this * train a region back; that is the whole point of the card. */ function entryConflict( s: GameState, node: Extract, id: TrayId, tray: CrewTray, events: GameEvent[], startsAtBack = false, ): 'collided' | 'held' | null { if (mainlineProfile(node.card).trainsMayPass) return null; const start = startRegion(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node))); const ahead = node.transits.find( (t) => t.tray !== id && t.direction === tray.direction && regionOfTransit(node.card, t.stagesRemaining) === start, ); if (!ahead) return null; /** * A BACKSTOP, not the main path. `evaluateClearance` already refuses to clear a train onto a card * carrying ABS, so in the ordinary run of things nothing reaches here with signals up. It stays * because the two rules answer to different questions — clearance looks at the whole Subdivision, * this looks at one region — and a card that promises no rear-enders should not depend on the * wider check happening to fire first. */ if (node.absSignals) { events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: 'ABS Signals — held short of the train ahead', }); return 'held'; } // §10 — the Superintendent cleared it into an occupied region, so it is the Superintendent's fault. collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline'); return 'collided'; } /** Puts a train onto a Mainline card with its crossing time already computed. */ function enterMainline( s: GameState, node: Extract, id: TrayId, tray: CrewTray, index: number, startsAtBack = false, ): void { const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node))); node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction }); tray.position = { at: 'mainline', index }; // It is running now, so it is no longer being assembled (state.ts). A train at a Division Point // needs no equivalent: leaving one changes its position, which is what that case reads. delete tray.beingMadeUp; /** * OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT. * * `facing` is a port on the card the train is standing on, and switching round a district leaves * it holding a real compass port — 'n' or 's' off a curve. Nothing cleared it when the train * departed, so it carried that port out onto a Division that runs east and west, and then into * the next Office, whose card has no north or south edge at all. * * That is not cosmetic. `movesFor` explores from `facing` and from its opposite, and a card with * neither port yields no destinations either way — so a train that had been shunted onto a spur * arrived at the next Office **unable to make a single Move**. It also drew a ▲ on the Division * map, where there is no north to point at. * * A train out here is running one way along an east-west railroad with its engine at one end, so * this is what `facing` means on the Division; there is nothing else it could be. `railFacing` * follows for the same reason — this IS the east-west sense, freshly known. */ tray.facing = tray.direction === 'west' ? 'w' : 'e'; tray.railFacing = tray.facing; } /** * Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other * train's number". Spends the best available device the owning player has, once per Day each. * * Returns the bonus applied to the opposing train's number (0 if none was available or needed). */ function spendDispatchBonus( s: GameState, tray: CrewTray, other: CrewTray, events: GameEvent[], ): number { const mine = tray.trainNumber ?? 99; const theirs = other.trainNumber ?? 99; /** * The Fedora is held by a PLAYER; the devices are installed in an Office Area, which is keyed by * SEAT. `areaOf` is what reconciles the two — it resolves through `seatOf` — so this is correct * under Employee Rotation and not only while seating happens to be the identity map. * * This comment used to say the opposite, warning that indexing one with the other was safe only * while seating was identity. It read as a live bug and was not one: `areaOf(s, player)` IS * `areaAtSeat(s, seatOf(s, player))`. Pinned by test rather than asserted here — see #101's * "spends the Superintendent's own devices under non-identity seating". */ const area = areaOf(s, s.clock.superintendent); // Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4). for (const key of ['radio', 'telephone', 'telegraph'] as const) { if (area.dispatchUsedToday.includes(key)) continue; const present = [...area.grid.values()].some((c) => c.enhancements.includes(key)); if (!present) continue; const bonus = enhancementRule(key)?.dispatchBonus ?? 0; // Spend it only if it actually wins the meet — the device makes the OTHER train count as // junior, so ours may proceed. if (mine >= theirs + bonus) continue; area.dispatchUsedToday.push(key); events.push({ type: 'dispatchBonusUsed', key, bonus, trainNumber: tray.trainNumber ?? 0, againstTrain: other.trainNumber ?? 0, }); return bonus; } return 0; } /** * Q3 — is this train subject to the "kept ready" fault (§7)? * * Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any * other train, and may be switched normally while it stands. What it means is that it must not be * held anywhere but the station: a train the player parks on Secondary Track to clear a switching * move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`). * * Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign * Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while * the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first * stop, so the train is exempt once and subject to the fault thereafter. */ export function isExpedited(tray: { trainNumber: number | null; trainIsExtra: boolean; speechMade?: boolean; }): boolean { const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules; if (!rules) return false; if (rules.expedite) return true; return rules.stopThenExpedite === true && tray.speechMade === true; } function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome { const dir = step(tray.direction); if (tray.position.at === 'divisionPoint') { // Read BEFORE `enterMainline` overwrites `tray.position`: the departure line named the side the // train was leaving FROM, and taking it afterwards read the mainline position instead — so every // train reported leaving the Eastern Division Point, including the eastbound ones that had just // been made up at the Western one. const fromSide = (tray.position as { side: Direction }).side; const dpIndex = s.division.nodes.findIndex( (n) => n.kind === 'divisionPoint' && n.side === fromSide, ); const target = dpIndex + dir; const node = s.division.nodes[target]; if (!node || node.kind !== 'mainline') return 'held'; const clearance = evaluateClearance(s, id, tray, target, events); if (clearance === 'blocked') return 'held'; if (clearance === 'ask') return 'needsClearance'; const conflict = entryConflict(s, node, id, tray, events); if (conflict === 'held') return 'held'; if (conflict === 'collided') return 'moved'; enterMainline(s, node, id, tray, target); const dp = s.division.nodes[dpIndex]; if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id); events.push({ type: 'trainHighballed', trainNumber: tray.trainNumber ?? 0, from: `the ${fromSide === 'west' ? 'Western' : 'Eastern'} Division Point`, to: 'the Mainline', // Narrated as a phase marker until now — "▸ train 8 highballed phase" — which is not a phase // and told the player nothing about why the train had started its run. why: 'it was made up and the Subdivision ahead was clear, so its run begins', }); return 'moved'; } // §8.1 — a train standing on an A/D track attempts to highball onto the next Mainline card. // Without this a train that arrives at an Office never leaves, holding an A/D track forever and // colliding with every train that follows it. if (tray.position.at === 'grid') { const seat = tray.position.seat; const area = areaAtSeat(s, seat); // Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b). if ( tray.position.coord.row !== area.officeCoord.row || tray.position.coord.col !== area.officeCoord.col ) { return 'held'; } /** * §8.2 — A TRAIN MUST BE MADE UP BEFORE IT MAY LEAVE. * * The engine leads, the cars follow, and a caboose rides at the rear. A train that has been * shunting can easily be in none of those states: taking cars on the nose puts them AHEAD of the * engine ("pushing them into the Facility"), and cars picked up in a run-around land wherever the * approach put them. Nothing checked, so a crew could shove a cut into a siding and then highball * onto the Mainline engine-last with the caboose in the middle. * * The remedy is in the player's hands and is the reason both exist: run around the train, or * spend a Move in a Small Yard, which re-makes it (`consistSorted` puts the engine back on the * nose). Held rather than rejected — a train that cannot leave stays where it is, which is what * makes an A/D track fill up and eventually bite. */ const badOrder = badlyMadeUp(tray); if (badOrder !== null) { events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: badOrder }); return 'held'; } const officeIndex = nodeIndexOfOffice(s, seat); const target = officeIndex + dir; const node = s.division.nodes[target]; if (!node) return 'held'; if (node.kind === 'mainline') { const clearance = evaluateClearance(s, id, tray, target, events); if (clearance === 'blocked') return 'held'; if (clearance === 'ask') return 'needsClearance'; const conflict = entryConflict(s, node, id, tray, events); if (conflict === 'held') return 'held'; // The wreck's A/D track is released by `collide` itself, which is why it has to be. if (conflict === 'collided') return 'moved'; enterMainline(s, node, id, tray, target); area.adOccupancy = area.adOccupancy.filter((t) => t !== id); events.push({ type: 'trainHighballed', trainNumber: tray.trainNumber ?? 0, from: 'the Office', to: 'the Mainline', why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase', }); return 'moved'; } /** * CURRENTLY UNREACHABLE, and kept correct rather than deleted. * * `buildDivision` always lays `DP · Mainline · Office · Mainline · … · DP`, so an Office is never * adjacent to a Division Point and this branch cannot be entered at any player count. It is left * in — with the departure paid, so it would behave — because the layout is a setup decision that * could reasonably change, and a branch that silently failed to score would be hard to spot. * `setup.test.ts` asserts the flanking invariant that makes this dead. */ if (node.kind === 'divisionPoint') { area.adOccupancy = area.adOccupancy.filter((t) => t !== id); retireTrain(s, id, tray, node.side, events); return 'moved'; } return 'held'; } if (tray.position.at === 'mainline') { const { index } = tray.position; const node = s.division.nodes[index]; if (!node || node.kind !== 'mainline') return 'held'; /** * §7/§8.1 — AN EXTRA HIGHBALLING OUT OF THE INTERCHANGE'S YARD. * * Standing in `holding` rather than crossing in `transits` (state.ts), which is the position an * Extra started at an Interchange begins in. It leaves exactly the way a train at a Division * Point does — clearance first, then onto the running line — except that the card it enters is * the one it is already standing beside rather than the next one along. * * That reuse is the whole point of modelling the yard separately: Jesse's rule is "a guaranteed * collision holds it at the Interchange for another Stage and it tries again; a potential one is * the Superintendent's to hold", and those are precisely `evaluateClearance`'s `blocked` and * `ask`. Nothing new decides collisions here. */ if (node.holding?.includes(id)) { const clearance = evaluateClearance(s, id, tray, index, events); if (clearance === 'blocked') { events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: 'held in the Interchange — the Subdivision ahead is occupied', }); return 'held'; } if (clearance === 'ask') return 'needsClearance'; /** * AN EXTRA PULLING OUT OF THE INTERCHANGE STARTS IN THE BACK REGION (Gitea#3) — "interchange * has new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is * 1 stage for ALL trains. So are interlockings, with a second stage for incoming extras to hold * at." A train running THROUGH the Interchange starts past that region and crosses in one * Stage; one that began its run here has the holding region to clear first. */ const conflict = entryConflict(s, node, id, tray, events, true); if (conflict === 'held') return 'held'; if (conflict === 'collided') return 'moved'; node.holding = node.holding.filter((t) => t !== id); enterMainline(s, node, id, tray, index, true); events.push({ type: 'trainHighballed', trainNumber: tray.trainNumber ?? 0, from: 'the Interchange', to: 'the Mainline', why: 'it was made up in the yard and the Subdivision was clear, so its run begins', }); return 'moved'; } const transit = node.transits.find((t) => t.tray === id); if (!transit) return 'held'; // Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's // Fast/Slow class. Count it down rather than stepping through printed cells. if (transit.stagesRemaining > 1) { /** * Q13 — REAR-END COLLISIONS, answered: a train collides when it CATCHES UP. * * §10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS * Signals exists to stop trains rear-ending each other — but §8.3's trigger list never named * one and none was implemented, so granting clearance was free: both trains survived, no * penalty, and ABS Signals protected against nothing. * * Colliding on CATCHING UP is the version that rewards judging the gap. A following train * that closes on the one ahead runs into it; a following train that never closes is fine, so * clearance becomes a bet on relative speed rather than a formality. §2.1 divides the card * into two regions, and sharing one is what "caught up" means. * * ABS Signals does what it says instead: the follower stops SHORT of the collision and holds. */ // NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS // two roads, so a train catching another there goes past it. That is what the card is for. // // The Uncontrolled Siding used to be in that set and no longer is: it keeps two trains apart // by putting the second one in the siding a region back (`takesTheSiding`), not by letting // them share a place. Marking it "may pass" skipped this test entirely and made the siding do // nothing at all. const mayPass = mainlineProfile(node.card).trainsMayPass; const next = regionOfTransit(node.card, transit.stagesRemaining - 1); const ahead = mayPass ? undefined : node.transits.find( (t) => t.tray !== id && t.direction === transit.direction && regionOfTransit(node.card, t.stagesRemaining) === next, ); if (ahead) { if (node.absSignals) { // "Trains on this card will not rear-end each other; they stop short of a collision." events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: 'ABS Signals — held short of the train ahead', }); return 'held'; } // §10 — the Superintendent let it in behind the other, so it is the Superintendent's fault. collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline'); return 'moved'; } transit.stagesRemaining -= 1; return 'moved'; } // Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2). const target = index + dir; const dest = s.division.nodes[target]; /** * §11 (Gitea#5) — the Yard Office offer is put BEFORE the train leaves the Mainline card, for * the same reason §8.1's clearance is: `needsClearance` unwinds the whole phase and the driver * re-enters here from the top, so anything already mutated is mutated twice or, worse, left * half-applied. Asking after the `transits` filter below cost the train its place on the card * and it was never seen again — the question was asked and the answer had nowhere to land. */ if (dest?.kind === 'office') { /** * §Q, RED FLAGS (Gitea#19) — asked and answered before the train leaves the card, for exactly * the reason the Yard Office offer is (see below): `needsClearance` unwinds the phase. * * Order matters. A flag stops the train OUTSIDE the Limits, so it never reaches the point * where the Yard Office would be offered — flagging is about keeping a train out altogether. */ const flagged = redFlagStop(s, id, tray, dest, events); if (flagged === 'ask') return 'needsClearance'; if (flagged === 'held') return 'held'; if (yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') return 'needsClearance'; } node.transits = node.transits.filter((t) => t.tray !== id); if (!dest) return 'held'; if (dest.kind === 'divisionPoint') { // The train has run the length of the Division and leaves the game (§2.3). dest.holding.push(id); tray.position = { at: 'divisionPoint', side: dest.side }; retireTrain(s, id, tray, dest.side, events); return 'moved'; } if (dest.kind === 'office') { return arriveAtOffice(s, id, tray, dest.seat, events); } } return 'held'; } /** * §8.1 — the highball conditions that involve the next Subdivision. * * A train moving TOWARDS the considered train is an absolute bar. A train moving the SAME * direction is the Superintendent's judgment call — and Gap 2 made the consequences automatic * precisely so that this decision carries full weight. */ function evaluateClearance( s: GameState, id: TrayId, tray: CrewTray, targetIndex: number, events: GameEvent[] = [], ): 'clear' | 'blocked' | 'ask' { // A ruling already given for this train is consumed here — this is what stops the driver from // re-asking the same question every time it re-evaluates the train. const answer = s.clock.decisionAnswer; if (answer && answer.kind === 'clearance' && answer.train === id) { s.clock.decisionAnswer = null; return answer.allow ? 'clear' : 'blocked'; } const node = s.division.nodes[targetIndex]; if (!node || node.kind !== 'mainline') return 'clear'; // Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block. const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card); if (profile?.trainsMayPass) return 'clear'; /** * §8.1 asks about the next SUBDIVISION, not the next card. * * "If there is a train in the next Subdivision moving towards the considered train, the considered * train will not depart" — and the same for a following train. This used to inspect only * `node.transits`, the one card being entered, so a train ran headlong into a Subdivision an * opposing train was two cards deep in and was stopped only on the Stage they met. `subdivisions()` * had existed for this the whole time and was called by nothing outside a test. * * It bites hardest early: every Office starts as a Whistle Post, so the entire railroad is ONE * Subdivision until someone upgrades, which is exactly why §8 describes early traffic as * constrained. Each Office upgrade to a Control Point splits one in two and buys capacity. */ const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex]; const occupants: { tray: TrayId; onCard: number }[] = []; for (const i of subdivision) { const n = s.division.nodes[i]; if (!n || n.kind !== 'mainline') continue; for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i }); } /** * TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked * against EVERY occupant before any same-direction judgment call is offered. * * A single pass returned on whichever occupant it examined first, in `node.transits` insertion * order — which is fine while a Subdivision holds at most one train, but the dispatch exception * below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same * card as whatever it was cleared past. Found from a playtest report where the Superintendent was * asked to rule on a same-direction train instead of being auto-held against an uncleared * opposite-direction one also on the card — the loop had reached the same-direction occupant * first simply because it entered the transit list first, and returned before ever looking at * the other. */ for (const { tray: other } of occupants) { if (other === id) continue; const otherTray = s.trays.get(other); if (!otherTray || otherTray.direction === tray.direction) continue; // §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands. // // The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the // Superintendent can arrange a meet with a facing train: "once a day, when dispatching // facing trains, add +4/+8/+12 to the other train's number". Without a device there is no // way to pass the order, so the train simply holds. if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue; return 'blocked'; } for (const { tray: other, onCard } of occupants) { if (other === id) continue; const otherTray = s.trays.get(other); if (!otherTray || otherTray.direction !== tray.direction) continue; const onNode = s.division.nodes[onCard]; // Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented // from moving". Flagging is per-train rather than per-card, so it protects one specific train // where ABS Signals protects everything on the card. // // Red Flags used to protect a stopped train here as well. Gitea#19 replaced that rule outright // (Jesse, 2026-08-29): a flag is now planted on a district's Limits and holds trains coming from // one direction, so it never applies out on the Mainline. ABS Signals is what protects a train // standing on a Mainline card now, and it always did the job better. /** * ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would * normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3). * * With signals in place a following train simply holds and the Superintendent has no judgment * call to make, which is the amendment to Gap 2's unconditional collisions. It is caught HERE * rather than at the entry itself, so the train never gets as far as the card. * * IT USED TO HOLD SILENTLY. A blocked clearance emits nothing on the Office and Division Point * paths, so the one card whose entire purpose is to stop a wreck did its job invisibly: the * train simply did not move, Stage after Stage, with nothing on screen saying why. The card is * unplayable to reason about without this line. */ if (onNode?.kind === 'mainline' && onNode.absSignals) { events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: 'ABS Signals — held short of the train ahead', }); return 'blocked'; } // Same direction — the Superintendent must rule (§8.1, fourth condition). s.clock.pendingDecision = { kind: 'clearance', train: id, occupiedBy: other }; events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other }); return 'ask'; } return 'clear'; } /** * CAN THIS TRAIN REACH THE YARD OFFICE, AND IS THE LEAD CLEAR? (Gitea#5) * * Three answers, because Jesse's ruling (2026-08-29) splits two failures his issue describes * separately: "if the Yard Office is not accessible in one move, you should not get the option" * and "cars on the tracks you use to get in result in a crash". * * - `clear` — a route exists and nothing is standing on it. Offer it; taking it is safe. * - `fouled` — a route exists and there are cars on it. Offer it; taking it collides. * - `none` — no route in one move. Do not offer it, and say why in the history. * * WALKED WITH THE ENGINE'S OWN MOVE RULES rather than a bespoke adjacency test. `exploreMoves` * already means exactly what the card's "in one move" means — any distance without changing * direction, finishing on Operational Rail (§2.4, §A.1) — so using it is what makes the code and * the card agree, which was the whole complaint. * * THE FOULING SIGNAL IS `couples`. The walk does not treat standing cars as obstructions: it * COUPLES them, because that is what a switching move does (§A.4). An arriving train is not * switching, so anything it would have coupled is instead something it is about to hit — the same * reading §8.3 already applies to the Running Track. * * The walk starts at the Office square, where a standard arrival puts the train, and leaves by the * way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can * only be reached by backing up is correctly "not in one move". */ /** * The flag comes down as it stops the train — one card, one train (Gitea#19). * * MUTATES RATHER THAN EMITTING A REDUCED EVENT, because this is the phase driver: `advance.ts` * changes state directly and then describes what it did, and roughly a third of the event types are * never reduced at all (`README.md`, and `tally.ts` on the same asymmetry). A `redFlagSpent` * reducer case looked right and never fired — the flag stayed up and held every train that came. */ function spendFlag( office: Extract, tray: CrewTray, side: Direction, events: GameEvent[], ): 'held' { delete office.redFlag; events.push({ type: 'redFlagSpent', seat: office.seat, side, trainNumber: tray.trainNumber ?? 0 }); events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: 'Red Flags — held short of the Limits', }); return 'held'; } /** * §Q, RED FLAGS (Gitea#19) — does a flag stop this train, and should its owner be offered one? * * Two jobs, because they are the same moment seen twice: a flag already planted stops the train * outright, and a train about to run into trouble is the cue to offer a flag to somebody holding * the card. "You can play the card normally or out of phase, but only if you need it." * * - `held` — a flag was up on the side this train is coming from. It loses this Mainline * Phase and the flag comes down with it: one card, one train (Jesse, 2026-08-29). * - `ask` — entering would collide and the district's owner holds a Red Flags card. * - `proceed` — neither. * * WHICH SIDE. A train running WEST arrives from the east, so `FLAG EAST` is what holds it — which * is the example the issue gives, and the reason the flag names a side rather than a heading. */ function redFlagStop( s: GameState, id: TrayId, tray: CrewTray, dest: Extract, events: GameEvent[], ): 'held' | 'ask' | 'proceed' { const from: Direction = tray.direction === 'east' ? 'west' : 'east'; // The answer to a prompt already put. Consumed here so the driver cannot ask twice. const answer = s.clock.decisionAnswer; if (answer && answer.kind === 'redFlag' && answer.train === id) { s.clock.decisionAnswer = null; if (!answer.flag) return 'proceed'; // The card was spent planting the flag; it stops this train and comes down again at once. return spendFlag(dest, tray, from, events); } if (dest.redFlag === from) return spendFlag(dest, tray, from, events); /** * "In actual cases of danger… if there is a train or cars on the track and there will be a * collision, then you break in with a dialog." The two ways an arrival collides are §8.3's own: * no free A/D track, and cars fouling the Running Track. Asked only of a player who can actually * answer — offering a flag to somebody holding no card is a prompt with one button. */ const owner = playerAtSeat(s, dest.seat); const holdsFlag = (s.decks.hands.get(owner) ?? []).some((cid) => { const c = s.cards.get(cid); return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; }); if (!holdsFlag) return 'proceed'; if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed'; s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from }; return 'ask'; } /** * Would this arrival collide? §8.3's two triggers, asked before the train commits. * * Deliberately a READ of the same conditions `arriveAtOffice` enforces rather than a second rule: * if these two ever diverge, the prompt offers a flag against a collision that will not happen, or * stays silent before one that will. */ function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean { const area = areaAtSeat(s, seat); const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking')); const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks; // Interlocking turns a full Office into a hold rather than a collision, so it is not danger. if (full && !hasInterlocking) return true; // A coach may legally stand at the Office while its engine switches (§A.4's carve-out), so it is // not a hazard to the next arrival. Anything else on the Running Track is. const officeCard = area.grid.get(coordKey(area.officeCoord)); return officeCard !== undefined && officeCard.standing.some((c) => c.type !== 'coach'); } /** * §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there * anything to ask about? * * Returns `ask` only when the offer is real: a coachless train, a Yard Office card in the district, * and a route to it in one move. Everything else is `proceed`, which means the ordinary arrival. * * ALSO THE PLACE THE HISTORY LEARNS WHY NOT. Jesse, 2026-08-29: "make sure this is logged in * history — why can't move so user knows why they can't get to yard." A qualifying train that is * simply never offered the choice looks exactly like the feature being broken, which is how the * missing reachability check went unnoticed for so long. */ function yardOfficeQuestion( s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex, events: GameEvent[], ): 'ask' | 'proceed' { // Already answered: `arriveAtOffice` consumes it. Asking again would loop the phase for ever. const answer = s.clock.decisionAnswer; if (answer && answer.kind === 'yardOffice' && answer.train === id) return 'proceed'; if (tray.consist.some((c) => c.type === 'coach')) return 'proceed'; const area = areaAtSeat(s, seat); if (![...area.grid.values()].some((c) => c.enhancements.includes('yardOffice'))) return 'proceed'; const route = yardOfficeRoute(s, seat, id, tray); if (route.kind === 'none') { events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, to: 'the Office', reason: `the Yard Office could not be offered — ${route.why}`, }); return 'proceed'; } s.clock.pendingDecision = { kind: 'yardOffice', train: id, seat }; return 'ask'; } type YardOfficeRoute = | { kind: 'clear' | 'fouled'; coord: GridCoord } | { kind: 'none'; why: string }; function yardOfficeRoute(s: GameState, seat: SeatIndex, id: TrayId, tray: CrewTray): YardOfficeRoute { const area = areaAtSeat(s, seat); const target = [...area.grid.entries()].find(([, card]) => card.enhancements.includes('yardOffice')); if (!target) return { kind: 'none', why: 'there is no Yard Office in this district' }; const [key] = target; const [row, col] = key.split(',').map(Number); const coord = { row: row!, col: col! }; const player = playerAtSeat(s, seat); const facing = railFacingOf(tray); const found = reachableDestinations( { area, occupancy: occupancyFor(s, player, id), consistSize: tray.consist.length, self: id, }, area.officeCoord, facing, ).find((d) => d.coord.row === coord.row && d.coord.col === coord.col); if (!found) { return { kind: 'none', why: 'it cannot be reached from the Office in one move, running the way this train is facing', }; } return { kind: found.couples.length > 0 ? 'fouled' : 'clear', coord }; } /** * §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds, * the collision happens, with no die roll and no judgment. */ function arriveAtOffice( s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex, events: GameEvent[], ): MoveOutcome { const area = areaAtSeat(s, seat); const capacity = officeProfile(area.tier).adTracks; const hasEnhancement = (key: string): boolean => [...area.grid.values()].some((c) => c.enhancements.includes(key)); /** * §11, THE YARD OFFICE (Gitea#5) — offered, not imposed. * * "Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a player's area * who has the yard office card get an extra ability. On the turn (mainline phase) that the train * arrives the game will offer that player the option to have that train go directly to the yard * office card instead of the standard office. They can of course still choose to have the train * go to the standard office." * * WHAT THIS USED TO DO, and why all three of the rule's conditions were missing: a qualifying * train was TELEPORTED onto the Yard Office card. The player was never asked, no route was ever * computed — so the card's own printed text, "that can reach the yard office in one move", was * unenforced — and because nothing was walked, nothing was ever met on the way in. * * The answer comes back through `pendingDecision`, so this returns `needsClearance` and is * re-entered once the player has answered. `yardOfficeOffer` below is where the route is walked. */ /** * The answer to the offer `yardOfficeQuestion` put before the train left the Mainline card. * Absent — because the train has no Yard Office, or no route to it, or carries coaches — this * falls straight through to the ordinary arrival below. */ const answer = s.clock.decisionAnswer; if (answer && answer.kind === 'yardOffice' && answer.train === id) { s.clock.decisionAnswer = null; const route = answer.take ? yardOfficeRoute(s, seat, id, tray) : { kind: 'none' as const }; if (route.kind !== 'none') { tray.position = { at: 'grid', seat, coord: route.coord }; events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, to: 'the Yard Office', reason: 'no coaches, so it need not occupy the Train Order Office', }); /** * Cars on the lead are a COLLISION, not a coupling — the same §8.3 rule that governs the * Running Track, and the third of the three things this implementation was missing. An * arriving train is at speed and is not expecting them (§A.4). */ if (route.kind === 'fouled') { collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the lead into the Yard Office', 'the Yard Office'); } return 'moved'; } // Declined: fall through to the standard Office, with its own capacity and collision rules. } // Gap 2d — no room at the station is a collision, and it is the local player's fault (§10). if (area.adOccupancy.length >= capacity) { // Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic // collision, the train is held at the Limits until an A/D track frees up. This is the designed // answer to the A/D overflow that Gap 2d made fatal. if (hasEnhancement('interlocking')) { area.heldAtLimits.push(id); events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, to: 'the Limits', reason: 'Interlocking held it clear of a full Office instead of a collision', }); return 'moved'; } collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office'); return 'moved'; } // A train held at the Limits takes the first free A/D track before any newcomer. if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) { const first = area.heldAtLimits.shift()!; area.adOccupancy.push(first); const held = s.trays.get(first); if (held) held.position = { at: 'grid', seat, coord: area.officeCoord }; } area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id); // §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed // is not expecting them (§A.4), so this is a collision too, not a coupling. // // A coach is the one exception (v0.5.0, §A.4's Office carve-out): it may be legally, deliberately // parked at the Office while its engine switches, so it must not become a hazard to the next // arrival. Anything else standing there is still illegal to have dropped in the first place — // `canDropCarsAt` already refuses it — so this filter only ever excludes a coach in practice. const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard && officeCard.standing.some((c) => c.type !== 'coach')) { collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track'); return 'moved'; } area.adOccupancy.push(id); tray.position = { at: 'grid', seat, coord: area.officeCoord }; events.push({ type: 'trainArrived', trainNumber: tray.trainNumber ?? 0, consist: tray.consist.map((c) => ({ ...c })), office: officeProfile(area.tier).name, expedited: isExpedited(tray), }); /** * X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after * that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every * later arrival, so it needs no guard against re-firing. */ if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) { tray.speechMade = true; } return 'moved'; } function collide( s: GameState, faultPlayer: PlayerIndex, trains: TrayId[], events: GameEvent[], reason: string, where = 'the Office', ): void { // Record WHAT was lost before removing it. The log said only "COLLISION: NO FREE A/D TRACK", so a // player could see the 5 points go without learning which train had just been written off, or // what it was carrying. const lost: { label: string; consist: RollingStock[] }[] = []; for (const id of trains) { const tray = s.trays.get(id); if (!tray) continue; lost.push({ label: tray.trainNumber === null ? 'the local crew' : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`, consist: [...tray.consist], }); // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification. // `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp // (state.ts) belongs to the load, not to the car that happened to be carrying it. for (const car of tray.consist) { if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car)); else s.yards.classificationYard.push(pooled(car)); } s.trays.delete(id); s.freeTrays.push(id); /** * TAKE THE WRECK OFF THE CARD. * * Nothing did. `s.trays.delete` removed the train and left its `Transit` sitting on the Mainline * card it died on, and `evaluateClearance` counts every transit as an occupant — so a rear-end * collision (the caller at "ran into the train ahead") permanently poisoned that card: every * later train was either held against a ghost or put to the Superintendent about one. The only * other place a transit is removed is a train rolling off the far end, which a destroyed train * never does. * * Found while adding the Interchange start, which clears onto the running line through that * same occupant list. */ for (const n of s.division.nodes) { if (n.kind !== 'mainline') continue; n.transits = n.transits.filter((t) => t.tray !== id); if (n.holding) n.holding = n.holding.filter((t) => t !== id); } /** * AND OFF THE A/D TRACK, for exactly the same reason as the transit above. * * It never mattered while every collision happened to a train already out on the road. Gitea#3 * adds one that can happen as a train LEAVES — a following train entering an occupied region — * and that train is still standing at the Office when it dies. Without this its A/D track stays * marked forever: the Office reads as permanently full, and every later arrival collides against * a train that no longer exists. */ for (const [, area] of s.officeAreas) { area.adOccupancy = area.adOccupancy.filter((t) => t !== id); } } if (lost.length > 0) { events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where }); } s.collisionsToday += 1; s.collisionsTotal += 1; const player = s.players[faultPlayer]; if (player) { player.revenue -= COLLISION_PENALTY; events.push({ type: 'revenueChanged', player: faultPlayer, delta: -COLLISION_PENALTY, total: player.revenue, reason: `collision: ${reason}`, }); } } /** * ONE REVENUE TO EVERY PLAYER WHEN A TRAIN COMPLETES ITS RUN. * * Jesse's rule, revised. It used to pay 1 to the Office a train departed — "it left YOUR section" — * which meant a five-Office railroad paid five times for one train and paid the most to whoever the * train happened to pass first. It pays once now, when the train runs off the end of the Division, * and it pays EVERYBODY: getting a train the whole length of the railroad is the shared achievement, * and every Office it crossed had to clear it. * * Unlike freight and passengers it pays for traffic no one has to work, which is the point: keeping * the line moving is the Superintendent's job, and nothing else paid for doing it well. It rewards * splitting a Subdivision with a Control Point, holding a following train rather than gambling on * it, and building the A/D capacity to turn arrivals around. * * A crew with no train number is a local switching move, not a run, so it earns nothing. * * NOW A DIAL, AND OFF BY DEFAULT. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0 — the * railroad was making most of its money from the one thing no one has to work, and the freight and * passenger economies it exists to reward could not be read through it. `trainPerTransit` sets the * rate per player, and 0 (the default) means no event at all rather than a run of "+0" entries. */ function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void { if (tray.trainNumber === null) return; const rate = houseRules(s.config).revenue.trainPerTransit; if (rate <= 0) return; for (const p of s.players) { p.revenue += rate; events.push({ type: 'revenueChanged', player: p.index, delta: rate, total: p.revenue, reason: 'a train completed its run', }); } } /** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */ function retireTrain( s: GameState, id: TrayId, tray: CrewTray, side: Direction, events: GameEvent[], ): void { // `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard. for (const car of tray.consist) { if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car)); else s.yards.classificationYard.push(pooled(car)); } s.trays.delete(id); s.freeTrays.push(id); const dp = s.division.nodes.find( (n) => n.kind === 'divisionPoint' && n.holding.includes(id), ); if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id); events.push({ type: 'trainCompleted', trainNumber: tray.trainNumber ?? 0, isExtra: tray.trainIsExtra, side, consist: tray.consist.map((c) => ({ ...c })), }); awardCompletedRun(s, tray, events); } // --------------------------------------------------------------------------- // Shift change, Stage and Day advance // --------------------------------------------------------------------------- function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult { // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12. if (s.clock.stage % STAGES_PER_SHIFT === 0) { s.clock.superintendent = playerLeftOf(s, s.clock.superintendent); events.push({ type: 'actorChanged', player: s.clock.superintendent }); } // §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase. for (const area of s.officeAreas.values()) { for (const card of area.grid.values()) { if (card.facility) card.facility.usedThisStage = { laborers: 0, porters: 0 }; } } if (s.clock.stage >= STAGES_PER_DAY) { // Telegraph/Telephone/Radio are each usable once a Day. for (const area of s.officeAreas.values()) area.dispatchUsedToday = []; s.clock.day += 1; s.clock.stage = 1; // Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is // drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`. s.collisionsPrevDay = s.collisionsToday; s.collisionsToday = 0; events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); rotateSeats(s, events); const finished = checkVictory(s, events); if (finished) return { events, needsInput: false }; } else { s.clock.stage += 1; events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage }); } /** * §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the * game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled * by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide, * not a bigger shared budget. * * SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode === * 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game * dialog offered them as live settings — so a solitaire player could set a collision limit, read * "the game ends in a loss" beside it, and crash as often as they liked. Found reviewing that * screen's wording (Jesse, 2026-08-30); his ruling is that the settings should do what they say, * so the gate is gone rather than the controls. * * A SOLITAIRE GAME CAN THEREFORE NOW END EARLY, which no measurement in `TODO.md` was taken * under. At the shipped defaults (3 a Day, 5 total) it is a rare ending rather than a common one — * the bot averages 0.06 collisions a game — but any figure quoted from a full-length run predates * it. */ { const perDayBreach = s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay; const totalBreach = s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal; if (perDayBreach || totalBreach) { s.status = 'finished'; /** * NOT EXTENDABLE, AND IT DOES NOT REWRITE A RECORDED RESULT (Gitea#11). * * A breach during an EXTENDED Day ends play at once, exactly as it would during the regular * game — but by then the official result already exists, and a railroad declared unsafe on * Day 9 does not retract who won on Day 5. `freezeOfficial` is what keeps that true: it * writes only when `official` is still null, so assigning `outcome` here is safe. */ s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' }; return { events, needsInput: false }; } } return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false }; } /** * §3.3 — evaluated at the end of a Day. * * Unified 2026-08-20 across all three modes: play `config.days`, then whoever has the most Revenue * wins — unless the table's combined Revenue missed `config.minCombinedRevenue`, in which case * everyone loses. Solitaire is "everyone" with one player, so this is the same win/lose shape it * always had, just against a configured floor instead of a `length`-preset `target`. Coop keeps its * "the table's score is everyone's Revenue summed" model — winner stays null, the achievement is * shared — now against the same configurable floor. */ /** * EMPLOYEE ROTATION (Appendix B) — "at the end of the day, all players move one chair to the left * and take over the next station up the line. Take your points (and the Fedora) with you." * * This is the rule the whole seat/player split exists for (D9, and `state.ts`'s note on * `SeatIndex`), which is why it is four lines: `seating` is the only thing that moves. Everything * keyed by PLAYER — Revenue, hands, the Superintendent, whose turn it is — travels with them for * free, and everything keyed by SEAT — the Office, the district, the grid, trains standing in it — * stays exactly where it is. Inheriting the state of the district you move into is the point of the * rule, not a side effect of it. * * "Left" is `seatOf + 1`, matching `playerLeftOf`, which is the convention the rest of the engine * already turns the table by. */ function rotateSeats(s: GameState, events: GameEvent[]): void { if (!s.config.optionalRules.employeeRotation || s.seating.length < 2) return; const n = s.seating.length; const next: PlayerIndex[] = new Array(n); for (let seat = 0; seat < n; seat++) next[(seat + 1) % n] = s.seating[seat]!; s.seating = next; events.push({ type: 'seatsRotated', day: s.clock.day, seating: [...next] }); } function checkVictory(s: GameState, _events: GameEvent[]): boolean { const daysElapsed = s.clock.day - 1; /** * `extraDays` is Gitea#11. `config.days` is never touched by an extension — it is what the * OFFICIAL result is decided at — so the Day the timetable currently runs to is the sum of the * two. On the first ending they are equal, which is why `freezeOfficial` can record `config.days` * as the official Day without asking anything further. */ if (daysElapsed < s.config.days + s.extraDays) return false; s.outcome = decideOutcome(s); /** * §3.3, EXTENDED PLAY — an ending the table may play past PAUSES rather than finishing. * * `freezeOfficial` (the `advance` wrapper) records the first of these as the official result, so * by the time a second one is reached the winner is already settled and everything here is * informational. The votes are cleared each time because the question is asked again at the end * of every extended Day: agreeing once does not agree to the rest of the game. */ if (isExtendable(s.outcome.reason)) { s.status = 'awaitingExtension'; s.extensionVotes = s.players.map(() => null); } else { s.status = 'finished'; } return true; } /** * WHO WON, on the evidence as it stands right now. * * Split out of `checkVictory` for Gitea#11: it is asked once per ending, and an extended game has * more than one. Unchanged in substance — the revenue floor, then co-op's shared achievement, then * the highest Revenue — it simply no longer writes to the state it is reasoning about. */ function decideOutcome(s: GameState): Outcome { const combined = totalRevenue(s); if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) { return { result: 'loss', winner: null, reason: 'revenueFloor' }; } if (s.config.mode === 'coop') { return { result: 'win', winner: null, reason: 'daysElapsed' }; } const best = Math.max(...s.players.map((p) => p.revenue)); return { result: 'win', winner: s.players.findIndex((p) => p.revenue === best), reason: 'daysElapsed', }; } // --------------------------------------------------------------------------- // Pump helper // --------------------------------------------------------------------------- /** Runs automatic work until a player must act or the game ends. */ export function pump(s: GameState, maxSteps = 10_000): GameEvent[] { const all: GameEvent[] = []; for (let i = 0; i < maxSteps; i++) { const r = advance(s); all.push(...r.events); if (r.needsInput || s.status === 'finished') return all; } throw new Error('phase driver failed to settle — probable infinite loop'); } export { nodeIndexOfOffice };