adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
+61
-2
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user