/** * Plain-English narration for the replay viewer. * * Two jobs, and the second matters more than it looks: * * 1. `narrate()` — turn each engine event into a sentence a person can read. * 2. `impediments()` — say what is currently BLOCKED and why. * * Actions are easy to show. Blocked states are what diagnosis actually needs: the open questions * are all of the form "why is nothing happening?" — why is a train at the Office only 18% of * Stages, why do Laborers sit idle, why does a facility stop working. A log of things that did * happen answers none of those. * * The impediment checks call the engine's own predicates rather than reimplementing them, so the * panel cannot drift from the rules. */ import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; import type { GameEvent } from '../engine/events.ts'; import { badlyMadeUp } from '../engine/advance.ts'; import type { CrewTray } from '../engine/state.ts'; // --------------------------------------------------------------------------- // Small formatters // --------------------------------------------------------------------------- const CLOCK: readonly string[] = [ 'Midnight', '2:00 AM', '4:00 AM', '6:00 AM', '8:00 AM', '10:00 AM', 'Noon', '2:00 PM', '4:00 PM', '6:00 PM', '8:00 PM', '10:00 PM', ]; export function clockTime(stage: number): string { return CLOCK[stage - 1] ?? `Stage ${stage}`; } /** * `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made * says so — the printed game's answer is to turn the chip upside down in the tray, and this is the * screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`), * so "loaded here" is the difference between a boxcar worth switching and one that has to leave the * district first. Omit it and the label is what it always was, which is what the replay viewers and * the history lines want: they describe a board, not a seat's view of one. */ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string { // A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even // though the supply marks every caboose loaded. Name it plainly. if (c.type === 'caboose') return 'caboose'; const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`; return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label; } /** * "a loaded boxcar", "an empty tank" — the article the word actually takes. * * The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the * whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so * there is no 'an hour' case to special-case and inventing one would be the more fragile choice. */ export function indefinite(label: string): string { return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`; } /** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */ export function trainLabel(trainNumber: number | null, isExtra: boolean): string { if (trainNumber === null) return 'the local crew'; return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`; } export function carsLabel(cars: RollingStock[]): string { if (cars.length === 0) return 'nothing'; return cars.map(carLabel).join(', '); } /** * X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col * storage order. * * These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for * the same square, side by side on the same screen. `view.ts` carried the comment explaining why * the display order is X,Y; this one had no comment at all and was simply the storage order * reaching the page. Jesse's call — the log and the action menu spell a square the same way. */ const at = (c: GridCoord): string => `(${c.col},${c.row})`; const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const; const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`; export function phaseLabel(phase: string): string { switch (phase) { case 'localOps': return 'Local Operations'; case 'newTrain': return 'New Train'; case 'mainline': return 'Mainline'; case 'loadUnload': return 'Cargo'; case 'shiftChange': return 'Supervisor Shift'; default: return phase; } } // --------------------------------------------------------------------------- // Event narration // --------------------------------------------------------------------------- export type Narration = { text: string; /** Colours the line in the viewer. */ tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet' | 'phase'; /** Board cell to highlight, if the event happened somewhere. */ where?: GridCoord; }; /** * Every member of `GameEvent` must produce a specific sentence. A test asserts the fallback is * never reached, so adding an event type without narrating it fails the build rather than quietly * degrading the replay. */ export type NarrateContext = { /** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */ cardName?: (id: string) => string; /** * Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read * "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the * game, phrased in internal identifiers. */ trainName?: (trayId: TrayId) => string; /** * Resolves a player index to their display name. Optional like the rest: an engine test narrating * events has no roster, and "Player 2" is a truthful fallback rather than a broken one. */ playerName?: (player: PlayerIndex) => string; /** * Names the Facility standing on one of a player's squares, or null where there is none. * * Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which * is the grid's own notation and means nothing at a table where people are looking at cards. The * industry is the whole point of the move, so it is what the line should say. */ facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null; }; export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { const card = (id: string): string => ctx.cardName?.(id) ?? 'a card'; const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id); // The industry on a square when there is one, and the coordinate when there is not — a crew works // plain track too, and "at nowhere" would be worse than the notation. const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c); switch (e.type) { // -- clock case 'stageBegan': return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` }; case 'seatsRotated': // Named players rather than seat numbers: the rule is that everyone MOVED, and a list of // indices does not say who is now next to whom. return { tone: 'clock', text: `Employee Rotation — everyone moves one chair left. West to East: ${e.seating .map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`) .join(' → ')}`, }; case 'superintendentChanged': /** * The Fedora is the only thing in the game that changes hands on a clock rather than because * somebody did something, so it is the one handover nobody at the table watches happen. */ return { tone: 'clock', text: `SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` + `at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` + `questions, and every round that goes round the table now starts with them.`, }; case 'phaseBegan': // Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so // the log read as one undifferentiated column and you could not see where a phase began. return { tone: 'phase', text: `▸ ${phaseLabel(e.phase)} phase` }; case 'actorChanged': return { tone: 'quiet', text: e.player === null ? 'No player acts — automatic phase' : 'to act', }; // -- local operations case 'localOpsOptionChosen': return { tone: 'plain', text: e.option === 'switch' ? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.' : e.option === 'draw' ? 'Chose to DRAW a card' /** * SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a * facility" — an assertion — and §6.3 requires no action at all, so when the Freight * Agent went idle the log claimed a car had moved and then fell silent about which. * Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by * `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and * the industry; an idle Agent is narrated by `freightAgentIdled`. */ : 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam', }; case 'trayMoved': // `via` rides on the event only when there was another legal route to the same square // (docs/plans/switching-paths.md) — so naming it here says which one the crew actually took, // rather than leaving a real choice invisible in the crew's own history. return { tone: 'plain', where: e.to, // NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended // to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it // once. It also pushed the useful half of the line out of the caption row, which shows one // step at a time and is the place a player reads a move as it happens. text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`, }; case 'carsCoupled': { /** * TAKING YOUR OWN CUT BACK IS NOT THE SAME EVENT AS FINDING CARS ON THE LINE, and the history * read as though it were — "coupled 2 cars" told a player who had just set those very cars out * that the game had silently undone their work. It has not: they were standing at the end the * train pulled out through, and coupling is mandatory (§A.4). */ const own = e.recoupled?.stock.length ?? 0; const found = e.stock.length - own; const parts: string[] = []; // `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram. const some = (cars: RollingStock[]): string => cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars); if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`); if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`); return { tone: 'plain', where: e.at, text: // "1 car(s)" was the plural of a machine. The count is already implied by the cars named // in `parts`, so the sentence leads with where and which end instead. `Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` + ` — ${parts.join(', and ')}`, }; } case 'consistSorted': { /** * WHERE THE ENGINE ENDED UP, and whether the train can still run. * * ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess * is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the * engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed, * with the engine buried among its own cars. A copy of that rule in the narrator would have * told a player their pushing train was stranded when it was not. */ const ahead = e.engineAt; const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray); const where = ahead === 0 ? 'so the right car is now on the end and can be spotted' : unfit === null ? `with the whole consist AHEAD of the engine — it runs as a pushing train` : `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`; return { tone: 'good', where: e.at, text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`, }; } case 'switchingEnded': { /** * The line that closes a switching turn, and the only one the history keeps from the middle of * it: what it cost, and where the crew was left standing. */ const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`; if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' }; if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` }; return { tone: 'plain', where: e.lastMove.to, text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`, }; } case 'carsDropped': // WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows: // the train may pull away from cars set out behind it and must couple back up to cars set out // in front. "Dropped 2 cars" left the one fact that matters out of the record. return { tone: 'plain', where: e.at, text: `Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`, }; // -- cards case 'cardDrawn': return { tone: 'plain', text: e.source === 'homeOffice' ? `Drew ${card(e.cardId)} from the Home Office deck` : `Took ${card(e.cardId)} from Department slot ${(e.slot ?? 0) + 1}`, }; case 'cardPlayed': return { tone: 'plain', ...(e.placement ? { where: e.placement } : {}), text: e.placement ? `Played ${card(e.cardId)} onto ${at(e.placement)}` : `Played ${card(e.cardId)}`, }; case 'mainlineModified': { // WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader // to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that // says what the play was worth. const kindName = (k: string | undefined): string => MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card'; return { tone: 'plain', text: e.became ? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}` : `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`, }; } case 'redFlagSpent': return { tone: 'good', text: `RED FLAG — Train ${e.trainNumber} stopped short of the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits. The flag comes down with it.`, }; case 'redFlagRuled': return e.flag ? { tone: 'plain', text: 'Flagged the approaching train' } : { tone: 'plain', text: 'Waved the train through' }; case 'redFlagsSet': return { tone: 'good', text: `RED FLAGS set out on the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits — the next train from that way is held short`, }; case 'flyingSwitch': return { tone: 'good', where: e.to, text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`, }; case 'officeUpgraded': return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` }; case 'cardDiscarded': return { tone: 'quiet', text: `Discarded ${card(e.cardId)} face-up on top of Department ${e.toSlot + 1}`, }; case 'deckReshuffled': return { tone: 'quiet', text: `Home Office deck ran out — the Salvage Yard and all three Department decks ` + `(${e.order.length} cards) were collected, reshuffled and dealt back out`, }; case 'departmentRefilled': return { tone: 'quiet', text: `Department slot ${e.slot + 1} refilled with ${card(e.cardId)}`, }; // -- train lifecycle case 'extraQueued': return { tone: 'good', text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`, }; case 'enhancementPlaced': { const name = e.key.replace(/([A-Z])/g, ' $1'); // Out on the Mainline is not a square in anyone's district, so it is named rather than // given a coordinate the Office Area does not have. if (e.at === undefined) { return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` }; } return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` }; } case 'secondSectionOrdered': return { tone: 'bad', text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train`, }; /** * WHERE THE PLAYER PUT IT, not where its number would have sent it. * * An Extra's direction comes from its start now (§7, Jesse's ruling), so the line that used to * explain the number's parity would be explaining a rule that no longer applies to this train. */ case 'extraStarted': { const where = e.at.kind === 'divisionPoint' ? `the ${e.at.side === 'west' ? 'Western' : 'Eastern'} Division Point` : e.at.kind === 'mainline' ? "the Interchange's yard" : `the Control Point in seat ${e.at.seat}`; const why = e.at.kind === 'divisionPoint' ? 'the end it runs away from — an Extra may start at either, and the end chooses the run' : e.at.kind === 'mainline' ? 'made up off the running line, so it highballs onto the Mainline once the Subdivision ' + 'is clear and may be held in the yard until it is' : 'an Extra may begin at any Office above a Whistle Post, and the player chooses the run'; return { tone: 'good', text: `EXTRA X${e.trainNumber} started at ${where}, running ${e.direction} — ${why}`, }; } case 'trainMadeUp': return { tone: 'good', text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`, }; case 'trainStoodStill': return { tone: 'good', text: `Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` + 'its card pays for the stop, not for the run (circus set-up)', }; case 'trainHeld': return { tone: 'bad', text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`, }; case 'trainHighballed': // The RULE that released it, not a restatement of the move. "Some trains seem to be moving // before I can switch them" was reported against a log in which every departure read alike. return { tone: 'plain', text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}. Why now: ${e.why}`, }; case 'trainArrived': /** * AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION. * * This used to say the train stands through Cargo and is forced out at the end of the Stage — * true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not * that it is rushed out early. It is switched, worked and released exactly like any other * arrival; the only difference is what happens if it is left on Secondary Track when the next * Mainline Phase begins (`expediteFault`). */ /** * WHOSE OFFICE, AND WHOSE TRAIN TO WORK — Jesse, playtest 2026-09-16. * * This said "ARRIVED at the Whistle Post" and then "You can work it in Cargo now". Both halves * are wrong at a table of four: every seat has an Office, so the tier alone does not say which * district the train is standing in, and the reader is usually NOT its Station Master — the * line was telling three players they could work a train they cannot touch. */ { const name = ctx.playerName?.(e.owner) ?? null; const whose = name === null ? `the ${e.office}` : `${name}'s ${e.office}`; const worker = name === null ? 'Its Station Master' : name; return { tone: 'plain', text: e.expedited ? `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it must stay on the Office square: parked anywhere else in that district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.` : `Train ${e.trainNumber} ARRIVED at ${whose} carrying ${carsLabel(e.consist)} — it stands there for the rest of this Stage. ${worker} can work it in Cargo now and switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`, }; } case 'expediteFault': return { tone: 'bad', text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`, }; case 'trainDiverted': return { tone: 'good', text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`, }; case 'trainCompleted': // The one line in the log that is good news for everybody, so it is not 'quiet'. return { tone: 'good', text: `Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` + `${e.side === 'east' ? 'Eastern' : 'Western'} Division Point carrying ${carsLabel(e.consist)}. ` + `All players get 1 Revenue. The crew is free again.`, }; // -- freight agent /** * THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives: * "(-1,1)" is the grid's notation and means nothing at a table where people are looking at * cards. The switching lines were moved to it and these three were missed, so a Freight Agent * turn was the one place the log still spoke in coordinates. Asked directly from a table on * Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or * from." The car and the direction were already here; the facility was not. */ case 'stockToOutbound': return { tone: 'plain', where: e.at, text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`, }; case 'inboundCleared': return { tone: 'plain', where: e.at, text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`, }; case 'facilityUnjammed': return { tone: 'bad', text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`, where: e.at, }; case 'freightAgentIdled': return { tone: 'quiet', text: 'Freight Agent found nothing worth doing — no green box could be stocked, no red box needed ' + 'clearing, and no load was jammed. §6.3 requires no action, and unjamming a healthy box ' + 'would destroy a load that cost a whole action to stock.', }; // -- trains case 'trainScheduled': { // §7 — roll 1D12 for the slot; if it is taken, work down the column. Reporting only the roll // makes a bumped train look like an arithmetic error. const bumped = e.slot + 1 !== e.roll; return { tone: 'good', text: bumped ? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage` : `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll}) — it will run at this time EVERY Day from now on`, }; } case 'carPlacedOnTrain': // NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for // what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16). return { tone: 'plain', text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`, }; case 'carPassed': return { tone: 'quiet', text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`, }; case 'makeUpShort': { // What it wanted, in the words the card uses, so the line can be checked against the card. const names: Record<'freight' | 'coach' | 'caboose', string> = { freight: 'freight car', coach: 'coach', caboose: 'caboose', }; const wants = e.missing.map((m) => names[m]).join(' or '); const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`; // §2.2 is the whole explanation and it is not guessable from the board: the cars are visible // in the Classification Yard, and why they will not come back is not. const why = e.waiting > 0 ? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.` : ' There are none in the Classification Yard either.'; return { tone: 'bad', text: `${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` + `Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`, }; } case 'dispatchBonusUsed': return { tone: 'good', text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`, }; case 'trainsDestroyed': { // Say what hit what, where, and what was written off. "COLLISION: NO FREE A/D TRACK" told a // player the score had changed and nothing else. const why = e.reason === 'no free A/D track' ? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it` : e.reason === 'cars fouling the Running Track' ? `it ran into cars left standing on ${e.where} between the Limits and the Office` : e.reason; const wrecked = e.trains .map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`) .join(' and '); /** * WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue * loss… we need to know which player received the penalty and why"). * * `player` is the seat at fault, and for everything that happens inside a district that is the * district's owner — so it names the place as well as the payer. A Mainline collision is the * Superintendent's by rule (§10), which is a different sentence: it happened on open road, not * in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line * never mentioned them; a player should not have to add two log entries together. */ const who = ctx.playerName?.(e.player) ?? null; const mainline = e.where === 'the Mainline'; const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`; const cost = who === null ? ' 5 Revenue is lost.' : mainline ? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.` : ` ${who} loses 5 Revenue — it happened in their district.`; return { tone: 'bad', text: `COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` + `to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` + `returns to its slot and runs again next Day; an Extra is gone for good.`, }; } case 'clearanceRequested': return { tone: 'bad', text: `SUPERINTENDENT MUST RULE: ${train(e.trainId)} wants to enter the Mainline card ` + `that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` + `into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` + `waits where it is, losing time but safe.`, }; case 'clearanceGiven': return { tone: e.allow ? 'bad' : 'plain', text: e.allow ? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision` : `Clearance DENIED — ${train(e.trainId)} holds where it is`, }; // -- passengers // §9.2 — "One Porter will allow you to do any ONE of the following actions", so a Depot with a // single Porter works exactly one coach per Stage. That is a limit, not a bug. case 'passengersBoarded': return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage`, }; case 'passengersDetrained': return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage`, }; // -- freight pipeline case 'loadStarted': return { tone: 'plain', where: e.at, text: `Laborer moved a ${e.carType} load from the green box onto MEN`, }; case 'loadAdvanced': return { tone: 'plain', where: e.at, text: `Laborer advanced the load ${boxName(e.fromBox)} → ${boxName(e.toBox)}`, }; case 'loadCompleted': return { tone: 'good', where: e.at, text: `LOAD FINISHED — ${e.carType} loaded onto the spotted car`, }; case 'unloadBegan': return { tone: 'plain', where: e.at, text: `Laborer began unloading a ${e.carType} — load lifted onto WORK`, }; case 'unloadCompleted': return { tone: 'good', where: e.at, text: `UNLOAD FINISHED — ${e.carType} delivered into the red Inbound box`, }; // -- consequences case 'revenueChanged': return e.delta < 0 ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` } : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; case 'phaseEnded': return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` }; // -- §3.3, extended play (Gitea#11) case 'extensionVoted': return e.agree ? { tone: 'plain', text: 'Would play one more Day' } : { tone: 'plain', text: 'Called time — the game ends here' }; case 'dayExtended': return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` }; case 'playConcluded': return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' }; // -- §11, the Yard Office (Gitea#5) case 'yardOfficeRuled': return e.take ? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` } : { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` }; } } /** Events that change nothing a viewer can see. Skipped when capturing frames. */ export function isVisible(e: GameEvent): boolean { return e.type !== 'actorChanged'; } /** * A phase that ended with nothing done needs saying so explicitly. "Player 0 finished Load/Unload" * with no preceding action reads as a gap in the replay when it is actually the game reporting that * there was no work available. */ export function idleNote(phase: string): string { switch (phase) { case 'loadUnload': return 'Nothing to do this Load/Unload — no load ready to advance, and no train at the platform with passengers to work.'; case 'localOps': return 'Nothing useful to do this Local Operations.'; default: return `Nothing to do this ${phaseLabel(phase)}.`; } } // --------------------------------------------------------------------------- // Impediments — "why is nothing happening?" // --------------------------------------------------------------------------- export type Impediment = { where: string; why: string; severity: 'stuck' | 'waiting' | 'risk' }; /** * Everything currently preventing progress. Derived live from engine predicates, never cached. * * This is the panel that should answer the standing questions: whether facilities jam, whether * trains are held for want of a crew, whether the Office is about to cause a collision. */ /** `coordKey`'s inverse — the grid is keyed by string and the engine predicates take coordinates. */ function uncoordKey(key: string): GridCoord { const [row, col] = key.split(',').map(Number); return { row: row ?? 0, col: col ?? 0 }; } /** * One of `passengerRefusal`'s codes, in words a player can act on. * * `NO_EMPTY_COACH_IN_YARD` gets the longest answer because it is the one that looks like a broken * game: the Division Yard is visibly full of cars, and the single type that has run out is the one * §9.2 needs. Where the missing coaches ARE, and the condition that brings them back, is the whole * of what the player needs to know — §2.2 returns the Classification Yard only when the Division * Yard is bare, so a yard with fifty freight cars in it will not refill for a long time. */ function passengerReason( s: GameState, player: PlayerIndex, at: GridCoord, dir: 'board' | 'detrain', ): string { const code = passengerRefusal(s, player, at, dir); switch (code) { case 'NO_TRAIN_AT_OFFICE': return dir === 'board' ? 'passengers waiting, no train at the platform to take them' : 'no train at the platform'; case 'NOT_A_TERMINAL': return 'the only train here stops at Terminals only — Porters may not work it at this Office'; case 'NO_PASSENGER_WORK': return 'the only train here is one its card bars Porters from working'; case 'NO_EMPTY_COACH': return 'passengers waiting, but every coach on the train is already full'; case 'INBOUND_BOX_FULL': return 'arrivals aboard, but the red Unloading slots are all occupied'; case 'LOADED_IN_THIS_DISTRICT': return 'the loaded coaches all boarded here — passengers must be carried to another Office ' + 'Area before they can alight'; case 'NO_EMPTY_COACH_IN_YARD': { const stuck = s.yards.classificationYard.filter((c) => c.type === 'coach').length; const total = s.yards.divisionYard.length; return ( 'arrivals aboard, but §9.2 needs a white empty coach from the Division Yard to swap in and ' + `there is none left${stuck > 0 ? ` — ${stuck} ${stuck === 1 ? 'coach is' : 'coaches are'} in the Classification Yard` : ''}. ` + `Classification returns only when the Division Yard is bare, and it still holds ${total} cars.` ); } default: return `Porters cannot work here (${code})`; } } export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] { const out: Impediment[] = []; const area = areaOf(s, player); for (const [key, card] of area.grid) { const f = card.facility; if (!f) continue; /** * A Freight Facility names itself off its own card; a Passenger Facility does NOT — it rides on * the `office` card, so `geometry.kind` is `'office'` and it fell through to the literal * "facility". Every passenger impediment therefore read `facility 0,0`, next to a freight row * saying `mineTipple 1,-3`. The Office Area's tier is the name it should carry, and there is * exactly one Office per Area, so `area.tier` is that card's own. */ const name = card.geometry.kind === 'facility' ? card.geometry.facility : card.geometry.kind === 'office' ? area.tier : 'facility'; /** * WHY THE PORTERS ARE STANDING THERE (Gitea#2). * * "Note that the sparrow (with two loaded coaches) pulled into the station. There are two * passengers on the platform. Four porters. My thought was to unload two and load two. I never * get the chance to load the last two." * * The engine was right — §9.2 needs a white coach out of the Division Yard to de-train into, * §2.2 returns the Classification Yard only when the Division Yard is BARE, and the Division * Yard was one empty coach short with eight more sitting in Classification unable to come back. * Jesse's ruling is that the shortage stays: "it is possible to run out — that's part of the * strategy." What was missing was any way to SEE it. A Porter action that cannot be taken is * simply absent from the menu, and this panel — the one that answers "why is nothing moving?" — * covered freight facilities only, so the platform had nothing to say for itself at all. * * The reason comes from `passengerRefusal`, the engine's own, so what is on screen is the rule * that actually refused rather than a second guess at it. */ if (f.kind === 'passenger') { /** * NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16). * * "He would like to have two passengers waiting in his depot… but the only option he had was * bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and * three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of * the facility's type out of the Division Yard, and there was not a loaded coach in it: six * were sitting in Classification, which §2.2 returns only when the Division Yard runs bare. * * Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running * out is part of the game. What must not stay is the silence — an action with no legal target * is simply absent from the menu, so the player is left to guess whether they misunderstood the * rules or the game is broken. */ if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) { const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length; if (loadedCoaches === 0) { const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length; out.push({ where: `${name} ${key}`, why: `room for ${f.capacity.outbound - f.outboundBox.length} more passenger` + `${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` + `coach in the Division Yard for the Freight Agent to bring over` + (waiting > 0 ? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` + 'back only when the Division Yard is bare' : ''), severity: 'waiting', }); } } if (portersLeft(f) > 0) { const coord = uncoordKey(key); // Passengers standing on the platform with nothing carrying them away. if (f.outboundBox.some((c) => c.type === 'coach' && c.loaded) && !canBoard(s, player, coord)) { out.push({ where: `${name} ${key}`, why: passengerReason(s, player, coord, 'board'), severity: 'waiting', }); } // A coach full of arrivals that cannot be emptied. const arriving = area.adOccupancy.some((id) => s.trays.get(id)?.consist.some((c) => c.type === 'coach' && c.loaded && c.origin !== seatOf(s, player)), ); if (arriving && !canDetrain(s, player, coord)) { out.push({ where: `${name} ${key}`, why: passengerReason(s, player, coord, 'detrain'), severity: 'stuck', }); } } continue; } if (f.kind !== 'freight') continue; const want = facilityCarType(f); // A load that cannot move, with Laborers standing by, is the worst state a facility reaches: // it also strips the industry track of Operational Rail status (§9.3), so no car can be // brought in to rescue it. for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) { const load = f.menAtWork![box]; if (!load || canAdvanceLoad(f, box)) continue; const reason = laborersLeft(f) < 1 ? 'all Laborers already used this Stage' : load.dir === 'out' ? `no empty ${want} spotted to load onto` : 'red Inbound box is full'; out.push({ where: `${name} ${key}`, why: `load STUCK on ${boxName(box)} — ${reason}`, severity: laborersLeft(f) < 1 ? 'waiting' : 'stuck', }); } if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) { // Two quite different reasons, and telling a player "MEN is occupied" when the real answer is // "there is no car to load onto" sends them to fix the wrong thing. const blockedByBox = f.menAtWork?.[0] != null; out.push({ where: `${name} ${key}`, why: blockedByBox ? 'green box has a load but MEN is occupied' : `a load is staged but no empty ${want} is spotted on this industry's track — it stays ` + 'in the green box until a crew sets one out here', severity: blockedByBox ? 'waiting' : 'stuck', }); } if (f.allows.outbound && f.outboundBox.length === 0) { /** * WHAT TO DO NEXT — AND THE TWO STEPS CAN BE DONE IN EITHER ORDER. * * §6.3 stocking needs only a car in the Division Yard; §9.3's "Load the car" is what needs an * empty car of the commodity standing on the industry's track. So an empty siding is not a * reason to hold the Freight Agent back — it is a second errand to run before the Laborers can * start. Saying "bring a car in FIRST" sent players to do them in a fixed order they are not * bound by, and wasted the Stage's one Freight Agent action. */ const spotted = f.industryTrack.cars.some((c) => !c.loaded && facilityCarTypes(f).includes(c.type)); out.push({ where: `${name} ${key}`, why: spotted ? 'green box empty — nothing to load (needs a Freight Agent action)' : `green box empty — the Freight Agent can stage a load now, but no empty ${want} is ` + 'spotted here, so a crew must set one out before Laborers can work it', severity: 'waiting', }); } // Four cars is the whole of any track card, industry or not — the same limit that caps a // consist. It is no longer the industry's box count, which is what used to make a one-box // industry report itself full with a single car standing on it. if (f.industryTrack.cars.length >= MAX_CONSIST) { out.push({ where: `${name} ${key}`, why: `industry track full (${MAX_CONSIST} cars) — no room to spot another`, severity: 'stuck', }); } } /** * WHY THE CREW CANNOT GET THERE. * * The panel is called "why nothing is moving" and covered everything except movement: jammed * facilities, held trains, a full Office. A crew standing one card short of an industry it cannot * enter had nothing here at all — reported as trains being blocked "in certain conditions", with * no way to find out which. * * Only while switching, and only the cards actually in the crew's way: `movesFor` reports the * squares the movement walk reached and refused, not every square on the board. */ /** * WHY A CAR WILL NOT COME OFF (Gitea#21). * * "I dropped the first tank car, but that was all I was allowed to do" — and this panel, asked * why, talked about the refinery's green box. The rule that actually refused is printed on the * train: trains 3/4, the Express, "may drop or pick up one freight car at every location". The * refusal was correct. Nothing said it. * * That is a worse failure than a missing button, because the panel did not stay silent — it * offered a true statement about the FACILITY, which sent the player to spend a Freight Agent * action that could not have helped. The rule was on the train card's tooltip, which is not * where anyone looks when a button they expected is simply absent. * * Only when the crew has a freight car it could otherwise set out. A budget spent by a train * with nothing left to drop is not blocking anything, and this panel earns its keep by being * short enough to read. */ const turnNow = turnOf(s, player); if (s.clock.phase === 'localOps' && turnNow.option === 'switch') { for (const [id, tray] of s.trays) { if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; if (!tray.consist.some(isFreight)) continue; if (!freightRuleSpentHere(s, player, id)) continue; const { row, col } = tray.position.coord; out.push({ where: `Train ${tray.trainNumber} at (${col},${row})`, why: 'ONE FREIGHT CAR PER LOCATION — this train has already worked a freight car on this ' + 'square, so no more come off or on here until next turn. It may still work one at the ' + 'next square it reaches.', // The printed rule doing its job, and it lifts by itself. Amber, not red. severity: 'waiting', }); } } const turn = turnOf(s, player); if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) { for (const [id, tray] of s.trays) { if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; const { blocked } = movesFor(s, player, id); for (const b of blocked) { // A turnout is not an obstruction — a train runs through one all day and simply may not // STOP on it. Listing every one would bury the four reasons that are genuinely in the way; // the board says it on the card instead. if (b.kind === 'noStopping') continue; out.push({ where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`, why: b.why, // Not "stuck": these are the shape of the district and of the other trains in it, which is // the puzzle rather than a fault. Amber, not red. severity: 'waiting', }); } } } /** * WHY THE PASSENGERS ARE NOT MOVING — boarding as well as de-training. * * Reported first as "I can't figure out how to have a train with three passenger coaches unload * all three at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one * coach a Stage and then needs a Freight Agent action to clear the box before the next. A Station * does two and a Terminal three. That is the Office ladder doing its job, and nothing on screen * said so. * * Reported again as "passenger trains arrive at my Office and move on before I can load or unload * them", which turned out to be two separate silences. This block only looked at trains carrying * a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card * carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all * Porters used" — to a player who had used none. * * §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel * mentioned at all. */ const office = area.grid.get(coordKey(area.officeCoord)); const pf = office?.facility; if (pf && pf.kind === 'passenger') { const coachTrains = area.adOccupancy .map((id) => s.trays.get(id)) .filter((t): t is NonNullable => !!t && t.consist.some((c) => c.type === 'coach')); if (coachTrains.length > 0) { const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => { out.push({ where: 'Office — passengers', why, severity }); }; const boarding = pf.outboundBox.some((c) => c.type === 'coach' && c.loaded); const emptyOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && !c.loaded)); const loadedOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)); if (pf.porters < 1) { say( 'this Office has NO Porters — a Whistle Post is not a Passenger Facility and cannot ' + 'work passengers at all. Upgrade it to a Depot or better.', 'stuck', ); } else if (portersLeft(pf) < 1) { say( `all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` + 'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)', ); } else { if (boarding && !emptyOnTrain) { say('passengers are waiting to board, but every coach standing here is already full'); } if (!boarding && !loadedOnTrain) { say('nobody waiting to travel and no loaded coach to set down — stock the platform with a Freight Agent action'); } if (loadedOnTrain && pf.inboundBox.length >= pf.capacity.inbound) { say( `the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` + 'a Freight Agent action clears it, which costs a whole Local Operations turn', ); } if (loadedOnTrain && !s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) { say('no EMPTY coach in the Division Yard to swap into the train — one is required'); } } } } /** * TRAINS HELD FOR WANT OF A CREW TRAY (§7) — the scarcity mechanic, made visible (#98). * * This covered the TIMETABLED train due out this Stage and nothing else, which meant the two * other things that queue for the same pool reported nothing at all. A player who spent a card on * an Extra, or ordered a second section, got an EMPTY panel while their train sat behind an * exhausted pool — and each had been announced once in the log in a line that promised a future * event ("as soon as a Crew Tray frees up") which nothing then confirmed. * * All three are one condition, so they are written as one block: no free tray, and something * waiting for one. The count rides along because "no free Crew Tray" reads like a permanent fact * about the game rather than a state that will pass. */ // `crewTrayCount` is the pool's size, asked rather than re-derived. `trays.size + freeTrays.length` // gives the same number in play — `retireTrain` moves a tray back — but it is a second way to know // one fact, which is the shape of every bug this release fixed. const trays = `${s.freeTrays.length} of ${crewTrayCount(s.players.length)} Crew Trays free`; if (s.freeTrays.length === 0) { const due = s.timetable[s.clock.stage - 1]; if (due !== null && due !== undefined) { out.push({ where: `Train ${due}`, why: `due to depart but HELD — no free Crew Tray (${trays})`, severity: 'stuck', }); } // An Extra belongs to the player who played the card (§7), so it is their errand and it is // reported to them. A second section is the table's, like any Timetabled train. for (const x of s.pendingExtras) { if (x.player !== player) continue; out.push({ where: `Extra X${x.trainNumber}`, why: `played and waiting to be made up — no free Crew Tray (${trays})`, severity: 'stuck', }); } for (const n of s.pendingSecondSections) { out.push({ where: `Train ${n}`, why: `second section ordered and waiting to be made up — no free Crew Tray (${trays})`, severity: 'stuck', }); } } /** * A TRAIN HELD AT THE LIMITS BY AN INTERLOCKING (#99). * * It is inside the player's Limits, not on an A/D track, and it takes the first track that frees * ahead of any newcomer. The map now draws it on the Limits square; this says what it is waiting * for, which is the Office emptying rather than anything the held train itself can do. */ for (const id of area.heldAtLimits) { const t = s.trays.get(id); if (!t) continue; out.push({ where: `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber ?? '—'}`, why: 'held at your Limits by the Interlocking instead of colliding — it takes the first A/D ' + 'track that frees, ahead of any train arriving after it', severity: 'waiting', }); } // A full Office means the next arrival is an automatic collision (Gap 2d). const cap = adTrackCount(s, seatOf(s, player)); if (area.adOccupancy.length >= cap) { out.push({ where: 'Office', why: `all ${cap} A/D track(s) occupied — the next arrival COLLIDES`, severity: 'risk', }); } if (s.clock.pendingDecision) { out.push({ where: 'Superintendent', why: 'must rule on a following-train clearance before the Mainline Phase continues', severity: 'waiting', }); } return out; } /** A one-line summary of where every train currently is. */ export function trainPositions(s: GameState): { id: TrayId; label: string; where: string }[] { const out: { id: TrayId; label: string; where: string }[] = []; for (const [id, tray] of s.trays) { const label = tray.trainNumber === null ? 'local crew' : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`; let where: string; switch (tray.position.at) { case 'divisionPoint': where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`; break; case 'mainline': where = `Mainline card ${tray.position.index}`; break; case 'grid': where = `Office Area ${at(tray.position.coord)}`; break; } out.push({ id, label, where }); } return out; } export { coordKey }; /** * What a batch of events should SOUND like. * * Shared between the live game and the replay so the two cannot disagree about when a Stage ended. * The model names what happened; the page decides what it sounds like. */ export function cuesFor(events: readonly GameEvent[]): string[] { const out: string[] = []; for (const e of events) { if (e.type === 'trainMadeUp') out.push('train'); // Coupling and setting out are what a switching move IS, and both were silent. A car leaving the // board with only a line of history to say where it went is the thing that most needs a noise. if (e.type === 'carsCoupled') out.push('couple'); if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop'); // The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed // silently, so the card was gone and the answer to "when does it run?" was a line of history. if (e.type === 'trainScheduled') out.push('schedule'); // A train running the whole length of the Division pays every player, and it is the one event // nobody made happen on the turn it lands. It should be heard, not found in the log. if (e.type === 'trainCompleted') out.push('completed'); // A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not // included — that one goes straight to a work track without the A/D stop `arrive` depicts. if (e.type === 'trainArrived') out.push('arrive'); // Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already // sounds `train` ("All aboard"), and a train clearing the whole Division already sounds // `completed` — this is the one case neither of those covers. if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart'); // §10 — the one event nobody wants to hear and everybody needs to. if (e.type === 'trainsDestroyed') out.push('crash'); if (e.type === 'stageBegan') { // A Stage BEGINNING is the previous one ending — except the first, which is the game opening // and has nothing behind it. A Day boundary rings the bell only: sounding both would collide, // and the bell is the bigger event. if (e.day > 1 && e.stage === 1) out.push('day'); else if (e.day > 1 || e.stage > 1) out.push('stage'); } } return out; }