2278 lines
96 KiB
TypeScript
2278 lines
96 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,
|
|
consistSize,
|
|
enhancementRule,
|
|
industryProfile,
|
|
mainlineModifierRule,
|
|
mainlineProfile,
|
|
modifierProfile,
|
|
nextOfficeTier,
|
|
officeProfile,
|
|
trainProfile,
|
|
} from './content.ts';
|
|
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
|
import type { GameEvent } from './events.ts';
|
|
import type { Intent, RejectionCode } from './intents.ts';
|
|
import type {
|
|
CardId,
|
|
CrewTray,
|
|
Facility,
|
|
GameState,
|
|
GridCoord,
|
|
Load,
|
|
OfficeArea,
|
|
PlayerIndex,
|
|
RollingStock,
|
|
TrackArc,
|
|
TrackCard,
|
|
TrayId,
|
|
TurnoutOrientation,
|
|
} from './state.ts';
|
|
import { createRng } from './rng.ts';
|
|
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
|
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
|
import {
|
|
canDropCarsAt,
|
|
canPlaceAt,
|
|
carriesThroughTrack,
|
|
exploreMoves,
|
|
facilityVariants,
|
|
opposite,
|
|
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);
|
|
if (tray?.facing) return tray.facing;
|
|
return tray?.direction === 'west' ? 'w' : 'e';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared predicates — used by BOTH check() below and legalActions()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function isActor(s: GameState, player: PlayerIndex): boolean {
|
|
return s.clock.currentActor === player;
|
|
}
|
|
|
|
export function inPhase(s: GameState, phase: GameState['clock']['phase']): boolean {
|
|
return s.clock.phase === phase;
|
|
}
|
|
|
|
/**
|
|
* §6 — "a player may do one of three things". You cannot do a thing that does not exist: a player
|
|
* with no Facility has no Freight Agent operation available, and one with no Crew Tray in his area
|
|
* has nothing to switch. Choosing such an option is illegal rather than a wasted turn.
|
|
*
|
|
* These deliberately inspect state directly rather than calling `check`, which would be circular.
|
|
*/
|
|
export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolean {
|
|
for (const card of areaOf(s, player).grid.values()) {
|
|
const f = card.facility;
|
|
if (!f) continue;
|
|
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true;
|
|
if (f.inboundBox.length > 0) return true;
|
|
if (f.menAtWork?.some((l) => l !== null)) return true;
|
|
if (f.outboundBox.length > 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* §9.1 — "Loading boxes are green, with the icon of the car type that can be loaded from there."
|
|
* A facility handles exactly one commodity, so only that car type may be stocked into it.
|
|
*
|
|
* Without this check a Mine Tipple could be stocked with a coach. The load would then wait forever
|
|
* for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK
|
|
* strips the industry track of Operational Rail status (§9.3), the facility would jam permanently.
|
|
*/
|
|
/**
|
|
* EVERY commodity a facility handles, not just the first one printed.
|
|
*
|
|
* Two of the six industries take two: a Power Plant burns coal OR oil (`['hopper','tank']`) and a
|
|
* Grocer's Warehouse receives dry goods OR perishables (`['boxcar','reefer']`). This used to return
|
|
* `carTypes[0]`, and because `freightAgent.stockOutbound` gates on it, the engine rejected the
|
|
* second commodity as WRONG_CAR_TYPE — the sheet said a Power Plant takes tank cars and the code
|
|
* said it did not. Measured effect: tank cars were dropped 0 times in 100 games.
|
|
*/
|
|
export function facilityCarTypes(f: Facility): readonly CarType[] {
|
|
if (f.kind !== 'freight') return f.kind === 'passenger' ? ['coach'] : [];
|
|
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes ?? [];
|
|
}
|
|
|
|
/** The commodity to NAME a facility by, where one word is wanted. Legality must use the full set. */
|
|
export function facilityCarType(f: Facility): CarType | null {
|
|
return facilityCarTypes(f)[0] ?? null;
|
|
}
|
|
|
|
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
|
|
for (const tray of s.trays.values()) {
|
|
if (tray.position.at === 'grid' && tray.position.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 {
|
|
// A Passenger Facility has no pipeline at all (§9.2 is porters, not MEN | AT | WORK), so freight
|
|
// work is refused here on the shape of the facility rather than on it happening to have 0 Laborers.
|
|
if (!f.menAtWork) return false;
|
|
if (box < 0 || box >= f.menAtWork.length) return false;
|
|
const load = f.menAtWork[box];
|
|
if (!load) return false;
|
|
if (laborersLeft(f) < 1) return false;
|
|
|
|
const next = load.dir === 'out' ? box + 1 : box - 1;
|
|
|
|
if (next >= f.menAtWork.length) {
|
|
// Off WORK and onto a spotted empty car of the right type.
|
|
return f.industryTrack.cars.some((c) => !c.loaded && c.type === load.type);
|
|
}
|
|
if (next < 0) {
|
|
// Off MEN and into the red Inbound box.
|
|
return f.inboundBox.length < f.capacity.inbound;
|
|
}
|
|
return f.menAtWork[next] === null;
|
|
}
|
|
|
|
/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */
|
|
/**
|
|
* A turnout's diverging leg, as an arc.
|
|
*
|
|
* The stem is always an east or west edge and the leg always leaves north or south, so the arc is
|
|
* just the two named together — `{stem:'w', diverge:'s'}` is `sw`. Naming the leg this way is what
|
|
* lets the upgrade rule below be stated in GEOMETRY rather than in hands, so it is unaffected by
|
|
* which printed row we call left.
|
|
*/
|
|
function divergingArc(t: TurnoutOrientation): TrackArc {
|
|
return `${t.diverge}${t.stem}` as TrackArc;
|
|
}
|
|
|
|
/**
|
|
* May this turnout be laid ON TOP of the card already on this square?
|
|
*
|
|
* Reported from playtesting: a district can only ever hang off a turnout, so a player who has laid
|
|
* a straight along the main and then wants to branch there had no move at all — the piece had to
|
|
* have been a turnout when it went down. A turnout may therefore UPGRADE:
|
|
*
|
|
* - a **straight**, at any of its orientations, because a turnout is a straight plus a leg; or
|
|
* - a **curve of the same arc** as the turnout's own diverging leg, which is the same road with a
|
|
* through track added beside it.
|
|
*
|
|
* Both are strict port SUPERSETS of what they replace — `{e,w}` for a straight, one arc for a curve
|
|
* — so an upgrade can never sever a join a neighbour already relies on, and needs no connection test
|
|
* of its own. The new leg is allowed to reach nothing at all; opening a direction is the point.
|
|
*
|
|
* Two things block it, and both are about the card being in use rather than about its shape: you
|
|
* cannot swap the track out from under a standing car, and an Interlocking or Telegraph built on the
|
|
* card would have to be lifted with it. The replaced card leaves play — board cards are never
|
|
* salvaged (see the `cardPlayed` reducer), so a lifted one is simply gone, as it would be at a table.
|
|
*/
|
|
function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCode | null {
|
|
const t = proto.geometry.kind === 'track' ? proto.geometry.turnout : undefined;
|
|
if (!t) return 'NOT_UPGRADEABLE_TRACK';
|
|
|
|
const g = existing.geometry;
|
|
if (g.kind !== 'track') return 'NOT_UPGRADEABLE_TRACK';
|
|
if (g.geometry === 'curved' || g.geometry === 'sharpCurved') {
|
|
// The ARC, not merely the diagonal: a `sw` curve and an `ne` one share a slope but leave by
|
|
// opposite edges, so replacing one with the other would move the leg off its neighbour.
|
|
if (g.arc !== divergingArc(t)) return 'NOT_UPGRADEABLE_TRACK';
|
|
} else if (g.geometry !== 'straight') {
|
|
return 'NOT_UPGRADEABLE_TRACK';
|
|
}
|
|
|
|
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
|
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The MEN | AT | WORK pipeline of a Freight Facility.
|
|
*
|
|
* `execute` and `reduce` run only after `check` has passed, and every freight-work check refuses a
|
|
* Passenger Facility — so reaching here with one is a broken invariant, not a case to handle. Throwing
|
|
* says that, where a `!` would quietly write into nothing and leave the fault to surface later.
|
|
*/
|
|
function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
|
|
if (!f.menAtWork) throw new Error('freight work attempted on a Passenger Facility');
|
|
return f.menAtWork;
|
|
}
|
|
|
|
export function canStartLoad(f: Facility): boolean {
|
|
if (!f.menAtWork) return false;
|
|
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;
|
|
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
|
// train these passengers can board however many empty coaches it is carrying.
|
|
return trainAtOfficeWith(
|
|
s, player,
|
|
(c) => c.type === 'coach' && !c.loaded,
|
|
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* §9.2 — de-training needs an open red slot, a train carrying a loaded coach, AND AN EMPTY COACH IN
|
|
* THE DIVISION YARD.
|
|
*
|
|
* "*Requirements: a white empty coach in the Division Yard and an unoccupied red Unloading slot.
|
|
* Replace the blue coach on the train with the white one.*" The empty coach is where the passengers
|
|
* were sitting — it has to come from somewhere, and the rule says where.
|
|
*
|
|
* That requirement was missing, and the reducer conjured the coach rather than taking it, so every
|
|
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
|
|
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
|
|
*/
|
|
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
|
const f = facilityAt(s, player, at);
|
|
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
|
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
|
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
|
return trainAtOfficeWith(
|
|
s, player,
|
|
(c) => c.type === 'coach' && c.loaded,
|
|
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
|
);
|
|
}
|
|
|
|
function trainAtOfficeWith(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
pred: (c: RollingStock) => boolean,
|
|
trayOk: (t: CrewTray) => boolean = () => true,
|
|
): boolean {
|
|
const area = areaOf(s, player);
|
|
return area.adOccupancy.some((id) => {
|
|
const t = s.trays.get(id);
|
|
return !!t && trayOk(t) && t.consist.some(pred);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §7 — the operating rules printed on a train's own card
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* What this tray's train card prints, or nothing at all.
|
|
*
|
|
* A local crew has no train number and therefore no printed rules — it is the player's own switcher
|
|
* and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a
|
|
* crew is unaffected by all of them.
|
|
*/
|
|
function rulesOf(tray: CrewTray): TrainRules {
|
|
if (tray.trainNumber === null) return {};
|
|
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
|
|
}
|
|
|
|
/** Freight cars this train has already exchanged on this square this turn (trains 3/4). */
|
|
function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
|
return `${trayId}@${coordKey(at)}`;
|
|
}
|
|
|
|
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
|
|
|
/**
|
|
* May this train work these freight cars on this square?
|
|
*
|
|
* Trains 3/4 Express print "may drop or pick up one freight car at EVERY location", so the budget is
|
|
* per square rather than per turn — it may work a car here, move on, and work another there. Both
|
|
* setting out and picking up spend from the same one, because the card says "drop OR pick up".
|
|
*/
|
|
function freightBudgetLeft(
|
|
s: GameState,
|
|
tray: CrewTray,
|
|
at: GridCoord,
|
|
wanted: number,
|
|
): boolean {
|
|
if (!rulesOf(tray).oneFreightPerLocation) return true;
|
|
const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
|
|
return already + wanted <= 1;
|
|
}
|
|
|
|
/**
|
|
* Whether a train may do switching work at all (§7).
|
|
*
|
|
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
|
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
|
* sorting alike, because all three are switching.
|
|
*/
|
|
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
|
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
|
}
|
|
|
|
/**
|
|
* Charge freight cars against this train's per-location budget (trains 3/4).
|
|
*
|
|
* Called from BOTH the coupling and the setting-out reducers, because the card says "drop OR pick up
|
|
* one" — the two share a budget rather than getting one each. Only recorded for trains the rule
|
|
* applies to, so the map stays empty for everything else.
|
|
*/
|
|
function spendFreightBudget(
|
|
s: GameState,
|
|
tray: CrewTray,
|
|
at: GridCoord,
|
|
stock: readonly RollingStock[],
|
|
): void {
|
|
if (!rulesOf(tray).oneFreightPerLocation) return;
|
|
const n = stock.filter(isFreight).length;
|
|
if (n === 0) return;
|
|
const key = freightWorkedKey(tray.id, at);
|
|
s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n;
|
|
}
|
|
|
|
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
|
|
function refusesPassengers(tray: CrewTray): boolean {
|
|
return rulesOf(tray).noPassengerWork === true;
|
|
}
|
|
|
|
/**
|
|
* Trains 1/2 Crack Limited — "stop at Terminals only". It runs into every Office and takes an A/D
|
|
* track like anything else, but Porters only work it where it is booked to stop, so passengers can
|
|
* neither board nor alight anywhere but a Terminal.
|
|
*/
|
|
function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
|
|
if (!rulesOf(tray).terminalsOnly) return false;
|
|
const area = s.officeAreas.get(player);
|
|
return !area || area.tier !== 'terminal';
|
|
}
|
|
|
|
/**
|
|
* WHY passenger work was refused — the printed rule if one is to blame, otherwise the general one.
|
|
*
|
|
* `canBoard`/`canDetrain` answer a single yes/no over every train at the Office, so when they say no
|
|
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
|
* reported "no train at the Office", which is both wrong and unhelpful.
|
|
*/
|
|
function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode {
|
|
const area = areaOf(s, player);
|
|
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
|
|
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
|
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
|
return 'NO_TRAIN_AT_OFFICE';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 noSwitch = switchingRefusal(tray);
|
|
if (noSwitch) return noSwitch;
|
|
const from = trayCoord(s, i.trayId);
|
|
if (!from) return 'ILLEGAL_MOVE';
|
|
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
|
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
|
if (!dest) return 'ILLEGAL_MOVE';
|
|
|
|
/**
|
|
* COUPLING IS MANDATORY (§A.4), so a train forbidden to pick something up may not make the
|
|
* MOVE that would pick it up. There is no "move but leave them"; the restriction has to bite
|
|
* on the move or it cannot bite at all.
|
|
*/
|
|
const rules = rulesOf(tray);
|
|
if (dest.couples.length > 0) {
|
|
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
|
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
|
const freight = dest.couples.filter(isFreight).length;
|
|
if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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';
|
|
const noSwitch = switchingRefusal(tray);
|
|
if (noSwitch) return noSwitch;
|
|
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
|
const here = trayCoord(s, i.trayId);
|
|
if (!here) return 'CANNOT_DROP_HERE';
|
|
/**
|
|
* A CUT COMES OFF AN OUTER END, never out of the middle.
|
|
*
|
|
* The tray runs `[cars ahead of the engine] ENGINE [cars behind it]`. Setting out from the
|
|
* nose takes from the front of that, and only the cars actually ahead of the engine; setting
|
|
* out from the tail takes from the back, and only the cars behind it. Without this, a drop
|
|
* could lift cars from beside the engine and leave the far end of the train still attached to
|
|
* nothing — a cut no coupler could make.
|
|
*/
|
|
const ahead = tray.engineAt;
|
|
const behind = tray.consist.length - tray.engineAt;
|
|
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
|
|
|
|
// The cut that would come off, so the printed rules can be asked about its contents.
|
|
const cut = i.fromNose
|
|
? tray.consist.slice(0, i.count)
|
|
: tray.consist.slice(tray.consist.length - i.count);
|
|
const dropRules = rulesOf(tray);
|
|
/**
|
|
* Trains 7/8 Local — "coach must remain on station track if switching", i.e. the coach is
|
|
* never set out during switching at all.
|
|
*
|
|
* The intended reading was "set out only at the Office", but §A.4 makes that unimplementable:
|
|
* `canDropCarsAt` refuses the Office square outright — "the Office track is Operational Rail,
|
|
* but Rolling Stock may not be left there" — so "only at the Office" and "nowhere" are the same
|
|
* rule. What is left is the effect that matters: the Local may shunt its freight car around the
|
|
* district, and may not abandon its coach at an industry or on a siding while it does.
|
|
* Flagged in `TODO.md` in case the station track is meant to become a real place to leave one.
|
|
*/
|
|
if (dropRules.coachStaysOnStationTrack && cut.some((c) => c.type === 'coach')) {
|
|
return 'COACH_MUST_STAY';
|
|
}
|
|
const droppedFreight = cut.filter(isFreight).length;
|
|
if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) {
|
|
return 'FREIGHT_WORKED_HERE';
|
|
}
|
|
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
|
}
|
|
|
|
case 'switch.sortConsist': {
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
if (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 noSwitch = switchingRefusal(tray);
|
|
if (noSwitch) return noSwitch;
|
|
const here = trayCoord(s, i.trayId);
|
|
if (!here) return 'ILLEGAL_MOVE';
|
|
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
|
const card = areaOf(s, player).grid.get(coordKey(here));
|
|
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
|
|
// The order must be a permutation of the current consist.
|
|
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
|
|
const seen = new Set(i.order);
|
|
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
|
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
|
return null;
|
|
}
|
|
|
|
case 'switch.end':
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
return 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]?.length ?? 0) > 0 ? 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, i.node);
|
|
}
|
|
|
|
case 'card.discard': {
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
const hand = s.decks.hands.get(player) ?? [];
|
|
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
|
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
|
return null;
|
|
}
|
|
|
|
case 'mainline.modify': {
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
if (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 is uncoupled and ROLLS to the industry under its own momentum, so the target must
|
|
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
|
|
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
|
|
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
|
|
// two cards along the same track. It also made the card effectively unplayable — held for
|
|
// 1,400 turns across 60 games and legal on 2.
|
|
const reachable = [
|
|
...destinationsFor(s, player, i.trayId, here, false),
|
|
...destinationsFor(s, player, i.trayId, here, true),
|
|
];
|
|
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
|
|
return 'NOT_CONNECTED';
|
|
}
|
|
const fsArea = areaOf(s, player);
|
|
const target = fsArea.grid.get(coordKey(i.to));
|
|
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
|
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
|
|
}
|
|
|
|
case 'draw.end': {
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
if (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 commodities, of which it may have two.
|
|
if (!facilityCarTypes(f).includes(i.carType)) return 'WRONG_CAR_TYPE';
|
|
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
|
|
return 'NO_SUITABLE_CAR';
|
|
}
|
|
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.end':
|
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
|
// §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the
|
|
// action unspent is a wasted Stage, which is the player's to waste — the alternative was
|
|
// forcing an unjam that destroys a load.
|
|
return s.turn.option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN';
|
|
|
|
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';
|
|
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
|
|
// train that is running, and loading one from the yard is teleporting cars onto it.
|
|
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
|
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
|
|
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
|
return 'NO_SUITABLE_CAR';
|
|
}
|
|
|
|
// §8.2 — "the train must be in the order listed on the train's card (engine on the front,
|
|
// Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
|
|
// but not out of order."
|
|
//
|
|
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
|
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
|
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
|
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
|
|
}
|
|
|
|
case 'newTrain.secondSection': {
|
|
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
|
// Only on a train that is actually due out this Stage — a second section follows a first.
|
|
if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
|
|
if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
|
|
return null;
|
|
}
|
|
|
|
case 'newTrain.passCar': {
|
|
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
|
const tray = s.trays.get(i.trayId);
|
|
if (!tray) return 'NO_SUCH_TRAY';
|
|
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
|
|
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
|
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
|
|
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
|
|
}
|
|
|
|
// -- Load / Unload --------------------------------------------------------
|
|
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 : passengerRefusal(s, player);
|
|
|
|
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 : passengerRefusal(s, player);
|
|
|
|
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';
|
|
/**
|
|
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
|
|
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
|
*
|
|
* The empty car requirement was not checked and the reducer conjured the car rather than
|
|
* taking it, so every unload minted one. Unloading is meant to consume supply.
|
|
*/
|
|
if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR';
|
|
// The load is placed on WORK, the last box, so that box must be free — and a Passenger
|
|
// Facility has no such box, so there is nothing to unload into.
|
|
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
|
|
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,
|
|
node?: number,
|
|
): RejectionCode | null {
|
|
const card = s.cards.get(cardId);
|
|
if (!card) return 'NO_SUCH_CARD';
|
|
const area = areaOf(s, player);
|
|
// A Division node and an Office Area square are different boards. Naming both is not a placement
|
|
// with extra detail, it is two contradictory answers to "where?".
|
|
if (node !== undefined && placement) return 'NO_PLACEMENT';
|
|
|
|
switch (card.kind.kind) {
|
|
case 'office': {
|
|
// An upgrade replaces the Office in place (Gap 8), so a placement is meaningless — and
|
|
// accepting one makes the event log claim the card was laid somewhere it was not.
|
|
if (placement) return 'NO_PLACEMENT';
|
|
// Gap 3b — strict sequence, no skipping.
|
|
const next = nextOfficeTier(area.tier);
|
|
return next === card.kind.tier ? null : 'NOT_UPGRADEABLE';
|
|
}
|
|
case 'track':
|
|
if (!placement) return 'NO_PLACEMENT';
|
|
{
|
|
const proto = protoCard(card.kind, variant);
|
|
if (!proto) return 'NO_PLACEMENT';
|
|
|
|
/**
|
|
* AN OCCUPIED SQUARE IS AN UPGRADE, not a placement.
|
|
*
|
|
* A turnout may be laid on top of a card already down — see `checkTurnoutUpgrade`. The one
|
|
* occupied square that is NOT an upgrade is a Limits sign on the Running Track: that is the
|
|
* growth point, and `canPlaceAt` moves it outward rather than building over it.
|
|
*/
|
|
const existing = area.grid.get(coordKey(placement));
|
|
const isMovableSign =
|
|
existing?.geometry.kind === 'limits' &&
|
|
placement.row === area.runningRow &&
|
|
existing.standing.length === 0;
|
|
if (existing && !isMovableSign) return checkTurnoutUpgrade(existing, proto);
|
|
|
|
// Said separately from NOT_CONNECTED because it is a different mistake: the card would join
|
|
// perfectly well, and would still leave the Running Track stopping dead at it.
|
|
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
|
|
return 'BREAKS_RUNNING_TRACK';
|
|
}
|
|
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
|
|
}
|
|
|
|
case 'freightFacility': {
|
|
if (!placement) return 'NO_PLACEMENT';
|
|
/**
|
|
* ON A STRAIGHT STUB, NEVER THE RUNNING TRACK.
|
|
*
|
|
* The sheet's "Placed" column reads the same for all six industries: **"Straight, Stub (not on
|
|
* Running Track)"**. An industry has to hang off a siding, which is what makes a siding worth
|
|
* building — the whole switching puzzle is getting a car from the main down to a spur and back.
|
|
*
|
|
* This was allowed, and merely warned about: an industry could sit on the Running Track and
|
|
* the card text noted that a car left standing there would be hit by the next arrival. That is
|
|
* a hazard, not a rule, and it let a player skip the district entirely and spot cars on the
|
|
* main line.
|
|
*/
|
|
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
|
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
|
|
// alongside the consumer of the same commodity.
|
|
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
|
|
const proto = protoCard(card.kind, variant);
|
|
if (!proto) return 'NO_PLACEMENT';
|
|
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
|
|
}
|
|
case 'modifier': {
|
|
if (!placement) return 'NO_PLACEMENT';
|
|
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
|
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
|
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
|
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
|
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
|
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
|
}
|
|
case 'enhancement': {
|
|
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
|
|
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
|
|
if (node === undefined) return 'NO_PLACEMENT';
|
|
const target = s.division.nodes[node];
|
|
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
|
}
|
|
if (!placement) return 'NO_PLACEMENT';
|
|
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
|
}
|
|
|
|
case 'mainlineModifier':
|
|
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
|
|
// with the same placement and the same effect. It is one card printed twice, so the mainline
|
|
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
|
|
if (card.kind.key === 'facingPointLocksMainline') {
|
|
if (!placement) return 'NO_PLACEMENT';
|
|
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
|
|
}
|
|
// The rest are laid on a Mainline card, which is not a grid coordinate — see
|
|
// `mainline.modify`.
|
|
return 'WRONG_INTENT';
|
|
|
|
case 'maneuver':
|
|
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
|
|
// the source", so there is nothing to implement.
|
|
return 'WRONG_INTENT';
|
|
|
|
case 'spaceUse':
|
|
case 'action':
|
|
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
|
|
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
|
|
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
|
|
return 'NOT_IMPLEMENTED';
|
|
|
|
case 'timetabledTrain':
|
|
case 'extraTrain':
|
|
// A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
|
|
// offer the same play at six different grid squares with six rotations apiece — all identical,
|
|
// because the placement was then ignored. Same reasoning as the Office upgrade above: silently
|
|
// discarding a placement makes the event log claim the card was laid somewhere it was not.
|
|
return placement ? 'NO_PLACEMENT' : null;
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function destinationsFor(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
trayId: TrayId,
|
|
from: GridCoord,
|
|
reverse: boolean,
|
|
) {
|
|
const tray = s.trays.get(trayId)!;
|
|
const facing = facingPort(s, trayId);
|
|
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
|
|
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
|
|
// have — and could never back out of a district spur. Combined with a facing that was itself
|
|
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
|
|
const exit: Port = reverse ? opposite(facing) : facing;
|
|
return reachableDestinations(
|
|
{
|
|
area: areaOf(s, player),
|
|
occupancy: occupancyFor(s, player, trayId),
|
|
consistSize: tray.consist.length,
|
|
self: trayId,
|
|
},
|
|
from,
|
|
exit,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
|
|
*
|
|
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
|
|
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
|
|
* a reason that applies in one direction should not be reported when the other direction works.
|
|
*
|
|
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
|
|
* that produced them.
|
|
*/
|
|
export function movesFor(
|
|
s: GameState,
|
|
player: PlayerIndex,
|
|
trayId: TrayId,
|
|
): { to: GridCoord[]; blocked: MoveBlock[] } {
|
|
const tray = s.trays.get(trayId);
|
|
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
|
|
const from = tray.position.coord;
|
|
const ctx = {
|
|
area: areaOf(s, player),
|
|
occupancy: occupancyFor(s, player, trayId),
|
|
consistSize: tray.consist.length,
|
|
self: trayId,
|
|
};
|
|
const facing = facingPort(s, trayId);
|
|
const forward = exploreMoves(ctx, from, facing);
|
|
const back = exploreMoves(ctx, from, opposite(facing));
|
|
|
|
const to = new Map<string, GridCoord>();
|
|
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
|
|
|
const blocked = new Map<string, MoveBlock>();
|
|
for (const b of [...forward.blocked, ...back.blocked]) {
|
|
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
|
|
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
|
|
}
|
|
|
|
return { to: [...to.values()], blocked: [...blocked.values()] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// execute — reads state, emits events, never mutates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|
switch (i.type) {
|
|
case 'localOps.choose':
|
|
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
|
|
|
case 'switch.move': {
|
|
const from = trayCoord(s, i.trayId)!;
|
|
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
|
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
|
|
// The crew leaves by the port opposite the one it entered through, which is what it will be
|
|
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
|
|
// onto a north-south spur can never move again.
|
|
const events: GameEvent[] = [
|
|
{
|
|
type: 'trayMoved',
|
|
trayId: i.trayId,
|
|
from,
|
|
to: i.to,
|
|
movesRemaining: s.turn.movesRemaining - 1,
|
|
/**
|
|
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
|
|
*
|
|
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
|
|
* every move — so one reverse move silently spun the train about. Everything then read
|
|
* "forward" again, and a run-around became pointless: you could change ends for free by
|
|
* backing up twice.
|
|
*
|
|
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
|
|
* Backing up it trails, still pointing the way it came, which is the port it arrived
|
|
* through. Both hold around a curve, where the compass heading changes but the engine's
|
|
* relationship to its train does not.
|
|
*/
|
|
facing: i.reverse ? dest.entry : opposite(dest.entry),
|
|
},
|
|
];
|
|
if (dest.couples.length > 0) {
|
|
// Name the cards the cars came from. The reducer used to clear every card in the district
|
|
// instead, so one coupling anywhere deleted every standing car and every industry track in
|
|
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
|
// boxcar somewhere else entirely.
|
|
const lifted = [
|
|
...dest.path.map((step) => step.coord),
|
|
i.to,
|
|
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
|
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
|
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
|
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
|
// decides which car is next to come off.
|
|
events.push({
|
|
type: 'carsCoupled',
|
|
trayId: i.trayId,
|
|
at: i.to,
|
|
stock: dest.couples,
|
|
from: lifted,
|
|
toNose: !i.reverse,
|
|
});
|
|
}
|
|
return events;
|
|
}
|
|
|
|
case 'switch.dropCars': {
|
|
const tray = s.trays.get(i.trayId)!;
|
|
const here = trayCoord(s, i.trayId)!;
|
|
// §A.3 — cars come off in the order they are seated in the tray, from whichever end is being
|
|
// set out. The nose is the end ahead of the engine.
|
|
const stock = i.fromNose
|
|
? tray.consist.slice(0, i.count)
|
|
: tray.consist.slice(tray.consist.length - i.count);
|
|
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
|
}
|
|
|
|
case 'switch.sortConsist': {
|
|
const tray = s.trays.get(i.trayId)!;
|
|
const here = trayCoord(s, i.trayId)!;
|
|
return [
|
|
{
|
|
type: 'consistSorted',
|
|
trayId: i.trayId,
|
|
at: here,
|
|
before: tray.consist.map((c) => ({ ...c })),
|
|
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
|
},
|
|
];
|
|
}
|
|
|
|
case 'switch.end':
|
|
case 'draw.end':
|
|
case 'freightAgent.end':
|
|
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
|
|
|
case 'draw.fromHomeOffice': {
|
|
const events: GameEvent[] = [
|
|
{
|
|
type: 'cardDrawn',
|
|
player,
|
|
source: 'homeOffice',
|
|
cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
|
|
},
|
|
];
|
|
const sweep = reshuffleIfDepleted(s, 1);
|
|
if (sweep) events.push(sweep);
|
|
return events;
|
|
}
|
|
|
|
case 'draw.fromDepartment': {
|
|
// §6.2 — "take the TOP face-up card". Never anything buried: a player who discarded onto this
|
|
// pile chose to put a card out of reach as much as to offer one, and letting a rival dig
|
|
// would take that decision away.
|
|
const pile = s.decks.departments[i.slot]!;
|
|
const events: GameEvent[] = [
|
|
{
|
|
type: 'cardDrawn',
|
|
player,
|
|
source: 'department',
|
|
slot: i.slot,
|
|
cardId: pile[pile.length - 1]!,
|
|
},
|
|
];
|
|
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in the
|
|
// empty spot." Only when taking the last card actually empties the pile; refilling on every
|
|
// draw would grow the Departments without limit and drain the Home Office deck into them.
|
|
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
|
|
if (pile.length === 1 && refill) {
|
|
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
|
|
const sweep = reshuffleIfDepleted(s, 1);
|
|
if (sweep) events.push(sweep);
|
|
}
|
|
return events;
|
|
}
|
|
|
|
case 'card.play': {
|
|
const card = s.cards.get(i.cardId)!;
|
|
const events: GameEvent[] = [
|
|
i.placement
|
|
? {
|
|
type: 'cardPlayed',
|
|
player,
|
|
cardId: i.cardId,
|
|
placement: i.placement,
|
|
variant: i.variant ?? 0,
|
|
}
|
|
: { type: 'cardPlayed', player, cardId: i.cardId },
|
|
];
|
|
if (card.kind.kind === 'office') {
|
|
events.push({
|
|
type: 'officeUpgraded',
|
|
player,
|
|
from: areaOf(s, player).tier,
|
|
to: card.kind.tier,
|
|
});
|
|
}
|
|
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
|
|
events.push(
|
|
i.node !== undefined
|
|
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
|
|
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
|
|
);
|
|
}
|
|
if (card.kind.kind === 'extraTrain') {
|
|
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
|
// to do nothing at all, which made all four Extra cards dead weight in the deck.
|
|
events.push({ type: 'extraQueued', player, trainNumber: card.kind.number });
|
|
}
|
|
if (card.kind.kind === 'timetabledTrain') {
|
|
// §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at
|
|
// the bottom. Without this no train ever runs, so nothing can ever be earned.
|
|
const rng = createRng(s.rngState);
|
|
const roll = rng.d12();
|
|
const slot = findTimetableSlot(s, roll - 1);
|
|
if (slot !== null) {
|
|
events.push({
|
|
type: 'trainScheduled',
|
|
player,
|
|
trainNumber: card.kind.number,
|
|
roll,
|
|
slot,
|
|
rngState: rng.getState(),
|
|
});
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
case 'card.discard':
|
|
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
|
|
|
|
case 'mainline.modify': {
|
|
const card = s.cards.get(i.cardId)!;
|
|
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
|
|
const node = s.division.nodes[i.node];
|
|
const became =
|
|
key === 'realignment' && node?.kind === 'mainline'
|
|
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
|
: undefined;
|
|
return [
|
|
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
|
];
|
|
}
|
|
|
|
case 'maneuver.redFlags': {
|
|
const tray = s.trays.get(i.trayId)!;
|
|
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
|
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
|
}
|
|
|
|
case 'maneuver.flyingSwitch': {
|
|
const tray = s.trays.get(i.trayId)!;
|
|
// §A.3 — cars come off the back, same as a normal drop.
|
|
const stock = tray.consist.slice(tray.consist.length - i.count);
|
|
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
|
|
}
|
|
|
|
case 'freightAgent.stockOutbound':
|
|
return [
|
|
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
|
];
|
|
|
|
case 'freightAgent.clearInbound': {
|
|
const f = facilityAt(s, player, i.at)!;
|
|
return [{ type: 'inboundCleared', player, at: i.at, stock: f.inboundBox[i.index]! }];
|
|
}
|
|
|
|
case 'freightAgent.unjam': {
|
|
const f = facilityAt(s, player, i.at)!;
|
|
const stock: RollingStock =
|
|
i.from === 'menAtWork'
|
|
? { type: workTrack(f)[i.index]!.type, loaded: true }
|
|
: (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
|
|
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
|
}
|
|
|
|
case 'newTrain.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 = workTrack(f)[i.box]!;
|
|
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
|
|
|
|
if (next >= workTrack(f).length) {
|
|
// Outbound complete: the load goes onto the spotted car (§9.3).
|
|
return [
|
|
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
|
|
{ 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 };
|
|
if (e.facing) tray.facing = e.facing;
|
|
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);
|
|
if (e.toNose) {
|
|
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
|
|
// engine is no longer at the front and its index has to follow. It never did, so a crew that
|
|
// shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's
|
|
// make-up rule had nothing truthful to check.
|
|
tray.consist.unshift(...e.stock);
|
|
tray.engineAt += e.stock.length;
|
|
} else {
|
|
tray.consist.push(...e.stock);
|
|
}
|
|
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
|
|
// never went near.
|
|
for (const coord of e.from) {
|
|
const card = area.grid.get(coordKey(coord));
|
|
if (!card) continue;
|
|
card.standing = [];
|
|
if (card.facility) card.facility.industryTrack.cars = [];
|
|
}
|
|
spendFreightBudget(s, tray, e.at, e.stock);
|
|
break;
|
|
}
|
|
|
|
case 'consistSorted': {
|
|
const tray = s.trays.get(e.trayId)!;
|
|
tray.consist = e.after.map((c) => ({ ...c }));
|
|
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
|
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
|
tray.engineAt = 0;
|
|
// "Spends one move in the yard" — the sort costs a Move.
|
|
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);
|
|
if (e.fromNose) {
|
|
// Off the front: everything ahead of the engine shortens, so the engine moves up by that
|
|
// much. This is how a train that took cars onto its nose gets back to being made up.
|
|
tray.consist.splice(0, e.stock.length);
|
|
tray.engineAt = Math.max(0, tray.engineAt - e.stock.length);
|
|
} else {
|
|
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
|
|
}
|
|
tray.engineAt = Math.min(tray.engineAt, tray.consist.length);
|
|
const card = area.grid.get(coordKey(e.at));
|
|
// On a Facility card the industry track is where cars stand (§9.3).
|
|
if (card) carsOn(card).push(...e.stock);
|
|
spendFreightBudget(s, tray, e.at, e.stock);
|
|
break;
|
|
}
|
|
|
|
case 'departmentRefilled':
|
|
s.decks.homeOffice.pop();
|
|
s.decks.departments[e.slot]!.push(e.cardId);
|
|
break;
|
|
|
|
case 'deckReshuffled': {
|
|
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
|
|
// turned face up as the Departments, the rest face down as the Home Office deck. The
|
|
// Departments start one deep again, exactly as at setup.
|
|
s.decks.salvageYard = [];
|
|
s.decks.departments = [[], [], []];
|
|
const order = [...e.order];
|
|
for (const pile of s.decks.departments) {
|
|
const card = order.pop();
|
|
if (card) pile.push(card);
|
|
}
|
|
// The END of the array is the top of the deck — `cardDrawn` pops from there.
|
|
s.decks.homeOffice = order;
|
|
s.rngState = e.rngState;
|
|
break;
|
|
}
|
|
|
|
case 'cardDrawn': {
|
|
const hand = s.decks.hands.get(e.player) ?? [];
|
|
if (e.source === 'homeOffice') s.decks.homeOffice.pop();
|
|
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
|
|
hand.push(e.cardId);
|
|
s.decks.hands.set(e.player, hand);
|
|
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 'officeUpgraded': {
|
|
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
|
const area = areaOf(s, e.player);
|
|
const from = officeProfile(e.from);
|
|
const to = officeProfile(e.to);
|
|
area.tier = e.to;
|
|
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
|
if (officeCard?.facility) {
|
|
/**
|
|
* THE TIER IS A DELTA, NOT AN OVERWRITE.
|
|
*
|
|
* This wrote the new tier's printed numbers straight over the facility, which silently
|
|
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
|
|
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
|
|
* with no message, after the card had been spent. Reported from a playtest where a
|
|
* Restaurant's porter never appeared — it had appeared and then been erased.
|
|
*
|
|
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
|
|
* upgrade is worth and leaves anything standing beside it untouched.
|
|
*/
|
|
const f = officeCard.facility;
|
|
f.porters += to.porters - from.porters;
|
|
f.capacity = {
|
|
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
|
|
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
|
|
};
|
|
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
|
|
// Post has no passenger boxes to add to.
|
|
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'cardDiscarded': {
|
|
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
|
|
s.decks.hands.set(e.player, hand);
|
|
// ON TOP of the pile. Assigning here overwrote whatever was already face up on that
|
|
// Department, quietly destroying a card from a closed deck — and it threw away the whole
|
|
// point of choosing WHICH Department to discard onto.
|
|
s.decks.departments[e.toSlot]!.push(e.cardId);
|
|
break;
|
|
}
|
|
|
|
case 'stockToOutbound': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const idx = s.yards.divisionYard.findIndex(
|
|
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
|
);
|
|
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
|
refillDivisionYardIfEmpty(s);
|
|
f.outboundBox.push(e.stock);
|
|
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 = workTrack(f).findIndex((l) => l !== null);
|
|
if (idx >= 0) workTrack(f)[idx] = null;
|
|
} else {
|
|
const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
|
|
const idx = box.findIndex((c) => c.type === e.stock.type);
|
|
if (idx >= 0) box.splice(idx, 1);
|
|
}
|
|
s.yards.classificationYard.push(e.stock);
|
|
s.turn.freightAgentUsed = true;
|
|
break;
|
|
}
|
|
|
|
case 'enhancementPlaced': {
|
|
if (e.node !== undefined) {
|
|
const node = s.division.nodes[e.node];
|
|
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
|
if (node?.kind === 'mainline') node.absSignals = true;
|
|
} else if (e.at) {
|
|
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
|
if (card) card.enhancements.push(e.key);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'extraQueued':
|
|
s.pendingExtras.push(e.trainNumber);
|
|
break;
|
|
|
|
case 'secondSectionOrdered':
|
|
s.pendingSecondSections.push(e.trainNumber);
|
|
break;
|
|
|
|
case 'trainScheduled':
|
|
s.timetable[e.slot] = e.trainNumber;
|
|
s.rngState = e.rngState;
|
|
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
|
break;
|
|
|
|
case 'carPlacedOnTrain': {
|
|
const tray = s.trays.get(e.trayId)!;
|
|
const idx = s.yards.divisionYard.findIndex(
|
|
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
|
);
|
|
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
|
refillDivisionYardIfEmpty(s);
|
|
tray.consist.push(e.stock);
|
|
break;
|
|
}
|
|
|
|
case 'passengersBoarded': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const area = areaOf(s, e.player);
|
|
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
|
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
|
for (const id of area.adOccupancy) {
|
|
const tray = s.trays.get(id);
|
|
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
|
|
if (tray && ci >= 0) {
|
|
s.yards.classificationYard.push(tray.consist[ci]!);
|
|
tray.consist[ci] = loaded;
|
|
break;
|
|
}
|
|
}
|
|
f.usedThisStage.porters += 1;
|
|
break;
|
|
}
|
|
|
|
case 'passengersDetrained': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const area = areaOf(s, e.player);
|
|
for (const id of area.adOccupancy) {
|
|
const tray = s.trays.get(id);
|
|
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
|
|
if (tray && ci >= 0) {
|
|
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
|
|
// which minted a coach on every de-training.
|
|
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
|
|
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: 'coach' as const, loaded: false };
|
|
refillDivisionYardIfEmpty(s);
|
|
f.inboundBox.push(tray.consist[ci]!);
|
|
tray.consist[ci] = empty;
|
|
break;
|
|
}
|
|
}
|
|
f.usedThisStage.porters += 1;
|
|
break;
|
|
}
|
|
|
|
case 'loadStarted': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const idx = f.outboundBox.findIndex((c) => c.type === e.carType);
|
|
if (idx >= 0) f.outboundBox.splice(idx, 1);
|
|
workTrack(f)[0] = { type: e.carType, dir: 'out' };
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'loadAdvanced': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
workTrack(f)[e.toBox] = workTrack(f)[e.fromBox]!;
|
|
workTrack(f)[e.fromBox] = null;
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'unloadCompleted': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
workTrack(f)[0] = null;
|
|
f.inboundBox.push({ type: e.carType, loaded: true });
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'loadCompleted': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
workTrack(f)[workTrack(f).length - 1] = null;
|
|
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
|
if (ci >= 0) {
|
|
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
|
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
|
|
}
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'unloadBegan': {
|
|
const f = facilityAt(s, e.player, e.at)!;
|
|
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
|
|
if (ci >= 0) {
|
|
// §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
|
|
// minted a car on every unload, and it also skipped a requirement the rule states.
|
|
const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded);
|
|
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: e.carType, loaded: false };
|
|
refillDivisionYardIfEmpty(s);
|
|
f.industryTrack.cars[ci] = empty;
|
|
}
|
|
workTrack(f)[workTrack(f).length - 1] = { type: e.carType, dir: 'in' };
|
|
f.usedThisStage.laborers += 1;
|
|
break;
|
|
}
|
|
|
|
case 'revenueChanged': {
|
|
const p = s.players[e.player];
|
|
if (p) p.revenue = e.total;
|
|
break;
|
|
}
|
|
|
|
case 'phaseEnded':
|
|
// The actor has finished; the phase driver moves on to the next player.
|
|
if (e.phase !== 'redFlag') 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. Returns null when the variant
|
|
* index is out of range, which `check` reports rather than silently defaulting — a wrong
|
|
* orientation is a different card, not a detail.
|
|
*/
|
|
function protoCard(
|
|
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
|
variant: number | undefined,
|
|
): TrackCard | null {
|
|
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
|
|
|
if (kind.kind === 'track') {
|
|
const geometry = kind.geometry as TrackGeometry;
|
|
// Track is a per-player supply rather than a deck card, so nothing reaches this branch today.
|
|
// It still needs a hand: handedness IS the slope, and defaulting silently would lay a card on
|
|
// the wrong diagonal.
|
|
const hand = (kind.hand as Hand | undefined) ?? 'left';
|
|
const options = variantsFor(geometry, hand);
|
|
const v = options[variant ?? 0];
|
|
if (!v) return null;
|
|
return {
|
|
geometry: {
|
|
kind: 'track',
|
|
geometry,
|
|
...(v.arc ? { arc: v.arc } : {}),
|
|
...(v.turnout ? { turnout: v.turnout } : {}),
|
|
...(v.bypass ? { bypass: v.bypass } : {}),
|
|
...(hand !== 'none' ? { hand } : {}),
|
|
},
|
|
baseOperationalRail: geometry !== 'turnout',
|
|
...base,
|
|
};
|
|
}
|
|
|
|
// A Facility is plain east-west track, as every industry card on the printed sheet is, so there
|
|
// is exactly one orientation and any index past it is out of range.
|
|
if (!facilityVariants()[variant ?? 0]) return null;
|
|
return {
|
|
geometry: { kind: 'facility', facility: kind.facility as never },
|
|
baseOperationalRail: true,
|
|
...base,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Instantiates a Freight Facility from its catalogue profile (card-reference.md §2).
|
|
*
|
|
* This was missing: placed facility cards were built with `facility: null`, making them inert —
|
|
* they could never be stocked, worked, or scored from. Every freight card played was dead weight.
|
|
*/
|
|
function buildFreightFacility(kind: FreightKind): Facility {
|
|
const p = FREIGHT_PROFILES.find((f) => f.kind === kind);
|
|
if (!p) throw new Error(`unknown freight facility: ${kind}`);
|
|
return {
|
|
kind: 'freight',
|
|
subtype: kind,
|
|
allows: {
|
|
outbound: p.flow === 'outbound' || p.flow === 'both',
|
|
inbound: p.flow === 'inbound' || p.flow === 'both',
|
|
},
|
|
outboundBox: [],
|
|
inboundBox: [],
|
|
// The design gives every industry ONE car out and ONE loader; capacity is grown by the
|
|
// industry-specific modifier cards, not printed large on the industry itself.
|
|
capacity: { outbound: p.baseOut, inbound: p.baseIn },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { 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.
|
|
*/
|
|
/**
|
|
* The neighbouring Facility a Modifier would attach to — and, when a Modifier is named, only one it
|
|
* is actually ALLOWED to attach to.
|
|
*
|
|
* Every Modifier prints its host: a Waiting Area, a Restaurant and a Hotel go beside a Passenger
|
|
* Facility, Forklifts beside a Freight House or Packing Sheds, and so on. That was not checked. Any
|
|
* square touching ANY facility was offered — so a Waiting Area was legal beside a Mine Tipple and
|
|
* `applyModifier` then handed its extra Porter to whichever facility the scan reached first, which
|
|
* could be a different one again. The player saw three legal spots for a card that has one.
|
|
*/
|
|
function adjacentFacilityCoord(
|
|
area: OfficeArea,
|
|
coord: GridCoord,
|
|
modifier?: ModifierKind,
|
|
): GridCoord | null {
|
|
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
|
|
// on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7
|
|
// guard here rejected the three above outright.
|
|
const hosts = modifier ? modifierProfile(modifier).hosts : null;
|
|
for (let dr = -1; dr <= 1; dr++) {
|
|
for (let dc = -1; dc <= 1; dc++) {
|
|
if (dr === 0 && dc === 0) continue;
|
|
const c = { row: coord.row + dr, col: coord.col + dc };
|
|
const f = area.grid.get(coordKey(c))?.facility;
|
|
if (!f) continue;
|
|
if (hosts && !hosts.includes(f.subtype)) continue;
|
|
return c;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
|
|
* their placement and state are implemented and their effect is read at the point of attack:
|
|
*
|
|
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
|
|
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
|
|
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
|
|
*
|
|
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
|
|
* queried here rather than being special-cased at each attack site.
|
|
*/
|
|
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
|
|
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
|
}
|
|
|
|
/** Facing Point Locks blocks a Derail played at this district. */
|
|
export function isProtectedFromDerail(area: OfficeArea): boolean {
|
|
return hasDistrictEnhancement(area, 'facingPointLocks');
|
|
}
|
|
|
|
/** A Water Column lets its owner clear a Watertower off their own grid. */
|
|
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
|
|
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
|
|
const out: GridCoord[] = [];
|
|
for (const [key, card] of area.grid) {
|
|
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
|
|
const [row, col] = key.split(',').map(Number);
|
|
out.push({ row: row!, col: col! });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Enhancements have per-card placement rules (implications.md §7):
|
|
* - Interlocking, Water Column, Telegraph → a Running Track straight
|
|
* - Yard Office, Small Yard → a Secondary Track straight
|
|
* - Telephone / Radio → stacked on the card below them
|
|
* - Facing Point Locks → needs an Interlocking in the district
|
|
* - ABS Signals → a Mainline card, not the Office Area
|
|
*/
|
|
export function checkEnhancementPlacement(
|
|
s: GameState,
|
|
area: OfficeArea,
|
|
key: string,
|
|
placement: GridCoord,
|
|
): RejectionCode | null {
|
|
const rule = enhancementRule(key);
|
|
if (!rule) return 'NOT_IMPLEMENTED';
|
|
|
|
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
|
|
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
|
|
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
|
|
|
|
const card = area.grid.get(coordKey(placement));
|
|
if (!card) return 'NOT_CONNECTED';
|
|
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
|
|
|
|
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
|
|
return 'NOT_CONNECTED';
|
|
}
|
|
if (rule.requiresInDistrict) {
|
|
const present = [...area.grid.values()].some((c) =>
|
|
c.enhancements.includes(rule.requiresInDistrict!),
|
|
);
|
|
if (!present) return 'NOT_CONNECTED';
|
|
}
|
|
|
|
const onRunning = placement.row === area.runningRow;
|
|
/**
|
|
* A STRAIGHT-PLACED ENHANCEMENT REPLACES THE STRAIGHT, so it cannot be stacked.
|
|
*
|
|
* The printed placement is "any Running Track Straight": the card goes down IN PLACE OF the
|
|
* straight, and what stands there afterwards is an Interlocking, not a straight carrying one. A
|
|
* second such card has no straight left to replace. This was unchecked — an enhancement only adds
|
|
* a string to `enhancements[]` and leaves the geometry alone, so a single straight could take
|
|
* Interlocking and Telegraph and a Water Column all at once.
|
|
*
|
|
* The `onCard` chain is untouched and is NOT an exception to this: Telephone prints "on Telegraph"
|
|
* and Radio "on Telephone", so those target a named card rather than a straight, which is exactly
|
|
* why they still stack. `requiresOnSameCard` above is what enforces it.
|
|
*/
|
|
const isBareStraight =
|
|
card.geometry.kind === 'track' &&
|
|
card.geometry.geometry === 'straight' &&
|
|
card.enhancements.length === 0;
|
|
|
|
switch (rule.placement) {
|
|
case 'runningTrackStraight':
|
|
return onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
|
case 'secondaryTrackStraight':
|
|
return !onRunning && isBareStraight ? null : 'NOT_CONNECTED';
|
|
case 'onCard':
|
|
return null;
|
|
default:
|
|
return 'NOT_CONNECTED';
|
|
}
|
|
}
|
|
|
|
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
|
|
function emptyCard(): TrackCard {
|
|
return {
|
|
geometry: { kind: 'limits' },
|
|
baseOperationalRail: false,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
}
|
|
|
|
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
|
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
|
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
|
s.decks.salvageYard.push(cardId);
|
|
}
|
|
|
|
/**
|
|
* §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards from
|
|
* the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office deck and
|
|
* Department slots."
|
|
*
|
|
* `taking` is how many cards the events already queued will pop off the deck, so this can be asked
|
|
* BEFORE they are applied: a draw takes one, and refilling an emptied Department takes another.
|
|
*
|
|
* Returns null when the deck is not about to run out, or when there is nothing to sweep — a game
|
|
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
|
|
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
|
|
*/
|
|
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
|
if (s.decks.homeOffice.length > taking) return null;
|
|
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
|
|
if (collected.length === 0) return null;
|
|
const rng = createRng(s.rngState);
|
|
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
|
}
|
|
|
|
/**
|
|
* Q4 — would building `kind` here conflict with something already in the district?
|
|
*
|
|
* TWO RULES, both from the sheet's "Lockouts" column.
|
|
*
|
|
* 1. **No two of the same industry in one Office Area.** The sheet states it in the Freight House
|
|
* row, which lists Freight House among its own lockouts; it is a general rule, so it is applied
|
|
* to every kind here rather than repeated in all six catalogue entries.
|
|
* 2. **Not a producer and the consumer of the same commodity.** Mine Tipple makes coal and the
|
|
* Power Plant burns it; the Refinery makes oil and the Power Plant burns that too; Packing Sheds
|
|
* fill reefers and the Grocer's Warehouse empties them. Build one end of a chain or the other,
|
|
* never both — which is what pushes freight to run BETWEEN districts instead of circling inside
|
|
* one.
|
|
*
|
|
* The relation is symmetric, so checking either direction is enough.
|
|
*/
|
|
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
|
|
const wanted = industryProfile(kind);
|
|
for (const card of area.grid.values()) {
|
|
if (card.geometry.kind !== 'facility') continue;
|
|
const present = card.geometry.facility;
|
|
if (present === kind) return true;
|
|
if (wanted.lockouts.includes(present)) return true;
|
|
if (industryProfile(present).lockouts.includes(kind)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Is a Modifier of this kind already standing in the district?
|
|
*
|
|
* Reported from playtesting: two Ice Houses could be built in one Office Area. Industries have been
|
|
* barred from doubling up since Q4 (`isLockedOut` above), but a Modifier is a different card kind
|
|
* and had no such check — every square adjacent to an eligible host was legal, however many copies
|
|
* you held. One of a kind per Office Area, the same rule the industries follow.
|
|
*
|
|
* Enhancements are deliberately NOT covered. An Interlocking is a plant at one junction, so a second
|
|
* on another straight is a different installation, and the Telegraph → Telephone → Radio chain is
|
|
* already gated per card by `checkEnhancementPlacement`.
|
|
*
|
|
* Consequence worth expecting: modifiers printed in more than one copy (Truck Dock 2, Ice House 2,
|
|
* Waiting Area 3) go partly dead in solitaire, where there is only one Office Area. That is correct
|
|
* — the spare copies exist for other players' districts.
|
|
*/
|
|
function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean {
|
|
for (const card of area.grid.values()) {
|
|
if (card.geometry.kind === 'modifier' && card.geometry.modifier === kind) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* How much of a Modifier's printed capacity its host can actually use.
|
|
*
|
|
* AN INDUSTRY'S PRINTED FLOW IS ABSOLUTE. A Grocer's Warehouse is `flow: 'inbound'`, so it has no
|
|
* green boxes and `freightAgent.stockOutbound` refuses it — yet an Ice House beside it prints "+1
|
|
* outbound" and the capacity was being raised anyway, on a direction that can never be drawn or
|
|
* stocked. Reported from playtesting as "the Ice House added the laborer but not the outbound slot":
|
|
* the laborer landed because Laborers have no direction, and the slot did not because there was
|
|
* nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped.
|
|
*
|
|
* It is not one card's quirk. The same trap catches **Truck Dock** and **Forklifts**, which also
|
|
* print `addOut` and also list `grocersWarehouse` among their hosts, and **Waiting Area**,
|
|
* **Restaurant** and **Hotel**, whose `hosts: ['office']` includes a Whistle Post — not a Passenger
|
|
* Facility, so `allows.outbound` is false there too.
|
|
*
|
|
* The industry track grows by what was actually applied, not by what was printed: a slot that does
|
|
* not exist must not lengthen the siding that would have served it.
|
|
*/
|
|
function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } {
|
|
return {
|
|
out: f.allows.outbound ? m.addOut : 0,
|
|
in: f.allows.inbound ? m.addIn : 0,
|
|
};
|
|
}
|
|
|
|
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
|
|
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
|
|
const target = adjacentFacilityCoord(area, coord, modifier);
|
|
if (!target) return;
|
|
const host = area.grid.get(coordKey(target));
|
|
const f = host?.facility;
|
|
if (!host || !f) return;
|
|
const m = modifierProfile(modifier);
|
|
/**
|
|
* Record WHICH facility this Modifier served.
|
|
*
|
|
* `TrackCard.modifiers` was initialised everywhere and appended nowhere, so the panel row listing
|
|
* "Modifier cards standing beside this industry" was permanently empty — and, more to the point,
|
|
* nothing downstream could tell which card had granted what. It is needed now to explain a grant
|
|
* that the host's flow discards (see `usableGrant`).
|
|
*/
|
|
host.modifiers.push(modifier);
|
|
const use = usableGrant(f, m);
|
|
f.capacity.outbound += use.out;
|
|
f.capacity.inbound += use.in;
|
|
f.laborers += m.addLoaders;
|
|
f.porters += m.addPorters;
|
|
/**
|
|
* The siding grows only where there IS one. A Passenger Facility has no industry track — passengers
|
|
* board off the platform — so a Waiting Area, whose "+1" is a passenger slot rather than a car,
|
|
* must not give the Office somewhere to spot a car. It did, and the board then drew the Depot a
|
|
* siding square: the same phantom siding the renderer was just fixed for, arriving by another door.
|
|
*/
|
|
if (f.kind === 'freight' && (use.out > 0 || use.in > 0)) {
|
|
f.industryTrack.length += use.out + use.in;
|
|
}
|
|
}
|
|
|
|
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
|
/**
|
|
* §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
|
|
*
|
|
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
|
|
* Limits card stranded mid-track: a district grew to
|
|
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
|
|
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
|
|
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
|
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
|
*/
|
|
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
|
if (placed.row !== area.runningRow) return;
|
|
|
|
if (placed.col <= area.limitsWest.col) {
|
|
area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
|
area.grid.set(coordKey(area.limitsWest), limitsCard());
|
|
}
|
|
if (placed.col >= area.limitsEast.col) {
|
|
area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
|
area.grid.set(coordKey(area.limitsEast), limitsCard());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* §8.2 — may this car be coupled onto this train right now?
|
|
*
|
|
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
|
|
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
|
|
* order." Fewer is allowed; more, or of the wrong category, is not.
|
|
*
|
|
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
|
|
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
|
* `check` rejected every option, so the Stage never completed.
|
|
*/
|
|
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
|
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
|
if (!profile) return true;
|
|
|
|
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
|
|
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
|
const adding = cat(carType);
|
|
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
|
|
const allowed =
|
|
adding === 'coach'
|
|
? profile.consist.coach
|
|
: adding === 'caboose'
|
|
? profile.consist.caboose
|
|
: profile.consist.freight;
|
|
if (already >= allowed) return false;
|
|
|
|
// The caboose rides last (§A.3), so nothing may be coupled behind one.
|
|
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
|
|
|
|
// The card also narrows WHICH freight types it will take.
|
|
const types = profile.consist.freightTypes;
|
|
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
|
*
|
|
* A train is made up where it is built, standing at a Division Point, and only until its consist
|
|
* matches its card. Everything else with a Crew Tray — a train working your district, a train
|
|
* halfway across the Division — is running, not being assembled.
|
|
*
|
|
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
|
|
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
|
|
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
|
|
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
|
|
* including Train 9 mid-crossing with three cars already aboard.
|
|
*/
|
|
export function isBeingMadeUp(tray: CrewTray): boolean {
|
|
if (tray.trainNumber === null) return false;
|
|
if (tray.position.at !== 'divisionPoint') return false;
|
|
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
|
if (!profile) return false;
|
|
return tray.consist.length < consistSize(profile.consist);
|
|
}
|
|
|
|
/**
|
|
* The tray the New Train Phase is waiting on, or null.
|
|
*
|
|
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
|
|
* to ask for a car that cannot be supplied.
|
|
*/
|
|
export function trainNeedingCars(s: GameState): TrayId | null {
|
|
for (const [id, tray] of s.trays) {
|
|
if (!isBeingMadeUp(tray)) continue;
|
|
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
|
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
|
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
|
// be added while `check` rejected every option, so the Stage never ended.
|
|
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
|
*
|
|
* Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight
|
|
* back to the Division Yard. The Classification Yard empties only when the Division Yard is bare —
|
|
* every car of every kind gone — and then all of it returns at once.
|
|
*
|
|
* Confirmed from the source after an earlier guess. The first implementation returned cars at the
|
|
* DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up
|
|
* continuously, where this lets it run down to nothing and refill in one go. That difference is the
|
|
* whole of the supply pressure the game is meant to have.
|
|
*
|
|
* Called wherever a car leaves the Division Yard, so the refill happens the moment it empties
|
|
* rather than at the next convenient tick.
|
|
*/
|
|
export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null {
|
|
if (s.yards.divisionYard.length > 0) return null;
|
|
if (s.yards.classificationYard.length === 0) return null;
|
|
const count = s.yards.classificationYard.length;
|
|
s.yards.divisionYard.push(...s.yards.classificationYard);
|
|
s.yards.classificationYard = [];
|
|
return { type: 'yardRefilled', count };
|
|
}
|
|
|
|
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
|
function limitsCard(): TrackCard {
|
|
return {
|
|
geometry: { kind: 'limits' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
|
const code = check(s, player, i);
|
|
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
|
|
|
|
const events = execute(s, player, i);
|
|
for (const e of events) reduce(s, e);
|
|
return { ok: true, events };
|
|
}
|
|
|
|
export { isOperationalRail, destinationsFor };
|