Files
station-master/src/engine/advance.ts
T
Jesse.Markowitz e9683cc8c5 v0.4.9h — a leg that is part of the row, the deck the sheet prints, and regions not miles per hour
The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a
multiplayer redesign and does not belong in a build people are mid-playtest on.

Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut
through a curve's south leg no longer couples it back to front, and a crew
pulling out through a leg takes its own cut with it.

Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12
office doubling and the Gap 12 industry tripling.

Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no
train crosses the gap.

Gitea#3 — the printed speeds are scenery. One Stage per printed region, and
where a train starts is what varies.
2026-08-26 15:21:49 -04:00

1345 lines
57 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,
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<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, 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 };