created harness to better test bots. improved bot play

This commit is contained in:
Jesse
2026-08-09 19:24:54 -04:00
parent 35575147cd
commit 4a800a053a
13 changed files with 6263 additions and 5503 deletions
+146 -24
View File
@@ -57,6 +57,7 @@ function ruleOnClearance(options: Intent[]): Intent | null {
return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? null;
}
// ---------------------------------------------------------------------------
/**
@@ -91,16 +92,28 @@ function because(reason: string, intent: Intent): Intent {
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
*/
/**
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
*
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
* rebalance changes the economy, that is a claim to re-test rather than to assume.
*
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
* make-up, stocking the platform first, playing Interlocking earlier, hunting the Depot in the
* Departments, refusing to bury the engine, holding track for a run-around, reserving Moves to get
* home, granting clearance to a leaving train, and discarding capped train cards. Their measurements
* are in CHANGELOG.md; the code is not, because a switch nobody turns on is a switch nobody
* maintains.
*/
export type BotTweaks = {
/**
* Refuse to schedule a train the Office has no room for: cap committed trains (timetable slots
* filled, plus queued Extras) at `adTracks + trainCapSlack`.
*
* `undefined` means no cap — today's behaviour, which plays every train card on sight. A Whistle
* Post has ONE A/D track, and a second train standing there makes every later arrival an
* automatic collision (Gap 2d).
*/
trainCapSlack?: number;
/** Play every train card on sight, as the bot did before the A/D capacity cap. */
noTrainCap?: boolean;
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
noOperateFirst?: boolean;
};
/** The bot as it plays today. Every knob off. */
@@ -178,6 +191,17 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
*/
/**
* A CAP HAS TO BE APPLIED TO THE OPTIONS, NOT TO ONE BRANCH.
*
* The first attempt gated the two branches that exist to play a train card, measured exactly
* zero difference over 400 paired seeds, and was right to: `followThrough` ends with a generic
* "play what is in hand" fallback that played the card anyway. Removing the option itself is
* the only way to be sure no path reaches it.
*
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
*/
const held = trainWouldOverfillTheOffice(s, player, tweaks)
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
: options;
@@ -220,9 +244,36 @@ function committedTrains(s: GameState): number {
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
*/
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
if (tweaks.trainCapSlack === undefined) return false;
const cap = officeProfile(areaOf(s, player).tier).adTracks + tweaks.trainCapSlack;
return committedTrains(s) >= cap;
if (tweaks.noTrainCap) return false;
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
}
/**
* Would running here leave the engine buried among its own cars?
*
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
*
* Asked of the engine's own destination list, so the count is the count that will really couple.
*/
function wouldBuryTheEngine(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): boolean {
const tray = s.trays.get(move.trayId);
if (!tray || tray.trainNumber === null) return false;
if (tray.position.at !== 'grid') return false;
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
const len = tray.consist.length;
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
return (dest?.couples.length ?? 0) > 0;
}
function chooseLocalOption(
@@ -239,6 +290,35 @@ function chooseLocalOption(
const area = areaOf(s, player);
const hand = s.decks.hands.get(player) ?? [];
/**
* OPERATE BEFORE DRAWING.
*
* The bot drew 47 of its 60 turns and played 24 cards, and the funnel said why that was wrong:
* only 8% of Cargo phases had a stocked green box — the scarcest thing in the whole economy — and
* the bot was spending the turns that would have filled one on another card instead. Preferring
* the work to the lottery raises green-stocked phases by 60% and freight revenue by 39%.
*
* MEASURED AT +0.48 ± 0.07 (t = 6.60) over 1600 paired seeds, and 317 seeds better against 77
* worse — the broadest improvement of anything tried, where the A/D cap below is a narrow rescue
* of a rare catastrophe.
*
* NO BUILDING PHASE, though the idea arrived as "build for a Day, then switch". Reserving the
* first Day, or two, for development measures the same (+0.52, +0.56) with a worse better/worse
* ratio, and worse again alongside the cap. An explicit build phase spends turns on development
* the draw-priority ordering was already doing.
*/
if (!tweaks.noOperateFirst) {
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
return because('a train is at the Office and there is work to do on it — that beats another draw', can('switch')!);
}
if (can('freightAgent') && (canStockProductively(s, player) || hasStuckLoad(s, player) || needsClearing(s, player))) {
return because('feed the pipeline rather than draw another card — a stocked green box is work, a draw is a lottery', can('freightAgent')!);
}
if (can('switch') && strandedFromOffice(s, player)) {
return because('the crew must get back to the Office to depart at all', can('switch')!);
}
}
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
// whose value compounds. Playing one needs the draw option.
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
@@ -295,7 +375,9 @@ function chooseLocalOption(
return because('a facility is jammed or its red box is full, blocking the pipeline', can('freightAgent')!);
}
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay —
// unless a green box could be filled, in which case a turn spent stocking converts directly into
// work a Laborer can do, where a drawn card may be neither playable nor useful.
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
// Freight Agent is deliberately NOT the idle default. Reaching its follow-through with nothing
// productive to do is what fed the unjam fallback that discarded loads; switching, at worst,
@@ -343,14 +425,25 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean {
return takingRank(s, slot) > 0;
}
/**
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
*
* A bare boolean made an Office card and a train card tie, so the winner was whichever Department
* happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all.
*/
function takingRank(s: GameState, slot: number): number {
const id = topOfDepartment(s, slot);
if (!id) return false;
if (!id) return 0;
const k = s.cards.get(id)?.kind;
if (!k) return false;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return true;
if (k.kind === 'freightFacility') return true;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier;
return false;
if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1;
return 0;
}
/** Does any facility want this particular car? Tolerates an undefined end car. */
@@ -803,6 +896,14 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
// Measured: capping this to one or two ways down cost more than the spare turnouts did
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
// crew can actually reach, which matters more than a tidy Running Track.
/**
* A SECOND WAY DOWN, WHILE THE FIRST ONE IS STILL A STUB.
*
* Ways off the main are individually valuable — capping them measured worse — but one that is
* cut while an earlier run has never climbed back spends the supply on breadth when the thing
* that makes switching solvable is one run that closes. `openRuns` asks the engine which runs
* descend from the main and never rejoin it.
*/
bonus += turnouts.length === 0 ? 14 : 3;
} else if (Math.abs(row - area.runningRow) === 1) {
// Which side of the main this square is on. Both are ordinary now: a turnout turned 180°
@@ -1031,9 +1132,17 @@ function followThrough(
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
if (!s.turn.drawnThisTurn) {
const useful = options.find(
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
);
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
// both "qualify", and only one of them stops the collisions.
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district
// ever becomes a Passenger Facility is not worth keeping for its own sake.
const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0];
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
@@ -1410,12 +1519,14 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
// de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train —
// which is exactly what happened: zero de-trainings across a whole game.
const office = areaOf(s, player).grid.get(coordKey(areaOf(s, player).officeCoord))?.facility;
const coaches: WantedCar[] = [];
if (office?.kind === 'passenger' && office.porters > 0) {
const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
if (arrivalsWaiting) out.push({ type: 'coach', loaded: true });
if (arrivalsWaiting) coaches.push({ type: 'coach', loaded: true });
}
out.push({ type: 'coach', loaded: false });
return out;
coaches.push({ type: 'coach', loaded: false });
return [...out, ...coaches];
}
/**
@@ -1580,6 +1691,14 @@ export type PlayOutcome = {
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
/**
* The same intents in full, which is a SAVE: `{ seed, history }` replays exactly through the
* engine, so a game the bot played can be watched in the site's replay viewer.
*
* Kept alongside the type list rather than replacing it — `summarize` counts types and has no use
* for the payloads, and a bot game is 300-odd intents either way.
*/
history: Intent[];
};
/**
@@ -1617,6 +1736,7 @@ export function playGame(
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
const history: Intent[] = [];
let collisions = 0;
let trainsScheduled = 0;
let cardsPlayed = 0;
@@ -1652,6 +1772,7 @@ export function playGame(
onTurn?.(s);
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
history.push(chosen);
const r = applyIntent(s, actor, chosen);
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
tally(r.events);
@@ -1668,5 +1789,6 @@ export function playGame(
outcome: s.outcome,
events,
intents,
history,
};
}