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