Files
station-master/src/engine/advance.ts
T
Jesse.MarkowitzandClaude Opus 5 9a9e50b3c6 v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can
work the train. The turn chart follows the animation queue, so being five behind
looks five behind across the whole screen rather than half of it. Pause sits
beside Skip and preserves the dwell a held step still owed. A one-render look at
another player's Office Area. The district summary counts the board being shown.
LIMITS is printed beneath its card instead of through its border. The Mainline
region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only
card that reads it. A passenger Modifier on a Whistle Post reports itself dormant
rather than claiming the facility "only receives". An automatic phase says what
the Division is doing instead of answering by negation. The version appears once
in the header rather than twice on every .s9pk. Save files carry the join code,
the Stage and the date.

The New Train phase, reviewed before being changed: the make-up panel now says
what the train STILL needs rather than only what its card calls for, explains
that a player adds one car before the round passes on, marks the train being
loaded on the Division map, and gives an addable car in the yard the same amber
every other clickable thing on the page wears.

Reasoning, measurements and the reports behind each are in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 16:06:01 -04:00

1830 lines
82 KiB
TypeScript

/**
* 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);
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
const at = tray.position;
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
if (at.at === 'mainline') return at.index;
if (at.at === 'divisionPoint') {
const side = at.side;
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
}
return null;
}
// ---------------------------------------------------------------------------
// 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.
*
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
*/
export 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<DivisionNode, { kind: 'mainline' }>,
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<DivisionNode, { kind: 'mainline' }>): 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<DivisionNode, { kind: 'mainline' }>,
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<DivisionNode, { kind: 'mainline' }>,
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];
/**
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
* threat at all.
*
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
* question, and this is not the place to answer it.
*/
const from = nodeIndexOfTray(s, tray);
const behind = (onCard: number): boolean =>
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) {
const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue;
if (behind(i)) 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<DivisionNode, { kind: 'office' }>,
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<DivisionNode, { kind: 'office' }>,
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,
owner: playerAtSeat(s, seat),
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<PlayerIndex>(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 };