Files
station-master/src/sim/view.ts
T

639 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The view-model: what a Station Master position LOOKS like.
*
* Split out of `replay.ts` so the playable browser build can use it. `replay.ts` writes files and
* reads `process.argv`, so importing it pulled `node:fs` into the bundle — and the engine's whole
* claim to run client-side rests on nothing in the import graph needing Node.
*
* Nothing here decides anything. It turns a `GameState` into boxes and labels, and turns an
* `Intent` into a sentence. The live game and the replay both render from this, so they cannot
* drift into two different pictures of the same board.
*/
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
SPACE_USE_CARDS,
industryProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
col: number;
kind: string;
label: string;
running: boolean;
/**
* Enhancements laid ON this card. They were invisible: playing an Overpass onto the Office
* announced itself in the log and then changed nothing on the board, so a permanent change to how
* the district works left no trace you could see.
*/
enhancements: string[];
tray: string | null;
cars: string[];
facility: FacilityView | null;
};
export type FacilityView = {
name: string;
commodity: string;
flow: string;
green: string[];
greenCap: number;
maw: (string | null)[];
red: string[];
redCap: number;
track: string[];
trackCap: number;
laborers: string;
porters: string;
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
* that held freight to a 3% completion rate.
*/
canFinish: boolean;
/** A load is sitting on WORK with no spotted car to receive it. */
jammed: boolean;
};
export type TrainChip = { label: string; consist: string[] };
export type DivisionView = {
kind: string;
label: string;
trains: TrainChip[][];
/** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
modifiers: string[];
/**
* Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the
* direction is a property of the placed card — and without showing it, a Brakeman or Helpers card
* on that grade has no visible meaning.
*/
gradeUp: string | null;
};
export type Frame = {
day: number;
stage: number;
clock: string;
phase: string;
actor: number | null;
superintendent: number;
revenue: number;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
whereFrom: { row: number; col: number } | null;
division: DivisionView[];
cells: CellView[];
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
handWhat: string[];
deck: number;
departments: string[];
/** What each face-up Department card does. */
departmentsWhat: string[];
/** 12 slots; the train number due out at each Stage, or null. */
timetable: (number | null)[];
blocked: Impediment[];
trains: { label: string; where: string }[];
/**
* Where you stand against the target. Nothing on screen said what the game was FOR, so a player
* had the score but no way to know whether it was good.
*/
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
/**
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
* got left" is a real planning question the board could not answer.
*/
trackSupply: { piece: string; left: number }[];
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
decision: Decision | null;
/** A Local Operations turn that changed nothing — the frames worth your attention. */
wasted: boolean;
};
/**
* The choice behind a frame.
*
* The whole point is `rejected`: the replay could always show what happened, never what COULD have
* happened, so a daft move was visible but the alternatives it passed over were not — which is
* exactly what you need to say what it should have done instead.
*/
export type Decision = {
actor: number;
chose: string;
why: string;
/** Every legal option not taken, grouped by kind with a count. */
rejected: { kind: string; count: number; detail: string }[];
totalOptions: number;
};
// ---------------------------------------------------------------------------
// Snapshotting
// ---------------------------------------------------------------------------
const FACILITY_NAMES: Record<string, string> = {
mineTipple: 'Mine Tipple',
produceShed: 'Produce Shed',
grocersWarehouse: "Grocer's Warehouse",
oilRefinery: 'Oil Refinery',
powerPlant: 'Power Plant',
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
// Passenger facilities were excluded entirely, so the Office's green and red slots never
// appeared — which is why stocking a coach into the green box looked like nothing happening.
if (!f) return null;
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
commodity: facilityCarType(f) ?? '?',
flow: f.kind === 'passenger'
? 'passengers on and off'
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
green: f.outboundBox.map(carLabel),
greenCap: f.capacity.outbound,
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
trackCap: f.industryTrack.length,
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
};
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
if (!pending) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
}
/**
* Summarise the choice: what was taken, why, and what was passed over.
*
* Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
* long hides the shape of the decision rather than showing it.
*/
export function describeDecision(
s: GameState,
actor: number,
chosen: Intent,
options: Intent[],
why: string,
): Decision {
const groups = new Map<string, Intent[]>();
for (const o of options) {
if (o === chosen) continue;
const list = groups.get(o.type) ?? [];
list.push(o);
groups.set(o.type, list);
}
const rejected = [...groups.entries()]
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
.sort((a, b) => b.count - a.count);
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
}
/** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
// actual range of choices — the opposite of what this panel is for.
const seen = new Set<string>();
for (const i of list) seen.add(describeIntent(s, i));
const unique = [...seen];
const shown = unique.slice(0, 4);
const more = unique.length - shown.length;
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}
/** One readable line for a single intent. */
export function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
switch (i.type) {
case 'localOps.choose':
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
// each option actually spends and buys.
return i.option === 'switch'
? 'SWITCH — six Moves to shunt cars: spot empties at industries, collect loads'
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.discard':
return `discard ${cardName(s, i.cardId)}`;
case 'track.lay':
return (
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant)}`
);
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
return `stock a ${i.carType} at ${at(i.at)}`;
case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound':
return `clear red box at ${at(i.at)}`;
case 'laborer.startLoad':
return `start a load at ${at(i.at)}`;
case 'laborer.advanceLoad':
return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
case 'porter.board':
return `board passengers at ${at(i.at)}`;
case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`;
case 'newTrain.placeCar':
return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags':
return `Red Flags on ${i.trayId}`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance':
return i.allow ? 'grant clearance' : 'refuse clearance';
case 'draw.fromDepartment': {
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
// the choice between a visible card and a blind draw is unmakeable without it.
const id = s.decks.departments[i.slot];
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
}
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end':
return 'done drawing — end my turn';
case 'switch.end':
return 'done switching — end my turn';
case 'loadUnload.end':
return 'done working — end the Load/Unload phase';
case 'newTrain.passCar':
return 'add no more cars to this train';
case 'newTrain.secondSection':
return `run a Second Section behind Train ${i.trainNumber}`;
case 'redFlag.play':
return 'play your red flag';
case 'maneuver.redFlags':
return `Red Flags on ${i.trayId}`;
default: {
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
// labels its buttons from this function, so a missing case ships as a button reading
// "maneuver.poling".
const unhandled: never = i;
return String((unhandled as { type: string }).type);
}
}
}
/**
* Build the view-model for a state. Shared with the playable web app so the live game and the
* replay cannot drift into two different pictures of the same board.
*/
export function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
): Frame {
const area = areaOf(s, 0);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
}
}
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
for (const [key, card] of area.grid) {
const [row, col] = key.split(',').map(Number);
const g = card.geometry;
const kind = g.kind;
let label: string;
if (g.kind === 'office') label = officeProfile(area.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
const fv = facilityView(card as never, officeProfile(area.tier).name);
if (fv) facilities.push(fv);
cells.push({
row: row!,
col: col!,
kind,
label,
running: row === area.runningRow,
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
}
const division: DivisionView[] = s.division.nodes.map((n) => {
if (n.kind === 'divisionPoint') {
return {
kind: 'dp',
label: n.side === 'west' ? 'West DP' : 'East DP',
trains: [n.holding.map((id) => trainChip(s, id))],
modifiers: [],
gradeUp: null,
};
}
if (n.kind === 'mainline') {
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
// with how long each still has to run.
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
return {
kind: 'ml',
label: name,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
})],
modifiers: [
...(n.modifiers ?? []).map(prettyKey),
...(n.absSignals ? ['ABS Signals'] : []),
],
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
modifiers: [],
gradeUp: null,
};
});
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
lines,
where,
whereFrom,
division,
cells,
facilities,
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
timetable: [...s.timetable],
decision,
wasted,
objective: objectiveOf(s),
trackSupply: [...area.trackSupply.entries()]
.map(([key, left]) => {
const [geometry, hand] = key.split(':');
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
})
.sort((a, b) => a.piece.localeCompare(b.piece)),
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
t.position.at === 'divisionPoint'
? `${t.position.side} Division Point`
: t.position.at === 'mainline'
? `Mainline card ${t.position.index}`
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
})),
};
}
/** A card id turned into something a person can read. */
export function cardName(s: GameState, id: string): string {
const k = s.cards.get(id)?.kind;
if (!k) return 'a card';
switch (k.kind) {
case 'timetabledTrain':
return `Train ${k.number}`;
case 'extraTrain':
return `Extra X${k.number}`;
case 'office':
return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
// Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a
// button a player is meant to read. The lookup tables are for names prettyKey cannot guess
// (Grocer's Warehouse), not the only source of a readable label.
case 'freightFacility':
return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility);
case 'modifier':
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
case 'track':
return `${geometryLabel(k.geometry)} track`;
case 'spaceUse':
case 'enhancement':
case 'mainlineModifier':
case 'maneuver':
case 'action':
return prettyKey(k.key);
}
}
/**
* What a card actually DOES, in one line.
*
* The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing
* about the effect — the difference between playing the game and clicking hopefully. Every fact
* here already existed in the content tables; none of it was reaching the screen.
*/
export function cardDescription(s: GameState, id: string): string {
const k = s.cards.get(id)?.kind;
if (!k) return '';
switch (k.kind) {
case 'timetabledTrain':
case 'extraTrain': {
const t = trainProfile(k.number, k.kind === 'extraTrain');
if (!t) return '';
const parts: string[] = [];
if (t.consist.freight > 0) {
const types = t.consist.freightTypes;
parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`);
}
if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`);
if (t.consist.caboose > 0) parts.push('caboose');
const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled';
// Several Extras leave the direction to the player, so "playerChoicebound" is not a word.
const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`;
const rule = t.rules.note ? ` · ${t.rules.note}` : '';
return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`;
}
case 'office': {
const p = officeProfile(k.tier);
return (
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
(p.isControlPoint ? ' · a Control Point' : '')
);
}
case 'freightFacility': {
const f = industryProfile(k.facility);
const flow = f.flow === 'both' ? 'ships out AND receives' : f.flow === 'outbound' ? 'ships out' : 'receives';
const lock = f.lockouts.length
? ` · cannot share a district with ${f.lockouts.map(facilityLabel).join(', ')}`
: '';
return `${flow} ${f.carTypes.join('/')} · ${f.baseLoaders} laborer${f.baseLoaders === 1 ? '' : 's'}${lock}`;
}
case 'modifier': {
const m = modifierProfile(k.modifier);
const adds: string[] = [];
if (m.addOut) adds.push(`+${m.addOut} out`);
if (m.addIn) adds.push(`+${m.addIn} in`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
return card ? `${card.effect} · played on ${card.placement}` : '';
}
}
}
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
function facilityLabel(key: string): string {
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
}
/** Every card category that carries a written effect rather than a profile. */
const SIMPLE_CARDS = [
...ENHANCEMENT_CARDS,
...MAINLINE_MODIFIER_CARDS,
...MANEUVER_CARDS,
...SPACE_USE_CARDS,
...ACTION_CARDS,
];
/** The goal, and whether the current score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const revenue = s.players[0]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
}
/**
* Which way a piece will point, in words.
*
* "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
* decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
* one rotation while the engine lays another.
*/
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
const v = variantsFor(geometry)[variant ?? 0];
if (!v) return '';
if (v.axis) return v.axis === 'ew' ? ' — eastwest' : ' — northsouth';
if (v.turnout) {
const dir = (p: string): string =>
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
}
return '';
}
/**
* A track shape in words. One place, because it is written on the board, in the action buttons and
* in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
*/
export function geometryLabel(geometry: string): string {
switch (geometry) {
case 'sharpCurved':
return 'sharp curve';
case 'curved':
return 'curve';
case 'turnout':
return 'turnout';
case 'straight':
return 'straight';
default:
return prettyKey(geometry);
}
}
/** camelCase key → readable name, for the card categories that carry only a key. */
function prettyKey(key: string): string {
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
}
const MODIFIER_NAMES: Record<string, string> = {
teamTrack: 'Team Track',
loadingDock: 'Loading Dock',
storageShed: 'Storage Shed',
extraPlatform: 'Extra Platform',
sectionGang: 'Section Gang',
};
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: t.consist.map(carLabel),
};
}