Initial commit
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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, 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 {
|
||||
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 'Load / Unload';
|
||||
case 'shiftChange':
|
||||
return 'Shift Change';
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event narration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Narration = {
|
||||
text: string;
|
||||
/** Colours the line in the viewer. */
|
||||
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
|
||||
/** 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 function narrate(e: GameEvent): Narration {
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
case 'stageBegan':
|
||||
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
|
||||
case 'phaseBegan':
|
||||
return { tone: 'quiet', text: `${phaseLabel(e.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'
|
||||
: 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} Moves left`,
|
||||
};
|
||||
case 'carsCoupled':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
||||
};
|
||||
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 a card from the Home Office deck'
|
||||
: `Took the face-up card 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',
|
||||
};
|
||||
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}` };
|
||||
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` };
|
||||
|
||||
// -- 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':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`,
|
||||
};
|
||||
case 'carPlacedOnTrain':
|
||||
return { tone: 'plain', text: `Put a ${carLabel(e.stock)} on the train being made up` };
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
case 'clearanceRequested':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
|
||||
};
|
||||
case 'clearanceGiven':
|
||||
return {
|
||||
tone: e.allow ? 'bad' : 'plain',
|
||||
text: e.allow
|
||||
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
|
||||
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
|
||||
};
|
||||
|
||||
// -- passengers
|
||||
case 'passengersBoarded':
|
||||
return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)}` };
|
||||
case 'passengersDetrained':
|
||||
return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)}` };
|
||||
|
||||
// -- 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';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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}, region ${tray.position.region + 1}`;
|
||||
break;
|
||||
case 'grid':
|
||||
where = `Office Area ${at(tray.position.coord)}`;
|
||||
break;
|
||||
}
|
||||
out.push({ id, label, where });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { coordKey };
|
||||
Reference in New Issue
Block a user