Patched directly onto 0.4.9a rather than the in-progress 0.5.0 line. A switching train vanished from the board the moment it left the Office. `selectedTrain` in `officeSvg` (board-svg.ts), which gates whether a card draws a train's crew badge, was only ever assigned inside the `cell.adTracks !== null` branch — true for the Office card alone. A train standing anywhere else, which is everywhere it stands while actually being switched, drew no badge at all. Game state was never affected; confirmed against the reported save with the engine directly. Fix hoists the assignment out of the guard so it runs for any card with a train on it. Unloading always took the westmost car, whichever one was picked. `laborer.beginUnload` carried the player's chosen `carIndex`, and `check()` validated that specific car, but the `unloadBegan` event it produced carried only the car's type — the reducer that performs the swap re-derived the target with `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in track order regardless of what was requested. Fix adds `carIndex` to the event and uses it directly. A legal decision could render with zero buttons, which looked exactly like a hang. The "where does this Extra start" decision is titled by `trainCardTitle`, beginning "Making up Extra X22…" — the same prefix `renderActions()` stripped from the action list on the assumption it only ever belonged to the separate yard-chip car-placement panel. With no tray yet being filled, that panel is null, so nothing rendered the Extra's decision either: a legal, correctly-computed option with no button anywhere on the page. Replaying the reported save found no engine deadlock at any step — the engine always had a move. Fix matches the exact title of the one group the yard-chip panel covers instead of a title-prefix regex. New tests for all three: a train parked on an ordinary facility card must draw its crew badge; three differently-typed loaded cars, unloading index 2, must leave indices 0 and 1 alone; the existing softlock regression test now mirrors the real render filter instead of only the raw menu, plus a deterministic test for the exact reported scenario. 590 tests, 0 failures.
180 lines
11 KiB
TypeScript
180 lines
11 KiB
TypeScript
/**
|
|
* 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, OfficeTier } from './content.ts';
|
|
import type { 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 }
|
|
| { 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'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
|
| {
|
|
type: 'carsCoupled';
|
|
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'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
|
| { type: 'consistSorted'; 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 }
|
|
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
|
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
|
| {
|
|
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.
|
|
*/
|
|
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; 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. */
|
|
| { type: 'extraStarted'; player: PlayerIndex; trainNumber: number; atSeat: SeatIndex | null }
|
|
| { 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
|
|
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
|
|
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
|
|
| { 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 };
|
|
|
|
export type EventType = GameEvent['type'];
|