adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+61 -2
View File
@@ -24,7 +24,7 @@ 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 { exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
import { connectionsFor, 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, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
@@ -524,6 +524,60 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
/**
* RAIL THAT CAN NEVER GO ANYWHERE.
*
* A port facing an EMPTY square is a promise: something may be built there later. A port butting
* an OCCUPIED square whose card has no matching port is nothing of the kind — that square is
* taken, so the rail stops dead and always will.
*
* Measured, 28% of the pieces the bot laid had one, at 4.26 a game: an arc dropped off a turnout
* with its far end jammed into a curve that could not accept it, which is exactly the placement
* that prompted this. Penalised rather than forbidden — a piece can still be worth laying for the
* end that DOES connect, and the last two attempts to forbid something outright starved the
* district instead of tidying it.
*/
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
const v = variantsFor(i.geometry)[i.variant ?? 0];
const probe = {
geometry: {
kind: 'track',
geometry: i.geometry,
...(v?.axis ? { axis: v.axis } : {}),
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
},
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
} as unknown as TrackCard;
let dead = 0;
for (const p of new Set(connectionsFor(probe).flat())) {
const n = neighbour(i.placement, p);
const nb = at(n.row, n.col);
if (!nb) continue; // empty — may still be built on
if (hasPort(nb, opposite(p))) continue; // joins
dead++;
}
return dead;
};
/**
* Arcs left to build BENEATH a turnout.
*
* A turnout is a hole in the Running Track and is worth cutting only for what hangs under it, so
* cutting one with nothing left to hang there is pure loss. Measured at 0% today — the bot builds
* the arc immediately after the turnout and never runs the supply dry first — so this is a guard
* against the supply changing rather than a fix for something happening now.
*/
const arcsLeft =
(area.trackSupply.get('curved:left') ?? 0) +
(area.trackSupply.get('curved:right') ?? 0) +
(area.trackSupply.get('sharpCurved:left') ?? 0) +
(area.trackSupply.get('sharpCurved:right') ?? 0);
// Where the district already turns down off the main.
const turnouts = [...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
@@ -562,7 +616,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
// 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') {
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft > 0) {
// 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
@@ -618,6 +672,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3;
// A TIE-BREAKER, not a veto. Applied to the distance score rather than the bonus, so it can
// never push a piece below the "builds nothing" floor and refuse it outright: given two
// placements the heuristics rate the same, take the one whose rail can still grow.
score -= deadEnds(i) * 0.5;
/**
* DECLINE A PIECE THAT BUILDS NOTHING.
*