Show the bot's reasoning and rejected options in the replay
This commit is contained in:
+96
-42
@@ -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
@@ -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
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user