/** * The intent vocabulary — every legal player choice, from docs/architecture/protocol.md §1. * * An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact. */ import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts'; import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts'; // --------------------------------------------------------------------------- // Local Operations Phase (§6) — a three-way exclusive choice // --------------------------------------------------------------------------- export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent'; export type Intent = | { type: 'localOps.choose'; option: LocalOpsOption } // -- switch (§6.1, Appendix A) /** * `via` names one INTERMEDIATE card on the chosen route, for the rare case where two routes to * `to` couple different cars (see docs/plans/switching-paths.md) — never the start, never `to` * itself. Absent, it resolves exactly as it always has: the first route `legal.ts` enumerated to * `to`. An index into the destination list would do the same job worse — intents are the * canonical record `undo` replays against, and enumeration order is not a stable thing to save. */ | { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord } /** * Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's * worked example does exactly this: "Back up and drop off everything on the nose of your train * (red and blue) on Card B." Without it, cars taken onto the nose could never be set out again and * an engine buried in its own train had no way back to an end. */ | { type: 'switch.dropCars'; trayId: TrayId; count: number; fromNose?: boolean } /** * Small Yard enhancement — "a train that spends one move in the yard may sort itself in any * order, including cars in front of the engine". This is the designed answer to §A.3's * come-off-in-seated-order constraint, which is what makes facing-point work possible. */ | { type: 'switch.sortConsist'; trayId: TrayId; order: number[] } | { type: 'switch.end' } // -- draw (§6.2) | { type: 'draw.fromHomeOffice' } | { type: 'draw.fromDepartment'; slot: number } /** * `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card * can be laid. Track is played through here like every other card; there is no separate lay * intent, because there is no separate supply to lay from. * * `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for * the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and * they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate * `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the * Running Track, where most districts start. So a real placement on row −1 was labelled "out on * the Mainline, node −2", and an ABS Signals placement highlighted whichever district card * happened to sit at that column. A sentinel inside a coordinate's own value range is not a * sentinel. */ | { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number } | { type: 'card.discard'; cardId: CardId; toSlot: number } | { type: 'draw.end' } // -- freight agent (§6.3) | { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType } | { type: 'freightAgent.clearInbound'; at: GridCoord; index: number } | { type: 'freightAgent.unjam'; at: GridCoord; from: 'outbound' | 'inbound' | 'menAtWork'; index: number } /** * Finish the turn having chosen the Freight Agent and done nothing. * * §6.3 lists three things the Freight Agent MAY do; it does not say one must be done. Without this * the option was a trap — having chosen it, the only legal moves were to stock, to clear, or to * UNJAM, and unjamming a healthy facility throws away a load that cost a whole action to stock. */ | { type: 'freightAgent.end' } // -- New Train Phase (§7) | { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean } | { type: 'newTrain.passCar'; trayId: TrayId } /** * §7 — "the player who played the card may place the Crew Tray in either division point for * immediate departure", extended by Jesse: an Extra starts at the Division Point its NUMBER sends * it to (odd runs west, even east, exactly as a timetabled train), or at any Control Point — any * Office above a Whistle Post — at the player's choice. `atSeat` null means the Division Point. */ | { type: 'newTrain.startExtra'; trainNumber: number; atSeat: SeatIndex | null } /** Q9 — run a second, identical section behind a train that is due out this Stage. */ | { type: 'newTrain.secondSection'; trainNumber: number } // -- Mainline Phase (§8.1) — the Superintendent's clearance ruling | { type: 'mainline.clearance'; allow: boolean } /** * Lay a Mainline modifier (Brakeman / Airbrakes / Helpers / Realignment) on a Mainline card. * `node` indexes `division.nodes`. */ | { type: 'mainline.modify'; cardId: CardId; node: number } /** * Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is * held instead of colliding. */ | { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId } /** * Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without * the engine entering it. */ | { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord } | { type: 'redFlag.play' } // -- Load/Unload Phase (§9) | { type: 'porter.board'; at: GridCoord } | { type: 'porter.detrain'; at: GridCoord } /** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */ | { type: 'laborer.startLoad'; at: GridCoord } | { type: 'laborer.advanceLoad'; at: GridCoord; box: number } | { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number } | { type: 'loadUnload.end' }; export type IntentType = Intent['type']; // --------------------------------------------------------------------------- // Rejections — protocol.md §2 // --------------------------------------------------------------------------- export type RejectionCode = | 'NOT_YOUR_TURN' | 'WRONG_PHASE' | 'OPTION_ALREADY_CHOSEN' | 'OPTION_NOT_CHOSEN' | 'NO_MOVES_REMAINING' | 'ILLEGAL_MOVE' | 'NO_SUCH_TRAY' | 'NO_SUCH_CARD' | 'NO_SUCH_FACILITY' | 'CANNOT_DROP_HERE' | 'CONSIST_EMPTY' | 'CONSIST_FULL' | 'HAND_LIMIT' | 'CARD_NOT_IN_HAND' | 'NO_PLACEMENT' | 'NOT_CONNECTED' | 'DECK_EMPTY' | 'SLOT_EMPTY' | 'RESOURCE_SPENT' | 'BOX_FULL' | 'BOX_EMPTY' | 'NO_SUITABLE_CAR' | 'SUITABLE_CAR_EXISTS' /** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */ | 'NOT_BEING_MADE_UP' | 'WRONG_CAR_TYPE' | 'NOT_SUPERINTENDENT' | 'NO_PENDING_DECISION' | 'NOT_UPGRADEABLE' | 'FACILITY_LOCKED' /** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */ | 'ON_RUNNING_TRACK' /** A card with no east-west road would dead-end the Running Track it was laid in. */ | 'BREAKS_RUNNING_TRACK' /** * §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A * Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it. */ | 'OUTSIDE_LIMITS' /** * A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg * — but only those, and only while the square is idle. See `checkTurnoutUpgrade`. */ | 'NOT_UPGRADEABLE_TRACK' /** You cannot swap the track out from under a standing car. */ | 'UPGRADE_OCCUPIED' /** An Interlocking or Telegraph is built on that card; the upgrade would have to lift it. */ | 'UPGRADE_ENHANCED' /** * §7 — the operating rules printed on a train's own card. Each names the restriction it broke, so * the UI can say which card is refusing rather than "illegal move". */ | 'NO_SWITCHING' | 'NOT_A_TERMINAL' | 'COACH_MUST_STAY' | 'FREIGHT_WORKED_HERE' | 'NO_PASSENGER_WORK' | 'PICKUP_NOT_ALLOWED' | 'EMPTIES_ONLY' | 'NOT_IMPLEMENTED' | 'WRONG_INTENT' | 'NOT_A_GRADE' | 'TRAIN_ON_CARD' | 'CONSIST_ORDER' | 'NO_TRAIN_AT_OFFICE' /** * WHY PASSENGER WORK WAS REFUSED, told apart. * * All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the * Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the * fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to * travel, 24 with passengers waiting and every coach on the train already full. */ /** * §9.3 — a load may only start down the sign when an empty car of THAT type is standing on the * industry's track and is not already promised to another load. Walking cargo onto WORK with * nothing to put it in is "just dropping it onto the tracks". * * STOCKING THE GREEN BOX IS NOT GATED ON IT. §6.3 lets the Freight Agent stage a load whenever * the Division Yard has the car and the box has room; the empty car only has to be there before * the Laborers can move it out of the box. */ | 'NO_EMPTY_CAR_SPOTTED' | 'NO_PORTERS_HERE' | 'NO_PASSENGERS_WAITING' | 'NO_EMPTY_COACH' | 'NO_LOADED_COACH' | 'INBOUND_BOX_FULL' | 'NO_EMPTY_COACH_IN_YARD' | 'NOT_A_CONTROL_POINT' | 'NO_EXTRA_PENDING' | 'NO_FREE_TRAY' | 'NO_FREE_AD_TRACK'; export type Rejection = { code: RejectionCode; message: string }; export function reject(code: RejectionCode, message: string): Rejection { return { code, message }; } // --------------------------------------------------------------------------- // Re-exports used by handlers // --------------------------------------------------------------------------- export type { CardId, Direction, GridCoord, OfficeTier, PlayerIndex, TrayId };