Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
3444 lines
160 KiB
TypeScript
3444 lines
160 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,
|
|
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, Direction, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
|
import type { GameEvent } from './events.ts';
|
|
import type { ExtraStart, 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 { tallyEvent } from './tally.ts';
|
|
import { createRng } from './rng.ts';
|
|
import {
|
|
carsOn,
|
|
coordKey,
|
|
cutTowards,
|
|
decisionActor,
|
|
officeNodeFor,
|
|
isOperationalRail,
|
|
overHandLimit,
|
|
playerAtSeat,
|
|
pooled,
|
|
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,
|
|
rowEndAt,
|
|
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). */
|
|
/**
|
|
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
|
* question a switching move asks — a second copy would be free to drift into a different answer
|
|
* about which cards are free.
|
|
*/
|
|
export 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';
|
|
/**
|
|
* NOTHING IS ASKED ABOUT THE NEIGHBOURS, deliberately (Gitea#15).
|
|
*
|
|
* A turnout adds a 45° leg the card underneath did not have, and that leg may well point into an
|
|
* occupied square with nothing to meet it. That is legal: RAR ruled (2026-08-26) that a rail may
|
|
* stop dead against its neighbour, and an upgrade is no different from laying the piece there in
|
|
* the first place. What must hold either way is that no train can cross the gap, which is
|
|
* `exploreMoves`' business and is tested in `track.test.ts`.
|
|
*/
|
|
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, trayId?: TrayId): 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;
|
|
return passengerWork(s, player, 'board', trayId) !== null;
|
|
}
|
|
|
|
/**
|
|
* §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, trayId?: TrayId): 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 passengerWork(s, player, 'detrain', trayId) !== null;
|
|
}
|
|
|
|
/**
|
|
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
|
|
*/
|
|
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
|
const kind = s.cards.get(cardId)?.kind.kind;
|
|
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
|
}
|
|
|
|
/**
|
|
* WHY THIS CARD CANNOT BE THROWN AWAY, or `null` if it can (§6.2, Gitea#9 superseding Gitea#6).
|
|
*
|
|
* The one place that answers the question, so `check`, the hand panel and the blocked "End Local
|
|
* Operations" button all give the same reason rather than three hand-written approximations of it.
|
|
* Gitea#6 made every train card unconditionally undiscardable; Gitea#9 narrows that:
|
|
*
|
|
* - a TIMETABLED train is discardable unless the `discardTimetabled` house rule is off. Jesse's
|
|
* reasoning is about a long game whose timetable has filled up — "the stations are jammed and
|
|
* the railroad doesn't need any more. You can toss it";
|
|
* - an EXTRA is never discardable. It never joins the timetable, so it cannot jam it, and the
|
|
* rule it would otherwise dodge is the hand limit.
|
|
*
|
|
* Returns the sentence rather than a code because it is written for a player, and the two cases
|
|
* fail for genuinely different reasons — "not in this game" and "not ever".
|
|
*/
|
|
export function keepReason(s: GameState, cardId: CardId): string | null {
|
|
const kind = s.cards.get(cardId)?.kind.kind;
|
|
if (kind === 'extraTrain') {
|
|
return 'An Extra is never discarded. It runs once and ends in the Salvage Yard, so it can only ' +
|
|
'be played — hold it for as many Stages and Days as you like.';
|
|
}
|
|
if (kind === 'timetabledTrain' && !houseRules(s.config).discardTimetabled) {
|
|
return 'A train card is never discarded in this game. The only way it leaves your hand is onto ' +
|
|
'the timetable — hold it for as many Stages and Days as you like.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
|
* all use, so a placement can never be checked against one square and made on another.
|
|
*
|
|
* §7 lets the player who played the card choose the start, and Jesse's ruling makes the start
|
|
* decide the direction rather than the number (`runDirection`'s comment carries the supersession):
|
|
*
|
|
* - a DIVISION POINT runs the train away from itself — the west end runs east, the east end west.
|
|
* `direction` on the intent is ignored rather than refused, because there is only one answer;
|
|
* - an INTERCHANGE or a CONTROL POINT sits in the middle of the railroad, where both ways are real
|
|
* runs, so the intent must say which.
|
|
*
|
|
* Which of those are on offer is the `extraStart` house rule. The two that belong to nobody — the
|
|
* Division Points and the Interchange — are always available; an Office is a seat's own ground and
|
|
* is gated, to `ownOffice` (the player who played the card) or `anyOffice`.
|
|
*
|
|
* Returns a refusal code rather than throwing, so `check` can hand it straight back.
|
|
*/
|
|
export function resolveExtraStart(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
i: { trainNumber: number; atSeat?: SeatIndex | null; start?: ExtraStart; direction?: Direction },
|
|
): { at: ExtraStart; direction: Direction } | RejectionCode {
|
|
/**
|
|
* A save written before the choice existed. `atSeat` null meant the Division Point the NUMBER
|
|
* sent the train to, a seat meant that Office, and both ran in the number's direction — so that
|
|
* is what these replay as, whatever the rules say today.
|
|
*/
|
|
if (!i.start) {
|
|
const direction = runDirection(i.trainNumber);
|
|
if (i.atSeat === null || i.atSeat === undefined) {
|
|
return { at: { kind: 'divisionPoint', side: startingDivisionPoint(i.trainNumber) }, direction };
|
|
}
|
|
return { at: { kind: 'office', seat: i.atSeat }, direction };
|
|
}
|
|
|
|
const start = i.start;
|
|
if (start.kind === 'divisionPoint') {
|
|
if (!s.division.nodes.some((n) => n.kind === 'divisionPoint' && n.side === start.side)) {
|
|
return 'NO_SUCH_DIVISION_POINT';
|
|
}
|
|
// Away from the end it is standing at. Nothing else is a run.
|
|
return { at: start, direction: start.side === 'west' ? 'east' : 'west' };
|
|
}
|
|
|
|
if (i.direction === undefined) return 'NO_DIRECTION_CHOSEN';
|
|
|
|
if (start.kind === 'mainline') {
|
|
const node = s.division.nodes[start.node];
|
|
if (!node || node.kind !== 'mainline') return 'NO_SUCH_CARD';
|
|
// The Interchange is the one Mainline card an Extra may be made up on — it is the one with a
|
|
// yard. `sortsCars` is what the card prints and the only thing that distinguishes it.
|
|
if (!mainlineProfile(node.card).sortsCars) return 'NOT_AN_INTERCHANGE';
|
|
return { at: start, direction: i.direction };
|
|
}
|
|
|
|
const rule = houseRules(s.config).extraStart;
|
|
if (rule === 'divisionPointsOnly') return 'OFFICE_STARTS_NOT_ALLOWED';
|
|
if (rule === 'ownOffice' && start.seat !== seatOf(s, player)) return 'NOT_YOUR_OFFICE';
|
|
const area = s.officeAreas.get(start.seat);
|
|
if (!area) return 'NO_SUCH_FACILITY';
|
|
// 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 — and no setting of the house
|
|
// rule lets one in.
|
|
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
|
|
return { at: start, direction: i.direction };
|
|
}
|
|
|
|
/**
|
|
* WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use.
|
|
*
|
|
* TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a
|
|
* station, the select button does not work — regardless of which you pick, it is always one train,
|
|
* not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the
|
|
* Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it
|
|
* found. The two were not even asking the same question: `check` skipped a train whose card refuses
|
|
* passenger work and the reducer did not, so a Military train could be boarded as long as some other
|
|
* train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the
|
|
* one place that resolves it.
|
|
*
|
|
* And "passengers just boarded cannot be immediately unloaded": a coach carries the district that
|
|
* filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may
|
|
* alight from — they have to be carried to a different Office Area first.
|
|
*
|
|
* `trayId` absent means "any eligible train", which is what every intent recorded before this
|
|
* existed meant, so an old save replays unchanged.
|
|
*/
|
|
function passengerWork(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
dir: 'board' | 'detrain',
|
|
trayId?: TrayId,
|
|
): { trayId: TrayId; coachIndex: number } | null {
|
|
const area = areaOf(s, player);
|
|
const seat = seatOf(s, player);
|
|
const wanted = (c: RollingStock): boolean =>
|
|
c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat);
|
|
for (const id of area.adOccupancy) {
|
|
if (trayId !== undefined && id !== trayId) continue;
|
|
const tray = s.trays.get(id);
|
|
if (!tray) continue;
|
|
// §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.
|
|
if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue;
|
|
const coachIndex = tray.consist.findIndex(wanted);
|
|
if (coachIndex >= 0) return { trayId: id, coachIndex };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §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)}`;
|
|
}
|
|
|
|
/** Exported so the Blocked panel counts a freight car the same way the reducers do (Gitea#21). */
|
|
export const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
|
|
|
/**
|
|
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
|
*
|
|
* `ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }` because §2.2's
|
|
* "a coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT,
|
|
* and a caboose has no white version — there is no such thing as an empty one to make up a train
|
|
* from. Every other reading of `.loaded` in this file is already scoped to a coach or to a named
|
|
* car type, so the flag's second meaning only ever escaped here.
|
|
*
|
|
* Reported as Gitea#8: X22 Pee-Dee, whose whole card is "may only pick up MTs", could not couple a
|
|
* caboose at all — including the one it was made up with. Drop it and it was stranded, which made
|
|
* the train unplayable rather than merely restricted.
|
|
*/
|
|
const carriesLoad = (c: RollingStock): boolean => c.loaded && 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 set out or sort cars (§7).
|
|
*
|
|
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
|
* Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
|
|
* train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
|
|
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
|
|
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
|
|
* move would make, not on the move itself.
|
|
*/
|
|
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
|
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
|
}
|
|
|
|
/**
|
|
* WHETHER TRAINS 3/4's PRINTED RULE IS WHAT IS STOPPING THIS CREW WHERE IT STANDS (Gitea#21).
|
|
*
|
|
* Exported for the Blocked panel, which needs to say so — and asks `freightBudgetLeft`, the same
|
|
* predicate the reducer refuses on, rather than rebuilding the key for itself. `narrate.ts` cannot
|
|
* then drift from the rule it is describing, which is the whole premise of that panel.
|
|
*/
|
|
export function freightRuleSpentHere(s: GameState, player: PlayerIndex, trayId: TrayId): boolean {
|
|
const tray = s.trays.get(trayId);
|
|
if (!tray || !rulesOf(tray).oneFreightPerLocation) return false;
|
|
if (tray.position.at !== 'grid') return false;
|
|
return !freightBudgetLeft(s, player, tray, tray.position.coord, 1);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function passengerRefusal(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
at: GridCoord,
|
|
dir: 'board' | 'detrain',
|
|
trayId?: TrayId,
|
|
): RejectionCode {
|
|
const area = areaOf(s, player);
|
|
const trains = area.adOccupancy
|
|
.filter((id) => trayId === undefined || id === trayId)
|
|
.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';
|
|
/**
|
|
* EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded
|
|
* coach". Told apart because the two read as opposite situations to a player: one is an empty
|
|
* train, the other is a train full of passengers who have not been anywhere yet.
|
|
*/
|
|
if (
|
|
dir === 'detrain' &&
|
|
trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) &&
|
|
trains.every((t) =>
|
|
t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)),
|
|
)
|
|
) {
|
|
return 'LOADED_IN_THIS_DISTRICT';
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
/**
|
|
* §3.3, EXTENDED PLAY (Gitea#11) — asked ABOVE the status guard, because the whole point of the
|
|
* vote is that it is the one thing the rules will take from a game that has stopped.
|
|
*
|
|
* Out of turn like `mainline.clearance` below, and unlike it open to every seat at once: it is a
|
|
* table decision rather than a ruling, so there is no actor to be.
|
|
*/
|
|
if (i.type === 'game.extend') {
|
|
if (s.status !== 'awaitingExtension') return 'NOT_AWAITING_EXTENSION';
|
|
// The intent NAMES its voter so that a save can be replayed (`intents.ts`), which makes it a
|
|
// claim until it is checked against the seat the caller authenticated. One seat may not vote
|
|
// for another.
|
|
if (i.player !== player) return 'NOT_YOUR_TURN';
|
|
if (s.extensionVotes[player] !== null) return 'ALREADY_VOTED';
|
|
return 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?.kind !== 'clearance') return 'NO_PENDING_DECISION';
|
|
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase.
|
|
*
|
|
* Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the
|
|
* card is checked again here because `check` is the authority and a hand can change between the
|
|
* prompt being raised and answered.
|
|
*/
|
|
if (i.type === 'mainline.redFlag') {
|
|
if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT';
|
|
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
|
if (!i.flag) return null;
|
|
const held = (s.decks.hands.get(player) ?? []).find((id) => {
|
|
const c = s.cards.get(id);
|
|
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
|
});
|
|
if (!held) return 'NO_SUCH_CARD';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase.
|
|
*
|
|
* Goes to the district's owner rather than the Superintendent, which is the whole reason
|
|
* `pendingDecision` became a union. `decisionActor` is the single place that mapping lives.
|
|
*/
|
|
if (i.type === 'mainline.yardOffice') {
|
|
if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER';
|
|
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
|
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 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) {
|
|
/**
|
|
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
|
|
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
|
|
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
|
|
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
|
|
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
|
|
*/
|
|
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
|
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
|
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) 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);
|
|
const dropArea = areaOf(s, player);
|
|
const atOffice = here.row === dropArea.officeCoord.row && here.col === dropArea.officeCoord.col;
|
|
/**
|
|
* Trains 7/8 Local — "coach must remain on station track if switching" — the coach may never
|
|
* be set out anywhere ELSE while switching. §A.4 now carries an exception for the Office
|
|
* square (v0.5.0, Jesse's call): any train may cut a coach loose there, which is exactly what
|
|
* "station track" meant on the card all along. So the coach is refused everywhere except the
|
|
* Office, rather than everywhere.
|
|
*/
|
|
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach') && !atOffice) {
|
|
return 'COACH_MUST_STAY';
|
|
}
|
|
const droppedFreight = cut.filter(isFreight).length;
|
|
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
|
|
return 'FREIGHT_WORKED_HERE';
|
|
}
|
|
const coachesOnly = cut.every((c) => c.type === 'coach');
|
|
return canDropCarsAt(dropArea, here, i.count, coachesOnly) ? 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';
|
|
/**
|
|
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
|
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
|
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
|
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
|
* different train from the one the player asked for.
|
|
*/
|
|
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > 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);
|
|
}
|
|
|
|
/**
|
|
* §6.2 — WHICH TRAIN CARDS MAY BE THROWN AWAY (Gitea#9, superseding Gitea#6).
|
|
*
|
|
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
|
|
* `discardTimetabled` house rule is off, and an Extra never is.
|
|
*
|
|
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
|
|
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
|
|
* where a rival can draw it from, so the second half needed no machinery at all.
|
|
*
|
|
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
|
|
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
|
|
* refuses while the hand is over the limit, and playing a train card is unconditionally legal
|
|
* (`card.play`'s `timetabledTrain` case refuses only a board placement), so it can never lock.
|
|
*
|
|
* `legal.ts` enumerates candidates and filters them through here, so an undiscardable card
|
|
* simply stops being offered; the bot needs no separate rule.
|
|
*/
|
|
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';
|
|
if (keepReason(s, i.cardId) !== null) return 'TRAINS_ARE_NEVER_DISCARDED';
|
|
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 && node.card !== 'heavyGrade') 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. A train standing in the Interchange's yard counts: it is on
|
|
// the card, and it is about to pull out onto the very rail being relaid.
|
|
if (node.transits.length > 0 || (node.holding?.length ?? 0) > 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';
|
|
// §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no
|
|
// placement to find: the district is yours, and the only question is which side.
|
|
if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED';
|
|
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". `overHandLimit`
|
|
// (state.ts) is the one copy of that test; the Frame and the page ask the same function.
|
|
return overHandLimit(s, player) ? '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';
|
|
const pending = s.pendingExtras.find((x) => x.trainNumber === i.trainNumber);
|
|
if (!pending) return 'NO_EXTRA_PENDING';
|
|
// §7 — "the player who played the card MAY place the Crew Tray": it is theirs to place, and
|
|
// nobody else's to place for them.
|
|
if (pending.player !== player) return 'NOT_YOUR_EXTRA';
|
|
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
|
|
const where = resolveExtraStart(s, player, i);
|
|
if (typeof where === 'string') return where;
|
|
if (where.at.kind === 'divisionPoint') return null;
|
|
if (where.at.kind === 'mainline') {
|
|
// Nothing to refuse. The train is made up in the Interchange's yard, off the running line,
|
|
// so however busy the card is this cannot be the collision §7 says it must not force. What
|
|
// it may not do is get OUT — that is §8.1's question, asked at the Mainline Phase.
|
|
return null;
|
|
}
|
|
const area = s.officeAreas.get(where.at.seat)!;
|
|
// 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, i.loaded, s.yards.divisionYard) ? 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';
|
|
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
|
return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId);
|
|
}
|
|
|
|
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';
|
|
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
|
return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId);
|
|
}
|
|
|
|
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';
|
|
/**
|
|
* A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e).
|
|
*
|
|
* Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately
|
|
* unloaded." They could — a Freight House permits both directions, so the car its own Laborers
|
|
* had just loaded was standing on its own track, loaded, with an empty of that type in the
|
|
* yard, and every gate below said yes. Full Revenue at both ends for a load that never moved.
|
|
*
|
|
* The rule is district-wide and permanent, not "not at this facility" and not "not this
|
|
* Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs
|
|
* BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force.
|
|
*/
|
|
if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT';
|
|
/**
|
|
* §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';
|
|
/**
|
|
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
|
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
|
*
|
|
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
|
* hang outside the Limits, because 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 that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
|
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
|
* §10 both reason about what lies inside a player's Limits.
|
|
*
|
|
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
|
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
|
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
|
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
|
*
|
|
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
|
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
|
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
|
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
|
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
|
*/
|
|
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
|
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
|
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
|
// 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];
|
|
}
|
|
|
|
/**
|
|
* ROUTES, WALKED ONCE PER POSITION.
|
|
*
|
|
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
|
|
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
|
|
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
|
|
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
|
|
*
|
|
* So while one position is being examined — a legal-action listing, or the check and execute of one
|
|
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
|
|
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
|
|
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
|
|
* mutate a returned route; nothing does.
|
|
*/
|
|
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
|
|
|
|
export function withRouteCache<T>(s: GameState, fn: () => T): T {
|
|
if (routeCache) return fn();
|
|
routeCache = { state: s, routes: new Map() };
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
routeCache = null;
|
|
}
|
|
}
|
|
|
|
function destinationsFor(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
trayId: TrayId,
|
|
from: GridCoord,
|
|
reverse: boolean,
|
|
): MoveDestination[] {
|
|
const cache = routeCache?.state === s ? routeCache.routes : null;
|
|
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
|
|
const hit = cache?.get(key);
|
|
if (hit) return hit;
|
|
|
|
const tray = s.trays.get(trayId)!;
|
|
const facing = facingPort(s, trayId);
|
|
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
|
|
const routes = reachableDestinations(
|
|
{
|
|
area: areaOf(s, player),
|
|
occupancy: occupancyFor(s, player, trayId),
|
|
consistSize: tray.consist.length,
|
|
self: trayId,
|
|
},
|
|
from,
|
|
exit,
|
|
);
|
|
cache?.set(key, routes);
|
|
return routes;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
|
return cutTowards(card, carsOn(card), rowEndAt(card, 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,
|
|
};
|
|
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) {
|
|
/**
|
|
* §3.3, EXTENDED PLAY (Gitea#11) — the vote, and what it settles.
|
|
*
|
|
* Decided HERE rather than in `reduce` because the answer depends on the votes as they stand
|
|
* BEFORE this one lands, and `execute` is the half that still sees that. Three outcomes:
|
|
*
|
|
* - a refusal ends it immediately. Unanimity means one "no" is decisive, so nobody is made to
|
|
* wait on a player who has already said no (Jesse's call, 2026-08-28);
|
|
* - the last outstanding "yes" grants the Day — ONE Day, and the question is put again at the
|
|
* end of it;
|
|
* - anything else is just a vote recorded, and the table waits.
|
|
*/
|
|
case 'game.extend': {
|
|
const vote: GameEvent = { type: 'extensionVoted', player, agree: i.agree };
|
|
if (!i.agree) return [vote, { type: 'playConcluded', declinedBy: player }];
|
|
const after = s.extensionVotes.map((v, p) => (p === player ? true : v));
|
|
return after.every((v) => v === true)
|
|
? [vote, { type: 'dayExtended', day: s.config.days + s.extraDays + 1 }]
|
|
: [vote];
|
|
}
|
|
|
|
case 'mainline.yardOffice': {
|
|
const pending = s.clock.pendingDecision;
|
|
const trainId = pending?.kind === 'yardOffice' ? pending.train : '';
|
|
return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }];
|
|
}
|
|
|
|
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',
|
|
player,
|
|
trayId: i.trayId,
|
|
from,
|
|
to: i.to,
|
|
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
|
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
|
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
|
movesAllowed: turnOf(s, player).movesAllowed,
|
|
/**
|
|
* 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`, computed here against the card's CURRENT split — the
|
|
* reducer applying this event goes on to zero `standingWest` on the destination card, never
|
|
* the origin, so nothing downstream needs this snapshot to have been taken any earlier.
|
|
*/
|
|
const grid = areaOf(s, player).grid;
|
|
const startCard = grid.get(coordKey(from)) ?? emptyCard();
|
|
const startCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, 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(startCard, carsOn(startCard));
|
|
// The OTHER end's cut, which stays behind — so it is the other end of the row, not the
|
|
// other port. A 45° leg is an end of the row too (`rowEndAt`, Gitea#17).
|
|
const stayed = rowEndAt(startCard, exitPort) === 'e' ? sides.west : 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',
|
|
player,
|
|
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', player, 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',
|
|
player,
|
|
trayId: i.trayId,
|
|
at: here,
|
|
before: tray.consist.map((c) => ({ ...c })),
|
|
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
|
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
|
// replays to exactly the train it built.
|
|
engineAt: i.engineAt ?? 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
|
* than as a postscript to it. Split out of the shared case below for that one line.
|
|
*/
|
|
case 'switch.end': {
|
|
const turn = turnOf(s, player);
|
|
const ended: GameEvent = {
|
|
type: 'switchingEnded',
|
|
player,
|
|
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
|
movesAllowed: turn.movesAllowed,
|
|
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
|
};
|
|
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
|
}
|
|
|
|
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,
|
|
...(node?.kind === 'mainline' ? { from: node.card } : {}),
|
|
...(became ? { became } : {}),
|
|
},
|
|
];
|
|
}
|
|
|
|
case 'maneuver.redFlags':
|
|
return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }];
|
|
|
|
case 'mainline.redFlag': {
|
|
const pending = s.clock.pendingDecision;
|
|
const trainId = pending?.kind === 'redFlag' ? pending.train : '';
|
|
const seat = pending?.kind === 'redFlag' ? pending.seat : 0;
|
|
const side = pending?.kind === 'redFlag' ? pending.from : 'east';
|
|
if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }];
|
|
const cardId =
|
|
i.cardId ??
|
|
(s.decks.hands.get(player) ?? []).find((id) => {
|
|
const c = s.cards.get(id);
|
|
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
|
})!;
|
|
return [
|
|
{ type: 'redFlagsSet', player, cardId, seat, side },
|
|
{ type: 'redFlagRuled', player, trainId, flag: true },
|
|
];
|
|
}
|
|
|
|
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': {
|
|
// `check` has already accepted this, so the resolve cannot fail here.
|
|
const where = resolveExtraStart(s, player, i) as { at: ExtraStart; direction: Direction };
|
|
return [
|
|
{ type: 'extraStarted', player, trainNumber: i.trainNumber, at: where.at, direction: where.direction },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
|
* about making up train 10 and how each person added each car to it").
|
|
*
|
|
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
|
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
|
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
|
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
|
*/
|
|
case 'newTrain.placeCar': {
|
|
const placeTray = s.trays.get(i.trayId);
|
|
return [
|
|
{
|
|
type: 'carPlacedOnTrain',
|
|
player,
|
|
trayId: i.trayId,
|
|
stock: { type: i.carType, loaded: i.loaded },
|
|
trainNumber: placeTray?.trainNumber ?? null,
|
|
isExtra: placeTray?.trainIsExtra ?? false,
|
|
},
|
|
];
|
|
}
|
|
|
|
case 'newTrain.passCar': {
|
|
const passTray = s.trays.get(i.trayId);
|
|
return [
|
|
{
|
|
type: 'carPassed',
|
|
player,
|
|
trayId: i.trayId,
|
|
trainNumber: passTray?.trainNumber ?? null,
|
|
isExtra: passTray?.trainIsExtra ?? false,
|
|
},
|
|
];
|
|
}
|
|
|
|
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': {
|
|
// `check` has already established there is one; resolving it HERE, once, is what stops the
|
|
// reducer from finding a different train than the one the rules were tested against.
|
|
const work = passengerWork(s, player, 'board', i.trayId)!;
|
|
return [
|
|
{ type: 'passengersBoarded', player, at: i.at, ...work },
|
|
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
|
];
|
|
}
|
|
|
|
case 'porter.detrain': {
|
|
const work = passengerWork(s, player, 'detrain', i.trayId)!;
|
|
return [
|
|
{ type: 'passengersDetrained', player, at: i.at, ...work },
|
|
...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,
|
|
carIndex: i.carIndex,
|
|
},
|
|
];
|
|
}
|
|
|
|
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) {
|
|
// -- §3.3, extended play (Gitea#11)
|
|
case 'extensionVoted':
|
|
s.extensionVotes[e.player] = e.agree;
|
|
break;
|
|
|
|
/**
|
|
* One more Day, and the votes are wiped: agreeing once does not agree to the rest of the game.
|
|
*
|
|
* `config.days` is deliberately untouched. It is what the OFFICIAL result was decided at
|
|
* (`state.ts`'s `FinalReport`), so leaving it alone is what makes "the winner is decided at the
|
|
* original game length" a fact about the code rather than a comment on it. `outcome` is left
|
|
* alone too — it is the last evaluation, and it is what the results screen shows while the extra
|
|
* Day is played.
|
|
*/
|
|
case 'dayExtended':
|
|
s.extraDays += 1;
|
|
s.extensionVotes = s.players.map(() => null);
|
|
s.status = 'active';
|
|
break;
|
|
|
|
case 'playConcluded':
|
|
s.status = 'finished';
|
|
break;
|
|
|
|
// §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for
|
|
// the arriving train to consume, or the driver asks again for ever.
|
|
case 'yardOfficeRuled':
|
|
s.clock.pendingDecision = null;
|
|
s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take };
|
|
break;
|
|
|
|
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.
|
|
const mover = turnOf(s, playerAtSeat(s, seat));
|
|
mover.movesRemaining = e.movesRemaining;
|
|
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
|
mover.lastMove = { trayId: e.trayId, to: e.to };
|
|
|
|
const area = areaAtSeat(s, seat);
|
|
|
|
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
|
|
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
|
|
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
|
|
// on a card with no train standing there, so a stale value left behind is inert, not wrong.
|
|
const destCard = area.grid.get(coordKey(e.to));
|
|
if (destCard) destCard.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 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 })));
|
|
}
|
|
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
|
|
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
|
|
// a card it has just emptied, and nothing of its own is left beside it there.
|
|
/**
|
|
* 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 }));
|
|
/**
|
|
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
|
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
|
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
|
*
|
|
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
|
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
|
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
|
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
|
* `departureRefusal`.
|
|
*/
|
|
tray.engineAt = e.engineAt;
|
|
// "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 card's own `standingWest`, read against this train's 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, card.standingWest));
|
|
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 the split along the row;
|
|
// cars set out to the east leave it where it was.
|
|
card.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.
|
|
// The spent trains stay where they are; everything else in the Yard has just been swept up.
|
|
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
|
|
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: [],
|
|
standingWest: 0,
|
|
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 = officeNodeFor(s, e.seat);
|
|
if (node) node.redFlag = e.side;
|
|
spendCard(s, e.player, e.cardId);
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase
|
|
* driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a
|
|
* case here would be dead code that reads as the live one.
|
|
*/
|
|
|
|
case 'redFlagRuled':
|
|
s.clock.pendingDecision = null;
|
|
s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag };
|
|
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);
|
|
// `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
|
|
// track for a freight facility, the card itself for everything else. Written out longhand
|
|
// here it was a second copy of that rule, and the copy was wrong for a Passenger Facility:
|
|
// it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office
|
|
// would have landed somewhere `carsOn` cannot see — cars on the board that no train can
|
|
// couple and no walk is blocked by. `check` refuses a non-freight target, so this never
|
|
// fired; a trap that needs another rule to stay unsprung is still a trap.
|
|
carsOn(card).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);
|
|
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
|
|
s.yards.classificationYard.push(pooled(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(pooled(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':
|
|
// §7 hands this train to the player who played the card, so the queue records them.
|
|
s.pendingExtras.push({ trainNumber: e.trainNumber, player: e.player });
|
|
break;
|
|
|
|
case 'secondSectionOrdered':
|
|
s.pendingSecondSections.push(e.trainNumber);
|
|
break;
|
|
|
|
case 'trainScheduled':
|
|
s.timetable[e.slot] = e.trainNumber;
|
|
s.rngState = e.rngState;
|
|
/**
|
|
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
|
|
*
|
|
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
|
|
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
|
|
* pile's depth, it displayed as "a card" because no such card exists, and
|
|
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
|
|
* nothing behind it. Removed 2026-09-10 (Gitea#23).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* THE THREE PLACES AN EXTRA MAY BE MADE UP (§7, Jesse's ruling) — and all three leave it
|
|
* STANDING somewhere it must later highball out of, never already running.
|
|
*/
|
|
case 'extraStarted': {
|
|
const trayId = s.freeTrays.pop()!;
|
|
s.pendingExtras = s.pendingExtras.filter((x) => x.trainNumber !== e.trainNumber);
|
|
const { direction } = e;
|
|
const base = {
|
|
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0,
|
|
consist: [] as RollingStock[], direction, movesUsed: 0,
|
|
// The queue is emptied here, before a single car goes on, so the tray carries the owner
|
|
// from now on — §7 lets the player who played it load the consist as they choose.
|
|
builtBy: e.player,
|
|
};
|
|
// Which way the tray physically points, for the cards it will pick up. A Division Point start
|
|
// does not set it: those trays have never carried a facing and `enterMainline` does not read
|
|
// one, so writing it here would be inventing state the DP path has always done without.
|
|
const facing = { facing: direction === 'west' ? ('w' as const) : ('e' as const),
|
|
railFacing: direction === 'west' ? ('w' as const) : ('e' as const) };
|
|
|
|
if (e.at.kind === 'divisionPoint') {
|
|
const side = e.at.side;
|
|
s.trays.set(trayId, { ...base, position: { at: 'divisionPoint', side } });
|
|
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
|
|
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
|
break;
|
|
}
|
|
|
|
if (e.at.kind === 'mainline') {
|
|
/**
|
|
* IN THE INTERCHANGE'S YARD, NOT ON THE MAINLINE — the distinction §7 turns on.
|
|
*
|
|
* `holding` rather than `transits`, so the card can be nose to tail with traffic and this
|
|
* placement still forces no collision. It joins the running line at a later Mainline Phase
|
|
* through `evaluateClearance`, which is what gives the Superintendent the hold Jesse asked
|
|
* for: an automatic one against a facing train, a ruling against a following one.
|
|
*/
|
|
const node = s.division.nodes[e.at.node];
|
|
s.trays.set(trayId, {
|
|
...base, ...facing, beingMadeUp: true, position: { at: 'mainline', index: e.at.node },
|
|
});
|
|
if (node?.kind === 'mainline') (node.holding ??= []).push(trayId);
|
|
break;
|
|
}
|
|
|
|
// 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.at.seat);
|
|
s.trays.set(trayId, {
|
|
...base, ...facing, beingMadeUp: true,
|
|
position: { at: 'grid', seat: e.at.seat, coord: area.officeCoord },
|
|
});
|
|
area.adOccupancy.push(trayId);
|
|
break;
|
|
}
|
|
|
|
/**
|
|
* THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks".
|
|
*
|
|
* This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains
|
|
* standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and
|
|
* why it could fill a coach on a train whose card refuses passenger work — `check` skipped such
|
|
* a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork`
|
|
* resolved for `check`, carried on the event rather than looked up again here.
|
|
*/
|
|
case 'passengersBoarded': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
|
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
|
const tray = s.trays.get(e.trayId);
|
|
if (tray && tray.consist[e.coachIndex]) {
|
|
s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
|
|
/**
|
|
* Stamped with the district that filled it — the chip turned upside down in the tray. These
|
|
* passengers may not alight anywhere in this Office Area; the train has to carry them to a
|
|
* different one. See `RollingStock.origin` in state.ts.
|
|
*/
|
|
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
|
|
}
|
|
f.usedThisStage.porters += 1;
|
|
break;
|
|
}
|
|
|
|
case 'passengersDetrained': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const tray = s.trays.get(e.trayId);
|
|
if (tray && tray.consist[e.coachIndex]) {
|
|
// 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);
|
|
// The arriving coach goes into the red box carrying nothing: the journey it was stamped for
|
|
// is over, and the box feeds straight back to a yard through the Freight Agent.
|
|
f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
|
|
tray.consist[e.coachIndex] = empty;
|
|
}
|
|
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(pooled(f.industryTrack.cars[ci]!));
|
|
/**
|
|
* THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the
|
|
* tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so
|
|
* this load now has to leave the Office Area on a train before anyone can break it. See
|
|
* `RollingStock.origin` in state.ts for the rule and why it is a seat.
|
|
*/
|
|
f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) };
|
|
}
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'unloadBegan': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
/**
|
|
* THE CAR THE PLAYER PICKED, not merely "a loaded one".
|
|
*
|
|
* REPORTED: with several loaded cars spotted on one industry track, unloading always took the
|
|
* WESTMOST car regardless of which one was chosen. This used to re-derive the target with
|
|
* `industryTrack.cars.findIndex(c => c.loaded)`, which returns the same answer — the first
|
|
* loaded car in track order — no matter which `carIndex` the intent actually named; `check`
|
|
* had already validated that specific car, and the choice was thrown away between there and
|
|
* here. `e.carIndex` is exactly what `laborer.beginUnload`'s reducer read off the intent.
|
|
*/
|
|
const ci = e.carIndex;
|
|
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.decisionAnswer = { kind: 'clearance', 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.
|
|
*/
|
|
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
|
|
export function protoCard(
|
|
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
|
variant: number | undefined,
|
|
): TrackCard | null {
|
|
const base = { standing: [], standingWest: 0, 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: [],
|
|
standingWest: 0,
|
|
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.
|
|
*/
|
|
/**
|
|
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
|
|
*
|
|
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
|
|
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
|
|
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
|
|
* potentially get reused later, and so should have that capability. Extras run one time and then
|
|
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
|
|
* could get run again."
|
|
*
|
|
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
|
|
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
|
|
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
|
|
* still open — so it comes back with everything else. An Extra is a single run rather than a
|
|
* standing slot, so a played one is free to be run again.
|
|
*/
|
|
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
|
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
|
|
}
|
|
|
|
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
|
if (s.decks.homeOffice.length > taking) return null;
|
|
const collected = [
|
|
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
|
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
|
// Every Department in full: a discarded train was never played, so it is still runnable.
|
|
...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.
|
|
*/
|
|
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
|
|
export 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,
|
|
/**
|
|
* Whether the car being offered is loaded, and what the Division Yard still holds.
|
|
*
|
|
* Both optional so that a caller asking the SHAPE question — "does this card take a car of this
|
|
* category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape
|
|
* question of every car in the yard; `check` asks the full one about a specific car a player has
|
|
* named. Omitting them skips the loading rules rather than guessing at them.
|
|
*/
|
|
loaded?: boolean,
|
|
yard?: readonly RollingStock[],
|
|
): 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;
|
|
|
|
if (loaded === undefined) return true;
|
|
|
|
/**
|
|
* X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY.
|
|
*
|
|
* `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both
|
|
* `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with
|
|
* loaded cars while its own card said it could not. Found while building Gitea#13, which is the
|
|
* same rule pointing the other way, and fixed with it rather than left as the odd one out.
|
|
*
|
|
* A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no
|
|
* such thing as an empty one — so applying this to the caboose would bar the Appleseed from the
|
|
* caboose its own consist calls for.
|
|
*/
|
|
if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false;
|
|
|
|
/**
|
|
* MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement.
|
|
*
|
|
* "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only
|
|
* while the yard can still supply a loaded car this train would accept; once it cannot, the empty
|
|
* becomes legal and the train may also simply depart short. Asked of the yard rather than
|
|
* remembered on the tray, because the yard is what the rule is about and it changes under the
|
|
* train as other consists are built.
|
|
*
|
|
* The caboose is exempt for the same reason as above.
|
|
*/
|
|
if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) {
|
|
const loadedAvailable = yard.some(
|
|
(c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type),
|
|
);
|
|
if (loadedAvailable) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
|
*
|
|
* A train is made up where it is built 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.
|
|
*
|
|
* WHERE that is used to be the whole test: "standing at a Division Point". True while a Division
|
|
* Point was the only place to build one, and wrong once an Extra could be started at a Control Point
|
|
* or in an Interchange's yard (§7) — those trains were never offered a car and ran empty. The extra
|
|
* clause is `beingMadeUp` (state.ts), a flag set on exactly those trays and cleared when they start
|
|
* running, rather than a second positional rule: a train STANDING at an Office is usually one that
|
|
* arrived, and must not be fillable from the yard.
|
|
*
|
|
* 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' && !tray.beingMadeUp) 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.
|
|
*
|
|
* ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer
|
|
* the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but
|
|
* loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would
|
|
* both be told a car was available and then refused every one of them — the very stall this
|
|
* comment was written about.
|
|
*/
|
|
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) {
|
|
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: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
|
|
*
|
|
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
|
|
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
|
|
* position and applies each to a COPY of it — the switching planner — which can then share that
|
|
* position's routes across every candidate instead of re-walking them on each copy.
|
|
*/
|
|
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
|
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
|
|
const code = check(s, player, i);
|
|
return code ? { code } : { events: execute(s, player, i) };
|
|
});
|
|
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
|
|
return { ok: true, events: prepared.events };
|
|
}
|
|
|
|
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
|
|
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
|
|
for (const e of events) reduce(s, e);
|
|
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
|
|
// for why it cannot simply live inside `reduce`.
|
|
for (const e of events) tallyEvent(s, e);
|
|
}
|
|
|
|
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
|
const r = prepareIntent(s, player, i);
|
|
if (r.ok) commitEvents(s, r.events);
|
|
return r;
|
|
}
|
|
|
|
export { isOperationalRail, destinationsFor };
|