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:
+195 -5
View File
@@ -23,10 +23,11 @@ import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from
import type { GameLength } from '../engine/content.ts';
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState } from '../engine/state.ts';
import { developerBot } from './bot.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
@@ -58,6 +59,14 @@ export type FacilityView = {
trackCap: number;
laborers: string;
porters: string;
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
* that held freight to a 3% completion rate.
*/
canFinish: boolean;
/** A load is sitting on WORK with no spotted car to receive it. */
jammed: boolean;
};
export type TrainChip = { label: string; consist: string[] };
@@ -85,6 +94,26 @@ export type Frame = {
timetable: (number | null)[];
blocked: Impediment[];
trains: { label: string; where: string }[];
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
decision: Decision | null;
/** A Local Operations turn that changed nothing — the frames worth your attention. */
wasted: boolean;
};
/**
* The choice behind a frame.
*
* The whole point is `rejected`: the replay could always show what happened, never what COULD have
* happened, so a daft move was visible but the alternatives it passed over were not — which is
* exactly what you need to say what it should have done instead.
*/
export type Decision = {
actor: number;
chose: string;
why: string;
/** Every legal option not taken, grouped by kind with a count. */
rejected: { kind: string; count: number; detail: string }[];
totalOptions: number;
};
// ---------------------------------------------------------------------------
@@ -124,14 +153,116 @@ function facilityView(
trackCap: f.industryTrack.length,
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
};
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
if (!pending) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
}
/**
* Summarise the choice: what was taken, why, and what was passed over.
*
* Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
* long hides the shape of the decision rather than showing it.
*/
function describeDecision(
s: GameState,
actor: number,
chosen: Intent,
options: Intent[],
why: string,
): Decision {
const groups = new Map<string, Intent[]>();
for (const o of options) {
if (o === chosen) continue;
const list = groups.get(o.type) ?? [];
list.push(o);
groups.set(o.type, list);
}
const rejected = [...groups.entries()]
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
.sort((a, b) => b.count - a.count);
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
}
/** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
// actual range of choices — the opposite of what this panel is for.
const seen = new Set<string>();
for (const i of list) seen.add(describeIntent(s, i));
const unique = [...seen];
const shown = unique.slice(0, 4);
const more = unique.length - shown.length;
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}
/** One readable line for a single intent. */
function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
switch (i.type) {
case 'localOps.choose':
return `choose ${i.option}`;
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.discard':
return `discard ${cardName(s, i.cardId)}`;
case 'track.lay':
return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`;
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
return `stock a ${i.carType} at ${at(i.at)}`;
case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound':
return `clear red box at ${at(i.at)}`;
case 'laborer.startLoad':
return `start a load at ${at(i.at)}`;
case 'laborer.advanceLoad':
return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
case 'porter.board':
return `board passengers at ${at(i.at)}`;
case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`;
case 'newTrain.placeCar':
return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags':
return `Red Flags on ${i.trayId}`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance':
return i.allow ? 'grant clearance' : 'refuse clearance';
case 'draw.fromDepartment':
return `draw the face-up card in slot ${i.slot + 1}`;
default:
return i.type;
}
}
function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
): Frame {
const area = areaOf(s, 0);
const trayAt = new Map<string, string>();
@@ -218,6 +349,8 @@ function snapshot(
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
timetable: [...s.timetable],
decision,
wasted,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -304,6 +437,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
let didSomething = false;
// The choice that produced the events about to be pushed.
let pendingDecision: Decision | null = null;
const push = (events: GameEvent[]): void => {
const visible = events.filter(isVisible);
@@ -328,7 +463,16 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
const moved = visible.find((e) => e.type === 'trayMoved');
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
frames.push(snapshot(s, lines, where ?? null, from));
const d = pendingDecision;
pendingDecision = null;
// A Local Operations turn that ended with nothing but the turn ending is a wasted action —
// six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless
// marked, and they are the ones worth reviewing.
const wasted =
d !== null &&
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
};
frames.push(
@@ -345,8 +489,11 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const applied = applyIntent(s, actor, developerBot.choose(s, actor, options));
const chosen = developerBot.choose(s, actor, options);
const decision = describeDecision(s, actor, chosen, options, lastChoiceReason());
const applied = applyIntent(s, actor, chosen);
if (!applied.ok) break;
pendingDecision = decision;
push(applied.events);
}
@@ -432,6 +579,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
.mini .box{padding:0 4px;font-size:9.5px}
.cell.office{border-color:var(--clock)}
.cell.modifier{border-style:dashed;opacity:.85}
.chose{font-size:15px;margin-bottom:4px}
.why{font-style:italic;opacity:.85;margin-bottom:2px}
.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto}
.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px}
.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px}
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
.fstat.idle{opacity:.6}
.wastedmark{color:#e06060;font-weight:bold}
</style></head><body>
<header>
@@ -455,6 +613,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</div>
<div>
<section><h2>What just happened</h2><div id="now"></div></section>
<section><h2>Decision — what it chose, and what it passed over</h2><div id="decision"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
@@ -468,7 +627,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<div class="legend">
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
<kbd>C</kbd> next collision.<br>
<kbd>C</kbd> next collision · <kbd>W</kbd> next wasted turn · <kbd>J</kbd> next jam.<br>
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
@@ -485,6 +644,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<button id="stage">next Stage ⏭</button>
<button id="money">next revenue 💰</button>
<button id="crash">next collision ⚠</button>
<button id="waste">next wasted turn 🗑</button>
<button id="jam">next jam 🔒</button>
<input type="range" id="scrub" min="0" value="0">
<select id="speed">
<option value="3000">extra slow</option>
@@ -585,9 +746,34 @@ function render() {
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
(x.jammed
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
: (x.canFinish
? 'ready — a matching empty car is spotted, so a load can finish'
: 'no car spotted — starting a load here would jam it')) +
'</div>' +
'</div>').join('');
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
var d = f.decision;
if (!d) {
$('decision').innerHTML = '<span class="dim">no choice here — the engine advanced on its own</span>';
} else {
var h = '';
if (f.wasted) h += '<div class="wasted">WASTED TURN — the whole action produced no change</div>';
h += '<div class="chose"><span class="dim">chose:</span> <b>' + esc(d.chose) + '</b></div>';
h += '<div class="why">why: ' + esc(d.why) + '</div>';
h += '<div class="dim" style="margin:6px 0 3px">passed over ' + (d.totalOptions - 1) +
' other legal option' + ((d.totalOptions - 1) === 1 ? '' : 's') + ':</div>';
if (d.rejected.length === 0) h += '<div class="dim">none — this was the only legal action</div>';
else h += '<ul class="rejected">' + d.rejected.map(function (r) {
return '<li><b>' + esc(r.kind) + '</b> <span class="dim">x' + r.count + '</span><br>' +
'<span class="dim">' + esc(r.detail) + '</span></li>';
}).join('') + '</ul>';
$('decision').innerHTML = h;
}
$('blocked').innerHTML = f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
@@ -615,6 +801,8 @@ $('fwd').onclick = () => go(i + 1);
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('waste').onclick = () => findNext((f) => f.wasted);
$('jam').onclick = () => findNext((f) => f.facilities.some((x) => x.jammed));
$('play').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
$('play').textContent = '⏸ pause';
@@ -632,6 +820,8 @@ document.onkeydown = (e) => {
else if (e.key === 's' || e.key === 'S') $('stage').click();
else if (e.key === 'r' || e.key === 'R') $('money').click();
else if (e.key === 'c' || e.key === 'C') $('crash').click();
else if (e.key === 'w' || e.key === 'W') $('waste').click();
else if (e.key === 'j' || e.key === 'J') $('jam').click();
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
};
render();
+37 -2
View File
@@ -191,7 +191,7 @@ describe('replay HTML', () => {
});
it('offers the controls that make it usable', () => {
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash']) {
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) {
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
}
});
@@ -207,7 +207,7 @@ describe('replay HTML', () => {
it('wires a handler to every control', () => {
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash']) {
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) {
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
}
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
@@ -233,6 +233,41 @@ describe('replay HTML', () => {
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
});
it('records what the bot chose, why, and what it passed over', () => {
// The replay could always show what happened, never what COULD have happened — so a daft move
// was visible but the alternatives it declined were not, which is what you need to say what it
// should have done instead.
const rec = record(202, 'standard');
const decided = rec.frames.filter((f) => f.decision !== null);
assert.ok(decided.length > 0, 'no frame carried a decision');
for (const f of decided) {
assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action');
assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason');
assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing');
}
// The reasons must come from the bot's own branches, not a generic fallback for everything.
const reasons = new Set(decided.map((f) => f.decision!.why));
assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`);
// Turns with real alternatives must show them, or the panel is decoration.
assert.ok(
decided.some((f) => f.decision!.rejected.length > 0),
'no frame ever recorded a rejected option',
);
});
it('flags jammed facilities and can tell them from ready ones', () => {
const rec = record(202, 'standard');
for (const f of rec.frames) {
for (const x of f.facilities) {
// A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'.
assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`);
}
}
});
it('stays a sane size', () => {
const mb = Buffer.byteLength(html) / 1024 / 1024;
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);