Expand game engine, replay, tests, and documentation
This commit is contained in:
+100
-14
@@ -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)}`;
|
||||
|
||||
Reference in New Issue
Block a user