/** * 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(); 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(['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 }; }