/** * 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, extendLimitsIfNeeded, isLockedOut, protoCard, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft, ownCutFor, } from '../engine/apply.ts'; import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts'; import type { CarType, FreightKind, 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 { canPlaceAt, 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'; import { planSwitchingTurn, switchFingerprint } from './switch-planner.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. The first two were worth about +1.5 revenue together when adopted, and * planning the switching turn (`noPlanSwitching`) +2.89 on its own; 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; /** * Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the * whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth * +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse. */ noPlanSwitching?: boolean; /** * Take a face-up train or industry card whether or not it could be played, as the bot did before * 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded * 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10 * (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211. */ noPlayableTakes?: boolean; /** * Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the * bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`). * Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds * in 22 of 60 districts against 9. */ noValueLays?: boolean; /** * Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because * `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the * bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass * or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds. */ noDeliberateNewTrain?: boolean; }; /** * A switching turn planned once and then played a step per decision. * * Keyed by the tweaks object, because that is what one policy owns — the server shares a single * `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is * submitted only while the position still matches the fingerprint the plan expected there; anything * else replans. A switching turn has no randomness, so in practice a plan is made once a turn. */ type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string }; const activePlans = new WeakMap>(); function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null { let mine = activePlans.get(tweaks); if (!mine) activePlans.set(tweaks, (mine = new Map())); const here = switchFingerprint(s, player); let active = mine.get(player); if (!active || active.keys[active.next] !== here) { const p = planSwitchingTurn(s, player); active = { steps: p.steps, keys: p.keys, next: 0, summary: `position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` + (p.complete ? '' : ', search budget reached'), }; mine.set(player, active); } if (active.next >= active.steps.length) { mine.delete(player); const end = options.find((i) => i.type === 'switch.end'); return end ? because(`planned switching turn complete — ${active.summary}`, end) : null; } const want = JSON.stringify(active.steps[active.next]); const match = options.find((i) => JSON.stringify(i) === want); if (!match) { mine.delete(player); return null; } active.next++; return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match); } /** 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); } if (!tweaks.noDeliberateNewTrain) { const move = pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ?? options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ?? options[0]!; return because('no car on offer is one our facilities need', move); } 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, ): 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 => 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[], tweaks: BotTweaks): 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, tweaks); 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, tweaks: BotTweaks): boolean { return takingRank(s, player, slot, tweaks) > 0; } /** * Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement * rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west * track, so the only squares worth asking about are empty ones east or west of a card already down. */ function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean { const area = areaOf(s, player); if (isLockedOut(area, kind)) return false; const probe = { geometry: { kind: 'facility', facility: kind }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], } as unknown as TrackCard; for (const key of area.grid.keys()) { const [row, col] = key.split(',').map(Number); for (const dc of [1, -1]) { const at = { row: row!, col: col! + dc }; if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue; if (canPlaceAt(area, at, probe)) return true; } } return false; } /** * 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, tweaks: BotTweaks): 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 !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2; } if (k.kind === 'freightFacility') { return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 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; seen: Set } { const main = area.runningRow; const rejoins = new Set(); const seen = new Set(); 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(); 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 { const reached = new Set(); 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 { const onLoop = new Set(); 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; } /** * WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15; * `noValueLays` turns it off. * * `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an * industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the * piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn * into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars * (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking * needs. The weights are a starting point to measure, not a result. */ function layoutValue(area: OfficeArea): number { let v = 0; const reachable = reachableOffMain(area); v += Math.min(reachable.size, 12) * 0.2; let ways = 0; let loops = 0; for (const side of SIDES) { for (const col of waysOff(area, side)) { ways++; if (descendFrom(area, col, side).rejoins.size > 0) loops++; } } v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!; if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2; // Squares an industry could legally be laid on, joined to track a crew can reach. const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!; const tried = new Set(); let sites = 0; for (const key of reachable) { const [row, col] = key.split(',').map(Number); for (const dc of [1, -1]) { const at = { row: row!, col: col! + dc }; const k = coordKey(at); if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue; tried.add(k); if (canPlaceAt(area, at, probe)) sites++; } } v += [0, 2, 3, 3.5][Math.min(sites, 3)]!; let mainStraight = false; for (const [key, card] of area.grid) { if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue; if (card.geometry.geometry === 'straight') mainStraight = true; // A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it. if (card.geometry.geometry === 'turnout') { const col = Number(key.split(',')[1]); for (const side of SIDES) { if (!hasPort(card, legPort(side))) continue; const beyond = area.grid.get(`${area.runningRow + side},${col}`); if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5; } } } if (mainStraight) v += 1.5; return v; } /** * The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer * places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is * offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is * the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office. */ function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null { const area = areaOf(s, player); const base = layoutValue(area); let best: Intent | null = null; let bestScore = -Infinity; for (const i of options) { if (i.type !== 'card.play' || i.placement === undefined) continue; const kind = s.cards.get(i.cardId)?.kind; if (kind?.kind !== 'track') continue; const built = protoCard(kind, i.variant); if (!built) continue; const after: OfficeArea = { ...area, grid: new Map(area.grid), limitsWest: { ...area.limitsWest }, limitsEast: { ...area.limitsEast }, }; after.grid.set(coordKey(i.placement), built); extendLimitsIfNeeded(after, i.placement); const gain = layoutValue(after) - base; if (mustBuild && gain <= 0.1) continue; const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col); const score = gain - distance * 0.01; 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, ): 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 => i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks), ); // 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, tweaks) - takingRank(s, player, a.slot, tweaks))[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 = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : 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 = !tweaks.noValueLays ? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ?? bestValuedLay(s, player, options, false) : 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, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!, ); } case 'switch': { // The planner decides the whole turn; the rules below are its fallback if the position is ever // not the one it planned for, and the whole of switching under `noPlanSwitching`. if (!tweaks.noPlanSwitching) { const planned = plannedSwitch(s, player, options, tweaks); if (planned) return planned; } /** * 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 => 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, }; }