/** * 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, OfficeTier } 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. */ /** * §Enhancements, Small Yard — one Move to re-make a train standing on the yard. * * `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE * ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and * what this action did unconditionally until 2026-09-17. `implications.md` records the design * source saying a train here "may sort itself into any order, including cars ahead of the engine", * and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will * not let out of the Office until it is sorted again. * * Optional, defaulting to 0, so every save written before this replays exactly as it did. */ | { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: 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 } /** * §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district. * * "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from * that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or * wish to complete switching." * * `side` names the side of the district the flag goes on, so a train arriving from that side is * held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and * protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a * mechanic nobody used. ABS Signals already protects a train standing on a Mainline card. */ | { type: 'maneuver.redFlags'; cardId: CardId; side: Direction } /** * The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG * AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the * collision happen. The side is not asked for: the train is already coming from one. */ | { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId } /** * 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' } /** * §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day. * * ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to * the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false` * ends the game immediately — nobody waits on a player who has already refused. * * It is an intent, rather than a button the client handles by itself, because a save is * `{ seed, config, history }` replayed through the engine: a decision that is not in the history * did not happen, and an extended game would evaporate on the next reload, Undo, or server * restart. This is the record of the table agreeing. * * CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]` * with no seat recorded against each move: the replay DERIVES who acted from the turn order * (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`, * because there is exactly one seat it could have been. Here there is not — every seat may vote, * in any order — so a vote whose voter is not written down cannot be replayed at all, and a * resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat * it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim. */ | { type: 'game.extend'; player: PlayerIndex; agree: boolean } /** * §11 (Gitea#5) — take the Yard Office, or the standard Office. * * Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player: * whoever sits in the district the train is arriving at. Offered only when a route exists, so * `take: true` always has somewhere to go — though it may still meet cars on the lead and crash, * which is the point of the rule. */ | { type: 'mainline.yardOffice'; take: boolean }; 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' /** * A passenger Modifier — Waiting Area, Restaurant, Hotel — played at an Office that is still a * Whistle Post. A Whistle Post is not a Passenger Facility (§9), so it has nothing to add to. */ | 'OFFICE_NOT_PASSENGER' | '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' /** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */ | 'NOT_AWAITING_EXTENSION' /** §3.3 (Gitea#11) — this seat has already voted on this extension. */ | 'ALREADY_VOTED' /** §11 (Gitea#5) — answering a Yard Office offer that is not open. */ | 'NO_YARD_OFFICE_OFFER' /** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */ | 'NO_RED_FLAG_PROMPT' /** §Q (Gitea#19) — this district already has a flag on that side. */ | 'ALREADY_FLAGGED'; 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 };