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

480 lines
17 KiB
TypeScript

/**
* End-of-game statistics.
*
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
* engine to produce any of this.
*
* 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';
// ---------------------------------------------------------------------------
// Per-game summary
// ---------------------------------------------------------------------------
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>;
};
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,
): 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;
const area = final.officeAreas.get(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,
};
}
// ---------------------------------------------------------------------------
// 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');
}
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('\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');
}