Files
station-master/src/engine/apply.ts
T

1532 lines
57 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 `state = fold(events)` true by construction, which is what makes replay and restart
* recovery work. 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,
enhancementRule,
industryProfile,
mainlineModifierRule,
mainlineProfile,
modifierProfile,
nextOfficeTier,
officeProfile,
} from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
Facility,
GameState,
GridCoord,
OfficeArea,
PlayerIndex,
RollingStock,
TrackCard,
TrayId,
} from './state.ts';
import { createRng } from './rng.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import type { Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
canPlaceAt,
facilityVariants,
reachableDestinations,
variantsFor,
} from './track.ts';
export type ApplyResult =
| { ok: true; events: GameEvent[] }
| { ok: false; code: RejectionCode; message: string };
// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------
export function areaOf(s: GameState, player: PlayerIndex): OfficeArea {
const a = s.officeAreas.get(player);
if (!a) throw new Error(`no Office Area for player ${player}`);
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;
},
};
}
/** 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);
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.
*/
export function facilityCarType(f: Facility): CarType | null {
if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null;
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[0] ?? null;
}
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === 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 {
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. */
export function canStartLoad(f: Facility): boolean {
if (laborersLeft(f) < 1) return false;
if (f.outboundBox.length === 0) return false;
return f.menAtWork[0] === 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;
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
}
/** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */
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;
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded);
}
function trainAtOfficeWith(
s: GameState,
player: PlayerIndex,
pred: (c: RollingStock) => boolean,
): boolean {
const area = areaOf(s, player);
return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false);
}
// ---------------------------------------------------------------------------
// 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 (s.turn.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 (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)
? null
: 'ILLEGAL_MOVE';
}
case 'switch.dropCars': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
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 s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
// -- Draw a card ----------------------------------------------------------
case 'draw.fromHomeOffice':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.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 (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
return s.decks.departments[i.slot] ? null : 'SLOT_EMPTY';
case 'card.play': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.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);
}
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 'track.lay': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
const key = `${i.geometry}:${i.hand}`;
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
}
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.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 (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.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 rolls into an ADJACENT industry — the engine never enters, which is the whole point
// of the move and why it beats a normal spot.
const adjacent =
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
if (!adjacent) 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 (s.turn.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 (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.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 commodity.
if (facilityCarType(f) !== i.carType) return 'WRONG_CAR_TYPE';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
return 'NO_SUITABLE_CAR';
}
return null;
}
case 'freightAgent.clearInbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.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.unjam': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.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.placeCar': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
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';
}
return null;
}
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';
// §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 --------------------------------------------------------
case 'porter.board':
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
case 'porter.detrain':
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';
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';
return canStartLoad(f) ? null : 'BOX_EMPTY';
}
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';
// The load is placed on WORK, the last box, so that box must be free.
return f.menAtWork[f.menAtWork.length - 1] === null ? null : '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,
): RejectionCode | null {
const card = s.cards.get(cardId);
if (!card) return 'NO_SUCH_CARD';
const area = areaOf(s, player);
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';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'freightFacility': {
if (!placement) return 'NO_PLACEMENT';
// Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and
// the consumer of the same commodity, so this forces traffic to flow between districts.
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';
// §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
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';
default:
// Train cards go to the timetable; the phase driver schedules them.
return null;
}
}
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 ? (facing === 'e' ? 'w' : 'e') : facing;
return reachableDestinations(
{
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
},
from,
exit,
);
}
// ---------------------------------------------------------------------------
// 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 = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
const events: GameEvent[] = [
{
type: 'trayMoved',
trayId: i.trayId,
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
},
];
if (dest.couples.length > 0) {
events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples });
}
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.
const stock = tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
}
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':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'draw.fromHomeOffice':
return [
{
type: 'cardDrawn',
player,
source: 'homeOffice',
cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
},
];
case 'draw.fromDepartment': {
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
source: 'department',
slot: i.slot,
cardId: s.decks.departments[i.slot]!,
},
];
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in
// the empty spot." Without this the three face-up slots empty out permanently and the market
// silently disappears from the game.
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
if (refill) {
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
}
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) {
events.push({
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 'track.lay': {
const key = `${i.geometry}:${i.hand}`;
return [
{
type: 'trackLaid',
player,
geometry: i.geometry,
hand: i.hand,
at: i.placement,
variant: i.variant ?? 0,
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
},
];
}
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: f.menAtWork[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.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,
},
];
case 'porter.board':
return [
{ type: 'passengersBoarded', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' },
];
case 'porter.detrain':
return [
{ type: 'passengersDetrained', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' },
];
case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!;
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }];
}
case 'laborer.advanceLoad': {
const f = facilityAt(s, player, i.at)!;
const load = f.menAtWork[i.box]!;
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
if (next >= f.menAtWork.length) {
// Outbound complete: the load goes onto the spotted car (§9.3).
return [
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: '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 },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: '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;
}
/** §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. state = fold(events).
// ---------------------------------------------------------------------------
export function reduce(s: GameState, e: GameEvent): void {
switch (e.type) {
case 'localOpsOptionChosen':
s.turn.option = e.option;
break;
case 'trayMoved': {
const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
tray.position = { at: 'grid', owner, coord: e.to };
s.turn.movesRemaining = e.movesRemaining;
// 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 = areaOf(s, owner);
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 = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
tray.consist.push(...e.stock);
// Every card along the path is cleared; the event carries what was taken.
for (const card of area.grid.values()) {
if (card.standing.length > 0) card.standing = [];
if (card.facility) card.facility.industryTrack.cars = [];
}
break;
}
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// "Spends one move in the yard" — the sort costs a Move.
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
break;
}
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
const card = area.grid.get(coordKey(e.at));
// On a Facility card the industry track is where cars stand (§9.3).
if (card) carsOn(card).push(...e.stock);
break;
}
case 'departmentRefilled':
s.decks.homeOffice.pop();
s.decks.departments[e.slot] = e.cardId;
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] = null;
hand.push(e.cardId);
s.decks.hands.set(e.player, hand);
s.turn.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);
}
s.turn.movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
break;
}
case 'trackLaid': {
const area = areaOf(s, e.player);
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
s.turn.laidThisTurn = true;
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
if (built) {
area.grid.set(coordKey(e.at), built);
extendLimitsIfNeeded(area, e.at);
}
break;
}
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
area.tier = e.to;
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard?.facility) {
const p = officeProfile(e.to);
officeCard.facility.porters = p.porters;
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
}
break;
}
case 'cardDiscarded': {
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
s.decks.hands.set(e.player, hand);
s.decks.departments[e.toSlot] = 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);
f.outboundBox.push(e.stock);
s.turn.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);
s.turn.freightAgentUsed = true;
break;
}
case 'facilityUnjammed': {
const f = facilityAt(s, e.player, e.at)!;
if (e.from === 'menAtWork') {
const idx = f.menAtWork.findIndex((l) => l !== null);
if (idx >= 0) f.menAtWork[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);
s.turn.freightAgentUsed = true;
break;
}
case 'enhancementPlaced': {
const rule = enhancementRule(e.key);
if (rule?.placement === 'mainlineCard') {
const node = s.division.nodes[e.at.col];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else {
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);
tray.consist.push(e.stock);
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) {
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = { type: 'coach', loaded: false };
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);
f.menAtWork[0] = { type: e.carType, dir: 'out' };
f.usedThisStage.laborers += 1;
break;
}
case 'loadAdvanced': {
const f = facilityAt(s, e.player, e.at)!;
f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!;
f.menAtWork[e.fromBox] = null;
f.usedThisStage.laborers += 1;
break;
}
case 'unloadCompleted': {
const f = facilityAt(s, e.player, e.at)!;
f.menAtWork[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)!;
f.menAtWork[f.menAtWork.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) f.industryTrack.cars[ci] = { type: e.carType, loaded: false };
f.menAtWork[f.menAtWork.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') s.turn.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 (Gap 11). 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 },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
const options = variantsFor(geometry);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(v.bypass ? { bypass: v.bypass } : {}),
},
baseOperationalRail: geometry !== 'turnout',
...base,
};
}
const options = facilityVariants();
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'facility',
facility: kind.facility as never,
...(v.axis ? { axis: v.axis } : {}),
},
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: { length: Math.max(1, p.baseOut + p.baseIn), 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.
*/
function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
if (coord.row > area.runningRow) return 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 };
if (c.row > area.runningRow) continue;
if (area.grid.get(coordKey(c))?.facility) 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';
// ABS Signals goes on a Mainline card; `placement.col` names which one.
if (rule.placement === 'mainlineCard') {
const node = s.division.nodes[placement.col];
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
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;
const isStraight =
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
switch (rule.placement) {
case 'runningTrackStraight':
return onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'secondaryTrackStraight':
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'onCard':
return null;
default:
return 'NOT_CONNECTED';
}
}
/** 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);
}
/** A track piece from the player's own supply, at the chosen rotation. */
function protoTrackCard(
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
): TrackCard | null {
const options = variantsFor(geometry);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
* 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 (wanted.lockouts.includes(present)) return true;
if (industryProfile(present).lockouts.includes(kind)) return true;
}
return false;
}
/** 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);
if (!target) return;
const f = area.grid.get(coordKey(target))?.facility;
if (!f) return;
const m = modifierProfile(modifier);
f.capacity.outbound += m.addOut;
f.capacity.inbound += m.addIn;
f.laborers += m.addLoaders;
f.porters += m.addPorters;
if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn;
}
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
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 };
if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
}
// ---------------------------------------------------------------------------
// 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 };