Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in TODO.md under Play Balance. GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered, chosen by number parity. The number no longer decides an Extra's direction — the start does, which supersedes the recorded ruling that "the number decides, like everything else on the timetable". The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division on its first move having crossed nothing, and be paid for the run. Either end now runs the train away from itself; at an Interchange or a Control Point the player picks the direction. The Interchange start is a YARD, off the running line, which is what makes the Superintendent clause work: placing it can never force a collision, a guaranteed one holds it there for another Stage, and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and `ask`, so nothing new decides collisions. Where an Extra may start is now a house rule (divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The legacy `atSeat` intent field still replays as it always meant. FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested position alone, so the Control Point start has been shipping since it was added with a train that could never be given a consist. Found by playing it, not by the tests, which had only asserted where the tray landed. FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision permanently poisoned that Mainline card for every later train. THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a Division could hold two Interchanges and Plains carried the weight of a card printed once. "An Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are retired too — two of those were already dead before this release and nobody had noticed. GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The player is told on the card and on the button. GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards, so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts and the Home Deck reference carry what the game plays. CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never existed. Every Enhancement row cited its implementation by line number and every citation had rotted; they name functions now. Card counts came out of the comments, since they move with play balance; source-sheet figures and dated measurements stayed. TODO.md gains an item for a card reference generated from content.ts, in six sections, so the documentation cannot disagree with the game. 715 tests pass, tsc clean, site builds.
201 lines
12 KiB
TypeScript
201 lines
12 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, 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'; 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. */
|
|
/**
|
|
* 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 };
|
|
|
|
export type EventType = GameEvent['type'];
|