/** * End-of-game statistics. * * Dev-side. Compiles a readable account of how a game actually went, from the event log. Every event * the game emits is in there, so nothing needs instrumenting in the engine to produce any of this — * which is a claim about COVERAGE, not about reducibility: the log narrates the game completely and * reconstructs it not at all (`protocol.md` §3). * * Three purposes, in ascending order of usefulness: * * 1. It is interesting to see what happens in a game. * 2. It may reveal strategies — whether freight specialists outscore passenger specialists, say. * 3. **It exposes bugs.** Every serious bug found so far was found by noticing a number that was * wrong or absent, not by a failing test: facilities that were never worked, cars that were * never spotted, trains that never departed. So `anomalies()` below treats "this never * happened" as a first-class finding rather than something to scroll past. */ import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; import type { GameState } from '../engine/state.ts'; import { areaAtSeat, areaOf, facilityCarTypes } from '../engine/apply.ts'; import { officeProfile } from '../engine/content.ts'; // --------------------------------------------------------------------------- // Per-game summary // --------------------------------------------------------------------------- /** * THE FUNNEL — how far each opportunity got before it stopped. * * The totals above say what happened; this says what ALMOST happened, which is what a change to the * bot has to move. Revenue can rise because a channel started working or because a channel was * abandoned in favour of a cheaper one, and the two look identical in a single number: capping the * trains a bot schedules raises revenue while cutting freight events nearly in half. * * SAMPLED FROM LIVE STATE, not from events, because the interesting gates are conditions rather than * occurrences — "was a green box stocked while a car was spotted" is not a thing that happens, it is * a thing that is true or false at a moment. `playGame` calls the probe once per decision and the * probe dedupes per phase, so these count PHASES, not turns. */ export type Funnel = { /** Trains reaching an Office, and what they were carrying when they got there. */ arrivals: number; arrivalsAtPassengerOffice: number; /** Boarding needs an empty coach aboard (§9.2); detraining needs a loaded one. */ arrivalsWithEmptyCoach: number; arrivalsWithLoadedCoach: number; /** A car of a type some industry in the district actually wants, loaded or empty. */ arrivalsWithWantedCar: number; /** Cargo phases, and how many had each half of what a load needs. */ cargoPhases: number; cargoWithFacility: number; cargoGreenStocked: number; cargoCarSpotted: number; cargoLaborerFree: number; /** All three at ONE industry — the only combination that can actually move a load. */ cargoReady: number; /** * Decisions taken with a train's engine buried mid-consist, and how many of those had a legal way * to dig it out. §8.2 will not let such a train leave, and Rolling Stock may not be set out at the * Office — so the gap between these two numbers is the trap the bot cannot escape from. */ buriedTurns: number; buriedWithDigAvailable: number; }; function emptyFunnel(): Funnel { return { arrivals: 0, arrivalsAtPassengerOffice: 0, arrivalsWithEmptyCoach: 0, arrivalsWithLoadedCoach: 0, arrivalsWithWantedCar: 0, cargoPhases: 0, cargoWithFacility: 0, cargoGreenStocked: 0, cargoCarSpotted: 0, cargoLaborerFree: 0, cargoReady: 0, buriedTurns: 0, buriedWithDigAvailable: 0, }; } /** * A probe to hand to `playGame`: an event hook, a per-decision hook, and the totals they fill. * * One per game. Kept here beside the statistics it produces rather than in the harness, so the * definition of "a Cargo phase that was ready" lives in exactly one place. */ export function makeFunnelProbe(player = 0): { funnel: Funnel; onEvent: (e: GameEvent, s: GameState) => void; onTurn: (s: GameState) => void; } { const funnel = emptyFunnel(); // Phases are sampled once, on the first decision taken in them. let lastPhaseKey = ''; const onEvent = (e: GameEvent, s: GameState): void => { if (e.type !== 'trainArrived') return; funnel.arrivals += 1; const area = areaOf(s, player); if (officeProfile(area.tier).isPassengerFacility) funnel.arrivalsAtPassengerOffice += 1; if (e.consist.some((c) => c.type === 'coach' && !c.loaded)) funnel.arrivalsWithEmptyCoach += 1; if (e.consist.some((c) => c.type === 'coach' && c.loaded)) funnel.arrivalsWithLoadedCoach += 1; for (const card of area.grid.values()) { const f = card.facility; if (!f || f.kind !== 'freight') continue; const want = facilityCarTypes(f); const useful = e.consist.some( (c) => want.includes(c.type) && ((f.allows.outbound && !c.loaded) || (f.allows.inbound && c.loaded)), ); if (useful) { funnel.arrivalsWithWantedCar += 1; break; } } }; const onTurn = (s: GameState): void => { const area = areaOf(s, player); // A buried engine is a per-decision condition: every turn it persists is a turn the train is // stuck, so this counts turns rather than trains. for (const tray of s.trays.values()) { if (tray.position.at !== 'grid' || tray.position.seat !== player) continue; if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue; funnel.buriedTurns += 1; // Cars may never be set out at the Office (§A.4), which is where this almost always happens. const atOffice = tray.position.coord.row === area.officeCoord.row && tray.position.coord.col === area.officeCoord.col; if (!atOffice) funnel.buriedWithDigAvailable += 1; break; } if (s.clock.phase !== 'loadUnload') return; const key = `${s.clock.day}/${s.clock.stage}`; if (key === lastPhaseKey) return; lastPhaseKey = key; funnel.cargoPhases += 1; let facility = false, green = false, car = false, laborer = false, ready = false; for (const card of area.grid.values()) { const f = card.facility; if (!f || f.kind !== 'freight') continue; facility = true; const g = f.outboundBox.length > 0; const c = f.industryTrack.cars.length > 0; const l = f.laborers - f.usedThisStage.laborers > 0; green ||= g; car ||= c; laborer ||= l; ready ||= g && c && l; } if (facility) funnel.cargoWithFacility += 1; if (green) funnel.cargoGreenStocked += 1; if (car) funnel.cargoCarSpotted += 1; if (laborer) funnel.cargoLaborerFree += 1; if (ready) funnel.cargoReady += 1; }; return { funnel, onEvent, onTurn }; } export type RevenueBreakdown = { freightLoad: number; freightUnload: number; passengerBoard: number; passengerDetrain: number; collisionLoss: number; net: number; }; export type GameStats = { seed: number; result: string; reason: string; days: number; turns: number; revenue: RevenueBreakdown; /** Freight's share of gross positive Revenue, 0..1. The strategy signal. */ freightShare: number; trains: { scheduled: number; highballed: number; arrived: number; completed: number; clearanceRequests: number; clearancesAllowed: number; }; development: { finalTier: string; gridSize: number; freightFacilities: number; cardsDrawn: number; cardsPlayed: number; cardsDiscarded: number; officeUpgrades: number; }; actions: { switch: number; draw: number; freightAgent: number; moves: number; drops: number; couples: number; }; workers: { laborerActions: number; porterActions: number }; collisions: number; eventCounts: Record; intentCounts: Record; /** * How far each opportunity got. Optional because it needs a live probe during the game, which a * caller reconstructing statistics from a saved event log cannot supply. */ funnel?: Funnel; }; function inc(map: Record, key: string, by = 1): void { map[key] = (map[key] ?? 0) + by; } export function summarize( seed: number, events: GameEvent[], intents: Intent['type'][], final: GameState, funnel?: Funnel, ): GameStats { const eventCounts: Record = {}; const intentCounts: Record = {}; const rev: RevenueBreakdown = { freightLoad: 0, freightUnload: 0, passengerBoard: 0, passengerDetrain: 0, collisionLoss: 0, net: 0, }; let clearanceRequests = 0; let clearancesAllowed = 0; let highballed = 0; let arrived = 0; let completed = 0; for (const e of events) { inc(eventCounts, e.type); if (e.type === 'revenueChanged') { const reason = e.reason; if (reason.startsWith('collision')) rev.collisionLoss += -e.delta; else if (reason === 'freightLoad') rev.freightLoad += e.delta; // `freightUnload` is its own reason on its own event (apply.ts). It used to be counted from // `unloadBegan` instead — unloads STARTED, not Revenue EARNED — and the two differ by every // unload that never finished. else if (reason === 'freightUnload') rev.freightUnload += e.delta; else if (reason === 'boarding') rev.passengerBoard += e.delta; else if (reason === 'detraining') rev.passengerDetrain += e.delta; } if (e.type === 'clearanceRequested') clearanceRequests++; if (e.type === 'clearanceGiven' && e.allow) clearancesAllowed++; // Train lifecycle now has proper event types; it used to be smuggled through `phaseBegan` // free text, which made the replay unable to say anything useful about it. if (e.type === 'trainHighballed') highballed++; else if (e.type === 'trainArrived') arrived++; else if (e.type === 'trainCompleted') completed++; } for (const i of intents) inc(intentCounts, i); rev.net = final.players.reduce((n, p) => n + p.revenue, 0); /** * FREIGHT IS BOTH HALVES. * * A completed load and a completed unload each earn a point, on two distinct `revenueChanged` * reasons — and this counted only the first, so `freightShare` reported roughly 60% of the * freight actually earned. That is the number the "freight is only 13-18% of gross" finding was * read from, and it drove the Gap 12 industry-density change. * * The comment that stood here claimed "an unload scores through the same event as a load * completion in the reducer", which is not true: `apply.ts` emits `freightUnload` separately. It * was a comment asserting a fact about code a few lines away, and it was wrong. */ const grossFreight = rev.freightLoad + rev.freightUnload; const grossPassenger = rev.passengerBoard + rev.passengerDetrain; const gross = grossFreight + grossPassenger; // Seat 0 explicitly: these are SOLITAIRE summaries, where there is exactly one Office Area and // seat 0 is the only player. `areaAtSeat` rather than `officeAreas.get` so the key's meaning is // stated — a multi-player report would have to sum over seats, and would fail loudly here first. const area = areaAtSeat(final, 0); let freightFacilities = 0; if (area) { for (const card of area.grid.values()) { if (card.facility?.kind === 'freight') freightFacilities++; } } return { seed, result: final.outcome?.result ?? 'unfinished', reason: final.outcome?.reason ?? 'none', days: Math.max(1, final.clock.day - 1), turns: intents.length, revenue: rev, freightShare: gross > 0 ? grossFreight / gross : 0, trains: { scheduled: eventCounts['trainScheduled'] ?? 0, highballed, arrived, completed, clearanceRequests, clearancesAllowed, }, development: { finalTier: area?.tier ?? 'unknown', gridSize: area?.grid.size ?? 0, freightFacilities, cardsDrawn: eventCounts['cardDrawn'] ?? 0, cardsPlayed: eventCounts['cardPlayed'] ?? 0, cardsDiscarded: eventCounts['cardDiscarded'] ?? 0, officeUpgrades: eventCounts['officeUpgraded'] ?? 0, }, actions: { switch: intentCounts['switch.move'] ?? 0, draw: (intentCounts['draw.fromHomeOffice'] ?? 0) + (intentCounts['draw.fromDepartment'] ?? 0), freightAgent: (intentCounts['freightAgent.stockOutbound'] ?? 0) + (intentCounts['freightAgent.clearInbound'] ?? 0) + (intentCounts['freightAgent.unjam'] ?? 0), moves: eventCounts['trayMoved'] ?? 0, drops: eventCounts['carsDropped'] ?? 0, couples: eventCounts['carsCoupled'] ?? 0, }, workers: { laborerActions: (eventCounts['loadAdvanced'] ?? 0) + (eventCounts['loadCompleted'] ?? 0) + (eventCounts['unloadBegan'] ?? 0), porterActions: (eventCounts['passengersBoarded'] ?? 0) + (eventCounts['passengersDetrained'] ?? 0), }, collisions: events.filter( (e) => e.type === 'revenueChanged' && e.reason.startsWith('collision'), ).length, eventCounts, intentCounts, ...(funnel ? { funnel } : {}), }; } // --------------------------------------------------------------------------- // Anomaly detection — purpose 3 // --------------------------------------------------------------------------- /** * Everything the engine can emit or accept. A member of these lists that NEVER fires across a whole * run is the signal this module exists for: it means a rule is unreachable, and unreachable rules * are where the bugs have been. */ const EXPECTED_EVENTS = [ 'trainScheduled', 'extraQueued', 'trainMadeUp', 'trainHighballed', 'trainArrived', 'trainCompleted', 'carPlacedOnTrain', 'trayMoved', 'carsCoupled', 'mainlineModified', 'redFlagsSet', 'flyingSwitch', 'carsDropped', 'cardDrawn', 'cardPlayed', 'cardDiscarded', 'officeUpgraded', 'stockToOutbound', 'inboundCleared', 'loadAdvanced', 'loadCompleted', 'unloadBegan', 'passengersBoarded', 'passengersDetrained', 'revenueChanged', 'clearanceRequested', 'clearanceGiven', ] as const; const EXPECTED_INTENTS = [ 'localOps.choose', 'switch.move', 'switch.dropCars', 'draw.fromHomeOffice', 'draw.fromDepartment', 'card.play', 'card.discard', 'freightAgent.stockOutbound', 'freightAgent.clearInbound', 'newTrain.placeCar', 'porter.board', 'porter.detrain', 'laborer.advanceLoad', 'laborer.beginUnload', 'mainline.clearance', ] as const; export type Anomaly = { severity: 'never' | 'rare' | 'odd'; what: string; detail: string }; export function anomalies(all: GameStats[]): Anomaly[] { const out: Anomaly[] = []; if (all.length === 0) return out; const totalEvents: Record = {}; const totalIntents: Record = {}; for (const g of all) { for (const [k, v] of Object.entries(g.eventCounts)) inc(totalEvents, k, v); for (const [k, v] of Object.entries(g.intentCounts)) inc(totalIntents, k, v); } for (const e of EXPECTED_EVENTS) { const n = totalEvents[e] ?? 0; if (n === 0) { out.push({ severity: 'never', what: `event ${e}`, detail: `never fired in ${all.length} games — the rule producing it may be unreachable`, }); } else if (n < all.length / 20) { out.push({ severity: 'rare', what: `event ${e}`, detail: `only ${n} across ${all.length} games`, }); } } for (const i of EXPECTED_INTENTS) { const n = totalIntents[i] ?? 0; if (n === 0) { out.push({ severity: 'never', what: `intent ${i}`, detail: `never chosen in ${all.length} games — unreachable, or the bot never wants it`, }); } } // Structural checks: things that must hold if the model is coherent. const scheduled = all.reduce((n, g) => n + g.trains.scheduled, 0); const arrived = all.reduce((n, g) => n + g.trains.arrived, 0); if (scheduled > 0 && arrived === 0) { out.push({ severity: 'never', what: 'train arrivals', detail: `${scheduled} trains scheduled but none ever reached an Office`, }); } const highballed = all.reduce((n, g) => n + g.trains.highballed, 0); if (arrived > 0 && highballed < arrived) { out.push({ severity: 'odd', what: 'trains departing', detail: `${arrived} arrivals but only ${highballed} departures — trains may be parking`, }); } const tiers = new Set(all.map((g) => g.development.finalTier)); if (tiers.size === 1) { out.push({ severity: 'odd', what: 'Office tier', detail: `every game ended at "${[...tiers][0]}" — upgrades may be unreachable or automatic`, }); } return out; } // --------------------------------------------------------------------------- // Strategy analysis — purpose 2 // --------------------------------------------------------------------------- export type StrategyBucket = { label: string; games: number; meanRevenue: number; winRate: number; }; /** * Buckets games by how their Revenue was earned, then compares outcomes. If freight specialists * consistently outscore passenger specialists (or vice versa), it shows up here. */ export function strategyBuckets(all: GameStats[]): StrategyBucket[] { const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0); const buckets: { label: string; test: (g: GameStats) => boolean }[] = [ { label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 }, { label: 'passenger-heavy (<33%)', test: (g) => g.freightShare > 0 && g.freightShare < 0.33 }, { label: 'balanced (33-66%)', test: (g) => g.freightShare >= 0.33 && g.freightShare <= 0.66 }, { label: 'freight-heavy (>66%)', test: (g) => g.freightShare > 0.66 && g.freightShare < 1 }, { label: 'freight-only (100%)', test: (g) => g.freightShare === 1 }, ]; return buckets.map(({ label, test }) => { const members = scored.filter(test); return { label, games: members.length, meanRevenue: members.length > 0 ? members.reduce((n, g) => n + g.revenue.net, 0) / members.length : 0, winRate: members.length > 0 ? members.filter((g) => g.result === 'win').length / members.length : 0, }; }); } // --------------------------------------------------------------------------- // Reporting // --------------------------------------------------------------------------- const num = (n: number, w = 7, dp = 1): string => n.toFixed(dp).padStart(w); function meanOf(all: GameStats[], get: (g: GameStats) => number): number { return all.length === 0 ? 0 : all.reduce((n, g) => n + get(g), 0) / all.length; } export function formatGameStats(g: GameStats): string { const out: string[] = []; out.push(`\n--- game seed ${g.seed} · ${g.result}/${g.reason} · ${g.days} Days · ${g.turns} turns`); out.push(` revenue net ${g.revenue.net}`); out.push(` freight loads ${g.revenue.freightLoad}`); out.push(` freight unloads ${g.revenue.freightUnload}`); out.push(` passengers on ${g.revenue.passengerBoard}`); out.push(` passengers off ${g.revenue.passengerDetrain}`); out.push(` lost to collisions -${g.revenue.collisionLoss}`); out.push(` freight share ${(g.freightShare * 100).toFixed(0)}%`); out.push( ` trains scheduled ${g.trains.scheduled} highballed ${g.trains.highballed}` + ` arrived ${g.trains.arrived} completed ${g.trains.completed}`, ); out.push( ` office ${g.development.finalTier} grid ${g.development.gridSize}` + ` facilities ${g.development.freightFacilities}`, ); return out.join('\n'); } /** * THE FUNNEL, AS PERCENTAGES OF THE THING THEY GATE. * * Deliberately not per-game averages: "3.6 arrivals carried an empty coach" says nothing without the * 7.3 arrivals it is out of, and the whole point is to see WHERE an opportunity stops. Read the * arrivals block as "of every train that reached an Office" and the Cargo block as "of every Cargo * phase". Empty when nothing was probed. */ export function funnelReport(all: GameStats[]): string { const fs = all.map((g) => g.funnel).filter((f): f is Funnel => f !== undefined); if (fs.length === 0) return ''; const sum = (pick: (f: Funnel) => number): number => fs.reduce((n, f) => n + pick(f), 0); const pct = (n: number, d: number): string => d === 0 ? ' —' : `${((n / d) * 100).toFixed(0).padStart(3)}%`; const arr = sum((f) => f.arrivals); const cargo = sum((f) => f.cargoPhases); const out: string[] = []; out.push(`\n PASSENGER FUNNEL — ${(arr / fs.length).toFixed(1)} arrivals a game`); out.push(` at a Passenger Facility (Depot+) ${pct(sum((f) => f.arrivalsAtPassengerOffice), arr)}`); out.push(` carrying an EMPTY coach (boarding) ${pct(sum((f) => f.arrivalsWithEmptyCoach), arr)}`); out.push(` carrying a LOADED coach (detrain) ${pct(sum((f) => f.arrivalsWithLoadedCoach), arr)}`); out.push(` carrying a car an industry wants ${pct(sum((f) => f.arrivalsWithWantedCar), arr)}`); out.push(`\n FREIGHT FUNNEL — ${(cargo / fs.length).toFixed(0)} Cargo phases a game`); out.push(` a freight facility was built ${pct(sum((f) => f.cargoWithFacility), cargo)}`); out.push(` a green box was stocked ${pct(sum((f) => f.cargoGreenStocked), cargo)}`); out.push(` a car was spotted on an industry ${pct(sum((f) => f.cargoCarSpotted), cargo)}`); out.push(` a Laborer was free ${pct(sum((f) => f.cargoLaborerFree), cargo)}`); out.push(` ALL THREE at one industry ${pct(sum((f) => f.cargoReady), cargo)}`); const buried = sum((f) => f.buriedTurns); out.push(`\n STUCK — ${(buried / fs.length).toFixed(1)} decisions a game with the engine buried mid-train`); out.push(` ... of those, able to set the nose cars out ${pct(sum((f) => f.buriedWithDigAvailable), buried)}`); return out.join('\n'); } export function formatAggregate(all: GameStats[]): string { const out: string[] = []; out.push(`\n=== end-of-game statistics · ${all.length} games ===\n`); out.push(' REVENUE (mean per game)'); out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`); out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`); out.push(` from freight unloads ${num(meanOf(all, (g) => g.revenue.freightUnload))}`); out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`); out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`); out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`); out.push(` freight share of gross ${num(meanOf(all, (g) => g.freightShare) * 100, 6, 0)}%`); out.push('\n TRAFFIC (mean per game)'); out.push(` trains scheduled ${num(meanOf(all, (g) => g.trains.scheduled))}`); out.push(` highballs ${num(meanOf(all, (g) => g.trains.highballed))}`); out.push(` arrivals at an Office ${num(meanOf(all, (g) => g.trains.arrived))}`); out.push(` runs completed ${num(meanOf(all, (g) => g.trains.completed))}`); out.push(` clearance requests ${num(meanOf(all, (g) => g.trains.clearanceRequests))}`); out.push(` collisions ${num(meanOf(all, (g) => g.collisions))}`); out.push('\n DEVELOPMENT (mean per game)'); out.push(` cards drawn ${num(meanOf(all, (g) => g.development.cardsDrawn))}`); out.push(` cards played ${num(meanOf(all, (g) => g.development.cardsPlayed))}`); out.push(` office upgrades ${num(meanOf(all, (g) => g.development.officeUpgrades))}`); out.push(` final grid size ${num(meanOf(all, (g) => g.development.gridSize))}`); out.push(` freight facilities ${num(meanOf(all, (g) => g.development.freightFacilities))}`); const tierCounts: Record = {}; for (const g of all) inc(tierCounts, g.development.finalTier); out.push(' final Office tier:'); for (const [t, n] of Object.entries(tierCounts).sort((a, b) => b[1] - a[1])) { out.push(` ${t.padEnd(14)} ${n} (${((n / all.length) * 100).toFixed(0)}%)`); } out.push(funnelReport(all)); out.push('\n ACTION MIX (mean per game)'); out.push(` switch moves ${num(meanOf(all, (g) => g.actions.moves))}`); out.push(` cars dropped ${num(meanOf(all, (g) => g.actions.drops))}`); out.push(` cars coupled ${num(meanOf(all, (g) => g.actions.couples))}`); out.push(` freight agent ops ${num(meanOf(all, (g) => g.actions.freightAgent))}`); out.push(` laborer actions ${num(meanOf(all, (g) => g.workers.laborerActions))}`); out.push(` porter actions ${num(meanOf(all, (g) => g.workers.porterActions))}`); out.push('\n STRATEGY — does the revenue mix predict the score?'); out.push(` ${'bucket'.padEnd(30)} games mean rev win rate`); for (const b of strategyBuckets(all)) { out.push( ` ${b.label.padEnd(30)} ${String(b.games).padStart(5)} ${num(b.meanRevenue, 8)} ${num(b.winRate * 100, 6, 0)}%`, ); } const found = anomalies(all); out.push('\n ANOMALIES'); if (found.length === 0) { out.push(' none — every expected event and intent occurred at a plausible rate'); } else { for (const a of found) { out.push(` [${a.severity.toUpperCase().padEnd(5)}] ${a.what}: ${a.detail}`); } } return out.join('\n'); }