added harness to support bots being able to run / test strategies.
This commit is contained in:
@@ -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))}`);
|
||||
|
||||
Reference in New Issue
Block a user