Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
/**
* 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, facilityCarType, laborersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } 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;
}
// ---------------------------------------------------------------------------
export const developerBot: BotPolicy = {
name: 'developer',
choose(s, player, options) {
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
// --- 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',
'laborer.startLoad',
'laborer.beginUnload',
);
return work ?? 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 match;
}
return 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.
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
// 2. A train standing at the Office is a fleeting chance to spot cars; it highballs next
// Mainline Phase whether or not anything was done with it.
if (area.adOccupancy.length > 0 && can('switch')) return can('switch')!;
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
return can('freightAgent')!;
}
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
if (can('draw')) return can('draw')!;
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
}
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;
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;
}
/** 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.
if (!s.turn.drawnThisTurn) {
const draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice');
if (draw) return draw;
}
// Train cards first — they take no placement and their value compounds every Day.
const train = options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
if (train) return train;
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
const play = options.find((i) => i.type === 'card.play');
if (play) return play;
const end = options.find((i) => i.type === 'draw.end');
if (end) return end;
return pickFirst(options, 'card.discard') ?? options[0]!;
}
case 'switch': {
// 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 (endCar && here) {
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
if (hereFacility && facilityWants(hereFacility, endCar)) {
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (drop) return drop;
}
// Otherwise head for a facility that does want this particular car.
const wanted = 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 toward;
}
const move = options.find((i) => i.type === 'switch.move');
if (move) return move;
return 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 unjam;
}
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
if (clear) return clear;
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return stock;
return 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 });
}
// A coach is worth carrying too: passenger work is a point per Porter action.
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;
}
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.
*/
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
): 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) {
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,
};
}