Files
station-master/src/sim/bot.ts
T
Jesse.MarkowitzandClaude Opus 5 19a6a47ab6 v0.7.4 — Red Flags hold a train out of your Limits (Gitea#19)
"If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your
limits from that direction (i.e. Flag East holds westbound trains). You can do this
if you see a problem or wish to complete switching."

REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a
stopped train out on the Mainline and protected it from a rear-ender: offered 4,212
times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already
does that job better. The flag is now planted on one side of your own district and
holds the next train arriving from that side.

SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to
build, nothing to forget, and a flag cannot quietly strangle the Division. The held
train loses one Mainline Phase and comes in on the next — it buys a Stage to clear
the lead, which is what "wish to complete switching" asks for.

PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would
certainly collide and the district's owner holds the card, the phase breaks in and
asks. Offered ONLY to somebody holding one — a prompt with a single button is not a
choice, and it would leak that a collision is coming. The danger is read from §8.3's
own two triggers rather than restated, so the prompt cannot offer a flag against a
collision that will not happen.

Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and
nothing else structural.

THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the
out-of-phase prompt unconditionally — the engine has already established the danger,
so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires
ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one
game in seven) to coincide with holding the card from a three-card hand out of 121.
So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the
bot is unwilling: it is measuring deck luck. What is left to fix is the half of the
card a human would use, planting a flag on purpose to buy switching time, and TODO.md
now says that instead of the old finding.

A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was
originally taken down in a `reduce` case, which never fires for an event advance.ts
emits — the phase driver mutates state and then describes it. The flag stayed up and
held every train that came. test/events.test.ts's unreduced-event registry is what
makes that class of mistake visible, and `redFlagSpent` is on it deliberately now,
with the reasoning.

858 tests pass.

Closes #19

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 07:12:36 -04:00

1950 lines
94 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,
facilityCarTypes,
laborersLeft,
ownCutFor,
} from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
import type { CarType, Hand, TrackGeometry } 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 { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import type { Port } from '../engine/track.ts';
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, OfficeArea, 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;
}
/**
* KNOBS FOR A/B MEASUREMENT, and nothing else.
*
* A heuristic change has to be measured against the bot it replaces, over the SAME deals — and
* editing the bot between runs makes that impossible to do honestly, because the two sides of the
* comparison never exist at once. Every flag here is off by default, so `makeDeveloperBot({})` is
* byte-identical to the bot that came before this existed.
*
* TEMPORARY BY CONSTRUCTION. When a flag measures well it becomes the default and the flag is
* deleted in the same commit; when it measures badly it is deleted with its finding recorded in the
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
*/
/**
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
*
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
* rebalance changes the economy, that is a claim to re-test rather than to assume.
*
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
* make-up, stocking the platform first, playing Interlocking earlier, hunting the Depot in the
* Departments, refusing to bury the engine, holding track for a run-around, reserving Moves to get
* home, granting clearance to a leaving train, and discarding capped train cards. Their measurements
* are in CHANGELOG.md; the code is not, because a switch nobody turns on is a switch nobody
* maintains.
*/
export type BotTweaks = {
/** Play every train card on sight, as the bot did before the A/D capacity cap. */
noTrainCap?: boolean;
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
noOperateFirst?: boolean;
};
/** The bot as it plays today. Every knob off. */
export const developerBot: BotPolicy = makeDeveloperBot({});
/** A variant, for measuring one change at a time against the bot above. */
export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
const suffix = Object.entries(tweaks)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${v}`)
.join(',');
return {
name: suffix ? `developer+${suffix}` : 'developer',
choose(s, player, options) {
lastReason = 'no specific reason — first legal option';
/**
* §3.3, EXTENDED PLAY (Gitea#11) — a bot never asks for another Day.
*
* "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28), which is what keeps
* the balance harness and every bot-only game ending at the timetable it was dealt with. It also
* makes this the SAFE DEFAULT everywhere else: a bot's agreement in a game with humans in it is
* decided by `server/session.ts`, which votes on the bots' behalf only once every human has
* already said yes, and never reaches this policy at all.
*/
const extend = options.find((i) => i.type === 'game.extend' && i.agree === false);
if (extend) return because('a bot plays the timetable it was dealt and no more', extend);
const clearance = ruleOnClearance(options);
if (clearance) return because('the Superintendent must rule on a following train', clearance);
/**
* §11 (Gitea#5) — the bot keeps its trains at the Train Order Office.
*
* A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office
* frees an A/D track, which is worth something on a busy district, but the lead into it may be
* fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot
* Performance — it cannot spot a car at a stub industry either). Declining is always safe, and
* it keeps the balance harness comparable with every measurement taken before this rule existed.
* Worth revisiting when the bot can judge the lead.
*/
const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false);
if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice);
// 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('the engine says this arrival collides, and we hold a Red Flag — now or never', 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') {
/**
* A CAP HAS TO BE APPLIED TO THE OPTIONS, NOT TO ONE BRANCH.
*
* The first attempt gated the two branches that exist to play a train card, measured exactly
* zero difference over 400 paired seeds, and was right to: `followThrough` ends with a generic
* "play what is in hand" fallback that played the train card anyway. Removing the option
* itself is the only way to be sure no path reaches it.
*
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
*/
/**
* A CAP HAS TO BE APPLIED TO THE OPTIONS, NOT TO ONE BRANCH.
*
* The first attempt gated the two branches that exist to play a train card, measured exactly
* zero difference over 400 paired seeds, and was right to: `followThrough` ends with a generic
* "play what is in hand" fallback that played the card anyway. Removing the option itself is
* the only way to be sure no path reaches it.
*
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
*/
const held = trainWouldOverfillTheOffice(s, player, tweaks)
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
: options;
const usable = held.length > 0 ? held : options;
if (turnOf(s, player).option === null) return chooseLocalOption(s, player, usable, tweaks);
return followThrough(s, player, usable, tweaks);
}
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.
*/
/**
* Trains this player has already committed to running: slots filled on the timetable, plus Extras
* played and waiting for a Crew Tray.
*
* A Second Section is deliberately NOT counted. Its whole purpose is to put a following train into
* an occupied Subdivision and force the Superintendent's ruling (Q9) — capping it would be capping
* the card's reason to exist.
*/
function committedTrains(s: GameState): number {
return s.timetable.filter((t) => t !== null).length + s.pendingExtras.length;
}
/**
* Would scheduling another train exceed what the Office can physically hold?
*
* §7 lets you play as many train cards as you draw, and a train that arrives with nowhere to stand
* is an automatic collision (Gap 2d) — so the two rules together make a train card actively harmful
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
*/
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
if (tweaks.noTrainCap) return false;
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
}
/**
* Would running here leave the engine buried among its own cars?
*
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
*
* Asked of the engine's own destination list, so the count is the count that will really couple.
*/
function wouldBuryTheEngine(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): boolean {
const tray = s.trays.get(move.trayId);
if (!tray || tray.trainNumber === null) return false;
if (tray.position.at !== 'grid') return false;
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
const len = tray.consist.length;
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
return (dest?.couples.length ?? 0) > 0;
}
function chooseLocalOption(
s: GameState,
player: PlayerIndex,
options: Intent[],
tweaks: BotTweaks,
): 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) ?? [];
/**
* OPERATE BEFORE DRAWING.
*
* The bot drew 47 of its 60 turns and played 24 cards, and the funnel said why that was wrong:
* only 8% of Cargo phases had a stocked green box — the scarcest thing in the whole economy — and
* the bot was spending the turns that would have filled one on another card instead. Preferring
* the work to the lottery raises green-stocked phases by 60% and freight revenue by 39%.
*
* MEASURED AT +0.48 ± 0.07 (t = 6.60) over 1600 paired seeds, and 317 seeds better against 77
* worse — the broadest improvement of anything tried, where the A/D cap below is a narrow rescue
* of a rare catastrophe.
*
* NO BUILDING PHASE, though the idea arrived as "build for a Day, then switch". Reserving the
* first Day, or two, for development measures the same (+0.52, +0.56) with a worse better/worse
* ratio, and worse again alongside the cap. An explicit build phase spends turns on development
* the draw-priority ordering was already doing.
*/
if (!tweaks.noOperateFirst) {
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
return because('a train is at the Office and there is work to do on it — that beats another draw', can('switch')!);
}
if (can('freightAgent') && (canStockProductively(s, player) || hasStuckLoad(s, player) || needsClearing(s, player))) {
return because('feed the pipeline rather than draw another card — a stocked green box is work, a draw is a lottery', can('freightAgent')!);
}
if (can('switch') && strandedFromOffice(s, player)) {
return because('the crew must get back to the Office to depart at all', can('switch')!);
}
}
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
// whose value compounds. Playing one needs the draw option.
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
// 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)) &&
!trainWouldOverfillTheOffice(s, player, tweaks)
) {
return because('a train card is in hand and its value compounds every Day', can('draw')!);
}
/**
* NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
*
* There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
* requires `turnOf(s, player).option === 'draw'` and this runs while the option is still null, so
* `legalActions` had already filtered it out — the branch could never fire and never had.
*
* Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
* paired over 400 seeds (t = -3.90, 104 seeds worse against 35 better). Its comment claimed the
* value compounds like a train card's; it does not. Spending a Local Operations action on a
* Mainline modifier costs more than the crossings it saves. Left unwritten deliberately.
*/
// 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 when the load can actually be worked — an empty car of the right type is
// already spotted. Staging ahead of the car is legal (§6.3) and cannot jam anything, but it
// spends the Stage's one Freight Agent action on a load no Laborer can touch yet, so it is not
// worth CHOOSING the option for; it is taken only as a fallback once the option is chosen.
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 —
// unless a green box could be filled, in which case a turn spent stocking converts directly into
// work a Laborer can do, where a drawn card may be neither playable nor useful.
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
// Freight Agent is deliberately NOT the idle default. Reaching its follow-through with nothing
// productive to do is what fed the unjam fallback that discarded loads; switching, at worst,
// moves the crew toward the Office, which a train must reach to depart at all (§8.1).
return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
}
/** The face-up card on top of a Department pile — the only one that may be taken. */
function topOfDepartment(s: GameState, slot: number): string | undefined {
const pile = s.decks.departments[slot] ?? [];
return pile[pile.length - 1];
}
/**
* WHERE TO DISCARD, now that the Departments are shared piles and a discard goes on TOP.
*
* Burying a card puts it out of reach — only the top of a pile may ever be taken — so the choice is
* which face-up card to cover. Cover the one you would not have taken anyway, and the cards you DO
* want stay reachable. `pickFirst` always chose Department 1, which buried whatever happened to be
* sitting there, including the Depot the bot was waiting on.
*
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.discard') continue;
// Cover a card that is no use, never one worth taking. Ties break toward the SHALLOWEST pile:
// three piles offer three face-up cards, and piling discards onto one of them leaves the other
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
return takingRank(s, player, slot) > 0;
}
/**
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
*
* A bare boolean made an Office card and a train card tie, so the winner was whichever Department
* happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all.
*/
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
const id = topOfDepartment(s, slot);
if (!id) return 0;
const k = s.cards.get(id)?.kind;
if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1;
return 0;
}
/** Does any facility want this particular car? Tolerates an undefined end car. */
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.
*
* A PRIORITY TEST, NOT A LEGALITY TEST — the engine stocks either way (§6.3). What it decides is
* whether stocking is worth the Local Operations action THIS Stage: with a car spotted the load
* moves next Stage, without one it sits.
*
* The type match matters when it does move: 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. `loadCanFinish` keeps the Laborers off such a load.
*/
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;
/**
* The load has to COME from somewhere. `freightAgent.stockOutbound` requires a LOADED car of
* that commodity in the Division Yard, and this predicate did not check for one — so the bot
* chose the Freight Agent option believing it could stock, found no legal stock, and fell
* through to the unjam fallback that discarded a green box. Two rules for the same act, and
* only one of them was true.
*/
const inYard = (type: string): boolean =>
s.yards.divisionYard.some((c) => c.type === type && c.loaded);
// 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') {
if (inYard('coach')) return true;
continue;
}
const want = facilityCarTypes(f);
if (!f.industryTrack.cars.some((c) => !c.loaded && want.includes(c.type))) continue;
if (want.some((t) => inYard(t))) 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?.menAtWork || 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.
*/
/**
* Which side of the Running Track a district is being walked on. `-1` is the rows below, `+1` the
* rows above — both real now that a turnout turned 180° sends its 45° leg north. Every walk below
* is written once and run for each side; the old code hard-coded "below" throughout and so could
* not see half the board.
*/
type Side = -1 | 1;
const SIDES: readonly Side[] = [-1, 1];
/** The edge of a main-line card that a district on this side hangs from. */
const legPort = (side: Side): Port => (side === -1 ? 's' : 'n');
/**
* Walk the Secondary Track away from the Running Track, using the ENGINE's own `exitsFrom`.
*
* Shared deliberately. Track laying and facility placement both need to know what a crew can
* actually reach, and two copies of this walk would eventually disagree about whether a district
* connects — which is the one thing both decisions rest on. A turnout's legs never join each other
* (§A.1), so this traverses in (cell, entry port) space rather than treating a cell as one node.
*
* Every step tests `joins`, not `hasPort`: two 45° legs can meet at the same point on a card edge
* and still not be a rail, if they lie on opposite diagonals.
*/
function descendFrom(
area: OfficeArea,
col: number,
side: Side,
): { rejoins: Set<number>; seen: Set<string> } {
const main = area.runningRow;
const rejoins = new Set<number>();
const seen = new Set<string>();
const entry = opposite(legPort(side));
const start = area.grid.get(`${main + side},${col}`);
if (!start || !hasPort(start, entry)) return { rejoins, seen };
const stack: { row: number; col: number; entry: Port }[] = [{ row: main + side, col, entry }];
const visited = new Set<string>();
while (stack.length > 0) {
const cur = stack.pop()!;
const id = `${cur.row},${cur.col}:${cur.entry}`;
if (visited.has(id)) continue;
visited.add(id);
const card = area.grid.get(`${cur.row},${cur.col}`);
if (!card) continue;
seen.add(`${cur.row},${cur.col}`);
for (const exit of exitsFrom(card, cur.entry)) {
const n = neighbour({ row: cur.row, col: cur.col }, exit);
const next = area.grid.get(`${n.row},${n.col}`);
if (!next || !joins(card, exit, next)) continue;
// Back onto the main, or past it onto the far side: either way the siding has rejoined.
if (side === -1 ? n.row >= main : n.row <= main) {
if (n.col !== col) rejoins.add(n.col);
continue;
}
stack.push({ row: n.row, col: n.col, entry: opposite(exit) });
}
}
return { rejoins, seen };
}
/** Every way off the main on one side, by column. */
function waysOff(area: OfficeArea, side: Side): number[] {
const out: number[] = [];
const p = legPort(side);
for (const [k, c] of area.grid.entries()) {
const [r, col] = k.split(',').map(Number);
if (r !== area.runningRow) continue;
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
if (beyond && joins(c, p, beyond)) out.push(col!);
}
return out;
}
/** Off-main squares a crew can reach from the Running Track at all — siding or stub, either side. */
function reachableOffMain(area: OfficeArea): Set<string> {
const reached = new Set<string>();
for (const side of SIDES) {
for (const col of waysOff(area, side)) {
for (const k of descendFrom(area, col, side).seen) reached.add(k);
}
}
return reached;
}
/**
* Off-main squares that lie on a RUN-AROUND — a siding reaching the main at both ends, so a crew
* can pass its own standing cars (§A.5). A facility here can be worked from either direction; one
* on a stub can only be reached by shoving in and backing out.
*/
function runAroundCells(area: OfficeArea): Set<string> {
const onLoop = new Set<string>();
for (const side of SIDES) {
for (const col of waysOff(area, side)) {
const { rejoins, seen } = descendFrom(area, col, side);
if (rejoins.size === 0) continue;
for (const k of seen) onLoop.add(k);
}
}
return onLoop;
}
/**
* WHERE AN INDUSTRY GOES.
*
* This was `options.find(i => i.placement !== undefined)` — the first legal placement the generator
* happened to list, entirely unscored, while track laying got sixty lines of scoring. The measured
* result: once the bot started closing a run-around in 91 games of 100, facilities sitting ON one
* stayed at 0.00 a game. The loops got built and the industries were put somewhere else.
*
* A facility is worked by spotting a matching car on its industry track (§9.3), so where it sits
* decides whether that is possible at all:
*
* - IN LINE with the siding, a facility becomes part of the run-around itself — its own through
* track is a segment of the loop — so a crew reaches it from either end and can pass its
* standing cars (§A.5). This is the placement worth paying for.
* - Merely touching reachable track still works, by shoving in and backing out.
* - On the Running Track a car left standing is hit by the next arrival (§11.2), so an industry
* there is one the crew dare not serve.
*/
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
const onLoop = runAroundCells(area);
const reachable = reachableOffMain(area);
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue;
const { row, col } = i.placement;
if (!facilityVariants()[i.variant ?? 0]) continue;
// An industry card is a plain east-west straight, as every one on the printed sheet is. There
// is no north-south rotation to weigh any more; a facility joins its neighbours along the row.
const ports: Port[] = ['e', 'w'];
// Closer to the Office is fewer Moves to reach, same as track scoring.
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
// The Running Track is not merely a bad square for an industry any more, it is an illegal one
// (`ON_RUNNING_TRACK`), so `check` has already filtered those out before this sees them.
{
let meetsLoop = false;
let meetsReachable = false;
for (const p of ports) {
const n = neighbour({ row, col }, p);
const nc = area.grid.get(`${n.row},${n.col}`);
if (!nc || !hasPort(nc, opposite(p))) continue;
const k = `${n.row},${n.col}`;
if (onLoop.has(k)) meetsLoop = true;
if (reachable.has(k) || n.row === area.runningRow) meetsReachable = true;
}
// A facility cannot be inserted into a loop that is already closed — one card per square — so
// the placement that lands ON a run-around is the one laid IN LINE with a siding still being
// built, along a row beside the main. That is scored highest deliberately.
const inLineWithSiding =
Math.abs(row - area.runningRow) === 1 &&
(reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
if (inLineWithSiding) score += 26;
else if (meetsLoop) score += 20;
else if (meetsReachable) score += 12;
}
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
/**
* Can the siding continue THROUGH this neighbour, into the square being scored?
*
* Asked by the neighbour's PORTS, not its card kind. Testing for "a track straight running
* east-west" meant an industry standing in the line read as a dead end — and once facilities
* started being placed on the siding on purpose, the run stopped extending past the first one and
* run-arounds fell from 91 games in 100 to 36. A Facility carries its own rails (§11.2); so does
* an Office; so does a Limits sign. What matters is whether a port faces this way.
*/
const carriesInto = (c: TrackCard | undefined, toward: Port): boolean =>
!!c && hasPort(c, toward);
/**
* A main-line card a siding on this side can climb back onto — asked of the ENGINE, not guessed
* from the card kind. It is not enough that the card has the edge: the arc climbing to it must lie
* on the same diagonal, so this takes the arc's own slope and compares.
*/
const wayBack = (c: TrackCard | undefined, side: Side, from: TrackCard): boolean =>
!!c && joins(from, opposite(legPort(side)), c);
/** Main-line columns with any leg on this side. Without one beyond it, a run goes nowhere. */
const waysBack = (side: Side): number[] =>
[...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && hasPort(c, legPort(side)))
.map(([k]) => Number(k.split(',')[1]));
const reachableOff = reachableOffMain(area);
/**
* Straights already on the Running Track — the only thing an Enhancement can attach to.
*
* Interlocking, Water Column and Telegraph all print "any Running Track Straight", and Telephone
* and Radio chain off Telegraph. The bot used to build minimal two-arc run-arounds, so it never
* needed a straight and never laid one: 0.00 on the Running Track across 100 games, and with it 13
* of the 18 enhancement cards that go on the board were structurally unplayable. Enhancements were
* drawn 3.78 a game and placed 0.64.
*
* The 45° geometry has since made straights unavoidable — a leg off the main only ever reaches the
* next row, so the run back along it is straights — and the bot now lays 3.44 a game against 1.01
* under the old rules. This bonus still earns its place for the FIRST one, which the sequence
* would otherwise reach only by accident.
*
* Interlocking is the expensive one. It is the designed answer to the ONLY penalty in the game —
* "no free A/D track", which cost 2.40 revenue a game, 27% of gross — and it had never once been
* placed, so no train was ever held at the Limits instead of colliding.
*/
const mainStraights = [...area.grid.entries()].filter(
([k, c]) =>
Number(k.split(',')[0]) === area.runningRow &&
c.geometry.kind === 'track' &&
c.geometry.geometry === 'straight',
).length;
/**
* A track play, split into the two things the scoring cares about.
*
* Track is an ordinary deck card now, so a lay arrives as `card.play` with a placement and a
* rotation, and the piece it would put down has to be read back off the card in hand.
*/
type Lay = {
intent: Intent;
geometry: TrackGeometry;
hand: Hand;
variant: number | undefined;
placement: GridCoord;
};
const laysIn = (opts: Intent[]): Lay[] => {
const out: Lay[] = [];
for (const i of opts) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
const kind = s.cards.get(i.cardId)?.kind;
if (kind?.kind !== 'track') continue;
out.push({ intent: i, geometry: kind.geometry, hand: kind.hand, variant: i.variant, placement: i.placement });
}
return out;
};
/** The card a lay would place, built exactly as the reducer builds it. */
const probeOf = (i: Lay): TrackCard => {
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
return {
geometry: {
kind: 'track',
geometry: i.geometry,
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
...(i.hand !== 'none' ? { hand: i.hand } : {}),
},
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
} as unknown as TrackCard;
};
const arcOf = (i: Lay): string | undefined => variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
/**
* RAIL THAT CAN NEVER GO ANYWHERE.
*
* A port facing an EMPTY square is a promise: something may be built there later. A port butting
* an OCCUPIED square whose card has no matching port is nothing of the kind — that square is
* taken, so the rail stops dead and always will.
*
* Measured, 28% of the pieces the bot laid had one, at 4.26 a game: an arc dropped off a turnout
* with its far end jammed into a curve that could not accept it, which is exactly the placement
* that prompted this. Penalised rather than forbidden — a piece can still be worth laying for the
* end that DOES connect, and the last two attempts to forbid something outright starved the
* district instead of tidying it.
*/
const deadEnds = (i: Lay): number => {
const probe = probeOf(i);
let dead = 0;
for (const p of new Set(connectionsFor(probe).flat())) {
const n = neighbour(i.placement, p);
const nb = at(n.row, n.col);
if (!nb) continue; // empty — may still be built on
if (joins(probe, p, nb)) continue; // a real rail crosses the edge
dead++;
}
return dead;
};
/**
* A curve of the MATCHING hand to hang under this turnout, somewhere the player can still reach.
*
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
* no help to it. That is a match on hands, not on a particular hand, so it was unaffected when
* the two were found to be inverted and swapped.
*
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
* a draw away rather than a certainty.
*/
const arcInHand = (hand: Hand): boolean =>
(s.decks.hands.get(player) ?? []).some((id) => {
const k = s.cards.get(id)?.kind;
return k?.kind === 'track' && (k.geometry === 'curved' || k.geometry === 'sharpCurved') && k.hand === hand;
});
// Where the district already turns off the main, either side.
const turnouts = [...area.grid.entries()]
.filter(
([k, c]) =>
Number(k.split(',')[0]) === area.runningRow &&
c.geometry.kind === 'track' &&
c.geometry.geometry === 'turnout',
)
.map(([k]) => Number(k.split(',')[1]));
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of laysIn(options)) {
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);
// Bonuses are tracked apart from the distance score so the bot can tell a piece that BUILDS
// something from one that is merely legal. Without that distinction it always returned its
// best-scoring option, and spent all 26 pieces on whatever was placeable.
let bonus = 0;
/**
* 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 === 'straight' && mainStraights < 1) {
// Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go
// without competing further with the ways down. Scored below a first turnout, which is still
// the most valuable single piece on the board, and below closing a run-around.
bonus += 13;
} else if (row === area.runningRow && i.geometry === 'turnout' && arcInHand(i.hand)) {
// 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.
/**
* A SECOND WAY DOWN, WHILE THE FIRST ONE IS STILL A STUB.
*
* Ways off the main are individually valuable — capping them measured worse — but one that is
* cut while an earlier run has never climbed back spends the supply on breadth when the thing
* that makes switching solvable is one run that closes. `openRuns` asks the engine which runs
* descend from the main and never rejoin it.
*/
bonus += turnouts.length === 0 ? 14 : 3;
} else if (Math.abs(row - area.runningRow) === 1) {
// Which side of the main this square is on. Both are ordinary now: a turnout turned 180°
// sends its leg north, so a siding above the Running Track is built exactly like one below.
const side: Side = row < area.runningRow ? -1 : 1;
const arc = arcOf(i);
// What this square is anchored to, and therefore which way a run through it would extend. The
// anchor must be reachable from the main: extending a stranded fragment builds nothing.
const anchoredAt = (c: number): boolean =>
reachableOff.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w');
const anchorWest = anchoredAt(col - 1);
const anchorEast = anchoredAt(col + 1);
// Does this arc climb back toward the main, rather than one row further away from it?
if (arc !== undefined && arc.includes(opposite(legPort(side)))) {
/**
* AN ARC IS ONLY WORTH LAYING WHERE IT CAN REACH THE MAIN.
*
* This used to score on shape alone: "close the loop back up to the main" was awarded to any
* arc beside an east-west run, whether or not there was anything above it to join. Measured,
* that produced 0 run-arounds in 100 games — the siding terminated in an arc pointing north
* into empty space, past the east end of the Running Track, every game.
*
* Two things now decide whether it actually reaches. An arc has exactly two ports, so which
* way it faces decides what it meets — `nw` joins north and WEST, `ne` north and EAST — and
* on top of that the main-line card's own leg must lie on the SAME DIAGONAL, or the two 45°
* rails meet at a point on the card edge and form a V. `wayBack` asks the engine both.
*/
if (wayBack(at(area.runningRow, col), side, probeOf(i))) {
const meetsRun = (arc.includes('w') && anchorWest) || (arc.includes('e') && anchorEast);
// Closing an existing run beats opening a new one: that is the whole difference between a
// run-around and one more stub.
bonus += meetsRun ? 15 : 13;
}
// An arc with nothing beyond it earns no bonus but is not forbidden — reaching a facility
// two rows out is still worth doing when there is no loop to close.
} else if (i.geometry === 'straight') {
/**
* Extend the siding beside the main, but only from something that already turned AND only
* while there is still somewhere to climb back. Unbounded, this rule ran the siding east
* past the last usable column in 96 of 100 games — the loop then missed by one card.
*/
const back = waysBack(side);
const canStillClose =
(anchorWest && back.some((c) => c > col)) || (anchorEast && back.some((c) => c < col));
if ((anchorWest || anchorEast) && canStillClose) {
bonus += 11;
}
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
// A TIE-BREAKER, not a veto. Applied to the distance score rather than the bonus, so it can
// never push a piece below the "builds nothing" floor and refuse it outright: given two
// placements the heuristics rate the same, take the one whose rail can still grow.
score -= deadEnds(i) * 0.5;
/**
* DECLINE A PIECE THAT BUILDS NOTHING.
*
* This used to return its best option unconditionally, so once the useful squares were taken it
* kept laying track simply because track was legal — 21 pieces a game, ending in straights and
* arcs east of the last column with a way up, joined to nothing. Returning null instead lets the
* turn fall through to playing a card, which is what the Stage is worth more spent on.
*/
if (bonus <= 0) continue;
if (score + bonus > bestScore) {
bestScore = score + bonus;
best = i.intent;
}
}
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;
}
/**
* §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger.
*
* The card was redefined: it plants a directional flag on your own Limits rather than protecting a
* stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline
* card") no longer describes anything the card does.
*
* The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an
* arrival would collide (`redFlagStop`). That is a better policy than the old one and a much
* simpler one: the engine has already established the danger, so there is nothing for the bot to
* judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and
* a flag spent early is a flag not there when a train is actually bearing down.
*/
function worthFlagging(_s: GameState, options: Intent[]): Intent | null {
return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? 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';
}
}
/**
* Would a load stocked here be workable straight away — is there a spotted empty car of that type
* not already promised to a load in the box or on the sign?
*
* A PREFERENCE, NOT A RULE. §6.3 stocking has no car requirement; this only ranks one legal stock
* above another, so the Stage's single Freight Agent action goes where a Laborer can use it.
*/
function stockCanBeWorked(s: GameState, player: PlayerIndex, at: GridCoord, carType: CarType): boolean {
const f = areaOf(s, player).grid.get(coordKey(at))?.facility;
if (!f) return false;
// Passengers board off the platform with no car spotted at all (§9.2), so a stocked coach is
// always immediately workable.
if (f.kind !== 'freight') return true;
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === carType).length;
const staged = f.outboundBox.filter((c) => c.type === carType).length;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === carType).length;
return spotted - staged - working > 0;
}
/**
* 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), and `couples` is ordered
* NEAREST FIRST — so the last car met ends up nose-most and the cut reverses on its way into the
* tray. Predicting it unreversed made the bot's run-around test read the wrong car as the tail
* whenever it approached a cut of two or more forwards.
*/
return move.reverse
? [...tray.consist, ...dest.couples]
: [...[...dest.couples].reverse(), ...tray.consist];
}
/**
* Would this move drag the cars the crew has just set out back onto the train?
*
* Coupling is mandatory, so pulling out through the end a cut is standing at picks it up again —
* which usually undoes the set-out the crew made this turn. The bot cannot make an ILLEGAL move
* (legality is enforced upstream), so this costs it nothing to ignore; it simply wastes the Move,
* and the crew arrives with the car it was trying to leave behind.
*/
function recouplesOwnCut(s: GameState, player: PlayerIndex, i: Intent): boolean {
return i.type === 'switch.move' && ownCutFor(s, player, i.trayId, i.reverse).length > 0;
}
/** 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[],
tweaks: BotTweaks,
): Intent {
switch (turnOf(s, player).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 (!turnOf(s, player).drawnThisTurn) {
const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
);
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
// both "qualify", and only one of them stops the collisions.
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district
// ever becomes a Passenger Facility is not worth keeping for its own sake.
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
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. Unless the
// Office cannot hold another one, in which case the card is HELD rather than played: an
// arrival with no free A/D track is an automatic collision, and it repeats every Day.
const train = trainWouldOverfillTheOffice(s, player, tweaks)
? undefined
: 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. Freight facilities are scored —
// where an industry sits decides whether a crew can ever serve it — and everything else
// placeable falls through to the first legal square.
const facility = bestFacilityPlay(s, player, options);
if (facility) return because('put the industry where a crew can actually work it', facility);
/**
* TRACK IS NOT A FALLBACK.
*
* Track became an ordinary `card.play` when it moved into the deck, so this generic "play
* anything placeable" catch dumped it on whatever square was legal — bypassing `bestTrackLay`
* entirely, which had already looked at the same piece and declined it. Measured: 26 of 60
* districts ran the siding east past the last column with a way up, spending track on a loop
* that could never close. A track card `bestTrackLay` will not use is a card to discard.
*/
const placed = options.find(
(i) =>
i.type === 'card.play' &&
i.placement !== undefined &&
s.cards.get(i.cardId)?.kind.kind !== 'track',
);
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 — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
case 'switch': {
/**
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
* before any heuristic gets to choose one.
*
* Coupling is mandatory (§A.4), so pulling out through the end a cut is standing at picks it
* straight back up — which undoes the set-out the crew just made. Measured before this filter:
* **625 of 1,029 set-outs across 60 games were recoupled by the very next move**, 219 of them
* cars that had just been spotted at an industry. The bot was not doing anything illegal; it
* was spending two Moves to end up where it started, over and over.
*
* Filtered here rather than at each `find` below because there are six of them and a rule that
* has to be remembered at six call sites is a rule that will be forgotten at the seventh. The
* originals come back if the filter would leave the crew with no move at all — backed into a
* stub behind its own cars, the only way out IS through them, and standing still is worse.
*/
const clear = options.filter((i) => !recouplesOwnCut(s, player, i));
if (options.some((i) => i.type === 'switch.move') && clear.some((i) => i.type === 'switch.move')) {
options = clear;
}
// 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);
/**
* DIG THE ENGINE OUT BEFORE ANYTHING ELSE.
*
* §8.2 will not let a train leave the Office with its engine buried among its own cars, and
* taking a cut onto the nose is exactly how that happens. Left alone it is terminal: the train
* holds an A/D track for the rest of the game, every train behind it collides, and the freight
* chain it was serving starves. Measured before this: 90 holds across 40 games, six of them
* with a train stuck permanently.
*
* Setting the nose cars out is the move Appendix A uses for it — "back up and drop off
* everything on the nose of your train" — and it is worth spending a Move on ahead of any
* freight work, because nothing else the crew does matters until the train can run again.
*/
const buried = trayOf(s, player);
if (buried && buried.engineAt > 0 && buried.engineAt < buried.consist.length) {
const dig = options.find(
(i) => i.type === 'switch.dropCars' && i.fromNose === true && i.count === buried.engineAt,
);
if (dig) return because('the engine is buried in its own train — set out the cars on the nose so it can leave the Office', dig);
}
/**
* A CABOOSE OUT OF PLACE STOPS THE TRAIN JUST AS DEAD.
*
* It has to ride at the rear (§8.2), and a run-around can easily leave it mid-train. If it is
* sitting at an outer end that is the WRONG end, setting it out is the one Move that frees the
* train — §8.2 lets a train depart with fewer cars, never with the wrong ones in the wrong
* order, so running cabooseless beats not running at all.
*/
if (buried) {
const n = buried.consist.length;
const cab = buried.consist.findIndex((c) => c.type === 'caboose');
const rear = buried.engineAt === 0 ? n - 1 : 0;
if (cab !== -1 && cab !== rear) {
const atNoseEnd = cab === 0 && buried.engineAt > 0;
const atTailEnd = cab === n - 1 && buried.engineAt < n;
const shed = options.find(
(i) => i.type === 'switch.dropCars' && i.count === 1 && (i.fromNose === true) === atNoseEnd,
);
if ((atNoseEnd || atTailEnd) && shed) {
return because('the caboose is not at the rear and the train cannot leave — set it out', shed);
}
}
}
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);
/**
* PREFER THE BOX WHOSE LOAD CAN ACTUALLY BE WORKED.
*
* §6.3 lets the Freight Agent stage a load with no car spotted — cargo may wait on the dock —
* so every facility with room and a matching car in the Division Yard is now offered, not just
* the ready ones. `canStockProductively` chose the option believing a ready facility existed;
* taking the first offer regardless would spend the Stage's one Freight Agent action on some
* other industry that cannot move its load for want of a car. Staging ahead is still taken as
* a fallback: it is legal, it cannot jam anything, and it saves an action later.
*/
const stockable = options.filter(
(i): i is Extract<Intent, { type: 'freightAgent.stockOutbound' }> =>
i.type === 'freightAgent.stockOutbound',
);
const workable = stockable.find((i) => stockCanBeWorked(s, player, i.at, i.carType));
const stock = workable ?? stockable[0];
if (stock) {
return because(
workable
? 'stock a green box whose load a Laborer can start next Stage'
: 'stage a load ahead of the car — it waits in the green box until one is spotted',
stock,
);
}
/**
* NEVER UNJAM A HEALTHY BOX.
*
* This used to fall through to `pickFirst(options, 'freightAgent.unjam')` — "clear whatever
* is stuck" — with nothing stuck. `facilityUnjammed` from `outbound` splices the load out of
* the green box and pushes it to the classification yard, so the fallback destroyed a load
* that had cost a Local Operations action to stock. Measured at 3.10 a game against 2.71
* loads STARTED: the bot threw away more freight than it worked.
*
* §6.3's unjam is for a load stranded on MEN|AT|WORK that cannot advance. A green box holding
* a load waiting for a car is not jammed, it is waiting.
*/
/**
* NOTHING WORTH DOING — SO DO NOTHING.
*
* The option used to have no "end" action, so this fell through to unjamming whichever box
* was least costly to wreck. §6.3 requires no action at all, and it now says so: a wasted
* Stage is strictly better than destroying a load that cost a whole action to stock.
*/
const idle = options.find((i) => i.type === 'freightAgent.end');
if (idle) return because('nothing productive at any facility — leave the Freight Agent idle rather than wreck a working box', idle);
const byCost = (['menAtWork', 'inbound', 'outbound'] as const).map((from) =>
options.find((i) => i.type === 'freightAgent.unjam' && i.from === from),
);
const fallback = byCost.find((i) => i !== undefined);
return because('nothing productive at any facility — clearing the least costly box', fallback ?? 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 >= MAX_CONSIST) continue;
// Every commodity the industry handles, asked of its profile. A hand-written switch here had
// drifted from the sheet: it named two industries that do not exist (`produceShed`,
// `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
// half the industries in the deck never asked for a car at all.
for (const type of facilityCarTypes(f)) {
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;
const coaches: WantedCar[] = [];
if (office?.kind === 'passenger' && office.porters > 0) {
const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
if (arrivalsWaiting) coaches.push({ type: 'coach', loaded: true });
}
coaches.push({ type: 'coach', loaded: false });
return [...out, ...coaches];
}
/**
* 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 room on the industry's
* track, which holds four cars like any other card (§9.3).
*/
function facilityWants(f: Facility, car: RollingStock): boolean {
if (f.kind !== 'freight') return false;
if (f.industryTrack.cars.length >= MAX_CONSIST) return false;
if (!facilityCarTypes(f).includes(car.type)) 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.seat === 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.seat === 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 — everything the game emitted, in order. Narration, not a reducible record. */
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
/**
* The same intents in full, which is a SAVE: `{ seed, history }` replays exactly through the
* engine, so a game the bot played can be watched in the site's replay viewer.
*
* Kept alongside the type list rather than replacing it — `summarize` counts types and has no use
* for the payloads, and a bot game is 300-odd intents either way.
*/
history: Intent[];
};
/**
* 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;
/**
* Called once per DECISION, immediately before the policy is asked to choose.
*
* Separate from `PlayObserver` because the two sample different things. An event is something that
* happened; a turn hook sees the position as the bot sees it, which is the only place to ask a
* question of the form "was anything ready to be done here?" — a condition rather than an
* occurrence. Sampling those off events instead would count them once per event in the batch.
*/
export type TurnObserver = (state: GameState) => void;
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
observe?: PlayObserver,
onTurn?: TurnObserver,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
const history: Intent[] = [];
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;
/**
* §3.3, EXTENDED PLAY (Gitea#11) — a simulated game plays the timetable it was dealt.
*
* DECIDED BY THE DRIVER, not by the policy, and that distinction is the whole point. A bot that
* is merely handed the two votes among its legal options will sometimes take another Day —
* `randomBot` does so half the time — and since the table can go on granting Days for ever, the
* game then runs until `maxTurns`. That is not a hypothetical: it turned `test/sim.test.ts` from
* under a second into an unbounded hang, because every seeded game in the harness suddenly played
* fifty thousand turns instead of two hundred.
*
* The harness exists to measure games of a configured length against a configured floor, so
* "would you like more Days?" has one answer here whatever the policy. `developerBot` declines on
* its own account too, which is what the server relies on when a table is all bots; this is the
* guarantee that holds for every OTHER policy, including ones not written yet.
*/
if (s.status === 'awaitingExtension') {
const voter = s.extensionVotes.findIndex((v) => v === null);
if (voter < 0) break;
const decline: Intent = { type: 'game.extend', player: voter, agree: false };
intents.push(decline.type);
history.push(decline);
const declined = applyIntent(s, voter, decline);
// A broken invariant, not a game ending early: the status says a vote is pending and `voter` is
// a seat that has not cast one. Thrown rather than broken out of, matching the illegal-action
// check below — silently returning a short game is how a dead replay looks like a real one.
if (!declined.ok) throw new Error(`the extension vote was refused with ${declined.code}`);
tally(declined.events);
continue;
}
const actor = actingPlayer(s);
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
onTurn?.(s);
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
history.push(chosen);
const r = applyIntent(s, actor, chosen);
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
tally(r.events);
}
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,
history,
};
}