/** * Events — protocol.md §3. * * An event is a FACT: ordered, append-only, and standalone. Events NARRATE the game — they drive the * log, the sounds and the replay's captions. * * THEY DO NOT RECONSTRUCT IT. This header claimed `state = fold(events)` until v0.4.0 and it was * never true. `applyIntent` does go through `reduce`, but the phase driver in `advance.ts` mutates * state and THEN emits a descriptive event, so fourteen of the forty-six types below are never * reduced — the clock, and the whole Mainline phase, which is every train movement in the game. * * The canonical record is `{ seed, history: Intent[] }`, replayed by `fromSave`. That is what save, * restore, undo, restart recovery and post-game replay all run on. See * `docs/architecture/protocol.md` §3, and `test/events.test.ts`, which pins the unreduced set so * that closing the gap is a deliberate act rather than a surprise. * * DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to, * not just an id the renderer has to resolve against live state — otherwise a replay viewer has to * reconstruct the whole board to draw one frame. */ import type { CarType, Direction, OfficeTier } from './content.ts'; import type { ExtraStart, LocalOpsOption } from './intents.ts'; import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts'; export type GameEvent = // -- clock | { type: 'stageBegan'; day: number; stage: number } /** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */ | { type: 'seatsRotated'; day: number; seating: PlayerIndex[] } | { type: 'phaseBegan'; phase: string } | { type: 'actorChanged'; player: PlayerIndex | null } // -- local operations | { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption } /** * `via` mirrors the intent's `via` (docs/plans/switching-paths.md) — present only when there was * more than one legal route to `to`, so the history can say which one ran rather than leaving a * choice the player made invisible in their own log. */ | { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord } | { type: 'carsCoupled'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; /** The cards the cars were lifted from — the ones the crew actually ran over. */ from: GridCoord[]; /** * Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was * running forward: the engine meets the cars head-on and takes them on the front. */ toNose: boolean; /** * Cars that STAY on the card the crew pulled out of, when it coupled its own cut. * * A train may set out off both ends on one square, and pulling forward picks up only the cut * at the end it leaves by — the one behind it is still standing there. `from` names the cards * to sweep and every other one is emptied outright, so the start card needs the exception said * explicitly rather than recomputed: the tray has already moved by the time this is reduced, * and `standingWest` went with it. */ leaves?: { at: GridCoord; stock: RollingStock[] }; /** * The train's OWN cut, lifted back off the square it set it out on. * * Recoupling your own cars on the square you are standing on is UNDOING the drop, not a fresh * pick-up (Jesse's call, of the two the plan put up). Trains 3/4 spend a per-location freight * budget on setting out, so without this a legal-looking drop became silently one-way: the * train could only ever back away from its own cut, never pull forward through it. The budget * is refunded here and not charged again at the far end. */ recoupled?: { at: GridCoord; stock: RollingStock[] }; } | { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean } | { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] } | { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId } /** * §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were * collected, reshuffled, and dealt back out as a fresh deck plus three face-up Departments. * * The full shuffled `order` rides the event rather than being recomputed from `rngState`. A save * is a seed plus the intents, so events are never serialised and the size costs nothing — and an * event that states the outcome outright cannot drift from the reducer the way a re-derivation * can. `rngState` still rides along so the next roll follows on. */ | { type: 'deckReshuffled'; order: CardId[]; rngState: number } /** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */ | { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number } /** * `from` is the card's kind BEFORE the change, carried so the log can say what was realigned * rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline * card 3 curves was converted to plains"). Events are derived by replaying a save, never stored, * so widening one strands nothing on disk. */ | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string } /** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */ | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction } /** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */ | { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number } /** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */ | { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean } | { type: 'trainsDestroyed'; player: PlayerIndex; trains: { label: string; consist: RollingStock[] }[]; reason: string; where: string; } | { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] } | { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier } | { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number } | { type: 'departmentRefilled'; slot: number; cardId: CardId } // -- freight agent | { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock } | { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock } | { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock } // -- trains /** * §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries * the advanced RNG so that folding events reproduces the draw exactly. */ | { type: 'trainScheduled'; player: PlayerIndex; trainNumber: number; roll: number; slot: number; rngState: number } /** * Train lifecycle. These were originally squeezed into `phaseBegan` with a free-text label, * which made the replay say "New Train" and nothing else. A train being made up, departing, * arriving or finishing its run are four distinct facts and deserve four event types. */ /** * `at` is a square in the Office Area; `node` is a Division node, for an Enhancement that goes on * a Mainline card. Exactly one is set — see `card.play` in intents.ts for why they are not one * field wearing a sentinel. */ | { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number } | { type: 'extraQueued'; player: PlayerIndex; trainNumber: number } | { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number } | { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string } | { type: 'trainHeld'; trainNumber: number; reason: string } /** A train whose card pays for standing still (X18 Circus) collected on it. */ | { type: 'trainStoodStill'; trainNumber: number; where: string } /** * Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if * it is still off the Office square — parked on Secondary Track, say, to clear a switching move — * when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs * Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`. */ | { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string } /** * `why` is the RULE that let it go now, not a restatement of the move. * * Reported from play: "some trains seem to be moving before I can switch or do other operations on * them." Every departure looked identical in the history, so the one that mattered — an Expedited * train leaving at the end of the Stage it arrived, with no Local Operations turn in between — * read exactly like an ordinary train leaving a Stage later. */ | { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string } /** * `expedited` flags a train that must not be left standing off the station (Q3) — it may be * switched normally like any other arrival, but it has to be back on the Office square before the * next Mainline Phase begins, or `expediteFault` fires. */ /** * `owner` is WHOSE Office it reached — the district's player, not whoever is acting. The Mainline * Phase has no actor, so nothing else in the line could name the seat, and the narration said only * "ARRIVED at the Whistle Post" — every seat's Office has a tier, and at a four-seat table three of * them are somebody else's (Jesse, playtest 2026-09-16). */ | { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; owner: PlayerIndex; expedited: boolean } | { type: 'trainDiverted'; trainNumber: number; to: string; reason: string } /** * The train ran the length of the Division and left it. `side` is the Division Point it left by, * which is what the announcement needs — and every player scores 1 for it, so this event is * always followed by one `revenueChanged` per player. */ | { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] } /** An Extra took a Crew Tray and started its run — at a Division Point, or at a Control Point. */ /** * Carries the RESOLVED start and direction (`resolveExtraStart`), not the raw intent fields, so * the reducer never re-answers a question `check` already answered — the same shape as * `passengersBoarded` carrying its tray and coach index. */ | { type: 'extraStarted'; player: PlayerIndex; trainNumber: number; at: ExtraStart; direction: Direction; } | { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock } | { type: 'carPassed'; player: PlayerIndex; trayId: TrayId } | { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number } | { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId } | { type: 'clearanceGiven'; trainId: TrayId; allow: boolean } // -- load / unload /** * `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the * reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving * "the first empty coach on the first train at the Office" is how two trains standing at one * station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not * chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked * up against live state cannot render standalone in a replay. */ | { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number } | { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number } | { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number } | { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType } | { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType } /** * `carIndex` carries the SPECIFIC car the player picked off the industry track — see the comment * on `laborer.beginUnload` in `apply.ts` for why the reducer must not re-derive it. */ | { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number } // -- consequences | { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string } | { type: 'phaseEnded'; player: PlayerIndex; phase: string } // -- §3.3, extended play (Gitea#11) /** * One seat's answer to "play one more Day?". Every seat votes; the vote is unanimous, and one * refusal ends it. In the log so that a table can see who is still being waited on, and who * called time. */ | { type: 'extensionVoted'; player: PlayerIndex; agree: boolean } /** The table agreed. `day` is the Day the extra one becomes — `config.days + extraDays`. */ | { type: 'dayExtended'; day: number } /** * Play is over for good — somebody declined the extension. * * Distinct from the ending itself, which `checkVictory` already announced by way of the result: an * ending that COULD have been played past and was not is a decision the table made, and the log * should say so rather than simply stopping. */ | { type: 'playConcluded'; declinedBy: PlayerIndex } /** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */ | { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean }; export type EventType = GameEvent['type'];