977 lines
41 KiB
TypeScript
977 lines
41 KiB
TypeScript
/**
|
||
* 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 } 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;
|
||
departments: string[];
|
||
/** What each face-up Department card does. */
|
||
departmentsWhat: string[];
|
||
/**
|
||
* 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 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] ?? 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':
|
||
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, i.hand)}`
|
||
);
|
||
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 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 '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((id) => (id ? cardName(s, id) : '—')),
|
||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||
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),
|
||
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)),
|
||
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':
|
||
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);
|
||
// 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 ')}`;
|
||
}
|
||
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';
|
||
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
|
||
// block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
|
||
// between the Limits and the Office a collision when a train arrives.
|
||
const where = onRunning
|
||
? ' · ON THE RUNNING TRACK — trains pass straight through, but 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 `${arcPhrase(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 — stem ${compass(t.stem)}, through ${compass(t.through)}, diverges ${compass(t.diverge)} at 45° · ` +
|
||
`a train may run stem↔through or stem↔diverge, but NEVER between ${compass(t.through)} and ` +
|
||
`${compass(t.diverge)}${slopePhrase(t.stem as Port, t.diverge as Port)}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||
|
||
/**
|
||
* A curve, as the printed card draws it: along the centre line to a frog, then out at 45° through
|
||
* the MIDDLE of an edge. Naming both halves is what stops it reading as a quarter-circle corner.
|
||
*/
|
||
function arcPhrase(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 `curve — runs ${compass(side!)} along the centre line, then leaves at 45° through the middle of the ${compass(leg!)} edge`;
|
||
}
|
||
|
||
/**
|
||
* 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 ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`;
|
||
}
|
||
if (v.arc) {
|
||
const [a, b] = [v.arc[0] as Port, v.arc[1] as Port];
|
||
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
|
||
return ` — ${compass(side!)} to the middle of the ${compass(leg!)} edge`;
|
||
}
|
||
return ' — east–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,
|
||
};
|
||
}
|
||
|