Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
/**
* Component 17 — Heuristic bot players.
*
* Dev-side only: this ships nowhere. Its job is to play well enough that the balance harness
* (component 18) produces numbers worth trusting. See architecture/components.md §2 D.17.
*
* A bot that plays LEGALLY is easy; one that plays WELL enough to judge balance is the harder
* half, and this is only the first attempt. Read the numbers it produces as a floor on what a
* competent human would score, not a prediction of one.
*
* THE POLICY, from the economy in docs/rules/card-reference.md §7. Local Operations actions are
* the currency — one per Stage, twelve per Day, roughly one Revenue point each — so the bot's
* whole job is deciding which of the three options to spend the Stage on:
*
* 1. Develop first. A Whistle Post is not a Passenger Facility and earns nothing from
* passengers, so upgrading and placing freight facilities dominates early.
* 2. Then keep the pipeline fed. Stocking a green box is what converts an action into a point.
* 3. Switch when a train is standing at the Office, because spotted cars are what let a load
* complete at all.
*/
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
export type BotPolicy = {
name: string;
choose(s: GameState, player: PlayerIndex, options: Intent[]): Intent;
};
// ---------------------------------------------------------------------------
function pickFirst(options: Intent[], ...types: Intent['type'][]): Intent | null {
for (const t of types) {
const found = options.find((i) => i.type === t);
if (found) return found;
}
return null;
}
/**
* §8.1 — the Superintendent's judgment call, and the bot's most consequential decision.
*
* Denying costs the following train one Stage. Allowing risks a collision at −5 Revenue, roughly a
* full Day's earnings, plus a train destroyed. At ~0.5 Revenue per Stage the arithmetic favours
* caution heavily, so this bot always denies. That is a deliberate policy choice, not an oversight:
* a bolder policy is worth simulating separately to see what following moves are actually worth.
*/
function ruleOnClearance(options: Intent[]): Intent | null {
return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? null;
}
// ---------------------------------------------------------------------------
export const developerBot: BotPolicy = {
name: 'developer',
choose(s, player, options) {
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
//
// Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
if (s.clock.phase === 'loadUnload') {
const work = pickFirst(
options,
'porter.board',
'porter.detrain',
'laborer.advanceLoad',
'laborer.startLoad',
'laborer.beginUnload',
);
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
}
// --- New Train: fill the consist, but with the RIGHT cars.
//
// This is subtler than it looks. §9.3 requires an EMPTY car of the matching type spotted on an
// outbound facility's track before a load can be worked onto it, and a LOADED car before an
// inbound facility can unload one. So the useful car to put on a train is the one this
// player's facilities are short of — not simply a loaded one.
if (s.clock.phase === 'newTrain') {
const wanted = wantedCars(s, player);
for (const w of wanted) {
const match = options.find(
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
);
if (match) return match;
}
return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
}
// --- Local Operations.
if (s.clock.phase === 'localOps') {
if (s.turn.option === null) return chooseLocalOption(s, player, options);
return followThrough(s, player, options);
}
return options[0]!;
},
};
/**
* The one decision that matters: which of §6's three things to spend this Stage on.
*
* The ordering below is the result of measurement, not intuition. An earlier version picked
* Freight Agent whenever it was available, which is *always* once a facility exists with box room.
* It therefore stopped drawing after roughly two Days, scheduled only 3 of a possible 12 trains,
* and spent the rest of the game stuffing green boxes whose loads could never complete for want of
* a car to load them onto.
*/
function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
const choices = options.filter(
(i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose',
);
const can = (o: 'switch' | 'draw' | 'freightAgent') => choices.find((c) => c.option === o);
const area = areaOf(s, player);
const hand = s.decks.hands.get(player) ?? [];
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
// whose value compounds. Playing one needs the draw option.
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
// 2. A train standing at the Office is a fleeting chance to spot cars; it highballs next
// Mainline Phase whether or not anything was done with it.
if (area.adOccupancy.length > 0 && can('switch')) return can('switch')!;
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
return can('freightAgent')!;
}
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
if (can('draw')) return can('draw')!;
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
}
/**
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
*
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
* in to rescue it. Stocking speculatively jams the facility.
*/
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || !f.allows.outbound) continue;
if (f.outboundBox.length >= f.capacity.outbound) continue;
const want = facilityCarType(f);
if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
}
return false;
}
/** A load on MEN|AT|WORK that cannot advance, with Laborers free to move it. */
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || laborersLeft(f) < 1) continue;
for (let box = 0; box < f.menAtWork.length; box++) {
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
}
}
return false;
}
function needsClearing(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f) continue;
if (f.inboundBox.length >= Math.max(1, f.capacity.inbound)) return true;
}
return false;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
case 'draw': {
// Draw before playing — otherwise the hand empties and never refills.
if (!s.turn.drawnThisTurn) {
const draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice');
if (draw) return draw;
}
// Train cards first — they take no placement and their value compounds every Day.
const train = options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
if (train) return train;
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
const play = options.find((i) => i.type === 'card.play');
if (play) return play;
const end = options.find((i) => i.type === 'draw.end');
if (end) return end;
return pickFirst(options, 'card.discard') ?? options[0]!;
}
case 'switch': {
// Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
//
// Measured: an earlier version dropped whichever car happened to be at the tray's end,
// which silted industry tracks up with coaches and cabooses — 532 coaches were observed
// sitting on freight sidings across 20 games. A facility whose track is full of the wrong
// commodity cannot accept the car it actually needs, and the freight chain starves.
//
// §A.3 forces the issue: cars come off in seated order, so only the end car is droppable.
// A strong player would use the six Moves to re-order the consist — that is the game's
// central switching puzzle, and this bot does not attempt it. It simply declines to drop a
// car that would do no work.
const tray = trayOf(s, player);
const here = trayLocation(s, player);
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
if (endCar && here) {
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
if (hereFacility && facilityWants(hereFacility, endCar)) {
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (drop) return drop;
}
// Otherwise head for a facility that does want this particular car.
const wanted = facilitiesWanting(s, player, endCar);
const toward = options.find(
(i) =>
i.type === 'switch.move' &&
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
);
if (toward) return toward;
}
const move = options.find((i) => i.type === 'switch.move');
if (move) return move;
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
}
case 'freightAgent': {
// A stuck load is the worst state a facility can be in: it blocks the pipeline AND strips
// the industry track of Operational Rail status (§9.3), so no car can be brought in to
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
if (hasStuckLoad(s, player)) {
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
if (unjam) return unjam;
}
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
if (clear) return clear;
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return stock;
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
}
default:
return options[0]!;
}
}
// ---------------------------------------------------------------------------
// What this player's facilities are actually short of
// ---------------------------------------------------------------------------
type WantedCar = { type: string; loaded: boolean };
/**
* An outbound facility needs EMPTY cars of its type to load onto; an inbound one needs LOADED cars
* to unload. Ordered so the most useful car comes first.
*/
function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
const out: WantedCar[] = [];
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
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 });
}
// A coach is worth carrying too: passenger work is a point per Porter action.
out.push({ type: 'coach', loaded: false });
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?
*
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
* industry track (§9.3).
*/
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 (!car.loaded && f.allows.outbound) return true;
if (car.loaded && f.allows.inbound) return true;
return false;
}
function facilitiesWanting(
s: GameState,
player: PlayerIndex,
car: RollingStock,
): { row: number; col: number }[] {
const out: { row: number; col: number }[] = [];
for (const [key, card] of areaOf(s, player).grid) {
if (!card.facility || !facilityWants(card.facility, car)) continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
}
return null;
}
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) {
return tray.position.coord;
}
}
return null;
}
// ---------------------------------------------------------------------------
/** Picks uniformly at random. A control, to show what the policy above is actually worth. */
export function randomBot(seed: number): BotPolicy {
let rng = seed >>> 0;
return {
name: 'random',
choose(_s, _p, options) {
rng = (rng * 1103515245 + 12345) >>> 0;
return options[rng % options.length]!;
},
};
}
// ---------------------------------------------------------------------------
export type PlayOutcome = {
finished: boolean;
turns: number;
days: number;
revenue: number[];
collisions: number;
trainsScheduled: number;
cardsPlayed: number;
outcome: GameState['outcome'];
/** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
events: GameEvent[];
/** Every intent the bot actually submitted, for action-mix analysis. */
intents: Intent['type'][];
};
/**
* Drives a game to completion with the given policy. This is the whole reason the phase driver
* lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
*/
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
let collisions = 0;
let trainsScheduled = 0;
let cardsPlayed = 0;
let turns = 0;
const tally = (batch: GameEvent[]): void => {
events.push(...batch);
for (const e of batch) {
if (e.type === 'trainScheduled') trainsScheduled++;
else if (e.type === 'cardPlayed') cardsPlayed++;
else if (
e.type === 'revenueChanged' &&
'reason' in e &&
String((e as { reason: string }).reason).startsWith('collision')
) {
collisions++;
}
}
};
for (; turns < maxTurns; turns++) {
tally(pumpFn(s));
if (s.status === 'finished') break;
const actor =
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const chosen = policy.choose(s, actor, options);
intents.push(chosen.type);
const r = applyIntent(s, actor, chosen);
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
tally(r.events);
}
return {
finished: s.status === 'finished',
turns,
days: s.clock.day,
revenue: s.players.map((p) => p.revenue),
collisions,
trainsScheduled,
cardsPlayed,
outcome: s.outcome,
events,
intents,
};
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Component 18 — Balance simulation harness.
*
* Dev-side only. Runs many seeded games and reports the numbers that the provisional values in
* docs/rules/card-reference.md were guessed at. See architecture/components.md §2 D.18.
*
* The point is narrow and worth stating: this can tell you whether the ARITHMETIC of the game
* works — whether targets are reachable, whether Crew Trays bottleneck, whether collisions fire at
* a sane rate. It cannot tell you whether the game is any fun. Only a table can do that.
*
* Run with: node src/sim/harness.ts [games] [length]
*/
import { pump } from '../engine/advance.ts';
import { collectiveRevenueFloor, lengthProfile } from '../engine/content.ts';
import { createGame } from '../engine/setup.ts';
import type { GameLength } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
import type { BotPolicy, PlayOutcome } from './bot.ts';
import { developerBot, playGame, randomBot } from './bot.ts';
import type { GameStats } from './stats.ts';
import { formatAggregate, summarize } from './stats.ts';
export type SimOptions = {
games: number;
length: GameLength;
mode: GameMode;
players: string[];
policy: BotPolicy;
};
export type SimReport = {
policy: string;
games: number;
finished: number;
wins: number;
revenuePerPlayer: Stats;
revenuePerPlayerPerDay: Stats;
collisionsPerGame: Stats;
trainsScheduled: Stats;
cardsPlayed: Stats;
daysPlayed: Stats;
outcomeReasons: Record<string, number>;
/** Per-game detail, for end-of-game statistics and anomaly detection. */
perGame: GameStats[];
};
export type Stats = { min: number; max: number; mean: number; median: number };
function statsOf(xs: number[]): Stats {
if (xs.length === 0) return { min: 0, max: 0, mean: 0, median: 0 };
const sorted = [...xs].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
min: sorted[0]!,
max: sorted[sorted.length - 1]!,
mean: xs.reduce((a, b) => a + b, 0) / xs.length,
median:
sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!,
};
}
function configFor(mode: GameMode, length: GameLength): GameConfig {
return {
mode,
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
}
export function simulate(opts: SimOptions): SimReport {
const results: PlayOutcome[] = [];
const stats: GameStats[] = [];
for (let i = 0; i < opts.games; i++) {
const seed = 1000 + i * 7919; // a prime stride, so seeds do not correlate
const s = createGame({
id: `sim-${i}`,
seed,
config: configFor(opts.mode, opts.length),
playerNames: opts.players,
});
const r = playGame(s, opts.policy, pump);
results.push(r);
stats.push(summarize(seed, r.events, r.intents, s));
}
const finished = results.filter((r) => r.finished);
const perPlayer = finished.flatMap((r) => r.revenue);
const days = finished.map((r) => Math.max(1, r.days - 1));
const perDay = finished.flatMap((r) =>
r.revenue.map((v) => v / Math.max(1, r.days - 1)),
);
const reasons: Record<string, number> = {};
for (const r of finished) {
const key = r.outcome ? `${r.outcome.result}/${r.outcome.reason}` : 'none';
reasons[key] = (reasons[key] ?? 0) + 1;
}
return {
policy: opts.policy.name,
games: opts.games,
finished: finished.length,
wins: finished.filter((r) => r.outcome?.result === 'win').length,
revenuePerPlayer: statsOf(perPlayer),
revenuePerPlayerPerDay: statsOf(perDay),
collisionsPerGame: statsOf(finished.map((r) => r.collisions)),
trainsScheduled: statsOf(finished.map((r) => r.trainsScheduled)),
cardsPlayed: statsOf(finished.map((r) => r.cardsPlayed)),
daysPlayed: statsOf(days),
outcomeReasons: reasons,
perGame: stats,
};
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
const f = (n: number): string => n.toFixed(1).padStart(6);
function line(label: string, s: Stats): string {
return ` ${label.padEnd(26)} ${f(s.mean)} ${f(s.median)} ${f(s.min)} ${f(s.max)}`;
}
export function formatReport(r: SimReport, length: GameLength, players: number): string {
const profile = lengthProfile(length);
const out: string[] = [];
out.push(`\n=== ${r.policy} bot · ${length} · ${players}p · ${r.games} games ===`);
out.push(` finished ${r.finished}/${r.games} wins ${r.wins}/${r.finished}`);
out.push(` ${''.padEnd(26)} mean median min max`);
out.push(line('revenue per player', r.revenuePerPlayer));
out.push(line('revenue per player per Day', r.revenuePerPlayerPerDay));
out.push(line('collisions per game', r.collisionsPerGame));
out.push(line('trains scheduled', r.trainsScheduled));
out.push(line('cards played', r.cardsPlayed));
out.push(line('days played', r.daysPlayed));
out.push('\n outcomes:');
for (const [k, v] of Object.entries(r.outcomeReasons).sort((a, b) => b[1] - a[1])) {
out.push(` ${k.padEnd(28)} ${v}`);
}
// The provisional numbers this exists to test.
out.push('\n against the provisional targets:');
out.push(` target ${profile.target} over ${profile.days} Days`);
out.push(
` predicted ~5-6 revenue/player/Day steady state; observed mean ` +
`${r.revenuePerPlayerPerDay.mean.toFixed(1)}`,
);
out.push(
` collective floor (competitive) ${collectiveRevenueFloor(players, profile.days)}` +
`; observed total mean ${(r.revenuePerPlayer.mean * players).toFixed(1)}`,
);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith('harness.ts') ?? false;
if (isMain) {
const games = Number(process.argv[2] ?? 200);
const length = (process.argv[3] ?? 'standard') as GameLength;
for (const policy of [developerBot, randomBot(12345)]) {
const report = simulate({
games,
length,
mode: 'solitaire',
players: ['bot'],
policy,
});
console.log(formatReport(report, length, 1));
if (policy === developerBot) console.log(formatAggregate(report.perGame));
}
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Plain-English narration for the replay viewer.
*
* Two jobs, and the second matters more than it looks:
*
* 1. `narrate()` — turn each engine event into a sentence a person can read.
* 2. `impediments()` — say what is currently BLOCKED and why.
*
* Actions are easy to show. Blocked states are what diagnosis actually needs: the open questions
* are all of the form "why is nothing happening?" — why is a train at the Office only 18% of
* Stages, why do Laborers sit idle, why does a facility stop working. A log of things that did
* happen answers none of those.
*
* The impediment checks call the engine's own predicates rather than reimplementing them, so the
* panel cannot drift from the rules.
*/
import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
// Small formatters
// ---------------------------------------------------------------------------
const CLOCK: readonly string[] = [
'Midnight', '2:00 AM', '4:00 AM', '6:00 AM', '8:00 AM', '10:00 AM',
'Noon', '2:00 PM', '4:00 PM', '6:00 PM', '8:00 PM', '10:00 PM',
];
export function clockTime(stage: number): string {
return CLOCK[stage - 1] ?? `Stage ${stage}`;
}
export function carLabel(c: RollingStock): string {
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
}
export function carsLabel(cars: RollingStock[]): string {
if (cars.length === 0) return 'nothing';
return cars.map(carLabel).join(', ');
}
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
export function phaseLabel(phase: string): string {
switch (phase) {
case 'localOps':
return 'Local Operations';
case 'newTrain':
return 'New Train';
case 'mainline':
return 'Mainline';
case 'loadUnload':
return 'Load / Unload';
case 'shiftChange':
return 'Shift Change';
default:
return phase;
}
}
// ---------------------------------------------------------------------------
// Event narration
// ---------------------------------------------------------------------------
export type Narration = {
text: string;
/** Colours the line in the viewer. */
tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
/** Board cell to highlight, if the event happened somewhere. */
where?: GridCoord;
};
/**
* Every member of `GameEvent` must produce a specific sentence. A test asserts the fallback is
* never reached, so adding an event type without narrating it fails the build rather than quietly
* degrading the replay.
*/
export function narrate(e: GameEvent): Narration {
switch (e.type) {
// -- clock
case 'stageBegan':
return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
case 'phaseBegan':
return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
case 'actorChanged':
return {
tone: 'quiet',
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
};
// -- local operations
case 'localOpsOptionChosen':
return {
tone: 'plain',
text:
e.option === 'switch'
? 'Chose to SWITCH — six Moves to shunt cars around the yard'
: e.option === 'draw'
? 'Chose to DRAW a card'
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
};
case 'trayMoved':
return {
tone: 'plain',
where: e.to,
text: `Crew moved ${at(e.from)} → ${at(e.to)} · ${e.movesRemaining} Moves left`,
};
case 'carsCoupled':
return {
tone: 'plain',
where: e.at,
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
};
case 'carsDropped':
return {
tone: 'plain',
where: e.at,
text: `Dropped ${carsLabel(e.stock)} at ${at(e.at)}`,
};
// -- cards
case 'cardDrawn':
return {
tone: 'plain',
text:
e.source === 'homeOffice'
? 'Drew a card from the Home Office deck'
: `Took the face-up card from Department slot ${(e.slot ?? 0) + 1}`,
};
case 'cardPlayed':
return {
tone: 'plain',
...(e.placement ? { where: e.placement } : {}),
text: e.placement ? `Played a card onto ${at(e.placement)}` : 'Played a card',
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
case 'cardDiscarded':
return { tone: 'quiet', text: `Discarded a card face-up to Department slot ${e.toSlot + 1}` };
case 'deckReshuffled':
return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
case 'departmentRefilled':
return { tone: 'quiet', text: `Department slot ${e.slot + 1} refilled from the deck` };
// -- freight agent
case 'stockToOutbound':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
};
case 'inboundCleared':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
};
case 'facilityUnjammed':
return {
tone: 'bad',
where: e.at,
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
};
// -- trains
case 'trainScheduled':
return {
tone: 'good',
text: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`,
};
case 'carPlacedOnTrain':
return { tone: 'plain', text: `Put a ${carLabel(e.stock)} on the train being made up` };
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
case 'clearanceRequested':
return {
tone: 'bad',
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
};
case 'clearanceGiven':
return {
tone: e.allow ? 'bad' : 'plain',
text: e.allow
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
};
// -- passengers
case 'passengersBoarded':
return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)}` };
case 'passengersDetrained':
return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)}` };
// -- freight pipeline
case 'loadStarted':
return {
tone: 'plain',
where: e.at,
text: `Laborer moved a ${e.carType} load from the green box onto MEN`,
};
case 'loadAdvanced':
return {
tone: 'plain',
where: e.at,
text: `Laborer advanced the load ${boxName(e.fromBox)} → ${boxName(e.toBox)}`,
};
case 'loadCompleted':
return {
tone: 'good',
where: e.at,
text: `LOAD FINISHED — ${e.carType} loaded onto the spotted car`,
};
case 'unloadBegan':
return {
tone: 'plain',
where: e.at,
text: `Laborer began unloading a ${e.carType} — load lifted onto WORK`,
};
case 'unloadCompleted':
return {
tone: 'good',
where: e.at,
text: `UNLOAD FINISHED — ${e.carType} delivered into the red Inbound box`,
};
// -- consequences
case 'revenueChanged':
return e.delta < 0
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
case 'phaseEnded':
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
}
}
/** Events that change nothing a viewer can see. Skipped when capturing frames. */
export function isVisible(e: GameEvent): boolean {
return e.type !== 'actorChanged';
}
// ---------------------------------------------------------------------------
// Impediments — "why is nothing happening?"
// ---------------------------------------------------------------------------
export type Impediment = { where: string; why: string; severity: 'stuck' | 'waiting' | 'risk' };
/**
* Everything currently preventing progress. Derived live from engine predicates, never cached.
*
* This is the panel that should answer the standing questions: whether facilities jam, whether
* trains are held for want of a crew, whether the Office is about to cause a collision.
*/
export function impediments(s: GameState, player = 0): Impediment[] {
const out: Impediment[] = [];
const area = s.officeAreas.get(player);
if (!area) return out;
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
const want = facilityCarType(f);
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
// it also strips the industry track of Operational Rail status (§9.3), so no car can be
// brought in to rescue it.
for (let box = 0; box < f.menAtWork.length; box++) {
const load = f.menAtWork[box];
if (!load || canAdvanceLoad(f, box)) continue;
const reason =
laborersLeft(f) < 1
? 'all Laborers already used this Stage'
: load.dir === 'out'
? `no empty ${want} spotted to load onto`
: 'red Inbound box is full';
out.push({
where: `${name} ${key}`,
why: `load STUCK on ${boxName(box)} — ${reason}`,
severity: laborersLeft(f) < 1 ? 'waiting' : 'stuck',
});
}
if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) {
out.push({
where: `${name} ${key}`,
why: 'green box has a load but MEN is occupied',
severity: 'waiting',
});
}
if (f.allows.outbound && f.outboundBox.length === 0) {
out.push({
where: `${name} ${key}`,
why: 'green box empty — nothing to load (needs a Freight Agent action)',
severity: 'waiting',
});
}
if (f.industryTrack.cars.length >= f.industryTrack.length) {
out.push({
where: `${name} ${key}`,
why: `industry track full (${f.industryTrack.length} cars) — no room to spot another`,
severity: 'stuck',
});
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
out.push({
where: `Train ${due}`,
why: 'due to depart but HELD — no free Crew Tray',
severity: 'stuck',
});
}
// A full Office means the next arrival is an automatic collision (Gap 2d).
const cap = adTrackCount(s, player);
if (area.adOccupancy.length >= cap) {
out.push({
where: 'Office',
why: `all ${cap} A/D track(s) occupied — the next arrival COLLIDES`,
severity: 'risk',
});
}
if (s.clock.pendingDecision) {
out.push({
where: 'Superintendent',
why: 'must rule on a following-train clearance before the Mainline Phase continues',
severity: 'waiting',
});
}
return out;
}
/** A one-line summary of where every train currently is. */
export function trainPositions(s: GameState): { id: TrayId; label: string; where: string }[] {
const out: { id: TrayId; label: string; where: string }[] = [];
for (const [id, tray] of s.trays) {
const label = tray.trainNumber === null ? 'local crew' : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
let where: string;
switch (tray.position.at) {
case 'divisionPoint':
where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`;
break;
case 'mainline':
where = `Mainline card ${tray.position.index}, region ${tray.position.region + 1}`;
break;
case 'grid':
where = `Office Area ${at(tray.position.coord)}`;
break;
}
out.push({ id, label, where });
}
return out;
}
export { coordKey };
+510
View File
@@ -0,0 +1,510 @@
/**
* Component 16 — Game replay viewer.
*
* Records one game and writes a **self-contained HTML file** that plays it back step by step.
*
* node src/sim/replay.ts [--seed 1234] [--length standard] [--out replay.html]
*
* The browser never runs the engine. Frames are precomputed here in Node and embedded as JSON, so
* the page is a dumb renderer with no bundling and no build step.
*
* The recorder drives `advance()` and `applyIntent()` itself rather than reusing `playGame`'s pump
* loop, because `pump` batches a whole automatic phase into one call — which would collapse an
* entire Mainline Phase into a single frame. Driving `advance` gives one frame per step.
*
* Priority is CLARITY over polish: plain boxes, words not symbols, and an explicit panel for what
* is currently blocked.
*/
import { writeFileSync } from 'node:fs';
import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import { lengthProfile, officeProfile } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState } from '../engine/state.ts';
import { developerBot } from './bot.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
// ---------------------------------------------------------------------------
export type CellView = {
row: number;
col: number;
kind: string;
label: string;
running: boolean;
tray: string | null;
cars: string[];
facility: FacilityView | null;
};
export type FacilityView = {
name: string;
commodity: string;
flow: string;
green: string[];
greenCap: number;
maw: (string | null)[];
red: string[];
redCap: number;
track: string[];
trackCap: number;
laborers: string;
porters: string;
};
export type DivisionView = { kind: string; label: string; trains: string[][] };
export type Frame = {
day: number;
stage: number;
clock: string;
phase: string;
actor: number | null;
superintendent: number;
revenue: number;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
division: DivisionView[];
cells: CellView[];
facilities: FacilityView[];
hand: number;
deck: number;
blocked: Impediment[];
trains: { label: string; where: string }[];
};
// ---------------------------------------------------------------------------
// Snapshotting
// ---------------------------------------------------------------------------
const FACILITY_NAMES: Record<string, string> = {
mineTipple: 'Mine Tipple',
produceShed: 'Produce Shed',
grocersWarehouse: "Grocer's Warehouse",
oilRefinery: 'Oil Refinery',
powerPlant: 'Power Plant',
};
function facilityView(card: { geometry: { kind: string; facility?: string }; facility: NonNullable<CellView['facility']> extends never ? never : unknown }): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
if (!f || f.kind !== 'freight') return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
name: FACILITY_NAMES[key] ?? key,
commodity: facilityCarType(f) ?? '?',
flow: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
green: f.outboundBox.map(carLabel),
greenCap: f.capacity.outbound,
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
trackCap: f.industryTrack.length,
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
};
}
function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
): Frame {
const area = areaOf(s, 0);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label);
}
}
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
for (const [key, card] of area.grid) {
const [row, col] = key.split(',').map(Number);
const g = card.geometry;
const kind = g.kind;
let label: string;
if (g.kind === 'office') label = officeProfile(area.tier).name;
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else label = g.geometry === 'runAround' ? 'run-around' : g.geometry;
const fv = facilityView(card as never);
if (fv) facilities.push(fv);
cells.push({
row: row!,
col: col!,
kind,
label,
running: row === area.runningRow,
tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
}
const division: DivisionView[] = s.division.nodes.map((n) => {
if (n.kind === 'divisionPoint') {
return {
kind: 'dp',
label: n.side === 'west' ? 'West DP' : 'East DP',
trains: [n.holding.map((id) => trainChip(s, id))],
};
}
if (n.kind === 'mainline') {
return {
kind: 'ml',
label: 'Mainline',
trains: n.regions.map((r) => (r.occupant ? [trainChip(s, r.occupant)] : [])),
};
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
};
});
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
lines,
where,
division,
cells,
facilities,
hand: (s.decks.hands.get(0) ?? []).length,
deck: s.decks.homeOffice.length,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
t.position.at === 'divisionPoint'
? `${t.position.side} Division Point`
: t.position.at === 'mainline'
? `Mainline ${t.position.index}, region ${t.position.region + 1}`
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
})),
};
}
function trainChip(s: GameState, id: string): string {
const t = s.trays.get(id);
if (!t) return id;
return t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
}
// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length,
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
const frames: Frame[] = [];
const push = (events: GameEvent[]): void => {
const visible = events.filter(isVisible);
if (visible.length === 0) return;
const narrated = visible.map(narrate);
const where = narrated.find((n) => n.where)?.where ?? null;
frames.push(snapshot(s, narrated.map((n) => ({ text: n.text, tone: n.tone })), where ?? null));
};
frames.push(snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], null));
for (let step = 0; step < maxSteps; step++) {
const r = advance(s);
push(r.events);
if (s.status === 'finished') break;
if (!r.needsInput) continue;
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const applied = applyIntent(s, actor, developerBot.choose(s, actor, options));
if (!applied.ok) break;
push(applied.events);
}
const o = s.outcome;
return {
seed,
length,
frames,
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
};
}
// ---------------------------------------------------------------------------
// HTML
// ---------------------------------------------------------------------------
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replay seed ${rec.seed}</title>
<style>
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
header{padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:14px;margin:0 0 6px;font-weight:600}
.bar{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline}
.big{font-size:19px;font-weight:700}
.dim{color:var(--dim)}
.wrap{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:14px;padding:14px}
@media(max-width:900px){.wrap{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin-bottom:14px}
h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:0 0 8px;font-weight:600}
.div-strip{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:5px;padding:6px 8px;min-width:96px;background:#20252c}
.node.office{border-color:var(--clock)}
.regions{display:flex;gap:4px;margin-top:5px}
.region{flex:1;min-height:22px;border:1px dashed var(--line);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:11px}
.chip{background:var(--clock);color:#0d1117;border-radius:3px;padding:1px 5px;font-weight:700;font-size:11px}
.grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:6px;min-height:64px;background:#20252c}
.cell.run{border-color:#4a545f;background:#252b33}
.cell.hl{outline:2px solid var(--warn);outline-offset:1px}
.cell .nm{font-weight:700;font-size:11px}
.cell .cars{color:var(--dim);font-size:10.5px;margin-top:3px}
.cell .tray{display:inline-block;margin-top:4px}
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto}
.line{padding:2px 0;border-bottom:1px solid #23272e}
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
.blocked li{margin-bottom:4px}
.sev-stuck{color:var(--bad)}.sev-risk{color:var(--warn)}.sev-waiting{color:var(--dim)}
footer{position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--line);padding:9px 14px;
display:flex;gap:8px;align-items:center;flex-wrap:wrap;z-index:5}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 11px;
font:inherit;cursor:pointer}
button:hover{background:#343b45}
input[type=range]{flex:1;min-width:180px}
.legend{font-size:11px;color:var(--dim);padding:0 14px 14px}
</style></head><body>
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="big" id="when">—</span>
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">hand <span id="hand">0</span> · deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
<div class="wrap">
<div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
<section><h2>Office Area</h2><div class="grid" id="grid"></div></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
<div>
<section><h2>What just happened</h2><div id="now"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Trains in play</h2><div id="trains" class="dim"></div></section>
<section><h2>History</h2><div class="log" id="log"></div></section>
</div>
</div>
<div class="legend">
Running Track is the lighter row. <span class="chip">T4</span> is a train. Green box = outbound loads waiting ·
MEN / AT / WORK = the three-step loading track (→ outbound, ← inbound) · red box = delivered loads.
Laborers and Porters show remaining/total for this Stage.
</div>
<footer>
<button id="first">⏮ start</button>
<button id="back">◀ step</button>
<button id="play">▶ play</button>
<button id="fwd">step ▶</button>
<button id="stage">next Stage ⏭</button>
<button id="money">next revenue 💰</button>
<button id="crash">next collision ⚠</button>
<input type="range" id="scrub" min="0" value="0">
<select id="speed">
<option value="600">slow</option>
<option value="250" selected>normal</option>
<option value="90">fast</option>
<option value="20">very fast</option>
</select>
</footer>
<script>
const FRAMES = ${JSON.stringify(rec.frames)};
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
function boxes(items, cap, cls) {
let h = '';
for (let k = 0; k < Math.max(cap, items.length); k++) {
const v = items[k];
h += '<span class="box ' + (v ? cls : 'empty') + '">' + (v ? esc(v) : '·') + '</span>';
}
return h || '<span class="dim">—</span>';
}
function render() {
const f = FRAMES[i];
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
$('phase').textContent = f.phase;
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
$('hand').textContent = f.hand;
$('deck').textContent = f.deck;
$('fno').textContent = i;
$('scrub').value = i;
$('division').innerHTML = f.division.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
'<div class="regions">' + n.trains.map((r) =>
'<div class="region">' + r.map((t) => '<span class="chip">' + esc(t) + '</span>').join('') + '</div>').join('') +
'</div></div>').join('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
const g = $('grid');
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
let cellsHtml = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (hl ? 'hl' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.tray ? '<span class="chip tray">' + esc(cell.tray) + '</span>' : '') +
(cell.cars.length ? '<div class="cars">' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
'</div>';
}
}
g.innerHTML = cellsHtml;
$('facs').innerHTML = f.facilities.length === 0
? '<span class="dim">no freight facilities built yet</span>'
: f.facilities.map((x) =>
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
'<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
'</div>').join('');
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
$('blocked').innerHTML = f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
$('trains').innerHTML = f.trains.length === 0
? 'no trains running'
: f.trains.map((t) => esc(t.label) + ' — ' + esc(t.where)).join('<br>');
let hist = '';
for (let k = Math.max(0, i - 24); k <= i; k++) {
for (const l of FRAMES[k].lines) hist += '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>';
}
$('log').innerHTML = hist;
$('log').scrollTop = $('log').scrollHeight;
}
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
$('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1;
$('scrub').oninput = (e) => go(+e.target.value);
$('first').onclick = () => go(0);
$('back').onclick = () => go(i - 1);
$('fwd').onclick = () => go(i + 1);
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('play').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
$('play').textContent = '⏸ pause';
timer = setInterval(() => {
if (i >= FRAMES.length - 1) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
go(i + 1);
}, +$('speed').value);
};
$('speed').onchange = () => { if (timer) { $('play').click(); $('play').click(); } };
document.onkeydown = (e) => {
if (e.key === 'ArrowRight') go(i + 1);
if (e.key === 'ArrowLeft') go(i - 1);
if (e.key === ' ') { e.preventDefault(); $('play').click(); }
};
render();
</script></body></html>`;
}
function esc(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c] ?? c);
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith('replay.ts') ?? false;
if (isMain) {
const arg = (name: string, dflt: string): string => {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? (process.argv[i + 1] ?? dflt) : dflt;
};
const seed = Number(arg('seed', '1234'));
const length = arg('length', 'standard') as GameLength;
const out = arg('out', 'replay.html');
const rec = record(seed, length);
writeFileSync(out, renderHtml(rec));
const kb = (Buffer.byteLength(renderHtml(rec)) / 1024).toFixed(0);
console.log(`wrote ${out} — ${rec.frames.length} frames, ${kb} KB`);
console.log(`seed ${seed} · ${length} · ${rec.outcome}`);
}
+458
View File
@@ -0,0 +1,458 @@
/**
* End-of-game statistics.
*
* Dev-side. Compiles a readable account of how a game actually went, from the event log. Because
* `state = fold(events)`, the log is the complete record — nothing needs instrumenting in the
* engine to produce any of this.
*
* Three purposes, in ascending order of usefulness:
*
* 1. It is interesting to see what happens in a game.
* 2. It may reveal strategies — whether freight specialists outscore passenger specialists, say.
* 3. **It exposes bugs.** Every serious bug found so far was found by noticing a number that was
* wrong or absent, not by a failing test: facilities that were never worked, cars that were
* never spotted, trains that never departed. So `anomalies()` below treats "this never
* happened" as a first-class finding rather than something to scroll past.
*/
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.ts';
// ---------------------------------------------------------------------------
// Per-game summary
// ---------------------------------------------------------------------------
export type RevenueBreakdown = {
freightLoad: number;
freightUnload: number;
passengerBoard: number;
passengerDetrain: number;
collisionLoss: number;
net: number;
};
export type GameStats = {
seed: number;
result: string;
reason: string;
days: number;
turns: number;
revenue: RevenueBreakdown;
/** Freight's share of gross positive Revenue, 0..1. The strategy signal. */
freightShare: number;
trains: {
scheduled: number;
highballed: number;
arrived: number;
completed: number;
clearanceRequests: number;
clearancesAllowed: number;
};
development: {
finalTier: string;
gridSize: number;
freightFacilities: number;
cardsDrawn: number;
cardsPlayed: number;
cardsDiscarded: number;
officeUpgrades: number;
};
actions: {
switch: number;
draw: number;
freightAgent: number;
moves: number;
drops: number;
couples: number;
};
workers: { laborerActions: number; porterActions: number };
collisions: number;
eventCounts: Record<string, number>;
intentCounts: Record<string, number>;
};
function inc(map: Record<string, number>, key: string, by = 1): void {
map[key] = (map[key] ?? 0) + by;
}
export function summarize(
seed: number,
events: GameEvent[],
intents: Intent['type'][],
final: GameState,
): GameStats {
const eventCounts: Record<string, number> = {};
const intentCounts: Record<string, number> = {};
const rev: RevenueBreakdown = {
freightLoad: 0,
freightUnload: 0,
passengerBoard: 0,
passengerDetrain: 0,
collisionLoss: 0,
net: 0,
};
let clearanceRequests = 0;
let clearancesAllowed = 0;
let highballed = 0;
let arrived = 0;
let completed = 0;
for (const e of events) {
inc(eventCounts, e.type);
if (e.type === 'revenueChanged') {
const reason = e.reason;
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
else if (reason === 'boarding') rev.passengerBoard += e.delta;
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
}
if (e.type === 'clearanceRequested') clearanceRequests++;
if (e.type === 'clearanceGiven' && e.allow) clearancesAllowed++;
// The driver reports train lifecycle through `phaseBegan` labels.
if (e.type === 'phaseBegan') {
if (e.phase.includes('highballed')) highballed++;
else if (e.phase.includes('arrived')) arrived++;
else if (e.phase.includes('completed')) completed++;
}
}
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;
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
const gross = grossFreight + grossPassenger;
const area = final.officeAreas.get(0);
let freightFacilities = 0;
if (area) {
for (const card of area.grid.values()) {
if (card.facility?.kind === 'freight') freightFacilities++;
}
}
return {
seed,
result: final.outcome?.result ?? 'unfinished',
reason: final.outcome?.reason ?? 'none',
days: Math.max(1, final.clock.day - 1),
turns: intents.length,
revenue: rev,
freightShare: gross > 0 ? grossFreight / gross : 0,
trains: {
scheduled: eventCounts['trainScheduled'] ?? 0,
highballed,
arrived,
completed,
clearanceRequests,
clearancesAllowed,
},
development: {
finalTier: area?.tier ?? 'unknown',
gridSize: area?.grid.size ?? 0,
freightFacilities,
cardsDrawn: eventCounts['cardDrawn'] ?? 0,
cardsPlayed: eventCounts['cardPlayed'] ?? 0,
cardsDiscarded: eventCounts['cardDiscarded'] ?? 0,
officeUpgrades: eventCounts['officeUpgraded'] ?? 0,
},
actions: {
switch: intentCounts['switch.move'] ?? 0,
draw: (intentCounts['draw.fromHomeOffice'] ?? 0) + (intentCounts['draw.fromDepartment'] ?? 0),
freightAgent:
(intentCounts['freightAgent.stockOutbound'] ?? 0) +
(intentCounts['freightAgent.clearInbound'] ?? 0) +
(intentCounts['freightAgent.unjam'] ?? 0),
moves: eventCounts['trayMoved'] ?? 0,
drops: eventCounts['carsDropped'] ?? 0,
couples: eventCounts['carsCoupled'] ?? 0,
},
workers: {
laborerActions: (eventCounts['loadAdvanced'] ?? 0) + (eventCounts['loadCompleted'] ?? 0) + (eventCounts['unloadBegan'] ?? 0),
porterActions: (eventCounts['passengersBoarded'] ?? 0) + (eventCounts['passengersDetrained'] ?? 0),
},
collisions: events.filter(
(e) => e.type === 'revenueChanged' && e.reason.startsWith('collision'),
).length,
eventCounts,
intentCounts,
};
}
// ---------------------------------------------------------------------------
// Anomaly detection — purpose 3
// ---------------------------------------------------------------------------
/**
* Everything the engine can emit or accept. A member of these lists that NEVER fires across a whole
* run is the signal this module exists for: it means a rule is unreachable, and unreachable rules
* are where the bugs have been.
*/
const EXPECTED_EVENTS = [
'trainScheduled',
'carPlacedOnTrain',
'trayMoved',
'carsCoupled',
'carsDropped',
'cardDrawn',
'cardPlayed',
'cardDiscarded',
'officeUpgraded',
'stockToOutbound',
'inboundCleared',
'loadAdvanced',
'loadCompleted',
'unloadBegan',
'passengersBoarded',
'passengersDetrained',
'revenueChanged',
'clearanceRequested',
'clearanceGiven',
] as const;
const EXPECTED_INTENTS = [
'localOps.choose',
'switch.move',
'switch.dropCars',
'draw.fromHomeOffice',
'draw.fromDepartment',
'card.play',
'card.discard',
'freightAgent.stockOutbound',
'freightAgent.clearInbound',
'newTrain.placeCar',
'porter.board',
'porter.detrain',
'laborer.advanceLoad',
'laborer.beginUnload',
'mainline.clearance',
] as const;
export type Anomaly = { severity: 'never' | 'rare' | 'odd'; what: string; detail: string };
export function anomalies(all: GameStats[]): Anomaly[] {
const out: Anomaly[] = [];
if (all.length === 0) return out;
const totalEvents: Record<string, number> = {};
const totalIntents: Record<string, number> = {};
for (const g of all) {
for (const [k, v] of Object.entries(g.eventCounts)) inc(totalEvents, k, v);
for (const [k, v] of Object.entries(g.intentCounts)) inc(totalIntents, k, v);
}
for (const e of EXPECTED_EVENTS) {
const n = totalEvents[e] ?? 0;
if (n === 0) {
out.push({
severity: 'never',
what: `event ${e}`,
detail: `never fired in ${all.length} games — the rule producing it may be unreachable`,
});
} else if (n < all.length / 20) {
out.push({
severity: 'rare',
what: `event ${e}`,
detail: `only ${n} across ${all.length} games`,
});
}
}
for (const i of EXPECTED_INTENTS) {
const n = totalIntents[i] ?? 0;
if (n === 0) {
out.push({
severity: 'never',
what: `intent ${i}`,
detail: `never chosen in ${all.length} games — unreachable, or the bot never wants it`,
});
}
}
// Structural checks: things that must hold if the model is coherent.
const scheduled = all.reduce((n, g) => n + g.trains.scheduled, 0);
const arrived = all.reduce((n, g) => n + g.trains.arrived, 0);
if (scheduled > 0 && arrived === 0) {
out.push({
severity: 'never',
what: 'train arrivals',
detail: `${scheduled} trains scheduled but none ever reached an Office`,
});
}
const highballed = all.reduce((n, g) => n + g.trains.highballed, 0);
if (arrived > 0 && highballed < arrived) {
out.push({
severity: 'odd',
what: 'trains departing',
detail: `${arrived} arrivals but only ${highballed} departures — trains may be parking`,
});
}
const tiers = new Set(all.map((g) => g.development.finalTier));
if (tiers.size === 1) {
out.push({
severity: 'odd',
what: 'Office tier',
detail: `every game ended at "${[...tiers][0]}" — upgrades may be unreachable or automatic`,
});
}
return out;
}
// ---------------------------------------------------------------------------
// Strategy analysis — purpose 2
// ---------------------------------------------------------------------------
export type StrategyBucket = {
label: string;
games: number;
meanRevenue: number;
winRate: number;
};
/**
* Buckets games by how their Revenue was earned, then compares outcomes. If freight specialists
* 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 buckets: { label: string; test: (g: GameStats) => boolean }[] = [
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
{ label: 'passenger-heavy (<33%)', test: (g) => g.freightShare > 0 && g.freightShare < 0.33 },
{ label: 'balanced (33-66%)', test: (g) => g.freightShare >= 0.33 && g.freightShare <= 0.66 },
{ label: 'freight-heavy (>66%)', test: (g) => g.freightShare > 0.66 && g.freightShare < 1 },
{ label: 'freight-only (100%)', test: (g) => g.freightShare === 1 },
];
return buckets.map(({ label, test }) => {
const members = scored.filter(test);
return {
label,
games: members.length,
meanRevenue:
members.length > 0 ? members.reduce((n, g) => n + g.revenue.net, 0) / members.length : 0,
winRate:
members.length > 0 ? members.filter((g) => g.result === 'win').length / members.length : 0,
};
});
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
const num = (n: number, w = 7, dp = 1): string => n.toFixed(dp).padStart(w);
function meanOf(all: GameStats[], get: (g: GameStats) => number): number {
return all.length === 0 ? 0 : all.reduce((n, g) => n + get(g), 0) / all.length;
}
export function formatGameStats(g: GameStats): string {
const out: string[] = [];
out.push(`\n--- game seed ${g.seed} · ${g.result}/${g.reason} · ${g.days} Days · ${g.turns} turns`);
out.push(` revenue net ${g.revenue.net}`);
out.push(` freight loads ${g.revenue.freightLoad}`);
out.push(` freight unloads ${g.revenue.freightUnload}`);
out.push(` passengers on ${g.revenue.passengerBoard}`);
out.push(` passengers off ${g.revenue.passengerDetrain}`);
out.push(` lost to collisions -${g.revenue.collisionLoss}`);
out.push(` freight share ${(g.freightShare * 100).toFixed(0)}%`);
out.push(
` trains scheduled ${g.trains.scheduled} highballed ${g.trains.highballed}` +
` arrived ${g.trains.arrived} completed ${g.trains.completed}`,
);
out.push(
` office ${g.development.finalTier} grid ${g.development.gridSize}` +
` facilities ${g.development.freightFacilities}`,
);
return out.join('\n');
}
export function formatAggregate(all: GameStats[]): string {
const out: string[] = [];
out.push(`\n=== end-of-game statistics · ${all.length} games ===\n`);
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 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))}`);
out.push(` freight share of gross ${num(meanOf(all, (g) => g.freightShare) * 100, 6, 0)}%`);
out.push('\n TRAFFIC (mean per game)');
out.push(` trains scheduled ${num(meanOf(all, (g) => g.trains.scheduled))}`);
out.push(` highballs ${num(meanOf(all, (g) => g.trains.highballed))}`);
out.push(` arrivals at an Office ${num(meanOf(all, (g) => g.trains.arrived))}`);
out.push(` runs completed ${num(meanOf(all, (g) => g.trains.completed))}`);
out.push(` clearance requests ${num(meanOf(all, (g) => g.trains.clearanceRequests))}`);
out.push(` collisions ${num(meanOf(all, (g) => g.collisions))}`);
out.push('\n DEVELOPMENT (mean per game)');
out.push(` cards drawn ${num(meanOf(all, (g) => g.development.cardsDrawn))}`);
out.push(` cards played ${num(meanOf(all, (g) => g.development.cardsPlayed))}`);
out.push(` office upgrades ${num(meanOf(all, (g) => g.development.officeUpgrades))}`);
out.push(` final grid size ${num(meanOf(all, (g) => g.development.gridSize))}`);
out.push(` freight facilities ${num(meanOf(all, (g) => g.development.freightFacilities))}`);
const tierCounts: Record<string, number> = {};
for (const g of all) inc(tierCounts, g.development.finalTier);
out.push(' final Office tier:');
for (const [t, n] of Object.entries(tierCounts).sort((a, b) => b[1] - a[1])) {
out.push(` ${t.padEnd(14)} ${n} (${((n / all.length) * 100).toFixed(0)}%)`);
}
out.push('\n ACTION MIX (mean per game)');
out.push(` switch moves ${num(meanOf(all, (g) => g.actions.moves))}`);
out.push(` cars dropped ${num(meanOf(all, (g) => g.actions.drops))}`);
out.push(` cars coupled ${num(meanOf(all, (g) => g.actions.couples))}`);
out.push(` freight agent ops ${num(meanOf(all, (g) => g.actions.freightAgent))}`);
out.push(` laborer actions ${num(meanOf(all, (g) => g.workers.laborerActions))}`);
out.push(` porter actions ${num(meanOf(all, (g) => g.workers.porterActions))}`);
out.push('\n STRATEGY — does the revenue mix predict the score?');
out.push(` ${'bucket'.padEnd(30)} games mean rev win rate`);
for (const b of strategyBuckets(all)) {
out.push(
` ${b.label.padEnd(30)} ${String(b.games).padStart(5)} ${num(b.meanRevenue, 8)} ${num(b.winRate * 100, 6, 0)}%`,
);
}
const found = anomalies(all);
out.push('\n ANOMALIES');
if (found.length === 0) {
out.push(' none — every expected event and intent occurred at a plausible rate');
} else {
for (const a of found) {
out.push(` [${a.severity.toUpperCase().padEnd(5)}] ${a.what}: ${a.detail}`);
}
}
return out.join('\n');
}