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

1031 lines
44 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,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, 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;
/**
* The port pairs this card joins, as two-letter codes — 'ew' for the through track, and 'ne',
* 'nw', 'se' or 'sw' for a 45° leg. There is no 'ns': no card joins north to south. Taken from
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
* not have.
*/
links: string[];
/**
* What this card DOES, now that it is on the board.
*
* A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
* "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
* the moment it starts mattering.
*/
what: string;
};
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[];
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
* Moves rather than by Stages, so it occupies a card outright.
*/
region?: number;
direction?: string;
};
/**
* One card of a player's Running Track, as the Division sees it.
*
* The through route between the Limits IS the Running Track — everything hanging beneath it is
* secondary, and a train crossing the Division never touches it. So the Division map expands an
* Office into this row and leaves sidings, industries and the load pipeline to the district view.
*/
export type RunningCardView = {
row: number;
col: number;
kind: string;
label: string;
/** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */
links: string[];
/** Trains standing ON this card of the Running Track. */
trains: TrainChip[];
};
export type DivisionView = {
kind: string;
label: string;
trains: TrainChip[][];
/**
* How many trains may stand here, or null for no limit. 1 on most Mainline cards, 2 where the
* card prints "trains may pass", the A/D count at an Office, unlimited at a Division Point.
*/
capacity: number | null;
/** 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;
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
regions?: number;
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[];
/** Office nodes only: whose district this is. */
owner?: number;
/**
* Office nodes only: crews working BELOW the Running Track.
*
* A crew down a siding has no position on the through route — projecting one would be a lie — so
* it is reported against the district as a whole and drawn exactly where it is in the zoomed view.
*/
switching?: TrainChip[];
};
export type Frame = {
day: number;
stage: number;
clock: string;
phase: string;
/** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */
phaseKey: string;
/**
* Sounds this frame earned — a Stage ending, a Day turning, a train being built. Filled by the
* replay recorder, which sees the events; the live game keeps its own on the Game object.
*/
cues?: 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[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
handWhat: string[];
deck: number;
/** The face-up card on top of each Department pile — the only one that may be drawn. */
departments: string[];
/** What each face-up Department card does. */
departmentsWhat: string[];
/**
* How deep each Department pile is.
*
* A discard goes on TOP, so a deep pile is a card a rival buried and a shallow one is a card
* freshly offered. Without the depth the board cannot say which, and choosing where to discard is
* the whole of the decision.
*/
departmentDepth: number[];
/**
* The Salvage Yard — face up (§2.6), so its top card and its depth are both public.
*
* It is where a played card goes when it does not stay on the board, and §6.2 sweeps it back into
* the Home Office deck when that runs out. Watching it fill is watching the reshuffle approach,
* which is the only warning a player gets that the deck is about to turn over.
*/
salvage: { top: string; depth: number };
/**
* The two yards, by car type.
*
* Rolling stock is finite and the Classification Yard only returns to service when the Division
* Yard is BARE, so watching the Division Yard run down is now real information — and the game
* showed neither yard at all.
*/
yards: {
division: { type: string; loaded: number; empty: number }[];
classification: { type: string; loaded: number; empty: number }[];
divisionTotal: number;
classificationTotal: number;
};
/** 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 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] ?? prettyKey(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': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
*
* All three discards described identically as "discard X", and the action list drops
* duplicate labels — so the three choices collapsed into one button and the player could not
* pick a Department at all. The choice IS the strategy: onto an empty-ish pile the card is an
* offer a rival may take, and on top of a card a rival wants it puts that card out of reach.
*/
const pile = s.decks.departments[i.toSlot] ?? [];
const top = pile[pile.length - 1];
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
}
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':
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance': {
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
// that granting it risks a rear-ender, or that refusing merely costs time.
const pending = s.clock.pendingDecision;
const who = pending ? trainName(s, pending.train) : 'the train';
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
// currently free. Saying otherwise invents a consequence the engine will never deliver.
// See implications.md §10 Q13.
return i.allow
? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
}
case 'draw.fromDepartment': {
// Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing,
// and the choice between a visible card and a blind draw is unmakeable without it.
const pile = s.decks.departments[i.slot] ?? [];
const id = pile[pile.length - 1];
if (!id) return `Department ${i.slot + 1} (empty)`;
const under = pile.length - 1;
const buried = under === 0 ? '' : `, ${under} buried beneath it`;
return `take ${cardName(s, id)} from Department ${i.slot + 1}${buried}`;
}
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end':
return 'End Local Operations';
case 'switch.end':
return 'End Local Operations';
case 'loadUnload.end':
return 'End my Cargo 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 `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
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';
// Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
// guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
// as the fallback put `refinery` and `viscosityBreakers` on the board.
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(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,
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
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))],
capacity: null,
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';
/**
* WHERE ON THE CARD, from what the crossing already cost.
*
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
* point as the thing that varies, exactly as the cards do:
*
* entry = REGIONS - stagesTotal position = entry + elapsed
*
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
* what "Start positions further along the card" means on the printed art. A 30 card is two
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
* card's distance is fixed and the train is simply slow across it.
*/
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
// two regions, so it notionally starts before the card and spends the extra Stage getting
// to the first region. Clamping only the final position keeps that Stage at the START,
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
const elapsed = t.stagesTotal - t.stagesRemaining;
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
};
return {
kind: 'ml',
label: name,
regions: REGIONS_PER_MAINLINE_CARD,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return {
...chip,
label: `${chip.label} (${t.stagesRemaining})`,
region: place(t),
direction: t.direction,
};
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
modifiers: [
...(n.modifiers ?? []).map(prettyKey),
...(n.absSignals ? ['ABS Signals'] : []),
],
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
const oa = areaOf(s, n.owner);
// Where every crew in this district actually is: on a Running Track card, or below it.
const onRunning = new Map<string, TrainChip[]>();
const below: TrainChip[] = [];
for (const [id, tray] of s.trays) {
const pos = tray.position;
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
const c = trainChip(s, id);
if (pos.coord.row === oa.runningRow) {
const k = `${pos.coord.row},${pos.coord.col}`;
onRunning.set(k, [...(onRunning.get(k) ?? []), c]);
} else {
below.push(c);
}
}
// Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the
// signs are the ends of the through route rather than obstacles on it.
const running: RunningCardView[] = [];
for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) {
const card = oa.grid.get(`${oa.runningRow},${col}`);
if (!card) continue;
const g = card.geometry;
let label: string;
if (g.kind === 'office') label = officeProfile(oa.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
running.push({
row: oa.runningRow,
col,
kind: g.kind,
label,
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
trains: onRunning.get(`${oa.runningRow},${col}`) ?? [],
});
}
return {
kind: 'office',
label: officeProfile(oa.tier).name,
trains: [oa.adOccupancy.map((id) => trainChip(s, id))],
capacity: officeProfile(oa.tier).adTracks,
modifiers: [],
gradeUp: null,
owner: n.owner,
running,
switching: below,
};
});
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
phaseKey: 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((pile) => {
const top = pile[pile.length - 1];
return top ? cardName(s, top) : '—';
}),
departmentsWhat: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
return top ? cardDescription(s, top) : '';
}),
departmentDepth: s.decks.departments.map((pile) => pile.length),
salvage: {
top: s.decks.salvageYard.length
? cardName(s, s.decks.salvageYard[s.decks.salvageYard.length - 1]!)
: '—',
depth: s.decks.salvageYard.length,
},
yards: {
division: countStock(s.yards.divisionYard),
classification: countStock(s.yards.classificationYard),
divisionTotal: s.yards.divisionYard.length,
classificationTotal: s.yards.classificationYard.length,
},
timetable: [...s.timetable],
decision,
wasted,
objective: objectiveOf(s),
runningRow: area.runningRow,
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':
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
// in the name: "curve" alone does not tell you what it can be joined to.
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
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);
// Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
// Office is a Depot. Without saying so, the card looks playable and simply never is.
const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
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' : '') +
prereq
);
}
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 ')}`;
}
case 'track': {
// Track is the largest category in the deck, so a player holds it constantly — and what it
// can be joined to is decided by the hand, which is not something the name alone conveys.
const cost = k.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
const stop = k.geometry === 'turnout' ? ' · a train may pass through but not stop on it' : '';
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
}
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}` : '';
}
}
}
/** The train riding a Crew Tray, for anything that has to talk about it. */
export function trainName(s: GameState, trayId: string): string {
const tray = s.trays.get(trayId);
if (!tray) return trayId;
if (tray.trainNumber === null) return 'the local crew';
return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
}
/** What a card on the board does, in one short line. */
function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string {
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
case 'modifier': {
const m = modifierProfile(g.modifier);
const adds: string[] = [];
if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
}
case 'spaceUse':
return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
case 'facility': {
const f = card.facility;
if (!f) return 'a facility';
if (f.kind === 'passenger') return 'passengers board and detrain here';
// Say where the work has actually got to — the squares on the card show it, this names it.
const inWork = f.menAtWork.findIndex((l) => l !== null);
const progress =
inWork >= 0
? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
inWork === f.menAtWork.length - 1
? 'one more Laborer action and it goes onto a spotted car'
: 'each Laborer action moves it one square right'
}`
: f.outboundBox.length > 0
? ' · a load waits in the green box for a Laborer to start it'
: '';
const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
// An industry can no longer BE on the Running Track — the sheet puts every one of them on a
// straight stub off it — so this reads as a leftover only if one is somehow there.
const where = onRunning
? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival'
: '';
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
}
case 'track':
switch (g.geometry) {
case 'straight':
return 'through track, east–west';
case 'curved':
case 'sharpCurved': {
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
}
case 'turnout': {
const t = g.turnout;
if (!t) return 'turnout';
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
return (
`turnout — ${turnoutPhrase(t)} · a train coming the other way, from the ` +
`${compass(t.through)} or the ${compass(t.diverge)}, may only leave by the ` +
`${compass(t.stem)} — the two roads never join` +
`${slopePhrase(t.stem as Port, t.diverge as Port)}`
);
}
}
}
}
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
/**
* WHAT A TURNOUT DOES, in the words a player would use at the table.
*
* "Right-hand turnout, stem east, through west, diverges north" is three pieces of jargon and a
* compass reading, and none of it answers the only question being asked: if my train comes in from
* over there, where can it go? §A.1's rule falls straight out of the same sentence — traffic from
* the stem may take either road, and traffic arriving on either road may only leave by the stem, so
* the two roads never join.
*/
function turnoutPhrase(t: TurnoutOrientation): string {
return `allows traffic from the ${compass(t.stem)} to travel ${compass(t.through)} or turn to the ${compass(t.diverge)}`;
}
/** The same, for a curve: it has one road and no choice to make. */
function curvePhrase(arc: string): string {
const [a, b] = [arc[0] as Port, arc[1] as Port];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
return `carries traffic from the ${compass(side!)} round to the ${compass(leg!)}`;
}
/**
* The matching rule, said out loud. Which diagonal a leg sits on decides what may sit above or
* below it, and that is not something a player can read off the card's shape at a glance.
*/
function slopePhrase(a: Port, b: Port): string {
const slope = slopeOfPair(a, b);
if (!slope) return '';
const mate = slope === 'ne_sw' ? 'north–east / south–west' : 'north–west / south–east';
return ` · its 45° leg lies on the ${mate} diagonal, and only meets a card whose leg lies on the same one`;
}
/** 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. There are only two: a printed card turns 180° but
* never flips, so `hand` and not the rotation decides which diagonal the 45° leg lies on.
*/
export function variantLabel(
geometry: TrackGeometry,
variant: number | undefined,
hand: Hand = 'none',
): string {
const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return '';
if (v.turnout) return ` — ${turnoutPhrase(v.turnout)}`;
if (v.arc) return ` — ${curvePhrase(v.arc)}`;
return ' — straight through, east to west';
}
/**
* 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. */
/** Cars in a yard, grouped by type and split loaded / empty, in a stable order. */
function countStock(
stock: readonly { type: string; loaded: boolean }[],
): { type: string; loaded: number; empty: number }[] {
const order = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
const by = new Map<string, { type: string; loaded: number; empty: number }>();
for (const c of stock) {
const row = by.get(c.type) ?? { type: c.type, loaded: 0, empty: 0 };
if (c.loaded) row.loaded += 1;
else row.empty += 1;
by.set(c.type, row);
}
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be pulling, pushing, or in
* the middle doing both — which is a thing a player has to be able to see, since it decides which
* end cars couple onto (§A.3) and which way the train can shove. It was recorded as a boolean
* that nothing read, so every consist was drawn as an anonymous row of cars.
*/
const cars = t.consist.map(carLabel);
const at = Math.max(0, Math.min(cars.length, t.engineAt));
cars.splice(at, 0, 'ENG');
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: cars,
};
}