Files
station-master/src/sim/bot.ts
T

1073 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Component 17 — Heuristic bot players.
*
* Dev-side only: this ships nowhere. Its job is to play well enough that the balance harness
* (component 18) produces numbers worth trusting. See architecture/components.md §2 D.17.
*
* A bot that plays LEGALLY is easy; one that plays WELL enough to judge balance is the harder
* half, and this is only the first attempt. Read the numbers it produces as a floor on what a
* competent human would score, not a prediction of one.
*
* THE POLICY, from the economy in docs/rules/card-reference.md §7. Local Operations actions are
* the currency — one per Stage, twelve per Day, roughly one Revenue point each — so the bot's
* whole job is deciding which of the three options to spend the Stage on:
*
* 1. Develop first. A Whistle Post is not a Passenger Facility and earns nothing from
* passengers, so upgrading and placing freight facilities dominates early.
* 2. Then keep the pipeline fed. Stocking a green box is what converts an action into a point.
* 3. Switch when a train is standing at the Office, because spotted cars are what let a load
* complete at all.
*/
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } 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 { variantsFor } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = {
name: string;
choose(s: GameState, player: PlayerIndex, options: Intent[]): Intent;
};
// ---------------------------------------------------------------------------
function pickFirst(options: Intent[], ...types: Intent['type'][]): Intent | null {
for (const t of types) {
const found = options.find((i) => i.type === t);
if (found) return found;
}
return null;
}
/**
* §8.1 — the Superintendent's judgment call, and the bot's most consequential decision.
*
* Denying costs the following train one Stage. Allowing risks a collision at 5 Revenue, roughly a
* full Day's earnings, plus a train destroyed. At ~0.5 Revenue per Stage the arithmetic favours
* caution heavily, so this bot always denies. That is a deliberate policy choice, not an oversight:
* a bolder policy is worth simulating separately to see what following moves are actually worth.
*/
function ruleOnClearance(options: Intent[]): Intent | null {
return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? 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 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 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.
//
// Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
if (s.clock.phase === 'loadUnload') {
const work =
pickFirst(options, 'porter.board', 'porter.detrain', 'laborer.advanceLoad') ??
// Only START a load that can FINISH. A load leaves MEN|AT|WORK onto a spotted empty car of
// its own type (§9.3), so starting one with no car spotted parks it on WORK — which locks
// the industry track, which blocks the very car that would clear it. A self-inflicted
// deadlock, and the loop that was eating the game: 415 loads started, 413 unjams, and 13
// completions across 60 games.
options.find((i) => i.type === 'laborer.startLoad' && loadCanFinish(s, player, i.at)) ??
pickFirst(options, 'laborer.beginUnload');
// NO fallback to an unfinishable load. Idling a Laborer is strictly better than jamming: a
// 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.
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.
//
// This is subtler than it looks. §9.3 requires an EMPTY car of the matching type spotted on an
// outbound facility's track before a load can be worked onto it, and a LOADED car before an
// inbound facility can unload one. So the useful car to put on a train is the one this
// player's facilities are short of — not simply a loaded one.
if (s.clock.phase === 'newTrain') {
const wanted = wantedCars(s, player);
for (const w of wanted) {
const match = options.find(
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
);
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
}
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
}
// --- Local Operations.
if (s.clock.phase === 'localOps') {
if (s.turn.option === null) return chooseLocalOption(s, player, options);
return followThrough(s, player, options);
}
return options[0]!;
},
};
/**
* The one decision that matters: which of §6's three things to spend this Stage on.
*
* The ordering below is the result of measurement, not intuition. An earlier version picked
* Freight Agent whenever it was available, which is *always* once a facility exists with box room.
* It therefore stopped drawing after roughly two Days, scheduled only 3 of a possible 12 trains,
* and spent the rest of the game stuffing green boxes whose loads could never complete for want of
* a car to load them onto.
*/
function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
const choices = options.filter(
(i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose',
);
const can = (o: 'switch' | 'draw' | 'freightAgent') => choices.find((c) => c.option === o);
const area = areaOf(s, player);
const hand = s.decks.hands.get(player) ?? [];
// 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
// collision, and collisions are the largest single drain on revenue.
if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) {
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 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 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 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 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 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 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 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. */
function isWorthTaking(s: GameState, slot: number): boolean {
const id = s.decks.departments[slot];
if (!id) return false;
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;
}
/** Does any facility want this particular car? Tolerates an undefined end car. */
function facilityWants2(s: GameState, player: PlayerIndex, car: RollingStock | undefined): boolean {
return car !== undefined && facilitiesWanting(s, player, car).length > 0;
}
function isEnhancement(s: GameState, cardId: string): boolean {
return s.cards.get(cardId)?.kind.kind === 'enhancement';
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
}
/**
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
*
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
* in to rescue it. Stocking speculatively jams the facility.
*/
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || !f.allows.outbound) continue;
if (f.outboundBox.length >= f.capacity.outbound) continue;
// A PASSENGER facility has no industry track — passengers board straight off the platform
// (§9.2). Requiring a spotted car here meant the Office's green slot was never stocked and
// passengers essentially never boarded: one boarding in a whole 5-Day game.
if (f.kind === 'passenger') return true;
const want = facilityCarType(f);
if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
}
return false;
}
/** A load on MEN|AT|WORK that cannot advance, with Laborers free to move it. */
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || laborersLeft(f) < 1) continue;
for (let box = 0; box < f.menAtWork.length; box++) {
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
}
}
return false;
}
function needsClearing(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f) continue;
if (f.inboundBox.length >= Math.max(1, f.capacity.inbound)) return true;
}
return false;
}
/**
* Grow the district outward from the Office, preferring straights.
*
* Straights are what Enhancements attach to and what Freight Facilities sit beside, so they are
* worth more than a turnout the bot has no plan for. Ties break toward the Office: a compact
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
*/
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
const below = area.runningRow - 1;
/** Does this card send a leg DOWN off the Running Track? */
const divergesSouth = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
/** A card on the siding row that runs east-west — the siding itself. */
const runsAcross = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
/** An arc that reaches up to the Running Track. */
const reachesUp = (c: TrackCard | undefined): boolean =>
!!c &&
c.geometry.kind === 'track' &&
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
// Where the district already turns down off the main.
const turnouts = [...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
.map(([k]) => Number(k.split(',')[1]));
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
const { row, col } = i.placement;
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
/**
* BUILD A SIDING, not a stub.
*
* A turnout dropping into a dead-end column is worth nothing: the crew can shove cars down it
* but never get past them. What makes switching solvable is a siding — down off the main, along
* beside it, and ideally back up — because that is what lets a crew run around its own train and
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
* one asked for it.
*/
if (row === area.runningRow && i.geometry === 'turnout') {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
// siding they were supposed to serve.
// 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.
score += turnouts.length === 0 ? 14 : 3;
} else if (row === below) {
const arc = arcOf(i);
const turnoutAbove = divergesSouth(at(area.runningRow, col));
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
score += 13;
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
// Extend the siding beside the main, but only from something that already turned.
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
score += 11;
}
} else if (arc && (arc === 'ne' || arc === 'nw') &&
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
// Close the loop back up to the main: the run-around is complete.
score += 12;
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function isEnhancementKey(s: GameState, cardId: string, key: string): boolean {
const k = s.cards.get(cardId)?.kind;
return k?.kind === 'enhancement' && k.key === key;
}
function isMainlineKey(s: GameState, cardId: string, key: string): boolean {
const k = s.cards.get(cardId)?.kind;
return k?.kind === 'mainlineModifier' && k.key === key;
}
/** Does the facility at `coord` want this particular car? */
function facilityWantsAt(
s: GameState,
player: PlayerIndex,
coord: { row: number; col: number },
car: { type: string; loaded: boolean } | undefined,
): boolean {
if (!car) return false;
const f = areaOf(s, player).grid.get(`${coord.row},${coord.col}`)?.facility;
return f ? facilityWants(f, car as never) : false;
}
/**
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
* with another train on the same card, which is the situation that becomes a rear-ender.
*/
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
for (const i of options) {
if (i.type !== 'maneuver.redFlags') continue;
const tray = s.trays.get(i.trayId);
if (!tray || tray.position.at !== 'mainline') continue;
const node = s.division.nodes[tray.position.index];
if (node?.kind !== 'mainline') continue;
if (node.transits.length > 1) return i;
}
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.
*/
function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = areaOf(s, player).grid.get(`${at.row},${at.col}`)?.facility;
const next = f?.outboundBox[0];
if (!f || !next) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
}
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
function reachableFrom(
s: GameState,
player: PlayerIndex,
trayId: string,
from: GridCoord,
): GridCoord[] {
return [
...destinationsFor(s, player, trayId, from, false),
...destinationsFor(s, player, trayId, from, true),
].map((d) => d.coord);
}
/**
* The consist this move would leave, coupling included.
*
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
* disagree with what actually happens when the move is submitted.
*/
function consistAfterMove(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): RollingStock[] | null {
const tray = s.trays.get(move.trayId);
if (!tray || tray.position.at !== 'grid') return null;
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
if (!dest) return null;
// Forward means the engine leads and meets the cars head-on (§A.3).
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
}
/** Is this player's crew sitting somewhere it can never depart from? */
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
const area = areaOf(s, player);
const here = trayLocation(s, player);
if (!here) return false;
return here.row !== area.officeCoord.row || here.col !== area.officeCoord.col;
}
/**
* A Move that gets the crew strictly closer to the Office, or null if it is already there or
* nothing helps. "Strictly closer" is what stops this becoming the shuttling loop that an earlier
* version of the switching heuristic fell into — the bot cannot oscillate if every step reduces the
* distance.
*/
function moveTowardOffice(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
const here = trayLocation(s, player);
if (!here) return null;
const dist = (c: { row: number; col: number }): number =>
Math.abs(c.row - area.officeCoord.row) + Math.abs(c.col - area.officeCoord.col);
if (dist(here) === 0) return null;
let best: Intent | null = null;
let bestDist = dist(here);
for (const i of options) {
if (i.type !== 'switch.move') continue;
const d = dist(i.to);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
case 'draw': {
// Draw before playing — otherwise the hand empties and never refills.
//
// Prefer a face-up Department card only when it is actually worth having. Taking the visible
// 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),
);
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);
const any = options.find((i) => i.type === 'draw.fromDepartment');
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
// Post, none at all where an Interlocking was down. A Depot doubles the capacity and turns
// the Office into a Passenger Facility, which is where the porters and passenger revenue come
// from. The bot previously had no play preference for office cards at all, so an upgrade sat
// in hand behind trains, enhancements and track.
const upgrade = options.find(
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
);
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 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
// everyone, where a grade card only helps trains going one way.
const realign = options.find(
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
);
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 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
// another piece of plain track.
// Interlocking ahead of the other enhancements: it holds an arrival at the Limits instead of
// colliding into a full Office, and no collision was ever recorded in a district that had one.
const interlock = options.find(
(i) =>
i.type === 'card.play' &&
i.placement !== undefined &&
isEnhancementKey(s, i.cardId, 'interlocking'),
);
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 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
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
// 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 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 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 because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
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': {
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
// pick an arbitrary legal move and burn the remaining Moves shuttling.
// Flying Switch is real work, so it must be weighed before concluding there is none left —
// otherwise the go-home branch below preempts it and the card never fires.
const flyingFirst = options.find(
(i) =>
i.type === 'maneuver.flyingSwitch' &&
i.count === 1 &&
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
);
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
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn
// wherever the work ran out, which stranded the crew: 77 of 93 trains still on the board at
// game end were sitting somewhere they could never depart from, 34 on the Running Track at
// 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 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 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.
//
// Measured: an earlier version dropped whichever car happened to be at the tray's end,
// which silted industry tracks up with coaches and cabooses — 532 coaches were observed
// sitting on freight sidings across 20 games. A facility whose track is full of the wrong
// commodity cannot accept the car it actually needs, and the freight chain starves.
//
// §A.3 forces the issue: cars come off in seated order, so only the end car is droppable.
// A strong player would use the six Moves to re-order the consist — that is the game's
// central switching puzzle, and this bot does not attempt it. It simply declines to drop a
// car that would do no work.
const tray = trayOf(s, player);
const here = trayLocation(s, player);
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
// If standing on a Small Yard, re-order so a car some facility actually wants ends up on
// the droppable end. This is the whole point of the card.
if (tray && here) {
const onYard = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.enhancements.includes('smallYard');
if (onYard && !facilityWants2(s, player, tray.consist[tray.consist.length - 1])) {
const wantedIdx = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (wantedIdx >= 0 && wantedIdx !== tray.consist.length - 1) {
const order = [...tray.consist.keys()].filter((k) => k !== wantedIdx);
order.push(wantedIdx);
const sort = options.find(
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
);
if (sort) return because('standing on a Small Yard — re-order so a car a facility wants ends up droppable', sort);
}
}
}
if (endCar && here) {
const area = areaOf(s, player);
const hereCard = area.grid.get(`${here.row},${here.col}`);
const hereFacility = hereCard?.facility ?? null;
// 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 because('standing on a facility that wants the back car — spot it', drop);
}
/**
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
* which car is next off the tail.
*
* That is what a siding is for. Rather than setting a useless car down and coming back for
* it, the crew can approach from the side that leaves the car it actually wants droppable.
* Both directions are offered on every move; this picks the one that ends with useful work
* at the tail.
*/
const runAround = options.find((i) => {
if (i.type !== 'switch.move') return false;
const after = consistAfterMove(s, player, i);
if (!after || after.length === 0) return false;
const tail = after[after.length - 1]!;
// The approach must change the answer: a useless car at the tail now, a wanted one after.
if (facilitiesWanting(s, player, endCar).length > 0) return false;
const wants = facilitiesWanting(s, player, tail);
if (wants.length === 0) return false;
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
wants.some((w) => w.row === c.row && w.col === c.col),
);
});
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
// SET OUT — and do it BEFORE travelling.
//
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
// drops any route whose pickups would overflow the tray, so it can never collect the loaded
// car it came for. And a crew whose back car is dead weight cannot deliver the wanted car
// hiding behind it, because §A.3 only lets the back car come off.
//
// Order matters. With travel first, the crew drove to a facility, found its end car
// undroppable, turned round for a spur, then drove back — the shuttling loop again, from a
// third direction. Fixing the consist first means every subsequent trip ends in a drop.
//
// A plain spur is the place to do it: dropping onto an industry track would silt it with the
// wrong commodity, and the Running Track has to stay clear.
const endCarUseless =
tray !== null &&
tray.consist.length > 1 &&
facilitiesWanting(s, player, endCar).length === 0 &&
tray.consist.some((c) => facilitiesWanting(s, player, c).length > 0);
if (tray && (tray.consist.length >= MAX_CONSIST || endCarUseless)) {
const isSpur = here.row !== area.runningRow && !hereFacility;
if (isSpur) {
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
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) =>
i.type === 'switch.move' &&
i.to.row !== area.runningRow &&
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
);
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
// the end car alone was the freight loop's real blocker: 744 switch turns produced 135 Moves
// and 741 immediate ends, because a crew holding wanted cars behind an unwanted one decided
// there was nothing to do. §A.3 makes the back car the only DROPPABLE one; it does not make
// it the only one worth travelling for.
const wanted = tray
? tray.consist.flatMap((c) => facilitiesWanting(s, player, c))
: facilitiesWanting(s, player, endCar);
const toward = options.find(
(i) =>
i.type === 'switch.move' &&
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
);
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
// shuttling bug, and it produced it again here: with the crew stranded but `usefulSwitching`
// still true, the go-home branch above is skipped and this fallback burned the remaining
// 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 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': {
// A stuck load is the worst state a facility can be in: it blocks the pipeline AND strips
// the industry track of Operational Rail status (§9.3), so no car can be brought in to
// 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 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 because('the red Inbound box is full and blocking further unloading', clear);
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
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:
return options[0]!;
}
}
// ---------------------------------------------------------------------------
// What this player's facilities are actually short of
// ---------------------------------------------------------------------------
type WantedCar = { type: string; loaded: boolean };
/**
* An outbound facility needs EMPTY cars of its type to load onto; an inbound one needs LOADED cars
* to unload. Ordered so the most useful car comes first.
*/
function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
const out: WantedCar[] = [];
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const spotted = f.industryTrack.cars.length;
if (spotted >= f.industryTrack.length) continue;
const type = carTypeOf(card);
if (!type) continue;
if (f.allows.outbound) out.push({ type, loaded: false });
if (f.allows.inbound) out.push({ type, loaded: true });
}
// Coaches are worth carrying in BOTH states, and this is easy to get wrong. An EMPTY coach is
// what a boarding passenger is put into (§9.2); a LOADED one carries arrivals who can be
// 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;
if (office?.kind === 'passenger' && office.porters > 0) {
const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
if (arrivalsWaiting) out.push({ type: 'coach', loaded: true });
}
out.push({ type: 'coach', loaded: false });
return out;
}
function carTypeOf(card: { geometry: { kind: string; facility?: string } }): string | null {
if (card.geometry.kind !== 'facility') return null;
switch (card.geometry.facility) {
case 'mineTipple':
case 'powerPlant':
return 'hopper';
case 'produceShed':
return 'reefer';
case 'grocersWarehouse':
return 'boxcar';
case 'oilRefinery':
return 'tank';
default:
return null;
}
}
/**
* Would spotting this car here do any work?
*
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
* industry track (§9.3).
*/
function facilityWants(f: Facility, car: RollingStock): boolean {
if (f.kind !== 'freight') return false;
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
if (car.type !== facilityCarType(f)) return false;
if (!car.loaded && f.allows.outbound) return true;
if (car.loaded && f.allows.inbound) return true;
return false;
}
function facilitiesWanting(
s: GameState,
player: PlayerIndex,
car: RollingStock,
): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
for (const [key, card] of areaOf(s, player).grid) {
if (!card.facility || !facilityWants(card.facility, car)) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/**
* Is there any point switching this Stage?
*
* True when the crew is carrying a car some facility wants, or a siding holds a car that ought to
* be hauled away. False for a pure passenger train, which needs no switching at all.
*/
function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
const tray = trayOf(s, player);
if (!tray) return false;
for (const car of tray.consist) {
if (facilitiesWanting(s, player, car).length > 0) return true;
}
if (sidingsWorthCollecting(s, player).length > 0) return true;
// A FULL consist is itself work. At MAX_CONSIST the crew cannot couple anything, so every loaded
// car the district produces is unreachable until cars are set out. Without this clause the bot
// ended its turn the moment no facility wanted the end car, which is precisely when a full crew
// most needs to break itself up.
if (tray.consist.length >= MAX_CONSIST) {
for (const card of areaOf(s, player).grid.values()) {
if (card.facility && card.facility.kind === 'freight') return true;
}
}
// Standing on a Small Yard with a wanted car buried in the consist is work worth doing.
const here = trayLocation(s, player);
if (here) {
const card = areaOf(s, player).grid.get(`${here.row},${here.col}`);
if (card?.enhancements.includes('smallYard')) {
const buried = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (buried >= 0 && buried !== tray.consist.length - 1) return true;
}
}
// A car left standing on ordinary track is also worth lifting if some facility wants it — the
// hopper stranded on the Running Track that the crew kept driving past.
if (tray.consist.length < MAX_CONSIST) {
for (const card of areaOf(s, player).grid.values()) {
for (const car of card.standing) {
if (facilitiesWanting(s, player, car).length > 0) return true;
}
}
}
return false;
}
/**
* Sidings holding a car the train should lift: a finished (loaded) car at an outbound facility, or
* an emptied car at an inbound one. Leaving them there fills the industry track and jams the
* facility — the "industry track full" impediment.
*/
function sidingsWorthCollecting(
s: GameState,
player: PlayerIndex,
): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
const tray = trayOf(s, player);
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
for (const [key, card] of areaOf(s, player).grid) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
// Skip both-direction facilities: a loaded car sitting there might be a finished outbound load
// OR an inbound one still waiting to be unloaded, and lifting the latter undoes the delivery.
if (f.allows.outbound && f.allows.inbound) continue;
const done = f.industryTrack.cars.some((c) => (f.allows.outbound ? c.loaded : !c.loaded));
if (!done) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/** Cars standing on ordinary track that some facility would actually take. */
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
const tray = trayOf(s, player);
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
for (const [key, card] of areaOf(s, player).grid) {
if (card.facility || card.standing.length === 0) continue;
if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
}
return null;
}
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) {
return tray.position.coord;
}
}
return null;
}
// ---------------------------------------------------------------------------
/** Picks uniformly at random. A control, to show what the policy above is actually worth. */
export function randomBot(seed: number): BotPolicy {
let rng = seed >>> 0;
return {
name: 'random',
choose(_s, _p, options) {
rng = (rng * 1103515245 + 12345) >>> 0;
return options[rng % options.length]!;
},
};
}
// ---------------------------------------------------------------------------
export type PlayOutcome = {
finished: boolean;
turns: number;
days: number;
revenue: number[];
collisions: number;
trainsScheduled: number;
cardsPlayed: number;
outcome: GameState['outcome'];
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
};
/**
* Drives a game to completion with the given policy. This is the whole reason the phase driver
* lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
*/
/**
* Watches each event as it fires, with the state as it stands at that moment.
*
* This exists because the returned `events` log answers "what happened" but not "what was true when
* it happened" — the Office tier at a collision, which cards were in hand at a draw. Measuring those
* meant hand-rolling this loop in a throwaway script, and a hand-rolled copy that watched only the
* `pump` events (and not the ones from `applyIntent`) reported "60 of 60 games never upgraded" while
* the tier histogram plainly showed Depots and Stations. One driver, one place to observe.
*/
export type PlayObserver = (event: GameEvent, state: GameState) => void;
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
observe?: PlayObserver,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
let collisions = 0;
let trainsScheduled = 0;
let cardsPlayed = 0;
let turns = 0;
const tally = (batch: GameEvent[]): void => {
events.push(...batch);
for (const e of batch) {
observe?.(e, s);
if (e.type === 'trainScheduled') trainsScheduled++;
else if (e.type === 'cardPlayed') cardsPlayed++;
else if (
e.type === 'revenueChanged' &&
'reason' in e &&
String((e as { reason: string }).reason).startsWith('collision')
) {
collisions++;
}
}
};
for (; turns < maxTurns; turns++) {
tally(pumpFn(s));
if (s.status === 'finished') break;
const actor =
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
const r = applyIntent(s, actor, chosen);
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
tally(r.events);
}
return {
finished: s.status === 'finished',
turns,
days: s.clock.day,
revenue: s.players.map((p) => p.revenue),
collisions,
trainsScheduled,
cardsPlayed,
outcome: s.outcome,
events,
intents,
};
}