669 lines
25 KiB
TypeScript
669 lines
25 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
intentCounts: Record<string, number>;
|
|
/**
|
|
* 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<string, number>, 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<string, number> = {};
|
|
const intentCounts: Record<string, number> = {};
|
|
|
|
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<string, number> = {};
|
|
const totalIntents: Record<string, number> = {};
|
|
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<string, number> = {};
|
|
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');
|
|
}
|