Files
station-master/src/sim/narrate.ts
T
Jesse.MarkowitzandClaude Opus 5 b90c0413d2 v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said
"any Mainline card". The engine was never wrong: check accepts any node whose
kind is mainline and legalActions filters by check, so all of them were legal.
The failure was the LABEL — describeIntent named i.placement and never i.node,
so every placement described itself as plain "play ABS Signals", and the action
list drops duplicate labels. All but the lowest-index node were discarded before
the menu saw them. This is the THIRD time that trap has fired and the file
documents the other two three lines apart: a turnout's two rotations, and three
Department discards. Same fix — name what distinguishes them.

The card is also called what the card face calls it. prettyKey rendered
absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no
key-splitter can recover, so the authored names now win. Three of those names
were transcribed in sentence case and were CORRECTED rather than adopted: the
repository says "Yard Office" 36 times against "Yard office" twice. A lookup
that imports its own source's typos is the drift it exists to prevent.

NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no
mark and you found out by hovering — the same complaint the Heavy Grade wedge
answered, and it matters more here because ABS decides whether a second train on
that card is safe. It draws a signal mast with a lit lamp now; a signal is the
literal object and needs no room for words, which is what lets it sit clear of a
name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers
draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce
takes the `became` branch and changes node.card, so a realigned Trestle IS an
Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding.

A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one
line. It asserted an outcome, where §6.3 requires no action and the bot declines
deliberately — unjamming a healthy box destroys a load that cost a whole action
to stock. An idle Agent was then silent, which read as a dropped turn; a new
freightAgentIdled event says so and why, reducing to nothing exactly like
switchingEnded. And the work named a coordinate rather than the industry, though
a `place` helper has existed for precisely that since the switching lines moved
to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House",
with the direction in capitals because to-or-from was the question asked.

THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote
(col,row) — X,Y, east/west then north/south — with a comment saying why;
narrate.ts wrote the internal storage order with no comment at all. So the menu
offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side.
Pinned by a test that renders one square through BOTH describers and compares
them to each other: a test written against either file alone would have passed.

THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED.
v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five
documents by relative path and every one 404'd on the package, verified against
the running container. The build publishes the full set, and the test reads the
links OUT OF the guide rather than listing them. They are linked from the This
Game card, where reference already lives, rather than the header that must not
wrap; no mode awareness is needed, because solitaire and multiplayer are the
same page on the same origin.

THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16
and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they
were first written against, kept only because 36 citations pointed at it — and
it read as documentation five minor versions stale. They are quickstart.md,
rules.md, home-deck.md, mainline-deck.md and components.md now, kept current
with each release rather than published as editions. Two errors surfaced while
checking them against this release, which is the argument for doing it:
home-deck.md filed ABS Signals under Enhancements "played into your district"
that "change what a square does" — it does neither, this release's bug written
down — and mainline-deck.md, which lists everything playable onto a Mainline
card, never mentioned it at all.

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 05:53:52 -04:00

1238 lines
58 KiB
TypeScript

/**
* 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<typeof t> => !!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;
}