Fix parking trains, freight deadlock, and Whistle Post lock-in

This commit is contained in:
Jesse
2026-07-31 15:09:16 -04:00
parent b085d5a0bb
commit d261ad7af8
15 changed files with 577 additions and 121 deletions
+1
View File
@@ -356,6 +356,7 @@ function enterMainline(
carriesPassengers,
node.modifiers ?? [],
tray.direction,
node.gradeUp ?? 'east',
);
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
+25 -6
View File
@@ -49,6 +49,7 @@ import {
canDropCarsAt,
canPlaceAt,
facilityVariants,
opposite,
reachableDestinations,
variantsFor,
} from './track.ts';
@@ -103,6 +104,7 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
if (tray?.facing) return tray.facing;
return tray?.direction === 'west' ? 'w' : 'e';
}
@@ -392,11 +394,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
// of the move and why it beats a normal spot.
const adjacent =
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
if (!adjacent) return 'NOT_CONNECTED';
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
// two cards along the same track. It also made the card effectively unplayable — held for
// 1,400 turns across 60 games and legal on 2.
const reachable = [
...destinationsFor(s, player, i.trayId, here, false),
...destinationsFor(s, player, i.trayId, here, true),
];
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
return 'NOT_CONNECTED';
}
const fsArea = areaOf(s, player);
const target = fsArea.grid.get(coordKey(i.to));
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
@@ -618,7 +628,11 @@ function destinationsFor(
) {
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing;
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
// have — and could never back out of a district spur. Combined with a facing that was itself
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
const exit: Port = reverse ? opposite(facing) : facing;
return reachableDestinations(
{
area: areaOf(s, player),
@@ -644,6 +658,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
// The crew leaves by the port opposite the one it entered through, which is what it will be
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
// onto a north-south spur can never move again.
const events: GameEvent[] = [
{
type: 'trayMoved',
@@ -651,6 +668,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -941,6 +959,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
tray.position = { at: 'grid', owner, coord: e.to };
if (e.facing) tray.facing = e.facing;
s.turn.movesRemaining = e.movesRemaining;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
+32 -13
View File
@@ -105,11 +105,30 @@ export type OfficeProfile = {
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
*/
/**
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
*
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
*
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
*
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
* settled and freight is carrying its intended share; the right answer may instead be fewer
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
];
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
@@ -393,6 +412,7 @@ export function crossingStages(
carriesPassengers: boolean,
modifiers: readonly string[] = [],
direction: Direction = 'east',
gradeUp: Direction = 'east',
): number {
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
@@ -414,15 +434,13 @@ export function crossingStages(
const base = mph >= 60 ? 1 : 2;
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
return Math.max(1, stages - gradeReduction(profile, modifiers, direction));
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
}
/**
* ASSUMPTION, flagged (§10 Q11): the recovered sheet says Brakeman and Airbrakes give "faster
* passage downhill" and Helpers "faster passage uphill", but never says which way a Heavy Grade
* climbs. Modelled as **climbing eastward** — an eastbound train is going uphill, a westbound one
* downhill. Needs confirming; if grades instead carry their own direction, this becomes a property
* of the Mainline node rather than a constant.
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
* travelling when it goes UPHILL; a train heading the other way is descending.
*
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
@@ -431,9 +449,10 @@ function gradeReduction(
profile: MainlineProfile,
modifiers: readonly string[],
direction: Direction,
gradeUp: Direction,
): number {
if (profile.speed.kind !== 'grade') return 0;
const downhill = direction === 'west';
const downhill = direction !== gradeUp;
let n = 0;
if (downhill) {
if (modifiers.includes('brakeman')) n++;
@@ -648,7 +667,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
}
// ---------------------------------------------------------------------------
// Deck composition — 133 cards
// Deck composition — 140 cards
// ---------------------------------------------------------------------------
/**
@@ -676,9 +695,9 @@ export function deckComposition(): { category: string; count: number }[] {
];
}
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 133
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
/** The solitaire deck drops the 22 opponent-directed cards, leaving 111. */
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
+1 -1
View File
@@ -21,7 +21,7 @@ export type GameEvent =
| { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
+2 -3
View File
@@ -34,9 +34,8 @@ export type Intent =
/**
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
*
* ASSUMPTION, flagged: the design moves track out of the deck into a per-player supply but does
* not say when you may lay it. Treated as a play available during the "draw a card" option,
* matching how track behaved when it WAS a card. See implications.md §10 Q10.
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
* Confirmed; see implications.md §10 Q10.
*/
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
| { type: 'draw.end' }
+6 -10
View File
@@ -109,16 +109,12 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { row, col } = tray.position.coord;
const around: GridCoord[] = [
{ row: row + 1, col },
{ row: row - 1, col },
{ row, col: col + 1 },
{ row, col: col - 1 },
];
for (const to of around) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to });
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
+13 -5
View File
@@ -17,6 +17,7 @@ import {
MODIFIER_PROFILES,
OFFICE_PROFILES,
MAINLINE_PROFILES,
mainlineProfile,
TRACK_SUPPLY,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
@@ -187,11 +188,18 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
function buildDivision(players: number, rng: Rng): DivisionNode[] {
const nodes: DivisionNode[] = [];
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
const mainline = (): DivisionNode => ({
kind: 'mainline',
card: kinds[rng.nextInt(kinds.length)]!,
transits: [],
});
const mainline = (): DivisionNode => {
const card = kinds[rng.nextInt(kinds.length)]!;
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
// least deterministic and varies between games; it should become a real player choice when
// setup gains an interactive phase. See implications.md §10 Q11.
if (mainlineProfile(card).speed.kind === 'grade') {
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
return node;
};
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
+14
View File
@@ -198,6 +198,15 @@ export type CrewTray = {
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
consist: RollingStock[];
direction: Direction;
/**
* Which way the engine points, as an actual port on the card beneath it.
*
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
* without one falls back to `direction`.
*/
facing?: 'n' | 's' | 'e' | 'w';
position: NodeRef;
movesUsed: number;
};
@@ -221,6 +230,11 @@ export type DivisionNode =
absSignals?: boolean;
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
modifiers?: string[];
/**
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
* "Player sets orientation", so the direction is chosen when the card is placed.
*/
gradeUp?: Direction;
/** Red Flags protecting a stopped train here, by tray. */
redFlagged?: TrayId[];
}
+177 -44
View File
@@ -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 (