658 lines
26 KiB
TypeScript
658 lines
26 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 { adTrackCount, coordKey } from '../engine/state.ts';
|
|
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
|
|
import { canAdvanceLoad, canStartLoad, facilityCarType, 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}`;
|
|
}
|
|
|
|
export function carLabel(c: RollingStock): 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';
|
|
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
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 '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':
|
|
return {
|
|
tone: 'plain',
|
|
where: e.to,
|
|
text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
|
};
|
|
case 'carsCoupled':
|
|
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'}` +
|
|
`: ${carsLabel(e.stock)}`,
|
|
};
|
|
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':
|
|
return {
|
|
tone: 'plain',
|
|
where: e.at,
|
|
text: `Dropped ${carsLabel(e.stock)} at ${at(e.at)}`,
|
|
};
|
|
|
|
// -- 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)`,
|
|
};
|
|
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':
|
|
return {
|
|
tone: 'plain',
|
|
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
|
|
};
|
|
case 'trainArrived':
|
|
/**
|
|
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND.
|
|
*
|
|
* This said "it will highball again next Mainline Phase, so any work must happen now" to every
|
|
* arrival. For an expedited train that is exactly backwards — Q3 departs it in the SAME Stage
|
|
* it arrived, so there is no turn in which to work it — and the log then contradicted itself
|
|
* two lines later when the train left. Reported after Train 4, the Express, did precisely that.
|
|
*/
|
|
return {
|
|
tone: 'plain',
|
|
text: e.expedited
|
|
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — and EXPEDITES, so it leaves again this same Mainline Phase; there is no turn in which to work it`
|
|
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands until the next Mainline Phase, so any work must happen before then`,
|
|
};
|
|
case 'trainDiverted':
|
|
return {
|
|
tone: 'good',
|
|
text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`,
|
|
};
|
|
case 'trainCompleted':
|
|
return {
|
|
tone: 'quiet',
|
|
text:
|
|
`Train ${e.trainNumber} finished its run and left the Division carrying ` +
|
|
`${carsLabel(e.consist)} — 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 = 0): Impediment[] {
|
|
const out: Impediment[] = [];
|
|
const area = s.officeAreas.get(player);
|
|
if (!area) return out;
|
|
|
|
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; 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) {
|
|
out.push({
|
|
where: `${name} ${key}`,
|
|
why: 'green box has a load but MEN is occupied',
|
|
severity: 'waiting',
|
|
});
|
|
}
|
|
|
|
if (f.allows.outbound && f.outboundBox.length === 0) {
|
|
out.push({
|
|
where: `${name} ${key}`,
|
|
why: 'green box empty — nothing to load (needs a Freight Agent action)',
|
|
severity: 'waiting',
|
|
});
|
|
}
|
|
|
|
if (f.industryTrack.cars.length >= f.industryTrack.length) {
|
|
out.push({
|
|
where: `${name} ${key}`,
|
|
why: `industry track full (${f.industryTrack.length} 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.
|
|
*/
|
|
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
|
|
for (const [id, tray] of s.trays) {
|
|
if (tray.position.at !== 'grid' || tray.position.owner !== 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.row},${b.coord.col})`,
|
|
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 GETTING OFF.
|
|
*
|
|
* Reported: "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.
|
|
*
|
|
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a
|
|
* requirement the engine only started enforcing recently and which no panel mentions at all.
|
|
*/
|
|
const office = area.grid.get(coordKey(area.officeCoord));
|
|
const pf = office?.facility;
|
|
if (pf && pf.kind === 'passenger') {
|
|
const waiting = area.adOccupancy
|
|
.map((id) => s.trays.get(id))
|
|
.filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded));
|
|
if (waiting.length > 0) {
|
|
const reasons: string[] = [];
|
|
if (portersLeft(pf) < 1) {
|
|
reasons.push(
|
|
`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)',
|
|
);
|
|
}
|
|
if (pf.inboundBox.length >= pf.capacity.inbound) {
|
|
reasons.push(
|
|
`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 (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
|
|
reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
|
}
|
|
for (const why of reasons) {
|
|
out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, 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');
|
|
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;
|
|
}
|