Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
+181
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user