Files
station-master/src/engine/events.ts
T
Jesse.MarkowitzandClaude Opus 5 a6657241de v0.8.0.14 — the coaches that never come back, and a district that ends at its own sign
Six reports from the Day 2-3 playtest of v0.8.0.13.

GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the
Limits, which makes a once-legal move illegal, so a save holding one is refused
at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call,
knowing it strands the game on the box. The file is untouched and v0.8.0.13
still finishes it.

The Sparrow running empty and Tom unable to unload his passengers are the same
shortage from opposite ends, and both are the rules working as printed. §9.2
boarding discards the emptied coach into the CLASSIFICATION yard, detraining
draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back
only when the Division Yard runs bare — so coaches move one way. Measured over
the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the
end, fifteen piled in Classification, the Division Yard steady at 46-47 freight
cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage
stays and the game says so. A train made up short now reports what its card
wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null
for "done" and for "cannot be done" alike, so the phase moved on in silence); the
yard panel warns while the condition lasts; the Depot's blocked panel was right
all along.

The modifier outside the Limits was working as designed and the design was
Jesse's own call, now reversed. What decided it is what the board shows — a card
beyond your own sign, in territory §8.1 and §10 reason about. The case that
motivated the exemption was checked on the reported move rather than argued away:
the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside
were free, legal and adjacent.

Switching filled the history with coordinates — a line per move, plus one per
mandatory coupling. It is still LOGGED in full; what the panel draws is the line
saying somebody switched, the first move, work at an INDUSTRY (named, not a
coordinate), the Small Yard sort, and a closing summary. The suppressed lines are
still WRITTEN, marked `trace`: dropping them outright was the first attempt and
the step-queue suite caught it, because dwellForStep pays nothing for a step that
said nothing, so the board stopped replaying switching at all. The last move
rides in the closing line rather than being kept in place — nothing knows a move
was the last until the turn is over, by which time the line has been streamed to
every client and cannot be revised. Two things fell out of reading those lines:
every move ended with a tutorial sentence the opener already gives, and the move
count said "of 6" with the six hardcoded, which is wrong on a night Stage.

Make-up lines name their train — they all read "the train being made up", so
looking back for train 10 found nothing under that name — and "a empty tank" is
now "an empty tank". The Small Yard's options read as the train they would build
instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable.
Two of those five were junk: bringing the last car to the end is the identity and
would have spent a Move, and a two-car reversal duplicated its only real option.
Both are filtered by the resulting order, not by the case that made them.

A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open
question. Two sources disagreed and the design notes won: the v0.4.5 card text
says the sort puts the engine at the nose, implications.md says "any order,
including cars ahead of the engine". `engineAt` is optional on the intent, so
older saves replay to the same train. The menu did not multiply — the engine is a
separate short list against the consist as it stands, eight options for a
four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is
deliberately direction-free, so a PUSHING train is fit to run and only a
broken-backed one is held. The button warns by asking that predicate rather than
copying it, and immediately earned itself — every one of train 10's eight options
is refused, the one asked for at the table included, because that train carries a
caboose and each sort moves it off the rear. That is the right answer rather than
a gap: the train is already made up, so every offer would break it, and the labels
say which is which. A made-up order is always on the menu for a train that needs
one, because "bring car k to the tail" is enumerated for every car and the caboose
is one of them.

Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow.
"Front to back" is not a direction a table can read — which end is the front
depends on which way the train points — and board-svg has reversed east-facing
consists since v0.8.0, so the button now describes the same train as the picture.

The Freight Agent, Porter and Laborer groups now say what the role is for, where
the role is chosen. Tom reached for the Freight Agent to detrain passengers,
which is a Porter's action in the Cargo phase; both halves were working and
neither was visible.

TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the
measurement, to revisit on a second game's data).

999 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
2026-09-17 20:49:31 -04:00

307 lines
18 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[] }
/**
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
*
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
* moment it carried that a player actually needed to see went past in silence. Reported from the
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
*/
| { type: 'superintendentChanged'; player: PlayerIndex; 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'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: 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[];
/**
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
*
* The Small Yard used to put the engine back on the front unconditionally, which is what the
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
* `implications.md` records the design source saying the opposite — "may sort itself into any
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
*/
engineAt: number;
}
/**
* A player finished their switching turn: what it cost, and where the crew was left.
*
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
* already over and that line has been sent.
*/
| {
type: 'switchingEnded';
player: PlayerIndex;
movesUsed: number;
movesAllowed: number;
lastMove?: { trayId: TrayId; to: GridCoord };
}
| { 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; trainNumber: number | null; isExtra: boolean }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
/**
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
*
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
*
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
* in Classification, and how far the Division Yard is from empty.
*/
| {
type: 'makeUpShort';
trainNumber: number;
isExtra: boolean;
/** How many cars it got, out of what the card calls for. */
placed: number;
wanted: number;
/** The categories the card still wants and the Division Yard cannot supply. */
missing: ('freight' | 'coach' | 'caboose')[];
/** Cars of those categories sitting in the Classification Yard. */
waiting: number;
/** §2.2 — Classification comes back only when this reaches zero. */
divisionYardHolds: number;
}
| { 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'];