"If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or wish to complete switching." REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a stopped train out on the Mainline and protected it from a rear-ender: offered 4,212 times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already does that job better. The flag is now planted on one side of your own district and holds the next train arriving from that side. SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to build, nothing to forget, and a flag cannot quietly strangle the Division. The held train loses one Mainline Phase and comes in on the next — it buys a Stage to clear the lead, which is what "wish to complete switching" asks for. PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would certainly collide and the district's owner holds the card, the phase breaks in and asks. Offered ONLY to somebody holding one — a prompt with a single button is not a choice, and it would leak that a collision is coming. The danger is read from §8.3's own two triggers rather than restated, so the prompt cannot offer a flag against a collision that will not happen. Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and nothing else structural. THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the out-of-phase prompt unconditionally — the engine has already established the danger, so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one game in seven) to coincide with holding the card from a three-card hand out of 121. So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the bot is unwilling: it is measuring deck luck. What is left to fix is the half of the card a human would use, planting a flag on purpose to buy switching time, and TODO.md now says that instead of the old finding. A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was originally taken down in a `reduce` case, which never fires for an event advance.ts emits — the phase driver mutates state and then describes it. The flag stayed up and held every train that came. test/events.test.ts's unreduced-event registry is what makes that class of mistake visible, and `redFlagSpent` is on it deliberately now, with the reasoning. 858 tests pass. Closes #19 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
930 lines
41 KiB
TypeScript
930 lines
41 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, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, 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 '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: `Player ${e.player} flagged the approaching train` }
|
|
: { tone: 'plain', text: `Player ${e.player} 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`).
|
|
*/
|
|
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`
|
|
: `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`
|
|
: 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 ');
|
|
return {
|
|
tone: 'bad',
|
|
text:
|
|
`COLLISION — ${wrecked} destroyed: ${why}. 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: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
|
|
|
// -- §3.3, extended play (Gitea#11)
|
|
case 'extensionVoted':
|
|
return e.agree
|
|
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
|
: { tone: 'plain', text: `Player ${e.player} 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: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
|
: { tone: 'plain', text: `Player ${e.player} 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') {
|
|
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.
|
|
*/
|
|
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.
|
|
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;
|
|
}
|