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.
1345 lines
57 KiB
TypeScript
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 };
|