Files
station-master/src/sim/narrate.ts
T
Jesse.Markowitz 7804756f11 v0.6.2 — an Extra starts where you put it, a train card is never discarded
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found
underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in
TODO.md under Play Balance.

GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered,
chosen by number parity. The number no longer decides an Extra's direction — the start does, which
supersedes the recorded ruling that "the number decides, like everything else on the timetable".
The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division
on its first move having crossed nothing, and be paid for the run. Either end now runs the train
away from itself; at an Interchange or a Control Point the player picks the direction. The
Interchange start is a YARD, off the running line, which is what makes the Superintendent clause
work: placing it can never force a collision, a guaranteed one holds it there for another Stage,
and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and
`ask`, so nothing new decides collisions. Where an Extra may start is now a house rule
(divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The
legacy `atSeat` intent field still replays as it always meant.

FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested
position alone, so the Control Point start has been shipping since it was added with a train that
could never be given a consist. Found by playing it, not by the tests, which had only asserted
where the tray landed.

FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit
entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision
permanently poisoned that Mainline card for every later train.

THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a
Division could hold two Interchanges and Plains carried the weight of a card printed once. "An
Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds
at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This
re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are
retired too — two of those were already dead before this release and nobody had noticed.

GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no
mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the
turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot
needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The
player is told on the card and on the button.

GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards,
so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts
and the Home Deck reference carry what the game plays.

CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an
office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never
existed. Every Enhancement row cited its implementation by line number and every citation had
rotted; they name functions now. Card counts came out of the comments, since they move with play
balance; source-sheet figures and dated measurements stayed.

TODO.md gains an item for a card reference generated from content.ts, in six sections, so the
documentation cannot disagree with the game.

715 tests pass, tsc clean, site builds.
2026-08-22 19:51:40 -04:00

795 lines
35 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 { MAX_CONSIST } 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, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.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;
}
export function carsLabel(cars: RollingStock[]): string {
if (cars.length === 0) return 'nothing';
return cars.map(carLabel).join(', ');
}
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
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;
};
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);
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 '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' : `Player ${e.player} 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'
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
};
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,
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
};
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[] = [];
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
return {
tone: 'plain',
where: e.at,
text:
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
` — ${parts.join(', and ')}`,
};
}
case 'consistSorted':
return {
tone: 'good',
where: e.at,
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
};
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 ${at(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':
return {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
: `Played ${e.key} on Mainline card ${e.node}`,
};
case 'redFlagsSet':
return {
tone: 'good',
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
};
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 (§8.1)`,
};
/**
* 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`).
*/
return {
tone: 'plain',
text: e.expedited
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, 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
case 'stockToOutbound':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
};
case 'inboundCleared':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
};
case 'facilityUnjammed':
return {
tone: 'bad',
where: e.at,
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
};
// -- 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 (§7)`
: `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':
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
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 (§8.3)`
: e.reason === 'cars fouling the Running Track'
? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)`
: e.reason;
const wrecked = e.trains
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
.join(' and ');
return {
tone: 'bad',
text:
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
`Yard, all other cars to the Classification Yard (§10). 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 (§8.1): ${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 (§9.2)`,
};
case 'passengersDetrained':
return {
tone: 'good',
where: e.at,
text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
};
// -- 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: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
}
}
/** 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.
*/
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 || f.kind !== 'freight') continue;
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
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 (§9.3)',
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 (§9.3)',
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.
*/
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 (§9) 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 (§9.2 requires one)');
}
}
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
out.push({
where: `Train ${due}`,
why: 'due to depart but HELD — no free Crew Tray',
severity: 'stuck',
});
}
// 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;
}