Draw freight on the card, and teach the bot to build a railroad

This commit is contained in:
Jesse
2026-08-04 04:59:23 -04:00
parent e23d81eade
commit 982952fd6c
11 changed files with 1288 additions and 89 deletions
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {