/** * 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, collectiveRevenueFloor, houseRules, lengthProfile, 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, trainNeedingCars } from './apply.ts'; import { legalActions } from './legal.ts'; import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.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]); 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 // --------------------------------------------------------------------------- export function advance(s: GameState): AdvanceResult { const events: GameEvent[] = []; if (s.status === 'finished') return { events, needsInput: false }; // 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. */ if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) { s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length); return { events, needsInput: true }; } // Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains. const filling = trainNeedingCars(s); if (filling) { s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length); 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 TRAIN — "one turn stopped on any track (circus set-up) earns 1 point". * * The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays * for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a * full Stage and no point arrived. Claimed once — an Extra runs once and is gone. * * "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 (!tray.stopPointClaimed && 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)); if (stillThere) { tray.stopPointClaimed = true; // The point goes to whoever is SITTING in the district it stopped in. const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0; const label = tray.position.at === 'grid' ? `(${tray.position.coord.col},${tray.position.coord.row})` : tray.position.at === 'mainline' ? `Mainline card ${tray.position.index}` : `the ${tray.position.side} Division Point`; 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: 'circus set-up — a Stage spent standing still', }); } } } 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, and the dispatch devices are installed in an Office Area, which // is keyed by SEAT. Indexing one with the other is right only while seating is the identity map. 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. */ function isExpedited(tray: CrewTray): 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." */ 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]; node.transits = node.transits.filter((t) => t.tray !== id); // Red Flags protect a train while it is stopped here; once it rolls, the flags come in. if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== 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 ruling = s.clock.clearanceRuling; if (ruling && ruling.train === id) { s.clock.clearanceRuling = null; return ruling.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. // // Both of these read the card the OTHER train is standing on rather than the card being // entered. They were the same card while this only looked one card ahead; across a Subdivision // they are not, and the protection belongs where the train it protects actually is. if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked'; // ABS Signals — "trains on this card will not rear-end each other; they stop short of a // collision". With signals in place a following train simply holds, and the Superintendent has // no judgment call to make. This is the amendment to Gap 2's unconditional collisions. if (onNode?.kind === 'mainline' && onNode.absSignals) { /** * ABS Signals — "if a collision would normally occur, the train moving onto the card is * instead held back" (RAR, Gitea#3). 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. */ 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 = { train: id, occupiedBy: other }; events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other }); return 'ask'; } return 'clear'; } /** * §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)); // Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office // track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely. const noCoaches = !tray.consist.some((c) => c.type === 'coach'); if (noCoaches && hasEnhancement('yardOffice')) { for (const [key, card] of area.grid) { if (!card.enhancements.includes('yardOffice')) continue; const [row, col] = key.split(',').map(Number); tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } }; events.push({ type: 'trainDiverted', trainNumber: tray.trainNumber ?? 0, to: 'the Yard Office', reason: 'no coaches, so it need not occupy the Train Order Office', }); return 'moved'; } } // 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. const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard && officeCard.standing.length > 0) { 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); if (n.redFlagged) n.redFlagged = n.redFlagged.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; 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; s.collisionsToday = 0; events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 }); 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 — Competitive only: three collisions in one Day and everyone loses. if (s.config.mode === 'competitive' && s.collisionsToday >= 3) { s.status = 'finished'; 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. */ function checkVictory(s: GameState, _events: GameEvent[]): boolean { const profile = lengthProfile(s.config.length); const daysElapsed = s.clock.day - 1; if (s.config.victory === 'firstToTarget') { const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target; const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue)); if (score >= target) { const winner = s.config.mode === 'coop' ? null : s.players.findIndex((p) => p.revenue === score); s.status = 'finished'; s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' }; return true; } return false; } if (daysElapsed < profile.days) return false; s.status = 'finished'; if (s.config.mode === 'competitive') { // §3.5 — all players' Revenue combined must clear the floor, or everyone loses. if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) { s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' }; return true; } const best = Math.max(...s.players.map((p) => p.revenue)); s.outcome = { result: 'win', winner: s.players.findIndex((p) => p.revenue === best), reason: 'daysElapsed', }; return true; } // Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score. const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target; const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0); s.outcome = score >= target ? { result: 'win', winner: null, reason: 'daysElapsed' } : { result: 'loss', winner: null, reason: 'revenueFloor' }; return true; } // --------------------------------------------------------------------------- // 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 };