Files
station-master/src/engine/events.ts
T
Jesse.MarkowitzandClaude Opus 5 45580d8b61 v0.7.3 — a game that asks before it ends, and a results screen worth reading
Two issues off the tracker, and they are halves of one thing: the end of a game.
Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and
these are not fixes people mid-playtest need.

EXTENDED PLAY (#11). The official result is settled at the original game length
and never changes: in a five-Day game extended to eight, the winner is whoever
led at the end of Day 5. Extending grants exactly one Day and the question is put
again at the end of it — solitaire the player decides alone, multiplayer it is
unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4
collision breach is final, during an extended Day exactly as during the scheduled
game.

It could not be a client-side change. `check` refused every intent once `status`
left `active`; the server never loads a `finished` game back into memory; and a
save is `{ seed, config, history }` replayed through the engine, so a "continue"
the history does not record did not happen. Hence a fourth status,
`awaitingExtension`, and a `game.extend` intent. `config.days` never moves —
`extraDays` counts the borrowed Days and `official` freezes the outcome, the
standings and the statistics at the first ending.

THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an
internal enum interpolated into the page at the one moment the game has the
player's whole attention. Every reason now has a sentence with the game's own
numbers in it. Around it: the result and winner, standings, the rules the game
was dealt under, a per-player breakdown, and the railroad — trains through the
Division and how many worked en route, loads made up and broken, passengers, cars
switched, trains destroyed. It shares the Day-end dialog's blocks rather than
reimplementing them, and stays reopenable so continuing does not cost you the
results.

Statistics are folded, not recorded: `state.tally` counts what the event stream
says happened, hooked at `applyIntent` and `advance` because `reduce` never sees
the phase driver's events — and those are the interesting ones. Nothing in the
rules reads it, and it rides the Frame, so multiplayer gets the same numbers as
solitaire from one implementation.

THREE BUGS FOUND IN TESTING, all of which would have shipped:

  - a saved game containing a vote could not be resumed (NO_ACTOR). A history is
    a flat Intent[] with no seat recorded; the replay derives who acted from the
    turn order, which cannot work for an intent every seat may send in any order.
    `game.extend` carries its voter, checked against the authenticated seat.
  - an all-bot game hung on the question for ever. `driveBots` loops on
    `currentActor`, null the moment the game stops, so it cannot cast a vote, and
    the bot-vote driver returned early with no humans to follow.
  - the balance harness became unbounded — `test/sim.test.ts` went from under a
    second to never finishing. `randomBot` took another Day about half the time,
    so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver,
    not in a policy, so it holds for bots not yet written.

All three have regression tests. 832 tests pass, against 793 before this change.

NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted
per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not:
reading advance.ts, it fires once per game and only for a train whose profile
sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a
second. The streak was built, rendered "1 Stage at (0,0)", and was taken out
again. There is no per-Stage "this train did not move" signal in the engine, so
"longest an engine sat on a siding" needs one first; TODO.md #36 records what it
would take, and the Circus set-up is reported instead. Badges remain the second
pass #16 asks for (TODO.md #33), and because the statistics are derived rather
than recorded, that pass can add any of them retroactively to games already
played and saved.

Extended play has not yet been played at a real table (TODO.md #35): the
multiplayer vote has only been driven through `session.intent`, never through two
browsers.

Closes #11
Closes #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 04:23:26 -04:00

218 lines
13 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 }
// -- §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 };
export type EventType = GameEvent['type'];