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:
co-authored by
Claude Opus 5
parent
072029b1f7
commit
76c6e103b3
@@ -522,8 +522,11 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
*
|
||||
* This is reachable purely through switching. A train arrives made up, and only comes apart because
|
||||
* the player took cars onto the nose or picked up a cut in a run-around.
|
||||
*
|
||||
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
|
||||
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
|
||||
*/
|
||||
function badlyMadeUp(tray: CrewTray): string | null {
|
||||
export function badlyMadeUp(tray: CrewTray): string | null {
|
||||
const n = tray.consist.length;
|
||||
if (n === 0) return null;
|
||||
const pulling = tray.engineAt === 0;
|
||||
|
||||
+63
-9
@@ -1508,17 +1508,48 @@ export function selectDestination(
|
||||
return chosen ?? atTo[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* ROUTES, WALKED ONCE PER POSITION.
|
||||
*
|
||||
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
|
||||
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
|
||||
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
|
||||
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
|
||||
*
|
||||
* So while one position is being examined — a legal-action listing, or the check and execute of one
|
||||
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
|
||||
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
|
||||
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
|
||||
* mutate a returned route; nothing does.
|
||||
*/
|
||||
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
|
||||
|
||||
export function withRouteCache<T>(s: GameState, fn: () => T): T {
|
||||
if (routeCache) return fn();
|
||||
routeCache = { state: s, routes: new Map() };
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
routeCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
function destinationsFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: TrayId,
|
||||
from: GridCoord,
|
||||
reverse: boolean,
|
||||
) {
|
||||
): MoveDestination[] {
|
||||
const cache = routeCache?.state === s ? routeCache.routes : null;
|
||||
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
|
||||
const hit = cache?.get(key);
|
||||
if (hit) return hit;
|
||||
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
|
||||
return reachableDestinations(
|
||||
const routes = reachableDestinations(
|
||||
{
|
||||
area: areaOf(s, player),
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
@@ -1528,6 +1559,8 @@ function destinationsFor(
|
||||
from,
|
||||
exit,
|
||||
);
|
||||
cache?.set(key, routes);
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2702,7 +2735,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
* index is out of range, which `check` reports rather than silently defaulting — a wrong
|
||||
* orientation is a different card, not a detail.
|
||||
*/
|
||||
function protoCard(
|
||||
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
|
||||
export function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
@@ -3093,7 +3127,8 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
||||
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
||||
*/
|
||||
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
|
||||
export function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
@@ -3287,16 +3322,35 @@ function limitsCard(): TrackCard {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const code = check(s, player, i);
|
||||
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
|
||||
/**
|
||||
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
|
||||
*
|
||||
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
|
||||
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
|
||||
* position and applies each to a COPY of it — the switching planner — which can then share that
|
||||
* position's routes across every candidate instead of re-walking them on each copy.
|
||||
*/
|
||||
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
|
||||
const code = check(s, player, i);
|
||||
return code ? { code } : { events: execute(s, player, i) };
|
||||
});
|
||||
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
|
||||
return { ok: true, events: prepared.events };
|
||||
}
|
||||
|
||||
const events = execute(s, player, i);
|
||||
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
|
||||
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
|
||||
for (const e of events) reduce(s, e);
|
||||
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
|
||||
// for why it cannot simply live inside `reduce`.
|
||||
for (const e of events) tallyEvent(s, e);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const r = prepareIntent(s, player, i);
|
||||
if (r.ok) commitEvents(s, r.events);
|
||||
return r;
|
||||
}
|
||||
|
||||
export { isOperationalRail, destinationsFor };
|
||||
|
||||
+76
-58
@@ -14,7 +14,7 @@
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
import { coordKey, seatOf } from './state.ts';
|
||||
@@ -53,7 +53,80 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||||
// One position, examined many times over: its routes are walked once (`withRouteCache`).
|
||||
return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
|
||||
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
|
||||
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
|
||||
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
|
||||
*/
|
||||
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
|
||||
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
@@ -138,62 +211,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
out.push(...switchCandidates(s, player));
|
||||
|
||||
// -- draw (§6.2)
|
||||
out.push({ type: 'draw.fromHomeOffice' });
|
||||
|
||||
+34
-13
@@ -377,7 +377,7 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
return exploreMoves(ctx, start, initialExit, false).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,12 +434,19 @@ export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
/**
|
||||
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
|
||||
* rejections are then not recorded at all. They never change a destination, and building them was
|
||||
* pure allocation on the hottest path in the engine.
|
||||
*/
|
||||
collectBlocks = true,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
if (!collectBlocks) return;
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
@@ -459,10 +466,25 @@ export function exploreMoves(
|
||||
couples: RollingStock[];
|
||||
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
|
||||
origins: string[];
|
||||
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
|
||||
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
|
||||
* routes this walk exists to find. */
|
||||
visited: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
|
||||
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
|
||||
* find.
|
||||
*
|
||||
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
|
||||
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
|
||||
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
|
||||
* was never added, and `path[0]` is that square, so the scan begins at 1.
|
||||
*/
|
||||
const onRoute = (node: Frontier, to: GridCoord): boolean => {
|
||||
if (sameCoord(to, start)) return true;
|
||||
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
|
||||
for (let k = 1; k < node.path.length; k++) {
|
||||
if (sameCoord(node.path[k]!.coord, to)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
|
||||
@@ -493,13 +515,14 @@ export function exploreMoves(
|
||||
path: [],
|
||||
couples: ownCut,
|
||||
origins: ownCut.map(() => startKey),
|
||||
visited: new Set([startKey]),
|
||||
},
|
||||
];
|
||||
let enumerated = 1;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const node = queue[head++]!;
|
||||
const card = cardAt(area, node.coord);
|
||||
if (!card) continue;
|
||||
|
||||
@@ -592,17 +615,15 @@ export function exploreMoves(
|
||||
// direction; it never says without repeating ground, but a train cannot occupy the same
|
||||
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
|
||||
// pass through a card this one already used.
|
||||
const toKey = coordKey(to);
|
||||
if (node.visited.has(toKey)) continue;
|
||||
if (onRoute(node, to)) continue;
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
|
||||
enumerated++;
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
const visited = new Set(node.visited);
|
||||
visited.add(toKey);
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectBlocks) return { destinations: results, blocked };
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
|
||||
Reference in New Issue
Block a user