Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+100 -14
View File
@@ -81,7 +81,14 @@ export type Narration = {
* never reached, so adding an event type without narrating it fails the build rather than quietly
* degrading the replay.
*/
export function narrate(e: GameEvent): Narration {
export type NarrateContext = {
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
cardName?: (id: string) => string;
};
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
switch (e.type) {
// -- clock
case 'stageBegan':
@@ -100,7 +107,7 @@ export function narrate(e: GameEvent): Narration {
tone: 'plain',
text:
e.option === 'switch'
? 'Chose to SWITCH — six Moves to shunt cars around the yard'
? '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',
@@ -109,7 +116,7 @@ export function narrate(e: GameEvent): Narration {
return {
tone: 'plain',
where: e.to,
text: `Crew moved ${at(e.from)}${at(e.to)} · ${e.movesRemaining} Moves left`,
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 {
@@ -130,23 +137,70 @@ export function narrate(e: GameEvent): Narration {
tone: 'plain',
text:
e.source === 'homeOffice'
? 'Drew a card from the Home Office deck'
: `Took the face-up card from Department slot ${(e.slot ?? 0) + 1}`,
? `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 a card onto ${at(e.placement)}` : 'Played a card',
text: e.placement
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`,
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from}${e.to}` };
case 'cardDiscarded':
return { tone: 'quiet', text: `Discarded a card face-up to Department slot ${e.toSlot + 1}` };
return {
tone: 'quiet',
text: `Discarded ${card(e.cardId)} face-up to Department slot ${e.toSlot + 1}`,
};
case 'deckReshuffled':
return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
case 'departmentRefilled':
return { tone: 'quiet', text: `Department slot ${e.slot + 1} refilled from the deck` };
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 '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 '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':
return {
tone: 'plain',
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
};
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':
@@ -169,13 +223,19 @@ export function narrate(e: GameEvent): Narration {
};
// -- trains
case 'trainScheduled':
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: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`,
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: `Put a ${carLabel(e.stock)} on the train being made up` };
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 'clearanceRequested':
@@ -192,10 +252,20 @@ export function narrate(e: GameEvent): Narration {
};
// -- 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)}` };
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)}` };
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':
@@ -244,6 +314,22 @@ 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?"
// ---------------------------------------------------------------------------
@@ -353,7 +439,7 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where
where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`;
break;
case 'mainline':
where = `Mainline card ${tray.position.index}, region ${tray.position.region + 1}`;
where = `Mainline card ${tray.position.index}`;
break;
case 'grid':
where = `Office Area ${at(tray.position.coord)}`;