Draw freight on the card, and teach the bot to build a railroad
This commit is contained in:
@@ -755,6 +755,26 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
|
||||
/**
|
||||
* THE CLASSIFICATION YARD GOES BACK INTO SERVICE OVERNIGHT.
|
||||
*
|
||||
* ASSUMPTION, flagged rather than derived: Gap 2c says engines and cabooses return to the
|
||||
* Division Yard and everything else to Classification, and the recovered rules never say how
|
||||
* Classification empties. It was write-only — seven places pushed into it and nothing ever read
|
||||
* it — so rolling stock drained one way and left the game. Measured at 30 cars a game dead by
|
||||
* the end, 37% of the 80 dealt at setup.
|
||||
*
|
||||
* Sorting cars for redistribution is what a classification yard is FOR, and a Day is its
|
||||
* natural cycle, so they return at the Day boundary. This does not bind a 5-Day game — the
|
||||
* Division Yard still held 16.6 loaded freight cars at the end and ran dry in 2 games of 100 —
|
||||
* but a campaign-length game would have starved.
|
||||
*/
|
||||
if (s.yards.classificationYard.length > 0) {
|
||||
s.yards.divisionYard.push(...s.yards.classificationYard);
|
||||
s.yards.classificationYard = [];
|
||||
}
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
|
||||
+18
-4
@@ -149,9 +149,23 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea
|
||||
* for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK
|
||||
* strips the industry track of Operational Rail status (§9.3), the facility would jam permanently.
|
||||
*/
|
||||
/**
|
||||
* EVERY commodity a facility handles, not just the first one printed.
|
||||
*
|
||||
* Two of the six industries take two: a Power Plant burns coal OR oil (`['hopper','tank']`) and a
|
||||
* Grocer's Warehouse receives dry goods OR perishables (`['boxcar','reefer']`). This used to return
|
||||
* `carTypes[0]`, and because `freightAgent.stockOutbound` gates on it, the engine rejected the
|
||||
* second commodity as WRONG_CAR_TYPE — the sheet said a Power Plant takes tank cars and the code
|
||||
* said it did not. Measured effect: tank cars were dropped 0 times in 100 games.
|
||||
*/
|
||||
export function facilityCarTypes(f: Facility): readonly CarType[] {
|
||||
if (f.kind !== 'freight') return f.kind === 'passenger' ? ['coach'] : [];
|
||||
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes ?? [];
|
||||
}
|
||||
|
||||
/** The commodity to NAME a facility by, where one word is wanted. Legality must use the full set. */
|
||||
export function facilityCarType(f: Facility): CarType | null {
|
||||
if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null;
|
||||
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[0] ?? null;
|
||||
return facilityCarTypes(f)[0] ?? null;
|
||||
}
|
||||
|
||||
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
|
||||
@@ -433,8 +447,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (!f.allows.outbound) return 'NO_SUCH_FACILITY';
|
||||
if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL';
|
||||
// §9.1 — the green box takes only this facility's commodity.
|
||||
if (facilityCarType(f) !== i.carType) return 'WRONG_CAR_TYPE';
|
||||
// §9.1 — the green box takes only this facility's commodities, of which it may have two.
|
||||
if (!facilityCarTypes(f).includes(i.carType)) return 'WRONG_CAR_TYPE';
|
||||
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
|
||||
return 'NO_SUITABLE_CAR';
|
||||
}
|
||||
|
||||
+50
-1
@@ -194,6 +194,44 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
|
||||
/**
|
||||
* THE LOAD PIPELINE, on the card itself.
|
||||
*
|
||||
* A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of
|
||||
* freight. It was drawn only in the side panel, so advancing a load changed nothing on the card
|
||||
* the player was looking at. The printed cards put these squares under the track for exactly
|
||||
* this reason.
|
||||
*/
|
||||
const f = cell.facility;
|
||||
if (f) {
|
||||
const cellW = 13;
|
||||
const box = (bx: number, by: number, filled: boolean, cls: string, label?: string): string =>
|
||||
`<rect class="bs-wb ${cls}${filled ? ' bs-occ' : ''}" x="${bx}" y="${by}" width="${cellW}" height="12" rx="2"/>` +
|
||||
(label ? `<text class="bs-wl" x="${bx + cellW / 2}" y="${by + 9}" text-anchor="middle">${esc(label)}</text>` : '');
|
||||
|
||||
let bx = 7;
|
||||
const by = H - 42;
|
||||
// Green: what is waiting to be worked.
|
||||
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
|
||||
out += box(bx, by, i < f.green.length, 'bs-green');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 8;
|
||||
// MEN | AT | WORK — a load sits in one of these while it is being worked.
|
||||
for (let i = 0; i < f.maw.length; i++) {
|
||||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||||
bx += cellW + 2;
|
||||
}
|
||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
|
||||
bx += 8;
|
||||
// Red: what has come off an arriving car and is waiting to be cleared.
|
||||
for (let i = 0; i < Math.max(1, f.redCap); i++) {
|
||||
out += box(bx, by, i < f.red.length, 'bs-red');
|
||||
bx += cellW + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
@@ -205,7 +243,9 @@ export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
}
|
||||
|
||||
if (cell.enhancements.length > 0) {
|
||||
out += `<text class="bs-enh" x="6" y="${H - 30}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
// Above the load pipeline, not on it: an `onCard` enhancement can land on a facility, and at
|
||||
// the old baseline the label printed straight through the green/MEN|AT|WORK/red squares.
|
||||
out += `<text class="bs-enh" x="6" y="${H - 46}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.tray) {
|
||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
||||
@@ -274,6 +314,15 @@ export const BOARD_CSS = `
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||||
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||
.bs-wb.bs-green{stroke:#3f7a52}
|
||||
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
|
||||
.bs-wb.bs-maw{stroke:#8a6d1f}
|
||||
.bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)}
|
||||
.bs-wb.bs-red{stroke:#8a5a5a}
|
||||
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
|
||||
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
|
||||
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
|
||||
+336
-63
@@ -19,14 +19,15 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { variantsFor } from '../engine/track.ts';
|
||||
import { exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -169,13 +170,18 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
return because('a train card is in hand and its value compounds every Day', 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 because('a Mainline modifier can be laid — every later crossing pays less', can('draw')!);
|
||||
}
|
||||
/**
|
||||
* NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
|
||||
*
|
||||
* There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
|
||||
* requires `s.turn.option === 'draw'` and this runs while the option is still null, so
|
||||
* `legalActions` had already filtered it out — the branch could never fire and never had.
|
||||
*
|
||||
* Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
|
||||
* paired over 400 seeds (t = -3.90, 104 seeds worse against 35 better). Its comment claimed the
|
||||
* value compounds like a train card's; it does not. Spending a Local Operations action on a
|
||||
* Mainline modifier costs more than the crossings it saves. Left unwritten deliberately.
|
||||
*/
|
||||
|
||||
// 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
|
||||
@@ -206,7 +212,10 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
|
||||
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
|
||||
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
|
||||
return because('no option has a clear purpose this turn', can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!);
|
||||
// Freight Agent is deliberately NOT the idle default. Reaching its follow-through with nothing
|
||||
// productive to do is what fed the unjam fallback that discarded loads; switching, at worst,
|
||||
// moves the crew toward the Office, which a train must reach to depart at all (§8.1).
|
||||
return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
@@ -248,13 +257,27 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
if (!f || !f.allows.outbound) continue;
|
||||
if (f.outboundBox.length >= f.capacity.outbound) continue;
|
||||
|
||||
/**
|
||||
* The load has to COME from somewhere. `freightAgent.stockOutbound` requires a LOADED car of
|
||||
* that commodity in the Division Yard, and this predicate did not check for one — so the bot
|
||||
* chose the Freight Agent option believing it could stock, found no legal stock, and fell
|
||||
* through to the unjam fallback that discarded a green box. Two rules for the same act, and
|
||||
* only one of them was true.
|
||||
*/
|
||||
const inYard = (type: string): boolean =>
|
||||
s.yards.divisionYard.some((c) => c.type === type && c.loaded);
|
||||
|
||||
// A PASSENGER facility has no industry track — passengers board straight off the platform
|
||||
// (§9.2). Requiring a spotted car here meant the Office's green slot was never stocked and
|
||||
// passengers essentially never boarded: one boarding in a whole 5-Day game.
|
||||
if (f.kind === 'passenger') return true;
|
||||
if (f.kind === 'passenger') {
|
||||
if (inYard('coach')) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const want = facilityCarType(f);
|
||||
if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
|
||||
const want = facilityCarTypes(f);
|
||||
if (!f.industryTrack.cars.some((c) => !c.loaded && want.includes(c.type))) continue;
|
||||
if (want.some((t) => inYard(t))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -287,6 +310,160 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Walk the Secondary Track from the Running Track, using the ENGINE's own `exitsFrom`.
|
||||
*
|
||||
* Shared deliberately. Track laying and facility placement both need to know what a crew can
|
||||
* actually reach, and two copies of this walk would eventually disagree about whether a district
|
||||
* connects — which is the one thing both decisions rest on. A turnout's legs never join each other
|
||||
* (§A.1), so this traverses in (cell, entry port) space rather than treating a cell as one node.
|
||||
*/
|
||||
function descendFrom(
|
||||
area: OfficeArea,
|
||||
col: number,
|
||||
): { rejoins: Set<number>; seen: Set<string> } {
|
||||
const main = area.runningRow;
|
||||
const rejoins = new Set<number>();
|
||||
const seen = new Set<string>();
|
||||
const start = area.grid.get(`${main - 1},${col}`);
|
||||
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
|
||||
|
||||
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
|
||||
const visited = new Set<string>();
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
const id = `${cur.row},${cur.col}:${cur.entry}`;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
const card = area.grid.get(`${cur.row},${cur.col}`);
|
||||
if (!card) continue;
|
||||
seen.add(`${cur.row},${cur.col}`);
|
||||
for (const exit of exitsFrom(card, cur.entry)) {
|
||||
const n = neighbour({ row: cur.row, col: cur.col }, exit);
|
||||
const next = area.grid.get(`${n.row},${n.col}`);
|
||||
if (!next || !hasPort(next, opposite(exit))) continue;
|
||||
if (n.row >= main) {
|
||||
if (n.col !== col) rejoins.add(n.col);
|
||||
continue;
|
||||
}
|
||||
stack.push({ row: n.row, col: n.col, entry: opposite(exit) });
|
||||
}
|
||||
}
|
||||
return { rejoins, seen };
|
||||
}
|
||||
|
||||
/** Every way down off the main, by column. */
|
||||
function waysDown(area: OfficeArea): number[] {
|
||||
const out: number[] = [];
|
||||
for (const [k, c] of area.grid.entries()) {
|
||||
const [r, col] = k.split(',').map(Number);
|
||||
if (r !== area.runningRow || !hasPort(c, 's')) continue;
|
||||
const under = area.grid.get(`${area.runningRow - 1},${col}`);
|
||||
if (under && hasPort(under, 'n')) out.push(col!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Below-main squares a crew can reach from the Running Track at all — siding or stub. */
|
||||
function reachableBelowMain(area: OfficeArea): Set<string> {
|
||||
const reached = new Set<string>();
|
||||
for (const col of waysDown(area)) {
|
||||
for (const k of descendFrom(area, col).seen) reached.add(k);
|
||||
}
|
||||
return reached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below-main squares that lie on a RUN-AROUND — a siding reaching the main at both ends, so a crew
|
||||
* can pass its own standing cars (§A.5). A facility here can be worked from either direction; one
|
||||
* on a stub can only be reached by shoving in and backing out.
|
||||
*/
|
||||
function runAroundCells(area: OfficeArea): Set<string> {
|
||||
const onLoop = new Set<string>();
|
||||
for (const col of waysDown(area)) {
|
||||
const { rejoins, seen } = descendFrom(area, col);
|
||||
if (rejoins.size === 0) continue;
|
||||
for (const k of seen) onLoop.add(k);
|
||||
}
|
||||
return onLoop;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE AN INDUSTRY GOES.
|
||||
*
|
||||
* This was `options.find(i => i.placement !== undefined)` — the first legal placement the generator
|
||||
* happened to list, entirely unscored, while track laying got sixty lines of scoring. The measured
|
||||
* result: once the bot started closing a run-around in 91 games of 100, facilities sitting ON one
|
||||
* stayed at 0.00 a game. The loops got built and the industries were put somewhere else.
|
||||
*
|
||||
* A facility is worked by spotting a matching car on its industry track (§9.3), so where it sits
|
||||
* decides whether that is possible at all:
|
||||
*
|
||||
* - IN LINE with the siding, a facility becomes part of the run-around itself — its own through
|
||||
* track is a segment of the loop — so a crew reaches it from either end and can pass its
|
||||
* standing cars (§A.5). This is the placement worth paying for.
|
||||
* - Merely touching reachable track still works, by shoving in and backing out.
|
||||
* - On the Running Track a car left standing is hit by the next arrival (§11.2), so an industry
|
||||
* there is one the crew dare not serve.
|
||||
*/
|
||||
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
|
||||
const onLoop = runAroundCells(area);
|
||||
const reachable = reachableBelowMain(area);
|
||||
const sidingRow = area.runningRow - 1;
|
||||
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const i of options) {
|
||||
if (i.type !== 'card.play' || i.placement === undefined) continue;
|
||||
if (s.cards.get(i.cardId)?.kind.kind !== 'freightFacility') continue;
|
||||
|
||||
const { row, col } = i.placement;
|
||||
const axis = facilityVariants()[i.variant ?? 0]?.axis;
|
||||
if (!axis) continue;
|
||||
const ports: Port[] = axis === 'ns' ? ['n', 's'] : ['e', 'w'];
|
||||
|
||||
// Closer to the Office is fewer Moves to reach, same as track scoring.
|
||||
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
|
||||
|
||||
if (row === area.runningRow) {
|
||||
score -= 20;
|
||||
} else {
|
||||
let meetsLoop = false;
|
||||
let meetsReachable = false;
|
||||
for (const p of ports) {
|
||||
const n = neighbour({ row, col }, p);
|
||||
const nc = area.grid.get(`${n.row},${n.col}`);
|
||||
if (!nc || !hasPort(nc, opposite(p))) continue;
|
||||
const k = `${n.row},${n.col}`;
|
||||
if (onLoop.has(k)) meetsLoop = true;
|
||||
if (reachable.has(k) || n.row >= area.runningRow) meetsReachable = true;
|
||||
}
|
||||
|
||||
// A facility cannot be inserted into a loop that is already closed — one card per square — so
|
||||
// the placement that lands ON a run-around is the one laid IN LINE with a siding still being
|
||||
// built, east-west on the siding row. That is scored highest deliberately.
|
||||
const inLineWithSiding =
|
||||
row === sidingRow &&
|
||||
axis === 'ew' &&
|
||||
(reachable.has(`${row},${col - 1}`) || reachable.has(`${row},${col + 1}`));
|
||||
|
||||
if (inLineWithSiding) score += 26;
|
||||
else if (meetsLoop) score += 20;
|
||||
else if (meetsReachable) score += 12;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
@@ -298,16 +475,51 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
const divergesSouth = (c: TrackCard | undefined): boolean =>
|
||||
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
|
||||
|
||||
/** A card on the siding row that runs east-west — the siding itself. */
|
||||
const runsAcross = (c: TrackCard | undefined): boolean =>
|
||||
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
|
||||
/**
|
||||
* Can the siding continue THROUGH this neighbour, into the square being scored?
|
||||
*
|
||||
* Asked by the neighbour's PORTS, not its card kind. Testing for "a track straight running
|
||||
* east-west" meant an industry standing in the line read as a dead end — and once facilities
|
||||
* started being placed on the siding on purpose, the run stopped extending past the first one and
|
||||
* run-arounds fell from 91 games in 100 to 36. A Facility carries its own rails (§11.2); so does
|
||||
* an Office; so does a Limits sign. What matters is whether a port faces this way.
|
||||
*/
|
||||
const carriesInto = (c: TrackCard | undefined, toward: Port): boolean =>
|
||||
!!c && hasPort(c, toward);
|
||||
|
||||
/** An arc that reaches up to the Running Track. */
|
||||
const reachesUp = (c: TrackCard | undefined): boolean =>
|
||||
!!c &&
|
||||
c.geometry.kind === 'track' &&
|
||||
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
|
||||
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
|
||||
/**
|
||||
* A main-line card a siding can actually climb back onto — asked of the ENGINE, not guessed from
|
||||
* the card kind. Turnouts diverge south, and so does the Office (its `e-s`/`w-s` pairs), so
|
||||
* testing for a turnout alone missed a way up that is on every board.
|
||||
*/
|
||||
const wayUp = (c: TrackCard | undefined): boolean => !!c && hasPort(c, 's');
|
||||
|
||||
/** Main-line columns a siding could rejoin at. Without one of these beyond it, a run goes nowhere. */
|
||||
const waysUp = [...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && wayUp(c))
|
||||
.map(([k]) => Number(k.split(',')[1]));
|
||||
|
||||
const reachableBelow = reachableBelowMain(area);
|
||||
|
||||
/**
|
||||
* Straights already on the Running Track — the only thing an Enhancement can attach to.
|
||||
*
|
||||
* Interlocking, Water Column and Telegraph all print "any Running Track Straight", and Telephone
|
||||
* and Radio chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw`
|
||||
* directly), so it never needed a straight and never laid one: 0.00 on the Running Track across
|
||||
* 100 games, and with it 13 of the 18 enhancement cards that go on the board were structurally
|
||||
* unplayable. Enhancements were drawn 3.78 a game and placed 0.64.
|
||||
*
|
||||
* Interlocking is the expensive one. It is the designed answer to the ONLY penalty in the game —
|
||||
* "no free A/D track", which cost 2.40 revenue a game, 27% of gross — and it had never once been
|
||||
* placed, so no train was ever held at the Limits instead of colliding.
|
||||
*/
|
||||
const mainStraights = [...area.grid.entries()].filter(
|
||||
([k, c]) =>
|
||||
Number(k.split(',')[0]) === area.runningRow &&
|
||||
c.geometry.kind === 'track' &&
|
||||
c.geometry.geometry === 'straight',
|
||||
).length;
|
||||
|
||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
||||
@@ -326,6 +538,10 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
const dRow = Math.abs(row - area.officeCoord.row);
|
||||
const dCol = Math.abs(col - area.officeCoord.col);
|
||||
let score = -(dRow * 2 + dCol);
|
||||
// Bonuses are tracked apart from the distance score so the bot can tell a piece that BUILDS
|
||||
// something from one that is merely legal. Without that distinction it always returned its
|
||||
// best-scoring option, and spent all 26 pieces on whatever was placeable.
|
||||
let bonus = 0;
|
||||
|
||||
/**
|
||||
* BUILD A SIDING, not a stub.
|
||||
@@ -336,7 +552,17 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
|
||||
* one asked for it.
|
||||
*/
|
||||
if (row === area.runningRow && i.geometry === 'turnout') {
|
||||
if (
|
||||
row === area.runningRow &&
|
||||
i.geometry === 'straight' &&
|
||||
variantsFor('straight')[i.variant ?? 0]?.axis === 'ew' &&
|
||||
mainStraights < 1
|
||||
) {
|
||||
// Two is enough: Interlocking needs one, and a second gives Telegraph somewhere to go
|
||||
// without competing further with the ways down. Scored below a first turnout, which is still
|
||||
// the most valuable single piece on the board, and below closing a run-around.
|
||||
bonus += 13;
|
||||
} else if (row === area.runningRow && i.geometry === 'turnout') {
|
||||
// A first way down is the most valuable single piece on the board; a second closes the
|
||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||
@@ -344,31 +570,66 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
// Measured: capping this to one or two ways down cost more than the spare turnouts did
|
||||
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
|
||||
// crew can actually reach, which matters more than a tidy Running Track.
|
||||
score += turnouts.length === 0 ? 14 : 3;
|
||||
bonus += turnouts.length === 0 ? 14 : 3;
|
||||
} else if (row === below) {
|
||||
const arc = arcOf(i);
|
||||
const turnoutAbove = divergesSouth(at(area.runningRow, col));
|
||||
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
|
||||
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
|
||||
score += 13;
|
||||
// What this square is anchored to, and therefore which way a run through it would extend. The
|
||||
// anchor must be reachable from the main: extending a stranded fragment builds nothing.
|
||||
const anchoredAt = (c: number): boolean =>
|
||||
reachableBelow.has(`${row},${c}`) && carriesInto(at(row, c), c < col ? 'e' : 'w');
|
||||
const anchorWest = anchoredAt(col - 1);
|
||||
const anchorEast = anchoredAt(col + 1);
|
||||
|
||||
if (arc === 'ne' || arc === 'nw') {
|
||||
/**
|
||||
* AN ARC IS ONLY WORTH LAYING WHERE IT CAN REACH THE MAIN.
|
||||
*
|
||||
* Both of these used to score on shape alone: "close the loop back up to the main" was
|
||||
* awarded to any arc beside an east-west run, whether or not there was anything above it to
|
||||
* join. Measured, that produced 0 run-arounds in 100 games — the siding terminated in an arc
|
||||
* pointing north into empty space, past the east end of the Running Track, every game.
|
||||
*
|
||||
* An arc also has exactly two ports, so which way it faces decides what it meets: `nw` joins
|
||||
* north and WEST, `ne` joins north and EAST. Scoring them alike let the bot close a run from
|
||||
* the wrong side, which connects nothing.
|
||||
*/
|
||||
if (wayUp(at(area.runningRow, col))) {
|
||||
const meetsRun = (arc === 'nw' && anchorWest) || (arc === 'ne' && anchorEast);
|
||||
// Closing an existing run beats opening a new one: that is the whole difference between a
|
||||
// run-around and one more stub.
|
||||
bonus += meetsRun ? 15 : 13;
|
||||
}
|
||||
// An arc with nothing above it earns no bonus but is not forbidden — reaching a facility
|
||||
// two rows down is still worth doing when there is no loop to close.
|
||||
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
|
||||
// Extend the siding beside the main, but only from something that already turned.
|
||||
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
|
||||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
|
||||
score += 11;
|
||||
/**
|
||||
* Extend the siding beside the main, but only from something that already turned AND only
|
||||
* while there is still somewhere to climb back up. Unbounded, this rule ran the siding east
|
||||
* past the last usable column in 96 of 100 games — the loop then missed by one card.
|
||||
*/
|
||||
const canStillClose =
|
||||
(anchorWest && waysUp.some((c) => c > col)) || (anchorEast && waysUp.some((c) => c < col));
|
||||
if ((anchorWest || anchorEast) && canStillClose) {
|
||||
bonus += 11;
|
||||
}
|
||||
} else if (arc && (arc === 'ne' || arc === 'nw') &&
|
||||
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
|
||||
// Close the loop back up to the main: the run-around is complete.
|
||||
score += 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (i.geometry === 'straight') score += 2;
|
||||
if (dRow > 0) score += 3;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
/**
|
||||
* DECLINE A PIECE THAT BUILDS NOTHING.
|
||||
*
|
||||
* This used to return its best option unconditionally, so once the useful squares were taken it
|
||||
* kept laying track simply because track was legal — 21 pieces a game, ending in straights and
|
||||
* arcs east of the last column with a way up, joined to nothing. Returning null instead lets the
|
||||
* turn fall through to playing a card, which is what the Stage is worth more spent on.
|
||||
*/
|
||||
if (bonus <= 0) continue;
|
||||
|
||||
if (score + bonus > bestScore) {
|
||||
bestScore = score + bonus;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
@@ -583,7 +844,12 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
const track = bestTrackLay(s, player, options);
|
||||
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
|
||||
|
||||
// Then real development: a card actually laid into the grid.
|
||||
// Then real development: a card actually laid into the grid. Freight facilities are scored —
|
||||
// where an industry sits decides whether a crew can ever serve it — and everything else
|
||||
// placeable falls through to the first legal square.
|
||||
const facility = bestFacilityPlay(s, player, options);
|
||||
if (facility) return because('put the industry where a crew can actually work it', facility);
|
||||
|
||||
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
||||
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||
const play = options.find((i) => i.type === 'card.play');
|
||||
@@ -770,7 +1036,27 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
|
||||
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
||||
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||
return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
|
||||
/**
|
||||
* NEVER UNJAM A HEALTHY BOX.
|
||||
*
|
||||
* This used to fall through to `pickFirst(options, 'freightAgent.unjam')` — "clear whatever
|
||||
* is stuck" — with nothing stuck. `facilityUnjammed` from `outbound` splices the load out of
|
||||
* the green box and pushes it to the classification yard, so the fallback destroyed a load
|
||||
* that had cost a Local Operations action to stock. Measured at 3.10 a game against 2.71
|
||||
* loads STARTED: the bot threw away more freight than it worked.
|
||||
*
|
||||
* §6.3's unjam is for a load stranded on MEN|AT|WORK that cannot advance. A green box holding
|
||||
* a load waiting for a car is not jammed, it is waiting.
|
||||
*/
|
||||
// The option has no "end" action, so something must be done. Order by what it costs to lose:
|
||||
// a load stranded on MEN|AT|WORK is already blocking the pipeline, a red box holds a load
|
||||
// whose Revenue is banked, and the green box is the only one where discarding destroys work
|
||||
// that has not been paid for yet. It is last deliberately.
|
||||
const byCost = (['menAtWork', 'inbound', 'outbound'] as const).map((from) =>
|
||||
options.find((i) => i.type === 'freightAgent.unjam' && i.from === from),
|
||||
);
|
||||
const fallback = byCost.find((i) => i !== undefined);
|
||||
return because('nothing productive at any facility — clearing the least costly box', fallback ?? options[0]!);
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -795,10 +1081,14 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
const spotted = f.industryTrack.cars.length;
|
||||
if (spotted >= f.industryTrack.length) continue;
|
||||
const type = carTypeOf(card);
|
||||
if (!type) continue;
|
||||
if (f.allows.outbound) out.push({ type, loaded: false });
|
||||
if (f.allows.inbound) out.push({ type, loaded: true });
|
||||
// Every commodity the industry handles, asked of its profile. A hand-written switch here had
|
||||
// drifted from the sheet: it named two industries that do not exist (`produceShed`,
|
||||
// `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
|
||||
// half the industries in the deck never asked for a car at all.
|
||||
for (const type of facilityCarTypes(f)) {
|
||||
if (f.allows.outbound) out.push({ type, loaded: false });
|
||||
if (f.allows.inbound) out.push({ type, loaded: true });
|
||||
}
|
||||
}
|
||||
// Coaches are worth carrying in BOTH states, and this is easy to get wrong. An EMPTY coach is
|
||||
// what a boarding passenger is put into (§9.2); a LOADED one carries arrivals who can be
|
||||
@@ -813,23 +1103,6 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function carTypeOf(card: { geometry: { kind: string; facility?: string } }): string | null {
|
||||
if (card.geometry.kind !== 'facility') return null;
|
||||
switch (card.geometry.facility) {
|
||||
case 'mineTipple':
|
||||
case 'powerPlant':
|
||||
return 'hopper';
|
||||
case 'produceShed':
|
||||
return 'reefer';
|
||||
case 'grocersWarehouse':
|
||||
return 'boxcar';
|
||||
case 'oilRefinery':
|
||||
return 'tank';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Would spotting this car here do any work?
|
||||
*
|
||||
@@ -840,7 +1113,7 @@ function carTypeOf(card: { geometry: { kind: string; facility?: string } }): str
|
||||
function facilityWants(f: Facility, car: RollingStock): boolean {
|
||||
if (f.kind !== 'freight') return false;
|
||||
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
|
||||
if (car.type !== facilityCarType(f)) return false;
|
||||
if (!facilityCarTypes(f).includes(car.type)) return false;
|
||||
if (!car.loaded && f.allows.outbound) return true;
|
||||
if (car.loaded && f.allows.inbound) return true;
|
||||
return false;
|
||||
|
||||
+13
-1
@@ -634,6 +634,18 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
const f = card.facility;
|
||||
if (!f) return 'a facility';
|
||||
if (f.kind === 'passenger') return 'passengers board and detrain here';
|
||||
// Say where the work has actually got to — the squares on the card show it, this names it.
|
||||
const inWork = f.menAtWork.findIndex((l) => l !== null);
|
||||
const progress =
|
||||
inWork >= 0
|
||||
? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
|
||||
inWork === f.menAtWork.length - 1
|
||||
? 'one more Laborer action and it goes onto a spotted car'
|
||||
: 'each Laborer action moves it one square right'
|
||||
}`
|
||||
: f.outboundBox.length > 0
|
||||
? ' · a load waits in the green box for a Laborer to start it'
|
||||
: '';
|
||||
const p = industryProfile(g.facility as never);
|
||||
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
|
||||
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
|
||||
@@ -642,7 +654,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
const where = onRunning
|
||||
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
|
||||
: '';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`;
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
|
||||
}
|
||||
case 'track':
|
||||
switch (g.geometry) {
|
||||
|
||||
Reference in New Issue
Block a user