Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track

This commit is contained in:
Jesse
2026-07-31 11:02:54 -04:00
parent 401b879140
commit b085d5a0bb
20 changed files with 1853 additions and 50 deletions
+181
View File
@@ -63,6 +63,11 @@ export const developerBot: BotPolicy = {
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
// Red Flags come before anything else — protection is only worth playing at the moment the
// collision is actually pending, and that moment passes.
const flags = worthFlagging(s, options);
if (flags) return flags;
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
//
// Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
@@ -129,6 +134,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// whose value compounds. Playing one needs the draw option.
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
// 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
// compounds — but only if it can be laid right now, so check for a legal target rather than for
// the card sitting in hand.
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) return can('draw')!;
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
// actually a car to spot or collect. Choosing to switch merely because a train is present
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
@@ -163,6 +174,15 @@ function isWorthTaking(s: GameState, slot: number): boolean {
return false;
}
/** Does any facility want this particular car? Tolerates an undefined end car. */
function facilityWants2(s: GameState, player: PlayerIndex, car: RollingStock | undefined): boolean {
return car !== undefined && facilitiesWanting(s, player, car).length > 0;
}
function isEnhancement(s: GameState, cardId: string): boolean {
return s.cards.get(cardId)?.kind.kind === 'enhancement';
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
@@ -213,6 +233,68 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
return false;
}
/**
* Grow the district outward from the Office, preferring straights.
*
* Straights are what Enhancements attach to and what Freight Facilities sit beside, so they are
* worth more than a turnout the bot has no plan for. Ties break toward the Office: a compact
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
*/
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
const dRow = Math.abs(i.placement.row - area.officeCoord.row);
const dCol = Math.abs(i.placement.col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
if (i.geometry === 'straight') score += 6;
// A district needs depth to hold facilities; a Running Track that only grows sideways gives
// Enhancements somewhere to live but Freight nowhere.
if (dRow > 0) score += 3;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function isMainlineKey(s: GameState, cardId: string, key: string): boolean {
const k = s.cards.get(cardId)?.kind;
return k?.kind === 'mainlineModifier' && k.key === key;
}
/** Does the facility at `coord` want this particular car? */
function facilityWantsAt(
s: GameState,
player: PlayerIndex,
coord: { row: number; col: number },
car: { type: string; loaded: boolean } | undefined,
): boolean {
if (!car) return false;
const f = areaOf(s, player).grid.get(`${coord.row},${coord.col}`)?.facility;
return f ? facilityWants(f, car as never) : false;
}
/**
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
* with another train on the same card, which is the situation that becomes a rear-ender.
*/
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
for (const i of options) {
if (i.type !== 'maneuver.redFlags') continue;
const tray = s.trays.get(i.trayId);
if (!tray || tray.position.at !== 'mainline') continue;
const node = s.division.nodes[tray.position.index];
if (node?.kind !== 'mainline') continue;
if (node.transits.length > 1) return i;
}
return null;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
@@ -238,6 +320,32 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
);
if (train) return train;
// Mainline modifiers rank with train cards: every train that crosses afterwards pays the
// lower price. Realignment first — converting Curves to Plains halves the crossing for
// everyone, where a grade card only helps trains going one way.
const realign = options.find(
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
);
if (realign) return realign;
const grade = options.find((i) => i.type === 'mainline.modify');
if (grade) return grade;
// 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.
const enh = options.find(
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
);
if (enh) return enh;
// Lay track before spending a card on the grid. Track is the scarce enabler, not the
// consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33.
const track = bestTrackLay(s, player, options);
if (track) return track;
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
@@ -272,6 +380,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const here = trayLocation(s, player);
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
// If standing on a Small Yard, re-order so a car some facility actually wants ends up on
// the droppable end. This is the whole point of the card.
if (tray && here) {
const onYard = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.enhancements.includes('smallYard');
if (onYard && !facilityWants2(s, player, tray.consist[tray.consist.length - 1])) {
const wantedIdx = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (wantedIdx >= 0 && wantedIdx !== tray.consist.length - 1) {
const order = [...tray.consist.keys()].filter((k) => k !== wantedIdx);
order.push(wantedIdx);
const sort = options.find(
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
);
if (sort) return sort;
}
}
}
// 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;
if (hereFacility && facilityWants(hereFacility, endCar)) {
@@ -287,6 +423,31 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
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.
//
// 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;
if (isSpur) {
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (setOut) return setOut;
}
const toSpur = options.find(
(i) =>
i.type === 'switch.move' &&
i.to.row !== area.runningRow &&
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
);
if (toSpur) return toSpur;
}
}
const move = options.find((i) => i.type === 'switch.move');
@@ -410,6 +571,26 @@ function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
}
if (sidingsWorthCollecting(s, player).length > 0) return true;
// A FULL consist is itself work. At MAX_CONSIST the crew cannot couple anything, so every loaded
// car the district produces is unreachable until cars are set out. Without this clause the bot
// ended its turn the moment no facility wanted the end car, which is precisely when a full crew
// most needs to break itself up.
if (tray.consist.length >= MAX_CONSIST) {
for (const card of areaOf(s, player).grid.values()) {
if (card.facility && card.facility.kind === 'freight') return true;
}
}
// Standing on a Small Yard with a wanted car buried in the consist is work worth doing.
const here = trayLocation(s, player);
if (here) {
const card = areaOf(s, player).grid.get(`${here.row},${here.col}`);
if (card?.enhancements.includes('smallYard')) {
const buried = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (buried >= 0 && buried !== tray.consist.length - 1) return true;
}
}
// A car left standing on ordinary track is also worth lifting if some facility wants it — the
// hopper stranded on the Running Track that the crew kept driving past.
if (tray.consist.length < MAX_CONSIST) {