/** * 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'; /** * Where an Extra is placed when it is started (§7). * * `mainline` names a node index in `division.nodes` and is only ever an Interchange; `office` names * a SEAT, which is what an Office Area belongs to, and only ever a Control Point. */ export type ExtraStart = | { kind: 'divisionPoint'; side: Direction } | { kind: 'mainline'; node: number } | { kind: 'office'; seat: SeatIndex }; 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 at EITHER Division Point for * immediate departure", plus Jesse's ruling on where else and which way. * * The number does not decide an Extra's direction — the START does. Either Division Point may be * chosen and the train runs away from it (west end runs east, east end runs west, since the other * reading is a train that leaves the Division having crossed nothing). At an Interchange or a * Control Point, which are in the middle of the railroad, both ways are real runs and `direction` * says which; it is required there and ignored at a Division Point. * * WHICH STARTS ARE OFFERED is the `extraStart` house rule (content.ts) — Division Points and the * Interchange always, Offices by setting. * * `atSeat` IS LEGACY AND WRITE-ONCE. Saves written before this choice existed carry only that * field: `null` meant "the Division Point this train's number sends it to" and a seat meant that * Office, both running in the number's direction. `start` absent is exactly what those saves said, * so they replay unchanged; everything written from now on carries `start` and `atSeat` is * omitted. `resolveExtraStart` (apply.ts) is the single place that reads either. */ | { type: 'newTrain.startExtra'; trainNumber: number; atSeat?: SeatIndex | null; start?: ExtraStart; direction?: Direction; } /** 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) /** * `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two * trains in a station, the select button does not work: regardless of which you pick, it is always * one train, not the other". It was: neither intent carried a train, so the reducer took the first * one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing. * * OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record * `undo` and every save replay against, and absent means what it has always meant — the first * eligible train at the Office. */ | { type: 'porter.board'; at: GridCoord; trayId?: TrayId } | { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId } /** §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' /** * §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not * be unloaded anywhere in that same Office Area. The load has to be carried out of the district by * a train first; a Freight House may not break the load it just made, and passengers may not * detrain at the platform they boarded from. * * Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes * ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded * boxcar standing on their own industry track deserves to be told that rather than "wrong car". */ | 'LOADED_IN_THIS_DISTRICT' | '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' /** §7 gives an Extra to the player who played the card; another seat may not place it for them. */ | 'NOT_YOUR_EXTRA' | 'NO_FREE_TRAY' | 'NO_FREE_AD_TRACK' // -- §7, where an Extra may be started (`resolveExtraStart`) | 'NO_SUCH_DIVISION_POINT' /** Only the Interchange has a yard an Extra can be made up in. */ | 'NOT_AN_INTERCHANGE' /** In the middle of the railroad both ways are real runs, so the intent has to say which. */ | 'NO_DIRECTION_CHOSEN' /** The `extraStart` house rule is `divisionPointsOnly`. */ | 'OFFICE_STARTS_NOT_ALLOWED' /** The `extraStart` house rule is `ownOffice` and this is somebody else's district. */ | 'NOT_YOUR_OFFICE' /** * §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like; * the only way it leaves your hand is onto the timetable. */ | 'TRAINS_ARE_NEVER_DISCARDED'; 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 };