Initial commit
This commit is contained in:
+458
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user