added harness to support bots being able to run / test strategies.

This commit is contained in:
Jesse
2026-08-08 15:07:42 -04:00
parent 5d825b97d2
commit 35575147cd
9 changed files with 866 additions and 23 deletions
+185
View File
@@ -18,11 +18,149 @@
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.ts';
import { 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.owner !== 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;
@@ -76,6 +214,11 @@ export type GameStats = {
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 {
@@ -87,6 +230,7 @@ export function summarize(
events: GameEvent[],
intents: Intent['type'][],
final: GameState,
funnel?: Funnel,
): GameStats {
const eventCounts: Record<string, number> = {};
const intentCounts: Record<string, number> = {};
@@ -211,6 +355,7 @@ export function summarize(
eventCounts,
intentCounts,
...(funnel ? { funnel } : {}),
};
}
@@ -414,6 +559,44 @@ export function formatGameStats(g: GameStats): string {
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`);
@@ -449,6 +632,8 @@ export function formatAggregate(all: GameStats[]): string {
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))}`);