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