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
348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|