Files
station-master/src/engine/apply.ts
T
JesseandClaude Sonnet 5 fbaa3d4147 switching paths: two routes to the same square
Builds docs/plans/switching-paths.md. A passing loop can offer two legal
routes between the same two squares, coupling different cars — the engine
only ever found one, an artifact of search order (Reported by Jesse, undo
379). exploreMoves now enumerates every simple route (per-path visited set,
capped at 4000 frontier nodes) and dedupes on outcome — destination, entry
side, and origin-tagged cars — rather than on reaching the square at all.

switch.move gains an optional `via: GridCoord` naming one intermediate
square on the chosen route; absent, it resolves exactly as before, so
every existing save and bot decision replays identically (575/575, then
579/579 with the new tests). Threaded through the label, the action-list
dedupe, the hover highlight (data-route), and the history (trayMoved.via).

Ruling recorded as Gap 14 in docs/rules/open-questions.md: the player may
choose the path; §A.4's "may not go around" a car does not reach a
different track the player declined to enter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
2026-08-19 16:52:10 -04:00

2747 lines
123 KiB
TypeScript

/**
* Component 4 — Intent validation and application.
*
* `applyIntent(state, actor, intent) -> events | rejection`.
* See architecture/components.md §2 A.4.
*
* STRUCTURE. Every intent is a `check` + `execute` pair:
* - `check` answers "is this legal right now?" and NEVER mutates.
* - `execute` reads state and emits events; it never mutates either.
* - `reduce` is the only thing that mutates, folding events into state.
*
* That keeps every INTENT-driven change reducible by construction. Replay and restart recovery run
* on the intents themselves (`protocol.md` §3), not on folding the log. It also lets component 6 (legalActions) call these very same `check` functions,
* so the two can never drift apart — see legal.ts.
*/
import {
FREIGHT_PROFILES,
HAND_LIMIT,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
consistSize,
enhancementRule,
industryProfile,
mainlineModifierRule,
mainlineProfile,
houseRules,
runDirection,
startingDivisionPoint,
modifierProfile,
nextOfficeTier,
officeProfile,
trainProfile,
} from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
CrewTray,
Facility,
GameState,
GridCoord,
Load,
OfficeArea,
PlayerIndex,
RollingStock,
SeatIndex,
TrackArc,
TrackCard,
TrayId,
TurnoutOrientation,
} from './state.ts';
import { createRng } from './rng.ts';
import {
carsOn,
coordKey,
cutTowards,
isOperationalRail,
playerAtSeat,
railFacingOf,
seatOf,
spaceOn,
standingSides,
trackOrder,
turnOf,
} from './state.ts';
import type { MoveBlock, MoveDestination, Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
exploreMoves,
facilityVariants,
opposite,
reachableDestinations,
variantsFor,
withinLimits,
} from './track.ts';
export type ApplyResult =
| { ok: true; events: GameEvent[] }
| { ok: false; code: RejectionCode; message: string };
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
/**
* The Office Area belonging to a PLAYER — that is, the one at the seat they currently occupy.
*
* Goes through `seatOf` rather than indexing directly, which is the whole point of the seat/player
* split: today seating is the identity mapping so this is exactly what it always was, and under
* Employee Rotation it follows the player to their new chair without a single caller changing.
*/
export function areaOf(s: GameState, player: PlayerIndex): OfficeArea {
return areaAtSeat(s, seatOf(s, player));
}
/** The Office Area at a POSITION on the Division, regardless of who is sitting there. */
export function areaAtSeat(s: GameState, seat: SeatIndex): OfficeArea {
const a = s.officeAreas.get(seat);
if (!a) throw new Error(`no Office Area at seat ${seat}`);
return a;
}
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
return area.grid.get(coordKey(c));
}
function facilityAt(s: GameState, player: PlayerIndex, c: GridCoord): Facility | null {
return cardAt(areaOf(s, player), c)?.facility ?? null;
}
function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
const tray = s.trays.get(trayId);
if (!tray || tray.position.at !== 'grid') return null;
return tray.position.coord;
}
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
const area = areaOf(s, player);
return {
trayAt: (c) => {
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) {
return id;
}
}
return null;
},
freeAdTracks: () => {
const cap = officeProfile(area.tier).adTracks;
return cap - area.adOccupancy.filter((t) => t !== self).length;
},
};
}
/**
* The port a train that came in through `entry` would carry on out by — read off the CARD.
*
* On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve
* it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges.
*
* A card reached by a Move has exactly one exit from the port it was entered by — the only card with
* three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller
* holding a card the walk never validated, and matches the old behaviour rather than throwing.
*/
function farPort(card: TrackCard | undefined, entry: Port): Port {
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
}
/**
* THE PORT A TRAIN BACKS OUT BY — the other end of the card it is standing on.
*
* Not `opposite(facing)`. A train stands on a two-port card (never a turnout: §A.1 forbids
* finishing a Move on one), and backing up means leaving by whichever of those two ports it is not
* facing. On a straight those coincide; on a curve they never do, because a curve joins ADJACENT
* edges — a crew facing north on a north-west curve backs out through WEST, and `opposite('n')` is
* a south port the card does not have.
*
* The consequence of getting this wrong is total: `exploreMoves` returns nothing at all from a port
* the card lacks, so the crew simply cannot back up. It could round a curve and never come off it,
* which is enough to make a siding unreachable and setting out a cut impossible.
*/
function reversePort(s: GameState, player: PlayerIndex, from: GridCoord, facing: Port): Port {
return farPort(areaOf(s, player).grid.get(coordKey(from)), facing);
}
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
if (tray?.facing) return tray.facing;
return tray?.direction === 'west' ? 'w' : 'e';
}
// ---------------------------------------------------------------------------
// Shared predicates — used by BOTH check() below and legalActions()
// ---------------------------------------------------------------------------
export function isActor(s: GameState, player: PlayerIndex): boolean {
return s.clock.currentActor === player;
}
export function inPhase(s: GameState, phase: GameState['clock']['phase']): boolean {
return s.clock.phase === phase;
}
/**
* §6 — "a player may do one of three things". You cannot do a thing that does not exist: a player
* with no Facility has no Freight Agent operation available, and one with no Crew Tray in his area
* has nothing to switch. Choosing such an option is illegal rather than a wasted turn.
*
* These deliberately inspect state directly rather than calling `check`, which would be circular.
*/
export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f) continue;
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true;
if (f.inboundBox.length > 0) return true;
if (f.menAtWork?.some((l) => l !== null)) return true;
if (f.outboundBox.length > 0) return true;
}
return false;
}
/**
* §9.1 — "Loading boxes are green, with the icon of the car type that can be loaded from there."
* A facility handles exactly one commodity, so only that car type may be stocked into it.
*
* Without this check a Mine Tipple could be stocked with a coach. The load would then wait forever
* for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK
* strips the industry track of Operational Rail status (§9.3), the facility would jam permanently.
*/
/**
* EVERY commodity a facility handles, not just the first one printed.
*
* Two of the six industries take two: a Power Plant burns coal OR oil (`['hopper','tank']`) and a
* Grocer's Warehouse receives dry goods OR perishables (`['boxcar','reefer']`). This used to return
* `carTypes[0]`, and because `freightAgent.stockOutbound` gates on it, the engine rejected the
* second commodity as WRONG_CAR_TYPE — the sheet said a Power Plant takes tank cars and the code
* said it did not. Measured effect: tank cars were dropped 0 times in 100 games.
*/
export function facilityCarTypes(f: Facility): readonly CarType[] {
if (f.kind !== 'freight') return f.kind === 'passenger' ? ['coach'] : [];
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes ?? [];
}
/** The commodity to NAME a facility by, where one word is wanted. Legality must use the full set. */
export function facilityCarType(f: Facility): CarType | null {
return facilityCarTypes(f)[0] ?? null;
}
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.seat === seatOf(s, player)) return true;
}
return false;
}
/** §9.1 — Laborers and Porters may be used once each per Stage. */
export function laborersLeft(f: Facility): number {
return f.laborers - f.usedThisStage.laborers;
}
export function portersLeft(f: Facility): number {
return f.porters - f.usedThisStage.porters;
}
/**
* Where a load may advance to along MEN | AT | WORK (§9.3).
*
* Direction depends on which way the load is travelling. **Outbound** runs Green -> MEN -> AT ->
* WORK -> onto a spotted empty car. **Inbound** runs car -> WORK -> AT -> MEN -> red box. Getting
* this wrong conflates loading with unloading, which is exactly the bug the statistics found:
* `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point.
*/
export function canAdvanceLoad(f: Facility, box: number): boolean {
// A Passenger Facility has no pipeline at all (§9.2 is porters, not MEN | AT | WORK), so freight
// work is refused here on the shape of the facility rather than on it happening to have 0 Laborers.
if (!f.menAtWork) return false;
if (box < 0 || box >= f.menAtWork.length) return false;
const load = f.menAtWork[box];
if (!load) return false;
if (laborersLeft(f) < 1) return false;
const next = load.dir === 'out' ? box + 1 : box - 1;
if (next >= f.menAtWork.length) {
// Off WORK and onto a spotted empty car of the right type.
return f.industryTrack.cars.some((c) => !c.loaded && c.type === load.type);
}
if (next < 0) {
// Off MEN and into the red Inbound box.
return f.inboundBox.length < f.capacity.inbound;
}
return f.menAtWork[next] === null;
}
/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */
/**
* A turnout's diverging leg, as an arc.
*
* The stem is always an east or west edge and the leg always leaves north or south, so the arc is
* just the two named together — `{stem:'w', diverge:'s'}` is `sw`. Naming the leg this way is what
* lets the upgrade rule below be stated in GEOMETRY rather than in hands, so it is unaffected by
* which printed row we call left.
*/
function divergingArc(t: TurnoutOrientation): TrackArc {
return `${t.diverge}${t.stem}` as TrackArc;
}
/**
* May this turnout be laid ON TOP of the card already on this square?
*
* Reported from playtesting: a district can only ever hang off a turnout, so a player who has laid
* a straight along the main and then wants to branch there had no move at all — the piece had to
* have been a turnout when it went down. A turnout may therefore UPGRADE:
*
* - a **straight**, at any of its orientations, because a turnout is a straight plus a leg; or
* - a **curve of the same arc** as the turnout's own diverging leg, which is the same road with a
* through track added beside it.
*
* Both are strict port SUPERSETS of what they replace — `{e,w}` for a straight, one arc for a curve
* — so an upgrade can never sever a join a neighbour already relies on, and needs no connection test
* of its own. The new leg is allowed to reach nothing at all; opening a direction is the point.
*
* Two things block it, and both are about the card being in use rather than about its shape: you
* cannot swap the track out from under a standing car, and an Interlocking or Telegraph built on the
* card would have to be lifted with it. The replaced card leaves play — board cards are never
* salvaged (see the `cardPlayed` reducer), so a lifted one is simply gone, as it would be at a table.
*/
function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCode | null {
const t = proto.geometry.kind === 'track' ? proto.geometry.turnout : undefined;
if (!t) return 'NOT_UPGRADEABLE_TRACK';
const g = existing.geometry;
if (g.kind !== 'track') return 'NOT_UPGRADEABLE_TRACK';
if (g.geometry === 'curved' || g.geometry === 'sharpCurved') {
// The ARC, not merely the diagonal: a `sw` curve and an `ne` one share a slope but leave by
// opposite edges, so replacing one with the other would move the leg off its neighbour.
if (g.arc !== divergingArc(t)) return 'NOT_UPGRADEABLE_TRACK';
} else if (g.geometry !== 'straight') {
return 'NOT_UPGRADEABLE_TRACK';
}
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
return null;
}
/**
* The MEN | AT | WORK pipeline of a Freight Facility.
*
* `execute` and `reduce` run only after `check` has passed, and every freight-work check refuses a
* Passenger Facility — so reaching here with one is a broken invariant, not a case to handle. Throwing
* says that, where a `!` would quietly write into nothing and leave the fault to surface later.
*/
function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
if (!f.menAtWork) throw new Error('freight work attempted on a Passenger Facility');
return f.menAtWork;
}
/**
* The green-box load that may start down the sign, or null if none may.
*
* §9.3 — a load has to have somewhere to go before the Laborers touch it. It may not start the
* MEN | AT | WORK moves until an empty car of the right type is standing on the industry's track,
* "otherwise you are just dropping cargo onto the tracks — pointless waste". The final swap needs a
* car of the LOAD's type, so a hopper load cannot be swapped onto a tank: strict matching, not "any
* empty car". THIS IS THE ONLY PLACE THAT RULE APPLIES — stocking the green box is free of it
* (§6.3), so cargo may wait on the dock for a car that has not been switched in yet.
*
* Not simply `outboundBox[0]`. A Power Plant takes hoppers AND tanks, so a green box holding a
* hopper load and a tank load against one spotted empty tank must start the TANK — taking the first
* entry regardless would report the whole facility as blocked while a perfectly legal load sat
* beside it.
*
* Cars are COUNTED against claims rather than merely looked for, because green boxes and industry
* tracks both grow past one slot with Modifiers (measured: capacity > 1 on 24.5% of freight
* facilities and a track longer than one on 41%). Two loads walking the sign toward one spotted car
* would strand the second on WORK, which is the jam this rule exists to prevent.
*/
export function startableLoad(f: Facility): number | null {
const claims = new Map<CarType, number>();
for (let i = 0; i < f.outboundBox.length; i++) {
const type = f.outboundBox[i]!.type;
// Each earlier entry of the same type has first claim on the spotted cars.
const ahead = claims.get(type) ?? 0;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
if (spotted - working - ahead > 0) return i;
claims.set(type, ahead + 1);
}
return null;
}
export function canStartLoad(f: Facility): boolean {
if (!f.menAtWork) return false;
if (laborersLeft(f) < 1) return false;
if (f.outboundBox.length === 0) return false;
if (f.menAtWork[0] !== null) return false;
return startableLoad(f) !== null;
}
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
// train these passengers can board however many empty coaches it is carrying.
return trainAtOfficeWith(
s, player,
(c) => c.type === 'coach' && !c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
}
/**
* §9.2 — de-training needs an open red slot, a train carrying a loaded coach, AND AN EMPTY COACH IN
* THE DIVISION YARD.
*
* "*Requirements: a white empty coach in the Division Yard and an unoccupied red Unloading slot.
* Replace the blue coach on the train with the white one.*" The empty coach is where the passengers
* were sitting — it has to come from somewhere, and the rule says where.
*
* That requirement was missing, and the reducer conjured the coach rather than taking it, so every
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
*/
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
if (f.inboundBox.length >= f.capacity.inbound) return false;
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
return trainAtOfficeWith(
s, player,
(c) => c.type === 'coach' && c.loaded,
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
);
}
function trainAtOfficeWith(
s: GameState,
player: PlayerIndex,
pred: (c: RollingStock) => boolean,
trayOk: (t: CrewTray) => boolean = () => true,
): boolean {
const area = areaOf(s, player);
return area.adOccupancy.some((id) => {
const t = s.trays.get(id);
return !!t && trayOk(t) && t.consist.some(pred);
});
}
// ---------------------------------------------------------------------------
// §7 — the operating rules printed on a train's own card
// ---------------------------------------------------------------------------
/**
* What this tray's train card prints, or nothing at all.
*
* A local crew has no train number and therefore no printed rules — it is the player's own switcher
* and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a
* crew is unaffected by all of them.
*/
/** Which district a tray is standing in; 0 when it is out on the Division. */
function trayySeat(tray: CrewTray): SeatIndex {
return tray.position.at === 'grid' ? tray.position.seat : 0;
}
function rulesOf(tray: CrewTray): TrainRules {
if (tray.trainNumber === null) return {};
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
}
/** Freight cars this train has already exchanged on this square this turn (trains 3/4). */
function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
return `${trayId}@${coordKey(at)}`;
}
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
/**
* May this train work these freight cars on this square?
*
* Trains 3/4 Express print "may drop or pick up one freight car at EVERY location", so the budget is
* per square rather than per turn — it may work a car here, move on, and work another there. Both
* setting out and picking up spend from the same one, because the card says "drop OR pick up".
*/
function freightBudgetLeft(
s: GameState,
player: PlayerIndex,
tray: CrewTray,
at: GridCoord,
wanted: number,
): boolean {
if (!rulesOf(tray).oneFreightPerLocation) return true;
const already = turnOf(s, player).freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
return already + wanted <= 1;
}
/**
* Whether a train may do switching work at all (§7).
*
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
* sorting alike, because all three are switching.
*/
function switchingRefusal(tray: CrewTray): RejectionCode | null {
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
}
/**
* Charge freight cars against this train's per-location budget (trains 3/4).
*
* Called from BOTH the coupling and the setting-out reducers, because the card says "drop OR pick up
* one" — the two share a budget rather than getting one each. Only recorded for trains the rule
* applies to, so the map stays empty for everything else.
*/
function spendFreightBudget(
s: GameState,
player: PlayerIndex,
tray: CrewTray,
at: GridCoord,
stock: readonly RollingStock[],
): void {
if (!rulesOf(tray).oneFreightPerLocation) return;
const n = stock.filter(isFreight).length;
if (n === 0) return;
const turn = turnOf(s, player);
const key = freightWorkedKey(tray.id, at);
turn.freightWorked[key] = (turn.freightWorked[key] ?? 0) + n;
}
/**
* Give back what a set-out spent, when the train picks its own cut straight back up.
*
* The mirror of `spendFreightBudget` and deliberately its own function: "undo the drop" is a rule,
* not an arithmetic detail, and it applies on the square the cars were LEFT on rather than the one
* the train ends up at.
*/
function refundFreightBudget(
s: GameState,
player: PlayerIndex,
tray: CrewTray,
at: GridCoord,
stock: readonly RollingStock[],
): void {
if (!rulesOf(tray).oneFreightPerLocation) return;
const n = stock.filter(isFreight).length;
if (n === 0) return;
const turn = turnOf(s, player);
const key = freightWorkedKey(tray.id, at);
turn.freightWorked[key] = Math.max(0, (turn.freightWorked[key] ?? 0) - n);
}
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
function refusesPassengers(tray: CrewTray): boolean {
return rulesOf(tray).noPassengerWork === true;
}
/**
* Trains 1/2 Crack Limited — "stop at Terminals only". It runs into every Office and takes an A/D
* track like anything else, but Porters only work it where it is booked to stop, so passengers can
* neither board nor alight anywhere but a Terminal.
*/
function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
if (!rulesOf(tray).terminalsOnly) return false;
return areaOf(s, player).tier !== 'terminal';
}
/**
* WHY passenger work was refused — the printed rule if one is to blame, otherwise the general one.
*
* `canBoard`/`canDetrain` answer a single yes/no over every train at the Office, so when they say no
* this works out whether a card is the reason. Without it a Military train standing at the platform
* reported "no train at the Office", which is both wrong and unhelpful.
*/
function passengerRefusal(
s: GameState,
player: PlayerIndex,
at: GridCoord,
dir: 'board' | 'detrain',
): RejectionCode {
const area = areaOf(s, player);
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
/**
* A TRAIN IS STANDING THERE, so say what is actually missing.
*
* This used to fall through to `NO_TRAIN_AT_OFFICE` — told to a player looking straight at a train
* on their own A/D track, which reads as a broken game rather than a rule. Measured over 60
* solitaire games it fired 51 times with a coach train in front of the player: 27 with nobody
* waiting to travel, 24 with passengers waiting and every coach already full.
*/
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger') return 'NO_SUCH_FACILITY';
if (dir === 'board') {
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return 'NO_PASSENGERS_WAITING';
return 'NO_EMPTY_COACH';
}
if (f.inboundBox.length >= f.capacity.inbound) return 'INBOUND_BOX_FULL';
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return 'NO_EMPTY_COACH_IN_YARD';
return 'NO_LOADED_COACH';
}
// ---------------------------------------------------------------------------
// check — never mutates
// ---------------------------------------------------------------------------
export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null {
if (s.status !== 'active') return 'WRONG_PHASE';
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
// automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
if (i.type === 'mainline.clearance') {
if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION';
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
return null;
}
if (!isActor(s, player)) return 'NOT_YOUR_TURN';
switch (i.type) {
// -- Local Operations -----------------------------------------------------
case 'localOps.choose':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== null) return 'OPTION_ALREADY_CHOSEN';
// An option with no possible follow-up is not available at all (§6).
if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY';
if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY';
return null;
case 'switch.move': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
if (noSwitch) return noSwitch;
const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = selectDestination(dests, i.to, i.via);
if (!dest) return 'ILLEGAL_MOVE';
/**
* COUPLING IS MANDATORY (§A.4), so a train forbidden to pick something up may not make the
* MOVE that would pick it up. There is no "move but leave them"; the restriction has to bite
* on the move or it cannot bite at all.
*/
const rules = rulesOf(tray);
/**
* THE TRAIN'S OWN CUT IS NOT A PICK-UP. Taking back the cars you just set out on the square you
* are standing on undoes the drop; it is not fresh work, and none of the three restrictions
* that gate picking cars up should bite on it.
*
* Left in and the rule reads absurdly: X13 prints "may drop but not pick up", so a legal drop
* off the nose would leave the train forbidden to pull forward past its own cars for the rest
* of the turn — a one-way move nothing warned about. Only the cars that were ALREADY there when
* the crew arrived are a pick-up, so the own cut is subtracted before the tests run.
*/
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
if (fresh.length > 0) {
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
const freight = fresh.filter(isFreight).length;
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
}
return null;
}
case 'switch.dropCars': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
if (noSwitch) return noSwitch;
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
/**
* A CUT COMES OFF AN OUTER END, never out of the middle.
*
* The tray runs `[cars ahead of the engine] ENGINE [cars behind it]`. Setting out from the
* nose takes from the front of that, and only the cars actually ahead of the engine; setting
* out from the tail takes from the back, and only the cars behind it. Without this, a drop
* could lift cars from beside the engine and leave the far end of the train still attached to
* nothing — a cut no coupler could make.
*/
const ahead = tray.engineAt;
const behind = tray.consist.length - tray.engineAt;
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
// The cut that would come off, so the printed rules can be asked about its contents.
const cut = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const dropRules = rulesOf(tray);
/**
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
* never set out during switching at all.
*
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
* district, and may not abandon its coach at an industry or on a siding while it does.
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
*/
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
return 'COACH_MUST_STAY';
}
const droppedFreight = cut.filter(isFreight).length;
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
return 'FREIGHT_WORKED_HERE';
}
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
if (noSwitch) return noSwitch;
const here = trayCoord(s, i.trayId);
if (!here) return 'ILLEGAL_MOVE';
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
const card = areaOf(s, player).grid.get(coordKey(here));
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
// The order must be a permutation of the current consist.
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
const seen = new Set(i.order);
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
return null;
}
case 'switch.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
return turnOf(s, player).option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
// -- Draw a card ----------------------------------------------------------
case 'draw.fromHomeOffice':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY';
return null;
case 'draw.fromDepartment':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY';
case 'card.play': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
}
case 'card.discard': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
return null;
}
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
const rule = mainlineModifierRule(card.kind.key);
if (!rule) return 'NOT_IMPLEMENTED';
const node = s.division.nodes[i.node];
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
const on = node.modifiers ?? [];
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
// restriction exists to prevent.
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
return 'NO_PLACEMENT';
}
return null;
}
case 'maneuver.redFlags': {
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
// Mainline card, which is the only place a rear-ender can happen.
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
const node = s.division.nodes[tray.position.index];
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
return null;
}
case 'maneuver.flyingSwitch': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
// two cards along the same track. It also made the card effectively unplayable — held for
// 1,400 turns across 60 games and legal on 2.
const reachable = [
...destinationsFor(s, player, i.trayId, here, false),
...destinationsFor(s, player, i.trayId, here, true),
];
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
return 'NOT_CONNECTED';
}
const fsArea = areaOf(s, player);
const target = fsArea.grid.get(coordKey(i.to));
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'draw.end': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
// §6.2 — "the player must reduce his hand to no more than three cards".
const hand = s.decks.hands.get(player) ?? [];
const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT;
return hand.length > limit ? 'HAND_LIMIT' : null;
}
// -- Freight Agent --------------------------------------------------------
case 'freightAgent.stockOutbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (!f.allows.outbound) return 'NO_SUCH_FACILITY';
if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL';
// §9.1 — the green box takes only this facility's commodities, of which it may have two.
if (!facilityCarTypes(f).includes(i.carType)) return 'WRONG_CAR_TYPE';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
return 'NO_SUITABLE_CAR';
}
/**
* STOCKING DOES NOT NEED A CAR SPOTTED — THE LABORERS DO.
*
* §6.3 asks for nothing but a car in the Division Yard and room in the box: the Freight Agent
* "select[s] one Rolling Stock from the Division Yard pile and place[s] it onto a Facility's
* green Outbound box". The empty-car requirement belongs one step later, to §9.3's "Load the
* car" — "*a load in the Green Loading Box AND an empty car of the required type on the
* industry's track*" — which is the action that walks the load down MEN | AT | WORK.
*
* The engine used to hoist that requirement forward onto stocking, and it made the ordinary
* play illegal: an agent may perfectly well have the cargo waiting on the dock while the car
* to ship it in is still being switched in. Nothing jams as a result — a load sitting in a
* green box is waiting, not stuck, and only a load on MEN | AT | WORK locks the industry
* track. `laborer.startLoad` holds the real gate (`startableLoad`), so a load can be staged
* early but still cannot start down the sign until its car is standing there.
*/
return null;
}
case 'freightAgent.clearInbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
return f.inboundBox[i.index] ? null : 'BOX_EMPTY';
}
case 'freightAgent.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
// §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the
// action unspent is a wasted Stage, which is the player's to waste — the alternative was
// forcing an unjam that destroys a load.
return turnOf(s, player).option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN';
case 'freightAgent.unjam': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY';
const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox;
return box[i.index] ? null : 'BOX_EMPTY';
}
// -- New Train ------------------------------------------------------------
case 'newTrain.startExtra': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
if (!s.pendingExtras.includes(i.trainNumber)) return 'NO_EXTRA_PENDING';
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
if (i.atSeat === null) return null;
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
// the whole reason upgrading buys a place for an Extra to start.
const area = s.officeAreas.get(i.atSeat);
if (!area) return 'NO_SUCH_FACILITY';
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
// It still has to fit: an Extra starting here takes an A/D track like any other arrival.
return area.adOccupancy.length >= officeProfile(area.tier).adTracks ? 'NO_FREE_AD_TRACK' : null;
}
case 'newTrain.placeCar': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
// train that is running, and loading one from the yard is teleporting cars onto it.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
return 'NO_SUITABLE_CAR';
}
// §8.2 — "the train must be in the order listed on the train's card (engine on the front,
// Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
// but not out of order."
//
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
}
case 'newTrain.secondSection': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
// Only on a train that is actually due out this Stage — a second section follows a first.
if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
return null;
}
case 'newTrain.passCar': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
}
// -- Load / Unload --------------------------------------------------------
/**
* A WHISTLE POST HAS NO PORTERS AT ALL, which is not the same as having used them.
*
* The Office card carries a passenger facility at every tier so that an upgrade is a property
* change rather than a card swap — but a Whistle Post's has `porters: 0`, so this fell into
* `RESOURCE_SPENT`, "all Porters already used this Stage". A player who had used nothing was
* told they had spent it all, when the answer was to upgrade the Office. 15 times in 60 games.
*/
case 'porter.board': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
}
case 'porter.detrain': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
}
case 'laborer.startLoad': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
if (f.outboundBox.length === 0) return 'BOX_EMPTY';
if (f.menAtWork[0] !== null) return 'BOX_FULL';
// §9.3's "Load the car" requires "*an empty car of the required type on the industry's
// track*", and THIS is where that is enforced — stocking the green box (§6.3) is deliberately
// free of it. Cargo may be staged against a car that is still being switched in; it simply
// cannot leave the box until the car is standing there. The check also has to be made at this
// moment rather than earlier because a car can be coupled away after the cargo was staged:
// running over an industry track couples whatever stands on it, mandatorily (§A.4).
return startableLoad(f) !== null ? null : 'NO_EMPTY_CAR_SPOTTED';
}
case 'laborer.advanceLoad': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
return canAdvanceLoad(f, i.box) ? null : 'BOX_EMPTY';
}
case 'laborer.beginUnload': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (!f.allows.inbound) return 'NO_SUCH_FACILITY';
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
const car = f.industryTrack.cars[i.carIndex];
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
/**
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
*
* The empty car requirement was not checked and the reducer conjured the car rather than
* taking it, so every unload minted one. Unloading is meant to consume supply.
*/
if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR';
// The load is placed on WORK, the last box, so that box must be free — and a Passenger
// Facility has no such box, so there is nothing to unload into.
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
if (f.menAtWork[f.menAtWork.length - 1] !== null) return 'BOX_FULL';
/**
* THE MIRROR OF THE LOAD RULE: a load coming IN needs somewhere to land too, and its
* destination is the red Inbound box. This was checked only at the last step, so an unload
* could be begun into a full box and walked W→A→M over three Stages before discovering it had
* nowhere to go — jamming the industry, which is then locked and needs a Freight Agent turn to
* clear. Measured: rare (2.2% of offers) but real, and seen jamming three times in 200 games.
*
* COUNTED, not just "is there a slot". Only the W box has to be free to begin, so once a load
* moves W→A a second can start behind it — two loads walking toward one slot. Every red box in
* play today holds exactly one car, which is precisely when that bites.
*/
const inFlight = f.menAtWork.filter((l) => l?.dir === 'in').length;
return f.capacity.inbound - f.inboundBox.length > inFlight ? null : 'INBOUND_BOX_FULL';
}
case 'loadUnload.end':
return inPhase(s, 'loadUnload') ? null : 'WRONG_PHASE';
case 'redFlag.play':
return s.decks.redFlags.get(player) ? null : 'NO_SUCH_CARD';
default:
return 'WRONG_PHASE';
}
}
/** Playing a card from hand (§6.2). Track, Facility and Office cards need a placement. */
function checkPlay(
s: GameState,
player: PlayerIndex,
cardId: string,
placement: GridCoord | undefined,
variant: number | undefined,
node?: number,
): RejectionCode | null {
const card = s.cards.get(cardId);
if (!card) return 'NO_SUCH_CARD';
const area = areaOf(s, player);
// A Division node and an Office Area square are different boards. Naming both is not a placement
// with extra detail, it is two contradictory answers to "where?".
if (node !== undefined && placement) return 'NO_PLACEMENT';
switch (card.kind.kind) {
case 'office': {
// An upgrade replaces the Office in place (Gap 8), so a placement is meaningless — and
// accepting one makes the event log claim the card was laid somewhere it was not.
if (placement) return 'NO_PLACEMENT';
// Gap 3b — strict sequence, no skipping.
const next = nextOfficeTier(area.tier);
return next === card.kind.tier ? null : 'NOT_UPGRADEABLE';
}
case 'track':
if (!placement) return 'NO_PLACEMENT';
{
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
/**
* AN OCCUPIED SQUARE IS AN UPGRADE, not a placement.
*
* A turnout may be laid on top of a card already down — see `checkTurnoutUpgrade`. The one
* occupied square that is NOT an upgrade is a Limits sign on the Running Track: that is the
* growth point, and `canPlaceAt` moves it outward rather than building over it.
*/
const existing = area.grid.get(coordKey(placement));
const isMovableSign =
existing?.geometry.kind === 'limits' &&
placement.row === area.runningRow &&
existing.standing.length === 0;
if (existing && !isMovableSign) return checkTurnoutUpgrade(existing, proto);
// Said separately from NOT_CONNECTED because it is a different mistake: the card would join
// perfectly well, and would still leave the Running Track stopping dead at it.
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
return 'BREAKS_RUNNING_TRACK';
}
// Same reasoning: a siding reaching past your own sign JOINS perfectly well, and is refused
// because it leaves your territory (§2.1). `canPlaceAt` enforces it too — this only names it.
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'freightFacility': {
if (!placement) return 'NO_PLACEMENT';
/**
* ON A STRAIGHT STUB, NEVER THE RUNNING TRACK.
*
* The sheet's "Placed" column reads the same for all six industries: **"Straight, Stub (not on
* Running Track)"**. An industry has to hang off a siding, which is what makes a siding worth
* building — the whole switching puzzle is getting a car from the main down to a spur and back.
*
* This was allowed, and merely warned about: an industry could sit on the Running Track and
* the card text noted that a car left standing there would be hit by the next arrival. That is
* a hazard, not a rule, and it let a player skip the district entirely and spot cars on the
* main line.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// A Facility CARRIES TRACK — "placing a Facility places track" (§11.2) — so it is bounded by
// the Limits exactly as a siding is. An industry outside them is how a district used to leave
// its own territory.
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
// alongside the consumer of the same commodity.
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'modifier': {
if (!placement) return 'NO_PLACEMENT';
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
/**
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
* halves.
*
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
* standing at the limit has three of its nine spots out there, and refusing them would make
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
if (node === undefined) return 'NO_PLACEMENT';
const target = s.division.nodes[node];
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
}
case 'mainlineModifier':
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
// with the same placement and the same effect. It is one card printed twice, so the mainline
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
if (card.kind.key === 'facingPointLocksMainline') {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
}
// The rest are laid on a Mainline card, which is not a grid coordinate — see
// `mainline.modify`.
return 'WRONG_INTENT';
case 'maneuver':
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
// the source", so there is nothing to implement.
return 'WRONG_INTENT';
case 'spaceUse':
case 'action':
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
return 'NOT_IMPLEMENTED';
case 'timetabledTrain':
case 'extraTrain':
// A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
// offer the same play at six different grid squares with six rotations apiece — all identical,
// because the placement was then ignored. Same reasoning as the Office upgrade above: silently
// discarding a placement makes the event log claim the card was laid somewhere it was not.
return placement ? 'NO_PLACEMENT' : null;
default:
return null;
}
}
/**
* Among destinations at `to`, the one `via` names — or the first `legal.ts` enumerated there, if
* `via` is absent or names no route on record. That fallback is what makes an old save (or any
* caller that never learned about routing) replay exactly as before: the first-enumerated route is
* the only one that ever existed until this feature did.
*/
export function selectDestination(
dests: MoveDestination[],
to: GridCoord,
via: GridCoord | undefined,
): MoveDestination | undefined {
const atTo = dests.filter((d) => d.coord.row === to.row && d.coord.col === to.col);
if (via === undefined) return atTo[0];
const chosen = atTo.find((d) => d.path.some((step) => step.coord.row === via.row && step.coord.col === via.col));
return chosen ?? atTo[0];
}
function destinationsFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
from: GridCoord,
reverse: boolean,
) {
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
return reachableDestinations(
{
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
},
from,
exit,
);
}
/**
* The cars this crew would take back off its OWN card by pulling out this way.
*
* Exported because three places need the same answer and none of them should re-derive the exit port
* to get it: the move check exempts these cars from a train's pick-up restrictions, the move label
* names them separately from cars found on the line, and the walk counts them against the four-car
* limit. Empty for a train with nothing standing beside it, which is almost every move.
*/
export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, reverse: boolean): RollingStock[] {
const tray = s.trays.get(trayId);
if (!tray || tray.position.at !== 'grid') return [];
const here = tray.position.coord;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
}
/**
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
*
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
* a reason that applies in one direction should not be reported when the other direction works.
*
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
* that produced them.
*/
export function movesFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
): { to: GridCoord[]; blocked: MoveBlock[] } {
const tray = s.trays.get(trayId);
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
const from = tray.position.coord;
const ctx = {
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
};
const facing = facingPort(s, trayId);
const forward = exploreMoves(ctx, from, facing);
// The card's other end, not the compass opposite — see `reversePort`. This is what the board
// highlights, so getting it wrong hides half the crew's legal moves rather than merely refusing
// one: the squares behind the train never light up at all.
const back = exploreMoves(ctx, from, reversePort(s, player, from, facing));
const to = new Map<string, GridCoord>();
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
const blocked = new Map<string, MoveBlock>();
for (const b of [...forward.blocked, ...back.blocked]) {
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
}
return { to: [...to.values()], blocked: [...blocked.values()] };
}
// ---------------------------------------------------------------------------
// execute — reads state, emits events, never mutates
// ---------------------------------------------------------------------------
function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
switch (i.type) {
case 'localOps.choose':
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
case 'switch.move': {
const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = selectDestination(dests, i.to, i.via)!;
const tray = s.trays.get(i.trayId)!;
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
// cut it recouples on the way out is the cut the walk counted.
const facingNow = facingPort(s, i.trayId);
const exitPort: Port = i.reverse ? reversePort(s, player, from, facingNow) : facingNow;
// The crew leaves by the port opposite the one it entered through, which is what it will be
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
// onto a north-south spur can never move again.
const events: GameEvent[] = [
{
type: 'trayMoved',
trayId: i.trayId,
from,
to: i.to,
movesRemaining: turnOf(s, player).movesRemaining - 1,
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
*
* `facing` is which way the ENGINE points, and this once set it to the direction of travel
* on every move — so one reverse move silently spun the train about, and a run-around
* became pointless: you could change ends for free by backing up twice.
*
* Backing up, the engine TRAILS, still pointing the way it came — out through the port the
* train arrived by. That holds whatever the track does underneath, so it is `dest.entry`
* and nothing else.
*
* Running forward, the engine LEADS, so it points out through the card's far end. That was
* written `opposite(entry)`, which is the far end of a straight and of nothing else: a
* curve is an arc between two ADJACENT edges, so entering a north-west curve through its
* west port leaves the engine facing NORTH, not east. The wrong port was not merely
* cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields
* no destinations at all, so a crew that rounded a curve could only back out the way it
* came. Reported as a consist drawn mirrored, which is the other half of the same bug: the
* east-west sense the board draws is carried from `facing` (`railFacingOf`).
*
* `farPort` asks the CARD. A destination is never a turnout — a train may not finish a
* Move on one (§A.1) — so there is exactly one way out of it.
*/
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
...(i.via ? { via: i.via } : {}),
},
];
if (dest.couples.length > 0) {
/**
* Name the cards the cars came from. The reducer used to clear every card in the district
* instead, so one coupling anywhere deleted every standing car and every industry track in
* the Office Area — loads worked over several Stages vanished when a crew picked up a single
* boxcar somewhere else entirely.
*
* `from` NOW BEGINS WITH THE SQUARE THE CREW LEFT. It could not before — the list was built
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
* cut recoupled off your own card was added to the consist and left standing on the board at
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
* and `standingWest` no longer describes this card.
*/
const grid = areaOf(s, player).grid;
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
const lifted = [
...(startCut.length > 0 ? [from] : []),
...dest.path.map((step) => step.coord),
i.to,
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
// up, they couple behind. Which end they land on is the whole point of a run-around: it
// decides which car is next to come off.
events.push({
type: 'carsCoupled',
trayId: i.trayId,
at: i.to,
stock: dest.couples,
from: lifted,
toNose: !i.reverse,
...(startCut.length > 0 && stayed.length > 0 ? { leaves: { at: from, stock: stayed } } : {}),
...(startCut.length > 0 ? { recoupled: { at: from, stock: startCut } } : {}),
});
}
return events;
}
case 'switch.dropCars': {
const tray = s.trays.get(i.trayId)!;
const here = trayCoord(s, i.trayId)!;
// §A.3 — cars come off in the order they are seated in the tray, from whichever end is being
// set out. The nose is the end ahead of the engine.
const stock = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
}
case 'switch.sortConsist': {
const tray = s.trays.get(i.trayId)!;
const here = trayCoord(s, i.trayId)!;
return [
{
type: 'consistSorted',
trayId: i.trayId,
at: here,
before: tray.consist.map((c) => ({ ...c })),
after: i.order.map((n) => ({ ...tray.consist[n]! })),
},
];
}
case 'switch.end':
case 'draw.end':
case 'freightAgent.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'draw.fromHomeOffice': {
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
source: 'homeOffice',
cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
},
];
const sweep = reshuffleIfDepleted(s, 1);
if (sweep) events.push(sweep);
return events;
}
case 'draw.fromDepartment': {
// §6.2 — "take the TOP face-up card". Never anything buried: a player who discarded onto this
// pile chose to put a card out of reach as much as to offer one, and letting a rival dig
// would take that decision away.
const pile = s.decks.departments[i.slot]!;
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
source: 'department',
slot: i.slot,
cardId: pile[pile.length - 1]!,
},
];
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in the
// empty spot." Only when taking the last card actually empties the pile; refilling on every
// draw would grow the Departments without limit and drain the Home Office deck into them.
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
if (pile.length === 1 && refill) {
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
const sweep = reshuffleIfDepleted(s, 1);
if (sweep) events.push(sweep);
}
return events;
}
case 'card.play': {
const card = s.cards.get(i.cardId)!;
const events: GameEvent[] = [
i.placement
? {
type: 'cardPlayed',
player,
cardId: i.cardId,
placement: i.placement,
variant: i.variant ?? 0,
}
: { type: 'cardPlayed', player, cardId: i.cardId },
];
if (card.kind.kind === 'office') {
events.push({
type: 'officeUpgraded',
player,
from: areaOf(s, player).tier,
to: card.kind.tier,
});
}
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
events.push(
i.node !== undefined
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
);
}
if (card.kind.kind === 'extraTrain') {
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
// to do nothing at all, which made all four Extra cards dead weight in the deck.
events.push({ type: 'extraQueued', player, trainNumber: card.kind.number });
}
if (card.kind.kind === 'timetabledTrain') {
// §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at
// the bottom. Without this no train ever runs, so nothing can ever be earned.
const rng = createRng(s.rngState);
const roll = rng.d12();
const slot = findTimetableSlot(s, roll - 1);
if (slot !== null) {
events.push({
type: 'trainScheduled',
player,
trainNumber: card.kind.number,
roll,
slot,
rngState: rng.getState(),
});
}
}
return events;
}
case 'card.discard':
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
case 'mainline.modify': {
const card = s.cards.get(i.cardId)!;
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
const node = s.division.nodes[i.node];
const became =
key === 'realignment' && node?.kind === 'mainline'
? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined;
return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
];
}
case 'maneuver.redFlags': {
const tray = s.trays.get(i.trayId)!;
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
}
case 'maneuver.flyingSwitch': {
const tray = s.trays.get(i.trayId)!;
// §A.3 — cars come off the back, same as a normal drop.
const stock = tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
}
case 'freightAgent.stockOutbound':
return [
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
];
case 'freightAgent.clearInbound': {
const f = facilityAt(s, player, i.at)!;
return [{ type: 'inboundCleared', player, at: i.at, stock: f.inboundBox[i.index]! }];
}
case 'freightAgent.unjam': {
const f = facilityAt(s, player, i.at)!;
const stock: RollingStock =
i.from === 'menAtWork'
? { type: workTrack(f)[i.index]!.type, loaded: true }
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
}
case 'newTrain.startExtra':
return [{ type: 'extraStarted', player, trainNumber: i.trainNumber, atSeat: i.atSeat }];
case 'newTrain.placeCar':
return [
{
type: 'carPlacedOnTrain',
player,
trayId: i.trayId,
stock: { type: i.carType, loaded: i.loaded },
},
];
case 'newTrain.passCar':
return [{ type: 'carPassed', player, trayId: i.trayId }];
case 'newTrain.secondSection':
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
case 'mainline.clearance':
return [
{
type: 'clearanceGiven',
trainId: s.clock.pendingDecision!.train,
allow: i.allow,
},
];
/**
* A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
* is named per COACH because a Porter handles exactly one coach per action.
*/
case 'porter.board':
return [
{ type: 'passengersBoarded', player, at: i.at },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
];
case 'porter.detrain':
return [
{ type: 'passengersDetrained', player, at: i.at },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
];
case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!;
// The first load with a car to land on, which is not always the first in the box.
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[startableLoad(f)!]!.type }];
}
case 'laborer.advanceLoad': {
const f = facilityAt(s, player, i.at)!;
const load = workTrack(f)[i.box]!;
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
// Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end
// pays `freightPerLoad` (`content.ts`).
if (next >= workTrack(f).length) {
// Outbound complete: the load goes onto the spotted car (§9.3).
return [
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightLoad'),
];
}
if (next < 0) {
// Inbound complete: the load reaches the red Unloading box (§9.3).
return [
{ type: 'unloadCompleted', player, at: i.at, carType: load.type },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightUnload'),
];
}
return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }];
}
case 'laborer.beginUnload': {
const f = facilityAt(s, player, i.at)!;
return [
{ type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type },
];
}
case 'loadUnload.end':
return [{ type: 'phaseEnded', player, phase: 'loadUnload' }];
case 'redFlag.play':
return [{ type: 'phaseEnded', player, phase: 'redFlag' }];
default:
return [];
}
}
function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number {
return (s.players[player]?.revenue ?? 0) + delta;
}
/**
* A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero.
*
* Zero is a real setting — it is how you switch one economy off to read the others — and a stream of
* "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing
* happened. The work still happens; it just does not pay.
*/
function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] {
if (rate <= 0) return [];
return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }];
}
/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */
function findTimetableSlot(s: GameState, from: number): number | null {
for (let i = 0; i < s.timetable.length; i++) {
const slot = (from + i) % s.timetable.length;
if (s.timetable[slot] === null) return slot;
}
return null;
}
// ---------------------------------------------------------------------------
// reduce — the only mutator on the INTENT path.
//
// `applyIntent` never touches state except through here, so everything a player does is reducible.
// The phase driver (`advance.ts`) does not: it mutates and then describes, so folding the whole log
// does NOT reconstruct a game. `protocol.md` §3 has the consequence — the intents are canonical.
// ---------------------------------------------------------------------------
export function reduce(s: GameState, e: GameEvent): void {
switch (e.type) {
case 'localOpsOptionChosen':
turnOf(s, e.player).option = e.option;
break;
case 'trayMoved': {
const tray = s.trays.get(e.trayId)!;
// A tray moving stays in the district it was already in — the seat does not change.
const seat = tray.position.at === 'grid' ? tray.position.seat : 0;
tray.position = { at: 'grid', seat, coord: e.to };
if (e.facing) {
tray.facing = e.facing;
// The east-west sense only exists on east-west track, so it is CARRIED across north-south
// track rather than recomputed there — see `railFacing` in state.ts.
if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing;
}
// Only the player sitting in this district can be switching this tray, so the Moves come off
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
// the square it left, so its place in that row is gone with it.
tray.standingWest = 0;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
const area = areaAtSeat(s, seat);
const atOffice =
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
if (atOffice) area.adOccupancy.push(e.trayId);
break;
}
case 'carsCoupled': {
const tray = s.trays.get(e.trayId)!;
const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0);
/**
* `stock` ARRIVES NEAREST-FIRST — the order the engine MET the cars, accumulated along the
* walk. A tray is ordered nose first, so only one of the two ends needs turning round.
*
* Behind the train, near-first is already right: the nearest car couples to the existing tail
* and each one after it goes further back, which is the order the array is in.
*
* On the nose it is exactly backwards. The FARTHEST car ends up nose-most — the engine pushes
* the first one it met ahead of it and picks up the next in front of that — so the cut reverses
* on the way into the tray. Getting this wrong is invisible in a single move and shows up as a
* run-around that changes nothing: approaching one parked cut from either end used to give the
* identical consist, when the whole point is that the two mirror.
*/
if (e.toNose) {
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
// engine is no longer at the front and its index has to follow. It never did, so a crew that
// shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's
// make-up rule had nothing truthful to check.
tray.consist.unshift(...[...e.stock].reverse());
tray.engineAt += e.stock.length;
} else {
tray.consist.push(...e.stock);
}
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
// never went near.
for (const coord of e.from) {
const card = area.grid.get(coordKey(coord));
if (!card) continue;
card.standing = [];
if (card.facility) card.facility.industryTrack.cars = [];
}
// The card the crew pulled OUT of may still be holding a cut set out off its other end, which
// the sweep above has just emptied along with everything else. Put it back.
if (e.leaves) {
const card = area.grid.get(coordKey(e.leaves.at));
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
}
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
// left beside it. Any cut it did leave behind is on the square it came FROM.
tray.standingWest = 0;
/**
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
* instead of being charged again at the far end.
*/
const owner = playerAtSeat(s, trayySeat(tray));
const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock;
spendFreightBudget(s, owner, tray, e.at, taken);
if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock);
break;
}
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
tray.engineAt = 0;
// "Spends one move in the yard" — the sort costs a Move.
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
break;
}
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0);
if (e.fromNose) {
// Off the front: everything ahead of the engine shortens, so the engine moves up by that
// much. This is how a train that took cars onto its nose gets back to being made up.
tray.consist.splice(0, e.stock.length);
tray.engineAt = Math.max(0, tray.engineAt - e.stock.length);
} else {
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
}
tray.engineAt = Math.min(tray.engineAt, tray.consist.length);
const card = area.grid.get(coordKey(e.at));
/**
* WHERE THE CUT LANDS ON THE CARD — the fix for "when dropping all 4 cars, order was
* reversed; it worked properly if we dropped cars individually".
*
* This was `carsOn(card).push(...stock)`, which is neither of the two things it needs to be.
* The cut arrives in TRAY order (nose first) and the card is ordered WEST TO EAST, so it has
* to be turned around for an east-facing train — `trackOrder`. And it has to go at the end of
* the row the cut physically occupies, which is not always the end of the array:
*
* - a NOSE cut is set out ahead of the engine, on the `facing` side;
* - a TAIL cut is set out behind it, on the other side.
*
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
* furthest away, and each one after it is left in the gap between the train and the last —
* so the insertion point is the train's own position in the row, `standingWest`, from either
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
* pairs all park in the same order, which is the reported bug and the regression test.
*/
if (card) {
const cars = carsOn(card);
const facing = railFacingOf(tray);
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
const cut = trackOrder(e.stock, facing);
cars.splice(k, 0, ...cut);
// The train has not moved, so cars set out on its WEST side push its index along the row;
// cars set out to the east leave it where it was.
tray.standingWest = onWestSide ? k + cut.length : k;
}
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
break;
}
case 'departmentRefilled':
s.decks.homeOffice.pop();
s.decks.departments[e.slot]!.push(e.cardId);
break;
case 'deckReshuffled': {
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
// turned face up as the Departments, the rest face down as the Home Office deck. The
// Departments start one deep again, exactly as at setup.
s.decks.salvageYard = [];
s.decks.departments = [[], [], []];
const order = [...e.order];
for (const pile of s.decks.departments) {
const card = order.pop();
if (card) pile.push(card);
}
// The END of the array is the top of the deck — `cardDrawn` pops from there.
s.decks.homeOffice = order;
s.rngState = e.rngState;
break;
}
case 'cardDrawn': {
const hand = s.decks.hands.get(e.player) ?? [];
if (e.source === 'homeOffice') s.decks.homeOffice.pop();
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
hand.push(e.cardId);
s.decks.hands.set(e.player, hand);
turnOf(s, e.player).drawnThisTurn = true;
break;
}
case 'cardPlayed': {
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
s.decks.hands.set(e.player, hand);
const card = s.cards.get(e.cardId)!;
const area = areaOf(s, e.player);
if (e.placement && (card.kind.kind === 'track' || card.kind.kind === 'freightFacility')) {
const built = protoCard(card.kind, e.variant);
if (built) {
if (card.kind.kind === 'freightFacility') built.facility = buildFreightFacility(card.kind.facility);
area.grid.set(coordKey(e.placement), built);
extendLimitsIfNeeded(area, e.placement);
}
} else if (e.placement && card.kind.kind === 'modifier') {
// A Modifier stays on the board beside its Facility, and its effect is applied. It used to
// be discarded straight to the Salvage Yard, so playing one did literally nothing.
area.grid.set(coordKey(e.placement), {
geometry: { kind: 'modifier', modifier: card.kind.modifier },
baseOperationalRail: false,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
applyModifier(area, e.placement, card.kind.modifier);
} else if (card.kind.kind !== 'office') {
s.decks.salvageYard.push(e.cardId);
}
break;
}
case 'mainlineModified': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
if (e.became) node.card = e.became as MainlineKind;
else node.modifiers = [...(node.modifiers ?? []), e.key];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'redFlagsSet': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'flyingSwitch': {
const tray = s.trays.get(e.trayId);
const area = areaOf(s, e.player);
const card = area.grid.get(coordKey(e.to));
if (tray && card) {
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
const track = card.facility?.industryTrack;
if (track) track.cars.push(...e.stock);
else card.standing.push(...e.stock);
}
turnOf(s, e.player).movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
break;
}
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
const from = officeProfile(e.from);
const to = officeProfile(e.to);
area.tier = e.to;
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard?.facility) {
/**
* THE TIER IS A DELTA, NOT AN OVERWRITE.
*
* This wrote the new tier's printed numbers straight over the facility, which silently
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
* with no message, after the card had been spent. Reported from a playtest where a
* Restaurant's porter never appeared — it had appeared and then been erased.
*
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
* upgrade is worth and leaves anything standing beside it untouched.
*/
const f = officeCard.facility;
f.porters += to.porters - from.porters;
f.capacity = {
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
};
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
// Post has no passenger boxes to add to.
const wasPassenger = f.allows.outbound || f.allows.inbound;
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
/**
* A GRANT SUPPRESSED AT A WHISTLE POST COMES BACK WHEN THE OFFICE CAN USE IT.
*
* Reported from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot
* only shows one green / one red box. I expected two, because Restaurant increases outbound
* by one." Exactly right, and here is why it happened: `hosts: ['office']` includes a Whistle
* Post, which is NOT a Passenger Facility, so `usableGrant` dropped the +1 outbound at the
* moment the card was played. The porter landed — porters have no direction gate — and the
* slot was gone for good, because the upgrade only ever applied the difference between two
* tiers and knew nothing about what had been discarded on the way.
*
* `TrackCard.modifiers` records which Modifiers served this facility, so what was dropped is
* recoverable: when the Office becomes a Passenger Facility, every one of them is granted the
* capacity it always printed. Keyed on the TRANSITION, so a Depot → Station upgrade does not
* pay them a second time.
*/
if (!wasPassenger && to.isPassengerFacility) {
for (const kind of officeCard.modifiers) {
const m = modifierProfile(kind);
f.capacity.outbound += m.addOut;
f.capacity.inbound += m.addIn;
}
}
}
break;
}
case 'cardDiscarded': {
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
s.decks.hands.set(e.player, hand);
// ON TOP of the pile. Assigning here overwrote whatever was already face up on that
// Department, quietly destroying a card from a closed deck — and it threw away the whole
// point of choosing WHICH Department to discard onto.
s.decks.departments[e.toSlot]!.push(e.cardId);
break;
}
case 'stockToOutbound': {
const f = facilityAt(s, e.player, e.at)!;
const idx = s.yards.divisionYard.findIndex(
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
f.outboundBox.push(e.stock);
turnOf(s, e.player).freightAgentUsed = true;
break;
}
case 'inboundCleared': {
const f = facilityAt(s, e.player, e.at)!;
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1);
s.yards.classificationYard.push(e.stock);
turnOf(s, e.player).freightAgentUsed = true;
break;
}
case 'facilityUnjammed': {
const f = facilityAt(s, e.player, e.at)!;
if (e.from === 'menAtWork') {
const idx = workTrack(f).findIndex((l) => l !== null);
if (idx >= 0) workTrack(f)[idx] = null;
} else {
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
const idx = box.findIndex((c) => c.type === e.stock.type);
if (idx >= 0) box.splice(idx, 1);
}
s.yards.classificationYard.push(e.stock);
turnOf(s, e.player).freightAgentUsed = true;
break;
}
case 'enhancementPlaced': {
if (e.node !== undefined) {
const node = s.division.nodes[e.node];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else if (e.at) {
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
if (card) card.enhancements.push(e.key);
}
break;
}
case 'extraQueued':
s.pendingExtras.push(e.trainNumber);
break;
case 'secondSectionOrdered':
s.pendingSecondSections.push(e.trainNumber);
break;
case 'trainScheduled':
s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState;
s.decks.salvageYard.push(`train-${e.trainNumber}`);
break;
case 'carPlacedOnTrain': {
const tray = s.trays.get(e.trayId)!;
const idx = s.yards.divisionYard.findIndex(
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
tray.consist.push(e.stock);
break;
}
case 'extraStarted': {
const trayId = s.freeTrays.pop()!;
s.pendingExtras = s.pendingExtras.filter((n) => n !== e.trainNumber);
const direction = runDirection(e.trainNumber);
if (e.atSeat === null) {
const side = startingDivisionPoint(e.trainNumber);
s.trays.set(trayId, {
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, 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);
} else {
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
// as though it had arrived there.
const area = areaAtSeat(s, e.atSeat);
s.trays.set(trayId, {
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
direction, facing: direction === 'west' ? 'w' : 'e', railFacing: direction === 'west' ? 'w' : 'e',
position: { at: 'grid', seat: e.atSeat, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push(trayId);
}
break;
}
case 'passengersBoarded': {
const f = facilityAt(s, e.player, e.at)!;
const area = areaOf(s, e.player);
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
const loaded = f.outboundBox.splice(idx, 1)[0]!;
for (const id of area.adOccupancy) {
const tray = s.trays.get(id);
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
if (tray && ci >= 0) {
s.yards.classificationYard.push(tray.consist[ci]!);
tray.consist[ci] = loaded;
break;
}
}
f.usedThisStage.porters += 1;
break;
}
case 'passengersDetrained': {
const f = facilityAt(s, e.player, e.at)!;
const area = areaOf(s, e.player);
for (const id of area.adOccupancy) {
const tray = s.trays.get(id);
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
if (tray && ci >= 0) {
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = empty;
break;
}
}
f.usedThisStage.porters += 1;
break;
}
case 'loadStarted': {
const f = facilityAt(s, e.player, e.at)!;
const idx = f.outboundBox.findIndex((c) => c.type === e.carType);
if (idx >= 0) f.outboundBox.splice(idx, 1);
workTrack(f)[0] = { type: e.carType, dir: 'out' };
f.usedThisStage.laborers += 1;
break;
}
case 'loadAdvanced': {
const f = facilityAt(s, e.player, e.at)!;
workTrack(f)[e.toBox] = workTrack(f)[e.fromBox]!;
workTrack(f)[e.fromBox] = null;
f.usedThisStage.laborers += 1;
break;
}
case 'unloadCompleted': {
const f = facilityAt(s, e.player, e.at)!;
workTrack(f)[0] = null;
f.inboundBox.push({ type: e.carType, loaded: true });
f.usedThisStage.laborers += 1;
break;
}
case 'loadCompleted': {
const f = facilityAt(s, e.player, e.at)!;
workTrack(f)[workTrack(f).length - 1] = null;
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
if (ci >= 0) {
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
}
f.usedThisStage.laborers += 1;
break;
}
case 'unloadBegan': {
const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
if (ci >= 0) {
/**
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
* minted a car on every unload, and it also skipped a requirement the rule states.
*
* THROWS rather than falling back. `check` guarantees the car is there, so reaching this is
* a broken invariant, and the old fallback quietly minted rolling stock instead of saying
* so — which is exactly the shape of leak `TODO.md` spent a conservation audit chasing.
*/
const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded);
if (yi < 0) throw new Error(`unloadBegan: no empty ${e.carType} in the Division Yard`);
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.industryTrack.cars[ci] = empty;
}
workTrack(f)[workTrack(f).length - 1] = { type: e.carType, dir: 'in' };
f.usedThisStage.laborers += 1;
break;
}
case 'revenueChanged': {
const p = s.players[e.player];
if (p) p.revenue = e.total;
break;
}
case 'phaseEnded':
// The actor has finished; the phase driver moves on to the next player.
if (e.phase !== 'redFlag') turnOf(s, e.player).done = true;
break;
case 'clearanceGiven':
s.clock.pendingDecision = null;
// Recorded for the asking train to consume; otherwise the driver asks again forever.
s.clock.clearanceRuling = { train: e.trainId, allow: e.allow };
break;
default:
break;
}
}
/**
* Builds the card a play would place, at the chosen orientation. Returns null when the variant
* index is out of range, which `check` reports rather than silently defaulting — a wrong
* orientation is a different card, not a detail.
*/
function protoCard(
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
// Track is a per-player supply rather than a deck card, so nothing reaches this branch today.
// It still needs a hand: handedness IS the slope, and defaulting silently would lay a card on
// the wrong diagonal.
const hand = (kind.hand as Hand | undefined) ?? 'left';
const options = variantsFor(geometry, hand);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(v.bypass ? { bypass: v.bypass } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
...base,
};
}
// A Facility is plain east-west track, as every industry card on the printed sheet is, so there
// is exactly one orientation and any index past it is out of range.
if (!facilityVariants()[variant ?? 0]) return null;
return {
geometry: { kind: 'facility', facility: kind.facility as never },
baseOperationalRail: true,
...base,
};
}
/**
* Instantiates a Freight Facility from its catalogue profile (card-reference.md §2).
*
* This was missing: placed facility cards were built with `facility: null`, making them inert —
* they could never be stocked, worked, or scored from. Every freight card played was dead weight.
*/
function buildFreightFacility(kind: FreightKind): Facility {
const p = FREIGHT_PROFILES.find((f) => f.kind === kind);
if (!p) throw new Error(`unknown freight facility: ${kind}`);
return {
kind: 'freight',
subtype: kind,
allows: {
outbound: p.flow === 'outbound' || p.flow === 'both',
inbound: p.flow === 'inbound' || p.flow === 'both',
},
outboundBox: [],
inboundBox: [],
// The design gives every industry ONE car out and ONE loader; capacity is grown by the
// industry-specific modifier cards, not printed large on the industry itself.
capacity: { outbound: p.baseOut, inbound: p.baseIn },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: p.baseLoaders,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
};
}
/**
* The Facility a Modifier at `coord` would serve — any of the nine nearby spots (§9).
*
* SIMPLIFICATION, deliberate and flagged: §9 says that when a Modifier touches two Facilities its
* effect may be used on only one per Stage. This applies the effect permanently to the first
* Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
* Load/Unload phase and is not worth it until the mechanic has been played.
*/
/**
* The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it
* is actually ALLOWED to attach to.
*
* Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger
* Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any
* square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and
* `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which
* could be a different one again. The player saw three legal spots for a card that has one.
*/
function adjacentFacilityCoord(
area: OfficeArea,
coord: GridCoord,
modifier?: ModifierKind,
): GridCoord | null {
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
// on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7
// guard here rejected the three above outright.
const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue;
return c;
}
}
return null;
}
/**
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
* their placement and state are implemented and their effect is read at the point of attack:
*
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
*
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
* queried here rather than being special-cased at each attack site.
*/
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
}
/** Facing Point Locks blocks a Derail played at this district. */
export function isProtectedFromDerail(area: OfficeArea): boolean {
return hasDistrictEnhancement(area, 'facingPointLocks');
}
/** A Water Column lets its owner clear a Watertower off their own grid. */
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
const out: GridCoord[] = [];
for (const [key, card] of area.grid) {
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/**
* Enhancements have per-card placement rules (implications.md §7):
* - Interlocking, Water Column, Telegraph → a Running Track straight
* - Yard Office, Small Yard → a Secondary Track straight
* - Telephone / Radio → stacked on the card below them
* - Facing Point Locks → needs an Interlocking in the district
* - ABS Signals → a Mainline card, not the Office Area
*/
export function checkEnhancementPlacement(
s: GameState,
area: OfficeArea,
key: string,
placement: GridCoord,
): RejectionCode | null {
const rule = enhancementRule(key);
if (!rule) return 'NOT_IMPLEMENTED';
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
const card = area.grid.get(coordKey(placement));
if (!card) return 'NOT_CONNECTED';
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
return 'NOT_CONNECTED';
}
if (rule.requiresInDistrict) {
const present = [...area.grid.values()].some((c) =>
c.enhancements.includes(rule.requiresInDistrict!),
);
if (!present) return 'NOT_CONNECTED';
}
const onRunning = placement.row === area.runningRow;
/**
* A STRAIGHT-PLACED ENHANCEMENT REPLACES THE STRAIGHT, so it cannot be stacked.
*
* The printed placement is "any Running Track Straight": the card goes down IN PLACE OF the
* straight, and what stands there afterwards is an Interlocking, not a straight carrying one. A
* second such card has no straight left to replace. This was unchecked — an enhancement only adds
* a string to `enhancements[]` and leaves the geometry alone, so a single straight could take
* Interlocking and Telegraph and a Water Column all at once.
*
* The `onCard` chain is untouched and is NOT an exception to this: Telephone prints "on Telegraph"
* and Radio "on Telephone", so those target a named card rather than a straight, which is exactly
* why they still stack. `requiresOnSameCard` above is what enforces it.
*/
const isBareStraight =
card.geometry.kind === 'track' &&
card.geometry.geometry === 'straight' &&
card.enhancements.length === 0;
switch (rule.placement) {
case 'runningTrackStraight':
return onRunning && isBareStraight ? null : 'NOT_CONNECTED';
case 'secondaryTrackStraight':
return !onRunning && isBareStraight ? null : 'NOT_CONNECTED';
case 'onCard':
return null;
default:
return 'NOT_CONNECTED';
}
}
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
function emptyCard(): TrackCard {
return {
geometry: { kind: 'limits' },
baseOperationalRail: false,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
s.decks.salvageYard.push(cardId);
}
/**
* §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards from
* the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office deck and
* Department slots."
*
* `taking` is how many cards the events already queued will pop off the deck, so this can be asked
* BEFORE they are applied: a draw takes one, and refilling an emptied Department takes another.
*
* Returns null when the deck is not about to run out, or when there is nothing to sweep — a game
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
*/
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return null;
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
if (collected.length === 0) return null;
const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
*
* TWO RULES, both from the sheet's "Lockouts" column.
*
* 1. **No two of the same industry in one Office Area.** The sheet states it in the Freight House
* row, which lists Freight House among its own lockouts; it is a general rule, so it is applied
* to every kind here rather than repeated in all six catalogue entries.
* 2. **Not a producer and the consumer of the same commodity.** Mine Tipple makes coal and the
* Power Plant burns it; the Refinery makes oil and the Power Plant burns that too; Packing Sheds
* fill reefers and the Grocer's Warehouse empties them. Build one end of a chain or the other,
* never both — which is what pushes freight to run BETWEEN districts instead of circling inside
* one.
*
* The relation is symmetric, so checking either direction is enough.
*/
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
const wanted = industryProfile(kind);
for (const card of area.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
const present = card.geometry.facility;
if (present === kind) return true;
if (wanted.lockouts.includes(present)) return true;
if (industryProfile(present).lockouts.includes(kind)) return true;
}
return false;
}
/**
* Is a Modifier of this kind already standing in the district?
*
* Reported from playtesting: two Ice Houses could be built in one Office Area. Industries have been
* barred from doubling up since Q4 (`isLockedOut` above), but a Modifier is a different card kind
* and had no such check — every square adjacent to an eligible host was legal, however many copies
* you held. One of a kind per Office Area, the same rule the industries follow.
*
* Enhancements are deliberately NOT covered. An Interlocking is a plant at one junction, so a second
* on another straight is a different installation, and the Telegraph → Telephone → Radio chain is
* already gated per card by `checkEnhancementPlacement`.
*
* Consequence worth expecting: modifiers printed in more than one copy (Truck Dock 2, Ice House 2,
* Waiting Area 3) go partly dead in solitaire, where there is only one Office Area. That is correct
* — the spare copies exist for other players' districts.
*/
function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean {
for (const card of area.grid.values()) {
if (card.geometry.kind === 'modifier' && card.geometry.modifier === kind) return true;
}
return false;
}
/**
* How much of a Modifier's printed capacity its host can actually use.
*
* AN INDUSTRY'S PRINTED FLOW IS ABSOLUTE. A Grocer's Warehouse is `flow: 'inbound'`, so it has no
* green boxes and `freightAgent.stockOutbound` refuses it — yet an Ice House beside it prints "+1
* outbound" and the capacity was being raised anyway, on a direction that can never be drawn or
* stocked. Reported from playtesting as "the Ice House added the laborer but not the outbound slot":
* the laborer landed because Laborers have no direction, and the slot did not because there was
* nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped.
*
* It is not one card's quirk, and it cuts both ways. **Waiting Area**, **Restaurant** and **Hotel**
* print `addOut` for `hosts: ['office']`, which includes a Whistle Post — not a Passenger Facility,
* so `allows.outbound` is false there too. **Truck Dock** is the mirror image: it prints `addIn` and
* lists **Packing Sheds**, which only ships, so its one grant is dropped there and the card does
* nothing at all. Which host you set it beside is the whole decision, and the hand tooltip says so
* before it is played.
*
* What is applied is what the host can actually use, not what the card printed. It affects the box
* count only — see `applyModifier` on why no Modifier lengthens an industry's track.
*/
function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } {
return {
out: f.allows.outbound ? m.addOut : 0,
in: f.allows.inbound ? m.addIn : 0,
};
}
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
const target = adjacentFacilityCoord(area, coord, modifier);
if (!target) return;
const host = area.grid.get(coordKey(target));
const f = host?.facility;
if (!host || !f) return;
const m = modifierProfile(modifier);
/**
* Record WHICH facility this Modifier served.
*
* `TrackCard.modifiers` was initialised everywhere and appended nowhere, so the panel row listing
* "Modifier cards standing beside this industry" was permanently empty — and, more to the point,
* nothing downstream could tell which card had granted what. It is needed now to explain a grant
* that the host's flow discards (see `usableGrant`).
*/
host.modifiers.push(modifier);
const use = usableGrant(f, m);
f.capacity.outbound += use.out;
f.capacity.inbound += use.in;
f.laborers += m.addLoaders;
f.porters += m.addPorters;
/**
* A MODIFIER ADDS A BOX, NEVER ROOM FOR A CAR.
*
* A Truck Dock beside a Grocer's Warehouse gives it a second RED box — somewhere for one more
* arriving load to be cleared to — and changes nothing about how many cars may be set out there,
* which was already four and stays four. This used to lengthen the industry track by the same
* amount, which is where the phantom siding came from: capacity and rail are different things.
*/
}
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
/**
* §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
*
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
* Limits card stranded mid-track: a district grew to
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
*/
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;
if (placed.col <= area.limitsWest.col) {
area.limitsWest = { row: placed.row, col: placed.col - 1 };
area.grid.set(coordKey(area.limitsWest), limitsCard());
}
if (placed.col >= area.limitsEast.col) {
area.limitsEast = { row: placed.row, col: placed.col + 1 };
area.grid.set(coordKey(area.limitsEast), limitsCard());
}
}
/**
* §8.2 — may this car be coupled onto this train right now?
*
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
* order." Fewer is allowed; more, or of the wrong category, is not.
*
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
* this. A second copy stalled the game outright: the phase believed a car could be added while
* `check` rejected every option, so the Stage never completed.
*/
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!profile) return true;
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
const adding = cat(carType);
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
const allowed =
adding === 'coach'
? profile.consist.coach
: adding === 'caboose'
? profile.consist.caboose
: profile.consist.freight;
if (already >= allowed) return false;
// The caboose rides last (§A.3), so nothing may be coupled behind one.
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
// The card also narrows WHICH freight types it will take.
const types = profile.consist.freightTypes;
if (adding === 'freight' && types && !types.includes(carType)) return false;
return true;
}
/**
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
*
* A train is made up where it is built, standing at a Division Point, and only until its consist
* matches its card. Everything else with a Crew Tray — a train working your district, a train
* halfway across the Division — is running, not being assembled.
*
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
* including Train 9 mid-crossing with three cars already aboard.
*/
export function isBeingMadeUp(tray: CrewTray): boolean {
if (tray.trainNumber === null) return false;
if (tray.position.at !== 'divisionPoint') return false;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) return false;
return tray.consist.length < consistSize(profile.consist);
}
/**
* The tray the New Train Phase is waiting on, or null.
*
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
* to ask for a car that cannot be supplied.
*/
export function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (!isBeingMadeUp(tray)) continue;
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
// uses: a separate copy of this test stalled the game, because the phase believed a car could
// be added while `check` rejected every option, so the Stage never ended.
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
}
return null;
}
/**
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
*
* Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight
* back to the Division Yard. The Classification Yard empties only when the Division Yard is bare —
* every car of every kind gone — and then all of it returns at once.
*
* Confirmed from the source after an earlier guess. The first implementation returned cars at the
* DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up
* continuously, where this lets it run down to nothing and refill in one go. That difference is the
* whole of the supply pressure the game is meant to have.
*
* Called wherever a car leaves the Division Yard, so the refill happens the moment it empties
* rather than at the next convenient tick.
*/
export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null {
if (s.yards.divisionYard.length > 0) return null;
if (s.yards.classificationYard.length === 0) return null;
const count = s.yards.classificationYard.length;
s.yards.divisionYard.push(...s.yards.classificationYard);
s.yards.classificationYard = [];
return { type: 'yardRefilled', count };
}
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
function limitsCard(): TrackCard {
return {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
const code = check(s, player, i);
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
const events = execute(s, player, i);
for (const e of events) reduce(s, e);
return { ok: true, events };
}
export { isOperationalRail, destinationsFor };