created harness to better test bots. improved bot play
This commit is contained in:
+146
-24
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user