v0.8.0.9 — the bot plans its switching turn, stops wasting its draws, and the engine walks each route once

The developer bot, re-measured decision by decision against the bot before it,
goes from about -0.3 revenue a game to about 4.8:

- plans the whole switching turn before its first Move (sim/switch-planner.ts),
  +2.89 over 1600 paired seeds; closes TODO #53
- takes a face-up card only if it could play it, +1.52 over 1600 seeds
- stops running Second Sections by accident in the New Train phase, +0.32
- lays track by what the district can do afterwards, +0.12 over 6400 seeds,
  run-arounds in 22 of 60 districts against 9

The engine is 2.8x faster with play proven identical: a route cache scoped to
one unchanged position, applyIntent split into prepareIntent + commitEvents,
and less allocation in exploreMoves. npm test now leaves out the bot
simulations, which run as npm run test:sim.

No rule changed; games in progress resume. Rejected candidates and the
Second Section card question are in CHANGELOG.md and TODO.md (#104-#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
Jesse.Markowitz
2026-09-15 15:30:42 -04:00
co-authored by Claude Opus 5
parent 072029b1f7
commit 76c6e103b3
13 changed files with 1550 additions and 112 deletions
+347
View File
@@ -0,0 +1,347 @@
/**
* Component 17b — planning a whole switching turn before making the first Move.
*
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
* from a ladder of rules, and its own comment names what that cannot do: "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." This attempts it, for one turn at a time.
*
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
* copy of the game is exactly what a player does by looking at the board. The score below reads only
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
*
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
*
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
* of one. The weights are a starting point to measure, not a result.
*/
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
import { MAX_CONSIST } from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import { legalSwitchingActions } from '../engine/legal.ts';
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export const SWITCH_WEIGHTS = {
/** A car standing where its industry can load or unload it — the point of switching. */
spot: 1.0,
/** The same, past what the industry's boxes can work at once. */
spotBeyondCapacity: 0.25,
/** A car the industry cannot work, taking room on its track. */
junkOnIndustry: -0.5,
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
finishedLeft: -0.15,
/** A car on one of this district's trains that some industry here would work. */
carriedWanted: 0.35,
/** The same car left on ordinary track, where a later turn can fetch it. */
stagedWanted: 0.2,
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
coachWithTrain: 0.3,
/** A coach left anywhere else, where no Porter can work it. */
coachStranded: -0.3,
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
fouling: -3,
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
trainAway: -0.25,
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
expeditedAway: -1.0,
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
notMadeUp: -0.6,
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
perMove: -0.02,
perSetOut: -0.005,
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
cardSpent: -0.1,
} as const;
export type PlanOptions = {
budget: number;
beam: number;
/**
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
* it is here so the planner can use the card the day it is dealt again.
*/
flyingSwitch?: boolean;
};
/**
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
*/
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
export type SwitchPlan = {
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
steps: Intent[];
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
keys: string[];
rootScore: number;
score: number;
/** Positions tried. */
expanded: number;
/** False when the budget ran out before the search did. */
complete: boolean;
};
/**
* A copy of the game that a switching intent can be applied to without touching the original.
*
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
* was 44% of all planning time.
*
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
* starts mutating a car or a card in place instead of replacing it.
*/
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
const seat = seatOf(s, player);
const area = areaAtSeat(s, seat);
const grid = new Map<string, TrackCard>();
for (const [key, card] of area.grid) {
const f = card.facility;
grid.set(key, {
...card,
standing: [...card.standing],
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
});
}
const officeAreas = new Map(s.officeAreas);
officeAreas.set(seat, {
...area,
grid,
adOccupancy: [...area.adOccupancy],
heldAtLimits: [...area.heldAtLimits],
dispatchUsedToday: [...area.dispatchUsedToday],
});
const trays = new Map(s.trays);
for (const [id, t] of s.trays) {
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
}
const turns = new Map(s.turns);
const turn = s.turns.get(player)!;
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
}
const carList = (xs: readonly RollingStock[]): string =>
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
/**
* Everything a switching intent can change, as a string — two positions with the same fingerprint
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
* apart, which is right: no intent names a car.
*/
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
const seat = seatOf(s, player);
const parts: string[] = [];
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const { row, col } = t.position.coord;
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
}
for (const [key, card] of areaOf(s, player).grid) {
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
}
const turn = turnOf(s, player);
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
return parts.join(';');
}
/**
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
*/
function works(f: Facility, c: RollingStock, seat: number): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
}
/**
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
*/
function finished(f: Facility, c: RollingStock): boolean {
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
return false;
}
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
/** How good this district's position is for the rest of the game, in rough Revenue points. */
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
const W = SWITCH_WEIGHTS;
const area = areaOf(s, player);
const seat = seatOf(s, player);
const officeKey = coordKey(area.officeCoord);
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
let v = 0;
const withRoom: Facility[] = [];
for (const card of area.grid.values()) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
let working = 0;
for (const c of f.industryTrack.cars) {
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
else if (finished(f, c)) v += W.finishedLeft;
else v += W.junkOnIndustry;
}
}
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
for (const [key, card] of area.grid) {
if (card.facility?.kind === 'freight') continue;
const atOffice = key === officeKey;
for (const c of card.standing) {
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
else if (atOffice) v += W.fouling;
else if (wanted(c)) v += W.stagedWanted;
}
}
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
for (const c of t.consist) {
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
else if (wanted(c)) v += W.carriedWanted;
}
if (t.trainNumber === null) continue;
if (!same(t.position.coord, area.officeCoord)) {
v += W.trainAway;
if (isExpedited(t)) v += W.expeditedAway;
}
if (badlyMadeUp(t)) v += W.notMadeUp;
}
return v;
}
/**
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
* the score can see until the car is set out, so without this the beam would drop the approach in
* favour of positions that merely look tidy.
*/
function approach(s: GameState, player: PlayerIndex): number {
const area = areaOf(s, player);
const seat = seatOf(s, player);
const targets: { at: GridCoord; f: Facility }[] = [];
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
const [row, col] = key.split(',').map(Number);
targets.push({ at: { row: row!, col: col! }, f });
}
let bonus = 0;
for (const t of s.trays.values()) {
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
const here = t.position.coord;
for (const c of t.consist) {
let nearest = Infinity;
for (const { at, f } of targets) {
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
}
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
}
}
return bonus;
}
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
type Node = {
s: GameState;
steps: Intent[];
keys: string[];
moves: number;
setOuts: number;
cards: number;
score: number;
rank: number;
};
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
export function planSwitchingTurn(
s: GameState,
player: PlayerIndex,
opts: PlanOptions = DEFAULT_PLAN,
): SwitchPlan {
const W = SWITCH_WEIGHTS;
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
const searched = (type: Intent['type']): boolean =>
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
const rootKey = switchFingerprint(s, player);
const rootScore = scoreOf(s, 0, 0, 0);
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
let best = root;
const seen = new Set([rootKey]);
let frontier: Node[] = [root];
let expanded = 0;
let complete = true;
search: while (frontier.length > 0) {
const next: Node[] = [];
for (const node of frontier) {
const movesLeft = turnOf(node.s, player).movesRemaining;
// Every candidate is decided against THIS position, inside one route cache, and only then applied
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
const decided = withRouteCache(node.s, () =>
legalSwitchingActions(node.s, player)
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
);
for (const { i, r } of decided) {
if (expanded >= opts.budget) {
complete = false;
break search;
}
expanded++;
if (!r.ok) continue;
const f = forkForSwitching(node.s, player);
commitEvents(f, r.events);
const key = switchFingerprint(f, player);
if (seen.has(key)) continue;
seen.add(key);
const setOut = i.type === 'switch.dropCars';
const moves = node.moves + (setOut ? 0 : 1);
const setOuts = node.setOuts + (setOut ? 1 : 0);
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
const score = scoreOf(f, moves, setOuts, cards);
const child: Node = {
s: f,
steps: [...node.steps, i],
keys: [...node.keys, key],
moves,
setOuts,
cards,
score,
rank: score + approach(f, player),
};
if (score > best.score + 1e-9) best = child;
next.push(child);
}
}
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
}
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
}