Show the bot's reasoning and rejected options in the replay

This commit is contained in:
Jesse
2026-07-31 15:22:45 -04:00
parent d261ad7af8
commit 160190da3f
3 changed files with 328 additions and 49 deletions
+96 -42
View File
@@ -56,17 +56,37 @@ function ruleOnClearance(options: Intent[]): Intent | null {
// ---------------------------------------------------------------------------
/**
* Why the developer bot made its last choice, for the replay's decision panel.
*
* WRITE-ONLY DIAGNOSTIC. Nothing reads this to make a decision, so it cannot affect play or
* determinism — it exists so the viewer can show the branch that fired instead of re-deriving the
* reasoning, which would drift from the bot exactly as a second copy of the rules would.
*/
let lastReason = '';
export function lastChoiceReason(): string {
return lastReason;
}
/** Records the branch that fired and returns the intent unchanged. */
function because(reason: string, intent: Intent): Intent {
lastReason = reason;
return intent;
}
export const developerBot: BotPolicy = {
name: 'developer',
choose(s, player, options) {
lastReason = 'no specific reason — first legal option';
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance);
// Red Flags come before anything else — protection is only worth playing at the moment the
// collision is actually pending, and that moment passes.
const flags = worthFlagging(s, options);
if (flags) return flags;
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
//
@@ -87,7 +107,8 @@ export const developerBot: BotPolicy = {
// jam costs a Freight Agent action to clear AND locks the industry track until it is cleared.
// A first attempt kept a last-resort `startLoad` here "rather than idle", and the measured
// result was identical to having no gate at all — 415 starts, 413 unjams, 13 completions.
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
if (work) return because(loadReason(work), work);
return because('every worker is spent or has nothing it can finish', options.find((i) => i.type === 'loadUnload.end') ?? options[0]!);
}
// --- New Train: fill the consist, but with the RIGHT cars.
@@ -102,9 +123,9 @@ export const developerBot: BotPolicy = {
const match = options.find(
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
);
if (match) return match;
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
}
return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
}
// --- Local Operations.
@@ -140,43 +161,51 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
// collision, and collisions are the largest single drain on revenue.
if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) {
return can('draw')!;
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))) return can('draw')!;
if (can('draw') && hand.some((id) => isTrainCard(s, id))) {
return because('a train card is in hand and its value compounds every Day', can('draw')!);
}
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
// 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
// compounds — but only if it can be laid right now, so check for a legal target rather than for
// the card sitting in hand.
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) return can('draw')!;
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) {
return because('a Mainline modifier can be laid — every later crossing pays less', can('draw')!);
}
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
// actually a car to spot or collect. Choosing to switch merely because a train is present
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
// no switching at all, because §9.2 works coaches straight off the A/D track.
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
return can('switch')!;
return because('a train is at the Office and there is switching worth doing', can('switch')!);
}
// A crew stranded away from the Office is worth a whole turn on its own. A train may only
// highball from the Office square, so one sitting anywhere else is out of the game permanently —
// and the condition above never fires for it, because a crew down in the district has no work
// left and would never claim the option needed to walk back.
if (can('switch') && strandedFromOffice(s, player)) return can('switch')!;
if (can('switch') && strandedFromOffice(s, player)) {
return because('the crew is away from the Office and can never depart from where it stands', can('switch')!);
}
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
if (can('freightAgent') && canStockProductively(s, player)) {
return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!);
}
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
return can('freightAgent')!;
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.
if (can('draw')) return can('draw')!;
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
return because('no option has a clear purpose this turn', can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!);
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
@@ -317,6 +346,24 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | null {
return null;
}
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
function loadReason(i: Intent): string {
switch (i.type) {
case 'porter.board':
return 'a Porter earns a point in ONE action — always the best use of a worker';
case 'porter.detrain':
return 'detraining passengers is a point for a single Porter action';
case 'laborer.advanceLoad':
return 'finish work already started — a load parked on WORK locks the industry track';
case 'laborer.startLoad':
return 'a matching empty car is spotted, so this load can actually finish';
case 'laborer.beginUnload':
return 'a loaded car is spotted and the red box has room';
default:
return 'the best remaining use of a worker';
}
}
/**
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
* the load can be started and walked across MEN|AT|WORK but can never come off.
@@ -376,11 +423,11 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const useful = options.find(
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
);
if (useful) return useful;
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 blind;
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
const any = options.find((i) => i.type === 'draw.fromDepartment');
if (any) return any;
if (any) return because('the deck is empty, so take a face-up card', any);
}
// UPGRADE THE OFFICE FIRST. A Whistle Post has ONE A/D track, so a second arrival is an
// automatic collision (§8.3, Gap 2a) — and measured, 25 of 26 collisions happened at Whistle
@@ -391,13 +438,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const upgrade = options.find(
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
);
if (upgrade) return upgrade;
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),
);
if (train) return train;
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
// lower price. Realignment first — converting Curves to Plains halves the crossing for
@@ -405,9 +452,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const realign = options.find(
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
);
if (realign) return realign;
if (realign) return because('Realignment converts this Mainline card into a faster one for every future crossing', realign);
const grade = options.find((i) => i.type === 'mainline.modify');
if (grade) return grade;
if (grade) return because('a grade modifier takes a Stage off every crossing in that direction', grade);
// Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office
// overflowing into a collision, and the dispatch devices win meets. All are worth more than
@@ -420,12 +467,12 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
i.placement !== undefined &&
isEnhancementKey(s, i.cardId, 'interlocking'),
);
if (interlock) return interlock;
if (interlock) return because('Interlocking holds an arrival at the Limits instead of colliding into a full Office', interlock);
const enh = options.find(
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
);
if (enh) return enh;
if (enh) return because('an Enhancement is permanent and changes how the district works', enh);
// Lay track before spending a card on the grid. Track is the scarce enabler, not the
// consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track
@@ -433,16 +480,16 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33.
const track = bestTrackLay(s, player, options);
if (track) return track;
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
if (play) return play;
if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
if (end) return end;
return pickFirst(options, 'card.discard') ?? options[0]!;
if (end) return because('nothing in hand can be played anywhere legal', end);
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
}
case 'switch': {
@@ -457,7 +504,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
i.count === 1 &&
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
);
if (flyingFirst) return flyingFirst;
if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst);
if (!usefulSwitching(s, player)) {
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
@@ -467,9 +514,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// the wrong column and 43 down in the district. A parked train earns nothing, holds its A/D
// track, and is unavailable for the next load.
const home = moveTowardOffice(s, player, options);
if (home) return home;
if (home) return because('no switching left worth doing — head back to the Office so the train can depart', home);
const stop = options.find((i) => i.type === 'switch.end');
if (stop) return stop;
if (stop) return because('no switching left worth doing, and the crew is already at the Office', stop);
}
// Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
@@ -499,7 +546,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const sort = options.find(
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
);
if (sort) return sort;
if (sort) return because('standing on a Small Yard — re-order so a car a facility wants ends up droppable', sort);
}
}
}
@@ -512,7 +559,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// Standing on a facility that wants the back car: put it down. This is the payoff move.
if (hereFacility && facilityWants(hereFacility, endCar)) {
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (drop) return drop;
if (drop) return because('standing on a facility that wants the back car — spot it', drop);
}
// SET OUT — and do it BEFORE travelling.
@@ -538,7 +585,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const isSpur = here.row !== area.runningRow && !hereFacility;
if (isSpur) {
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (setOut) return setOut;
if (setOut) {
return because(
tray.consist.length >= MAX_CONSIST
? 'the tray is full, so nothing can be coupled — set a car out here'
: 'the back car is dead weight hiding a car a facility wants — set it out to expose it',
setOut,
);
}
}
const toSpur = options.find(
(i) =>
@@ -546,7 +600,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
i.to.row !== area.runningRow &&
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
);
if (toSpur) return toSpur;
if (toSpur) return because('looking for a plain spur to set out on — an industry track would silt with the wrong commodity', toSpur);
}
// Now travel — to a facility that wants SOMETHING aboard, not only the back car. Weighing
@@ -562,7 +616,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
i.type === 'switch.move' &&
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
);
if (toward) return toward;
if (toward) return because('heading for a facility that wants a car we are carrying', toward);
}
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
@@ -571,8 +625,8 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// Moves oscillating between two cells. If there is no move with a purpose, the only useful
// thing left is to head for the Office, because a train that is not on it can never depart.
const goHome = moveTowardOffice(s, player, options);
if (goHome) return goHome;
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
if (goHome) return because('nothing productive left — head for the Office rather than shuttle aimlessly', goHome);
return because('out of useful switching moves', options.find((i) => i.type === 'switch.end') ?? options[0]!);
}
case 'freightAgent': {
@@ -581,13 +635,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
if (hasStuckLoad(s, player)) {
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
if (unjam) return unjam;
if (unjam) return because('a jammed load blocks the pipeline AND strips the industry track of Operational Rail', unjam);
}
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
if (clear) return clear;
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return stock;
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
}
default: