Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+123 -6
View File
@@ -20,9 +20,11 @@
*/
import { applyIntent, areaOf, canAdvanceLoad, 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 { coordKey } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
export type BotPolicy = {
@@ -127,9 +129,13 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// 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')!;
// 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 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.
@@ -145,6 +151,18 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return 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;
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
@@ -162,6 +180,12 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
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;
}
@@ -194,9 +218,19 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
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 draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice');
if (draw) return draw;
const useful = options.find(
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
);
if (useful) return useful;
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return blind;
const any = options.find((i) => i.type === 'draw.fromDepartment');
if (any) return any;
}
// Train cards first — they take no placement and their value compounds every Day.
const train = options.find(
@@ -215,6 +249,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
}
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.
if (!usefulSwitching(s, player)) {
const stop = options.find((i) => i.type === 'switch.end');
if (stop) return 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,
@@ -294,7 +336,15 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
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.
// 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;
}
@@ -346,6 +396,73 @@ function facilitiesWanting(
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 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;