/** * 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 = { 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(); 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(); 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(); 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' ? ' — east–west' : ' — north–south'; 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 = { 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), }; }