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