Fix parking trains, freight deadlock, and Whistle Post lock-in
This commit is contained in:
+177
-44
@@ -25,7 +25,7 @@ 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';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -74,14 +74,19 @@ export const developerBot: BotPolicy = {
|
||||
// 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',
|
||||
);
|
||||
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.
|
||||
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
|
||||
}
|
||||
|
||||
@@ -132,6 +137,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
|
||||
// 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 can('draw')!;
|
||||
}
|
||||
|
||||
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
|
||||
|
||||
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
|
||||
@@ -148,6 +159,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
return 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 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')!;
|
||||
@@ -262,6 +279,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
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;
|
||||
@@ -295,6 +317,52 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -314,7 +382,18 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
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.
|
||||
// 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 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),
|
||||
);
|
||||
@@ -333,6 +412,16 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
// 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 interlock;
|
||||
|
||||
const enh = options.find(
|
||||
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
|
||||
);
|
||||
@@ -360,7 +449,25 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
// 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 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 home;
|
||||
const stop = options.find((i) => i.type === 'switch.end');
|
||||
if (stop) return stop;
|
||||
}
|
||||
@@ -397,45 +504,38 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
}
|
||||
}
|
||||
|
||||
// Flying Switch — roll a cut straight into a neighbouring industry without the engine
|
||||
// entering. It beats a normal spot whenever the industry actually wants the back car, since
|
||||
// it saves the moves of running in and backing out again.
|
||||
const flying = options.find(
|
||||
(i) =>
|
||||
i.type === 'maneuver.flyingSwitch' &&
|
||||
i.count === 1 &&
|
||||
facilityWantsAt(s, player, i.to, tray?.consist[tray.consist.length - 1]),
|
||||
);
|
||||
if (flying) return flying;
|
||||
|
||||
if (endCar && here) {
|
||||
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
|
||||
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 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;
|
||||
|
||||
// SET OUT. A crew at MAX_CONSIST cannot couple anything — reachableDestinations drops any
|
||||
// route whose pickups would overflow the tray — so a full crew can never collect the loaded
|
||||
// car it came for. Measured: trays sat at 4 cars for 3,091 of 4,085 in-district observations
|
||||
// and coupled 1 car across 40 games, which is why freight revenue was 0.3 a game.
|
||||
// SET OUT — and do it BEFORE travelling.
|
||||
//
|
||||
// Real crews set out cars they are not using. Prefer a plain spur off the Running Track:
|
||||
// dropping onto an industry track would silt it with the wrong commodity (the bug the
|
||||
// spotting rule above exists to prevent), and the Running Track needs to stay clear.
|
||||
if (tray && tray.consist.length >= MAX_CONSIST) {
|
||||
const area = areaOf(s, player);
|
||||
const card = area.grid.get(`${here.row},${here.col}`);
|
||||
const isSpur = here.row !== area.runningRow && !card?.facility;
|
||||
// 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 setOut;
|
||||
@@ -448,10 +548,30 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
);
|
||||
if (toSpur) return 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 toward;
|
||||
}
|
||||
|
||||
const move = options.find((i) => i.type === 'switch.move');
|
||||
if (move) return move;
|
||||
// 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 goHome;
|
||||
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
|
||||
}
|
||||
|
||||
@@ -695,11 +815,23 @@ export type PlayOutcome = {
|
||||
* 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'][] = [];
|
||||
@@ -711,6 +843,7 @@ export function playGame(
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user