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
+118 -13
View File
@@ -20,7 +20,7 @@
*/
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
@@ -78,8 +78,42 @@ function because(reason: string, intent: Intent): Intent {
return intent;
}
export const developerBot: BotPolicy = {
name: 'developer',
/**
* KNOBS FOR A/B MEASUREMENT, and nothing else.
*
* A heuristic change has to be measured against the bot it replaces, over the SAME deals — and
* editing the bot between runs makes that impossible to do honestly, because the two sides of the
* comparison never exist at once. Every flag here is off by default, so `makeDeveloperBot({})` is
* byte-identical to the bot that came before this existed.
*
* TEMPORARY BY CONSTRUCTION. When a flag measures well it becomes the default and the flag is
* deleted in the same commit; when it measures badly it is deleted with its finding recorded in the
* 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.
*/
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;
};
/** The bot as it plays today. Every knob off. */
export const developerBot: BotPolicy = makeDeveloperBot({});
/** A variant, for measuring one change at a time against the bot above. */
export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
const suffix = Object.entries(tweaks)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${v}`)
.join(',');
return {
name: suffix ? `developer+${suffix}` : 'developer',
choose(s, player, options) {
lastReason = 'no specific reason — first legal option';
@@ -133,13 +167,29 @@ export const developerBot: BotPolicy = {
// --- Local Operations.
if (s.clock.phase === 'localOps') {
if (s.turn.option === null) return chooseLocalOption(s, player, options);
return followThrough(s, player, options);
/**
* 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 train 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;
const usable = held.length > 0 ? held : options;
if (s.turn.option === null) return chooseLocalOption(s, player, usable, tweaks);
return followThrough(s, player, usable, tweaks);
}
return options[0]!;
},
};
};
}
/**
* The one decision that matters: which of §6's three things to spend this Stage on.
@@ -150,7 +200,37 @@ export const developerBot: BotPolicy = {
* and spent the rest of the game stuffing green boxes whose loads could never complete for want of
* a car to load them onto.
*/
function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
/**
* Trains this player has already committed to running: slots filled on the timetable, plus Extras
* played and waiting for a Crew Tray.
*
* A Second Section is deliberately NOT counted. Its whole purpose is to put a following train into
* an occupied Subdivision and force the Superintendent's ruling (Q9) — capping it would be capping
* the card's reason to exist.
*/
function committedTrains(s: GameState): number {
return s.timetable.filter((t) => t !== null).length + s.pendingExtras.length;
}
/**
* Would scheduling another train exceed what the Office can physically hold?
*
* §7 lets you play as many train cards as you draw, and a train that arrives with nowhere to stand
* is an automatic collision (Gap 2d) — so the two rules together make a train card actively harmful
* 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;
}
function chooseLocalOption(
s: GameState,
player: PlayerIndex,
options: Intent[],
tweaks: BotTweaks,
): Intent {
const choices = options.filter(
(i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose',
);
@@ -167,7 +247,11 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return because('an Office upgrade is in hand — more A/D track means fewer collisions', can('draw')!);
}
if (can('draw') && hand.some((id) => isTrainCard(s, id))) {
if (
can('draw') &&
hand.some((id) => isTrainCard(s, id)) &&
!trainWouldOverfillTheOffice(s, player, tweaks)
) {
return because('a train card is in hand and its value compounds every Day', can('draw')!);
}
@@ -933,7 +1017,12 @@ function moveTowardOffice(s: GameState, player: PlayerIndex, options: Intent[]):
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
function followThrough(
s: GameState,
player: PlayerIndex,
options: Intent[],
tweaks: BotTweaks,
): Intent {
switch (s.turn.option) {
case 'draw': {
// Draw before playing — otherwise the hand empties and never refills.
@@ -962,10 +1051,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
);
if (upgrade) return because('upgrade the Office — a Whistle Post has ONE A/D track and a second arrival collides', upgrade);
// Train cards next — they take no placement and their value compounds every Day.
const train = options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
// Train cards next — they take no placement and their value compounds every Day. Unless the
// Office cannot hold another one, in which case the card is HELD rather than played: an
// arrival with no free A/D track is an automatic collision, and it repeats every Day.
const train = trainWouldOverfillTheOffice(s, player, tweaks)
? undefined
: options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
if (train) return because('a scheduled train runs every Day thereafter — the only card whose value compounds', train);
// Mainline modifiers rank with train cards: every train that crosses afterwards pays the
@@ -1504,12 +1597,23 @@ export type PlayOutcome = {
*/
export type PlayObserver = (event: GameEvent, state: GameState) => void;
/**
* Called once per DECISION, immediately before the policy is asked to choose.
*
* Separate from `PlayObserver` because the two sample different things. An event is something that
* happened; a turn hook sees the position as the bot sees it, which is the only place to ask a
* question of the form "was anything ready to be done here?" — a condition rather than an
* occurrence. Sampling those off events instead would count them once per event in the batch.
*/
export type TurnObserver = (state: GameState) => void;
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
observe?: PlayObserver,
onTurn?: TurnObserver,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
@@ -1545,6 +1649,7 @@ export function playGame(
const options = legalActions(s, actor);
if (options.length === 0) break;
onTurn?.(s);
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
const r = applyIntent(s, actor, chosen);