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.
*
+19 -5
View File
@@ -113,6 +113,10 @@ export function summarize(
const reason = e.reason;
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
// `freightUnload` is its own reason on its own event (apply.ts). It used to be counted from
// `unloadBegan` instead — unloads STARTED, not Revenue EARNED — and the two differ by every
// unload that never finished.
else if (reason === 'freightUnload') rev.freightUnload += e.delta;
else if (reason === 'boarding') rev.passengerBoard += e.delta;
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
}
@@ -129,12 +133,21 @@ export function summarize(
for (const i of intents) inc(intentCounts, i);
// An unload scores through the same event as a load completion in the reducer, so count the
// distinct operations directly.
rev.freightUnload = eventCounts['unloadBegan'] ?? 0;
rev.net = final.players.reduce((n, p) => n + p.revenue, 0);
const grossFreight = rev.freightLoad;
/**
* FREIGHT IS BOTH HALVES.
*
* A completed load and a completed unload each earn a point, on two distinct `revenueChanged`
* reasons — and this counted only the first, so `freightShare` reported roughly 60% of the
* freight actually earned. That is the number the "freight is only 13-18% of gross" finding was
* read from, and it drove the Gap 12 industry-density change.
*
* The comment that stood here claimed "an unload scores through the same event as a load
* completion in the reducer", which is not true: `apply.ts` emits `freightUnload` separately. It
* was a comment asserting a fact about code a few lines away, and it was wrong.
*/
const grossFreight = rev.freightLoad + rev.freightUnload;
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
const gross = grossFreight + grossPassenger;
@@ -347,7 +360,7 @@ export type StrategyBucket = {
* consistently outscore passenger specialists (or vice versa), it shows up here.
*/
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
@@ -408,6 +421,7 @@ export function formatAggregate(all: GameStats[]): string {
out.push(' REVENUE (mean per game)');
out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
out.push(` from freight unloads ${num(meanOf(all, (g) => g.revenue.freightUnload))}`);
out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
+46 -1
View File
@@ -179,6 +179,19 @@ export type Frame = {
departments: string[];
/** What each face-up Department card does. */
departmentsWhat: string[];
/**
* The two yards, by car type.
*
* Rolling stock is finite and the Classification Yard only returns to service when the Division
* Yard is BARE, so watching the Division Yard run down is now real information — and the game
* showed neither yard at all.
*/
yards: {
division: { type: string; loaded: number; empty: number }[];
classification: { type: string; loaded: number; empty: number }[];
divisionTotal: number;
classificationTotal: number;
};
/** 12 slots; the train number due out at each Stage, or null. */
timetable: (number | null)[];
blocked: Impediment[];
@@ -598,6 +611,12 @@ export function snapshot(
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
yards: {
division: countStock(s.yards.divisionYard),
classification: countStock(s.yards.classificationYard),
divisionTotal: s.yards.divisionYard.length,
classificationTotal: s.yards.classificationYard.length,
},
timetable: [...s.timetable],
decision,
wasted,
@@ -889,12 +908,38 @@ const MODIFIER_NAMES: Record<string, string> = {
};
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
/** Cars in a yard, grouped by type and split loaded / empty, in a stable order. */
function countStock(
stock: readonly { type: string; loaded: boolean }[],
): { type: string; loaded: number; empty: number }[] {
const order = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
const by = new Map<string, { type: string; loaded: number; empty: number }>();
for (const c of stock) {
const row = by.get(c.type) ?? { type: c.type, loaded: 0, empty: 0 };
if (c.loaded) row.loaded += 1;
else row.empty += 1;
by.set(c.type, row);
}
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be pulling, pushing, or in
* the middle doing both — which is a thing a player has to be able to see, since it decides which
* end cars couple onto (§A.3) and which way the train can shove. It was recorded as a boolean
* that nothing read, so every consist was drawn as an anonymous row of cars.
*/
const cars = t.consist.map(carLabel);
const at = Math.max(0, Math.min(cars.length, t.engineAt));
cars.splice(at, 0, 'ENG');
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: t.consist.map(carLabel),
consist: cars,
};
}