Expand game engine, replay, tests, and documentation
This commit is contained in:
+123
-6
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
@@ -127,9 +129,13 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
// 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')!;
|
||||
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
|
||||
// actually a car to spot or collect. Choosing to switch merely because a train is present
|
||||
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
|
||||
// no switching at all, because §9.2 works coaches straight off the A/D track.
|
||||
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
|
||||
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.
|
||||
@@ -145,6 +151,18 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
const id = s.decks.departments[slot];
|
||||
if (!id) return false;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return false;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return true;
|
||||
if (k.kind === 'freightFacility') return true;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTrainCard(s: GameState, cardId: string): boolean {
|
||||
const k = s.cards.get(cardId)?.kind.kind;
|
||||
return k === 'timetabledTrain' || k === 'extraTrain';
|
||||
@@ -162,6 +180,12 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
const f = card.facility;
|
||||
if (!f || !f.allows.outbound) continue;
|
||||
if (f.outboundBox.length >= f.capacity.outbound) continue;
|
||||
|
||||
// A PASSENGER facility has no industry track — passengers board straight off the platform
|
||||
// (§9.2). Requiring a spotted car here meant the Office's green slot was never stocked and
|
||||
// passengers essentially never boarded: one boarding in a whole 5-Day game.
|
||||
if (f.kind === 'passenger') return true;
|
||||
|
||||
const want = facilityCarType(f);
|
||||
if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
|
||||
}
|
||||
@@ -194,9 +218,19 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
switch (s.turn.option) {
|
||||
case 'draw': {
|
||||
// Draw before playing — otherwise the hand empties and never refills.
|
||||
//
|
||||
// Prefer a face-up Department card only when it is actually worth having. Taking the visible
|
||||
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
|
||||
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
|
||||
if (!s.turn.drawnThisTurn) {
|
||||
const draw = pickFirst(options, 'draw.fromDepartment', 'draw.fromHomeOffice');
|
||||
if (draw) return draw;
|
||||
const useful = options.find(
|
||||
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||
);
|
||||
if (useful) return useful;
|
||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||
if (blind) return blind;
|
||||
const any = options.find((i) => i.type === 'draw.fromDepartment');
|
||||
if (any) return any;
|
||||
}
|
||||
// Train cards first — they take no placement and their value compounds every Day.
|
||||
const train = options.find(
|
||||
@@ -215,6 +249,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
|
||||
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
|
||||
// pick an arbitrary legal move and burn the remaining Moves shuttling.
|
||||
if (!usefulSwitching(s, player)) {
|
||||
const stop = options.find((i) => i.type === 'switch.end');
|
||||
if (stop) return stop;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -294,7 +336,15 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
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.
|
||||
// Coaches are worth carrying in BOTH states, and this is easy to get wrong. An EMPTY coach is
|
||||
// what a boarding passenger is put into (§9.2); a LOADED one carries arrivals who can be
|
||||
// de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train —
|
||||
// which is exactly what happened: zero de-trainings across a whole game.
|
||||
const office = areaOf(s, player).grid.get(coordKey(areaOf(s, player).officeCoord))?.facility;
|
||||
if (office?.kind === 'passenger' && office.porters > 0) {
|
||||
const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
|
||||
if (arrivalsWaiting) out.push({ type: 'coach', loaded: true });
|
||||
}
|
||||
out.push({ type: 'coach', loaded: false });
|
||||
return out;
|
||||
}
|
||||
@@ -346,6 +396,73 @@ function facilitiesWanting(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there any point switching this Stage?
|
||||
*
|
||||
* True when the crew is carrying a car some facility wants, or a siding holds a car that ought to
|
||||
* be hauled away. False for a pure passenger train, which needs no switching at all.
|
||||
*/
|
||||
function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
|
||||
const tray = trayOf(s, player);
|
||||
if (!tray) return false;
|
||||
for (const car of tray.consist) {
|
||||
if (facilitiesWanting(s, player, car).length > 0) return true;
|
||||
}
|
||||
if (sidingsWorthCollecting(s, player).length > 0) return true;
|
||||
|
||||
// A car left standing on ordinary track is also worth lifting if some facility wants it — the
|
||||
// hopper stranded on the Running Track that the crew kept driving past.
|
||||
if (tray.consist.length < MAX_CONSIST) {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
for (const car of card.standing) {
|
||||
if (facilitiesWanting(s, player, car).length > 0) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidings holding a car the train should lift: a finished (loaded) car at an outbound facility, or
|
||||
* an emptied car at an inbound one. Leaving them there fills the industry track and jams the
|
||||
* facility — the "industry track full" impediment.
|
||||
*/
|
||||
function sidingsWorthCollecting(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
): { row: number; col: number }[] {
|
||||
const out: { row: number; col: number }[] = [];
|
||||
const tray = trayOf(s, player);
|
||||
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
|
||||
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
// Skip both-direction facilities: a loaded car sitting there might be a finished outbound load
|
||||
// OR an inbound one still waiting to be unloaded, and lifting the latter undoes the delivery.
|
||||
if (f.allows.outbound && f.allows.inbound) continue;
|
||||
const done = f.industryTrack.cars.some((c) => (f.allows.outbound ? c.loaded : !c.loaded));
|
||||
if (!done) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cars standing on ordinary track that some facility would actually take. */
|
||||
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
|
||||
const out: { row: number; col: number }[] = [];
|
||||
const tray = trayOf(s, player);
|
||||
if (!tray || tray.consist.length >= MAX_CONSIST) return out;
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
if (card.facility || card.standing.length === 0) continue;
|
||||
if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) 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;
|
||||
|
||||
+100
-14
@@ -81,7 +81,14 @@ export type Narration = {
|
||||
* 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 {
|
||||
export type NarrateContext = {
|
||||
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
|
||||
cardName?: (id: string) => string;
|
||||
};
|
||||
|
||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
case 'stageBegan':
|
||||
@@ -100,7 +107,7 @@ export function narrate(e: GameEvent): Narration {
|
||||
tone: 'plain',
|
||||
text:
|
||||
e.option === 'switch'
|
||||
? 'Chose to SWITCH — six Moves to shunt cars around the yard'
|
||||
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
|
||||
: e.option === 'draw'
|
||||
? 'Chose to DRAW a card'
|
||||
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
|
||||
@@ -109,7 +116,7 @@ export function narrate(e: GameEvent): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `Crew moved ${at(e.from)} → ${at(e.to)} · ${e.movesRemaining} Moves left`,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled':
|
||||
return {
|
||||
@@ -130,23 +137,70 @@ export function narrate(e: GameEvent): Narration {
|
||||
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}`,
|
||||
? `Drew ${card(e.cardId)} from the Home Office deck`
|
||||
: `Took ${card(e.cardId)} 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',
|
||||
text: e.placement
|
||||
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
|
||||
: `Played ${card(e.cardId)}`,
|
||||
};
|
||||
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}` };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Discarded ${card(e.cardId)} 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` };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Department slot ${e.slot + 1} refilled with ${card(e.cardId)}`,
|
||||
};
|
||||
|
||||
// -- train lifecycle
|
||||
case 'extraQueued':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
|
||||
};
|
||||
case 'secondSectionOrdered':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
|
||||
};
|
||||
case 'trainMadeUp':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
|
||||
};
|
||||
case 'trainHeld':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`,
|
||||
};
|
||||
case 'trainHighballed':
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
|
||||
};
|
||||
case 'trainArrived':
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
|
||||
};
|
||||
case 'trainCompleted':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text:
|
||||
`Train ${e.trainNumber} finished its run and left the Division carrying ` +
|
||||
`${carsLabel(e.consist)} — the crew is free again`,
|
||||
};
|
||||
|
||||
// -- freight agent
|
||||
case 'stockToOutbound':
|
||||
@@ -169,13 +223,19 @@ export function narrate(e: GameEvent): Narration {
|
||||
};
|
||||
|
||||
// -- trains
|
||||
case 'trainScheduled':
|
||||
case 'trainScheduled': {
|
||||
// §7 — roll 1D12 for the slot; if it is taken, work down the column. Reporting only the roll
|
||||
// makes a bumped train look like an arithmetic error.
|
||||
const bumped = e.slot + 1 !== e.roll;
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll})`,
|
||||
text: bumped
|
||||
? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage (§7)`
|
||||
: `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll}) — it will run at this time EVERY Day from now on`,
|
||||
};
|
||||
}
|
||||
case 'carPlacedOnTrain':
|
||||
return { tone: 'plain', text: `Put a ${carLabel(e.stock)} on the train being made up` };
|
||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
case 'clearanceRequested':
|
||||
@@ -192,10 +252,20 @@ export function narrate(e: GameEvent): Narration {
|
||||
};
|
||||
|
||||
// -- passengers
|
||||
// §9.2 — "One Porter will allow you to do any ONE of the following actions", so a Depot with a
|
||||
// single Porter works exactly one coach per Stage. That is a limit, not a bug.
|
||||
case 'passengersBoarded':
|
||||
return { tone: 'good', where: e.at, text: `Porter boarded passengers at ${at(e.at)}` };
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
|
||||
};
|
||||
case 'passengersDetrained':
|
||||
return { tone: 'good', where: e.at, text: `Porter de-trained passengers at ${at(e.at)}` };
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
|
||||
};
|
||||
|
||||
// -- freight pipeline
|
||||
case 'loadStarted':
|
||||
@@ -244,6 +314,22 @@ export function isVisible(e: GameEvent): boolean {
|
||||
return e.type !== 'actorChanged';
|
||||
}
|
||||
|
||||
/**
|
||||
* A phase that ended with nothing done needs saying so explicitly. "Player 0 finished Load/Unload"
|
||||
* with no preceding action reads as a gap in the replay when it is actually the game reporting that
|
||||
* there was no work available.
|
||||
*/
|
||||
export function idleNote(phase: string): string {
|
||||
switch (phase) {
|
||||
case 'loadUnload':
|
||||
return 'Nothing to do this Load/Unload — no load ready to advance, and no train at the platform with passengers to work.';
|
||||
case 'localOps':
|
||||
return 'Nothing useful to do this Local Operations.';
|
||||
default:
|
||||
return `Nothing to do this ${phaseLabel(phase)}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Impediments — "why is nothing happening?"
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -353,7 +439,7 @@ export function trainPositions(s: GameState): { id: TrayId; label: string; where
|
||||
where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`;
|
||||
break;
|
||||
case 'mainline':
|
||||
where = `Mainline card ${tray.position.index}, region ${tray.position.region + 1}`;
|
||||
where = `Mainline card ${tray.position.index}`;
|
||||
break;
|
||||
case 'grid':
|
||||
where = `Office Area ${at(tray.position.coord)}`;
|
||||
|
||||
+188
-34
@@ -21,14 +21,14 @@ 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 { MAINLINE_PROFILES, 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';
|
||||
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame shape — only what the viewer draws
|
||||
@@ -60,7 +60,8 @@ export type FacilityView = {
|
||||
porters: string;
|
||||
};
|
||||
|
||||
export type DivisionView = { kind: string; label: string; trains: string[][] };
|
||||
export type TrainChip = { label: string; consist: string[] };
|
||||
export type DivisionView = { kind: string; label: string; trains: TrainChip[][] };
|
||||
|
||||
export type Frame = {
|
||||
day: number;
|
||||
@@ -72,11 +73,16 @@ export type Frame = {
|
||||
revenue: number;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
whereFrom: { row: number; col: number } | null;
|
||||
division: DivisionView[];
|
||||
cells: CellView[];
|
||||
facilities: FacilityView[];
|
||||
hand: number;
|
||||
hand: string[];
|
||||
deck: number;
|
||||
departments: string[];
|
||||
/** 12 slots; the train number due out at each Stage, or null. */
|
||||
timetable: (number | null)[];
|
||||
blocked: Impediment[];
|
||||
trains: { label: string; where: string }[];
|
||||
};
|
||||
@@ -93,14 +99,22 @@ const FACILITY_NAMES: Record<string, string> = {
|
||||
powerPlant: 'Power Plant',
|
||||
};
|
||||
|
||||
function facilityView(card: { geometry: { kind: string; facility?: string }; facility: NonNullable<CellView['facility']> extends never ? never : unknown }): FacilityView | null {
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown },
|
||||
officeName: string,
|
||||
): FacilityView | null {
|
||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||
if (!f || f.kind !== 'freight') return null;
|
||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||
// appeared — which is why stocking a coach into the green box looked like nothing happening.
|
||||
if (!f) return null;
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
name: FACILITY_NAMES[key] ?? key,
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
|
||||
commodity: facilityCarType(f) ?? '?',
|
||||
flow: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
|
||||
flow: f.kind === 'passenger'
|
||||
? 'passengers on and off'
|
||||
: 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)),
|
||||
@@ -117,13 +131,15 @@ function snapshot(
|
||||
s: GameState,
|
||||
lines: { text: string; tone: string }[],
|
||||
where: { row: number; col: number } | null,
|
||||
whereFrom: { row: number; col: number } | null = 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 carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +153,10 @@ function snapshot(
|
||||
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;
|
||||
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
|
||||
else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
|
||||
|
||||
const fv = facilityView(card as never);
|
||||
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
||||
if (fv) facilities.push(fv);
|
||||
|
||||
cells.push({
|
||||
@@ -163,10 +180,16 @@ function snapshot(
|
||||
};
|
||||
}
|
||||
if (n.kind === 'mainline') {
|
||||
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
|
||||
// with how long each still has to run.
|
||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||
return {
|
||||
kind: 'ml',
|
||||
label: 'Mainline',
|
||||
trains: n.regions.map((r) => (r.occupant ? [trainChip(s, r.occupant)] : [])),
|
||||
label: name,
|
||||
trains: [n.transits.map((t) => {
|
||||
const chip = trainChip(s, t.tray);
|
||||
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
|
||||
})],
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -186,11 +209,14 @@ function snapshot(
|
||||
revenue: s.players[0]?.revenue ?? 0,
|
||||
lines,
|
||||
where,
|
||||
whereFrom,
|
||||
division,
|
||||
cells,
|
||||
facilities,
|
||||
hand: (s.decks.hands.get(0) ?? []).length,
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||
timetable: [...s.timetable],
|
||||
blocked: impediments(s, 0),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
@@ -198,16 +224,59 @@ function snapshot(
|
||||
t.position.at === 'divisionPoint'
|
||||
? `${t.position.side} Division Point`
|
||||
: t.position.at === 'mainline'
|
||||
? `Mainline ${t.position.index}, region ${t.position.region + 1}`
|
||||
? `Mainline card ${t.position.index}`
|
||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function trainChip(s: GameState, id: string): string {
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
case 'timetabledTrain':
|
||||
return `Train ${k.number}`;
|
||||
case 'extraTrain':
|
||||
return `Extra X${k.number}`;
|
||||
case 'office':
|
||||
return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
|
||||
case 'freightFacility':
|
||||
return FACILITY_NAMES[k.facility] ?? k.facility;
|
||||
case 'modifier':
|
||||
return MODIFIER_NAMES[k.modifier] ?? k.modifier;
|
||||
case 'track':
|
||||
return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
case 'maneuver':
|
||||
case 'action':
|
||||
return prettyKey(k.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** camelCase key → readable name, for the card categories that carry only a key. */
|
||||
function prettyKey(key: string): string {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const MODIFIER_NAMES: Record<string, string> = {
|
||||
teamTrack: 'Team Track',
|
||||
loadingDock: 'Loading Dock',
|
||||
storageShed: 'Storage Shed',
|
||||
extraPlatform: 'Extra Platform',
|
||||
sectionGang: 'Section Gang',
|
||||
};
|
||||
|
||||
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
||||
function trainChip(s: GameState, id: string): TrainChip {
|
||||
const t = s.trays.get(id);
|
||||
if (!t) return id;
|
||||
return t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
|
||||
if (!t) return { label: id, consist: [] };
|
||||
return {
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
consist: t.consist.map(carLabel),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -230,16 +299,40 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
};
|
||||
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
|
||||
const frames: Frame[] = [];
|
||||
const narrateCtx = { cardName: (id: string) => cardName(s, id) };
|
||||
|
||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||
let didSomething = false;
|
||||
|
||||
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));
|
||||
|
||||
const lines: { text: string; tone: string }[] = [];
|
||||
for (const e of visible) {
|
||||
if (e.type === 'phaseBegan') didSomething = false;
|
||||
if (e.type === 'phaseEnded' && !didSomething) {
|
||||
lines.push({ text: idleNote(e.phase), tone: 'quiet' });
|
||||
}
|
||||
if (
|
||||
e.type !== 'phaseBegan' &&
|
||||
e.type !== 'phaseEnded' &&
|
||||
e.type !== 'stageBegan'
|
||||
) {
|
||||
didSomething = true;
|
||||
}
|
||||
const n = narrate(e, narrateCtx);
|
||||
lines.push({ text: n.text, tone: n.tone });
|
||||
}
|
||||
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
|
||||
const moved = visible.find((e) => e.type === 'trayMoved');
|
||||
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
|
||||
frames.push(snapshot(s, lines, where ?? null, from));
|
||||
};
|
||||
|
||||
frames.push(snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], 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);
|
||||
@@ -299,6 +392,10 @@ h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim)
|
||||
.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.from{outline:2px dashed #6b7480;outline-offset:1px}
|
||||
/* The crew is the thing you follow around the yard, so it gets the loudest treatment on the card. */
|
||||
.crew{background:var(--warn);color:#0d1117;font-weight:700;border-radius:4px;padding:3px 6px;
|
||||
margin-top:5px;font-size:11px;line-height:1.35}
|
||||
.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}
|
||||
@@ -313,13 +410,27 @@ h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim)
|
||||
.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}
|
||||
/* FIXED, not sticky: a sticky footer shifts as panel heights change, so the mouse cannot stay
|
||||
parked on one button and keep clicking. */
|
||||
footer{position:fixed;left:0;right:0;bottom:0;background:var(--bg);border-top:1px solid var(--line);
|
||||
padding:9px 14px;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;z-index:10;overflow-x:auto}
|
||||
body{padding-bottom:64px}
|
||||
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}
|
||||
kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 4px;font-size:10px}
|
||||
.tt{display:grid;grid-template-columns:repeat(12,1fr);gap:3px}
|
||||
.tt div{border:1px solid var(--line);border-radius:3px;padding:3px 2px;text-align:center;font-size:10.5px}
|
||||
.tt div.due{background:var(--clock);color:#0d1117;font-weight:700}
|
||||
.tt div.now{outline:2px solid var(--warn)}
|
||||
.card{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px;background:#20252c}
|
||||
.consist{font-size:10px;color:var(--dim);margin-top:2px}
|
||||
.mini{margin-top:4px;font-size:9.5px;line-height:1.8}
|
||||
.mini .box{padding:0 4px;font-size:9.5px}
|
||||
.cell.office{border-color:var(--clock)}
|
||||
.cell.modifier{border-style:dashed;opacity:.85}
|
||||
</style></head><body>
|
||||
|
||||
<header>
|
||||
@@ -329,7 +440,7 @@ input[type=range]{flex:1;min-width:180px}
|
||||
<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">deck <span id="deck">0</span></span>
|
||||
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -339,9 +450,14 @@ input[type=range]{flex:1;min-width:180px}
|
||||
<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>
|
||||
<section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
|
||||
</div>
|
||||
<div>
|
||||
<section><h2>What just happened</h2><div id="now"></div></section>
|
||||
<section><h2>Cards</h2>
|
||||
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
|
||||
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></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>
|
||||
@@ -349,7 +465,13 @@ input[type=range]{flex:1;min-width:180px}
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
Running Track is the lighter row. <span class="chip">T4</span> is a train. Green box = outbound loads waiting ·
|
||||
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
|
||||
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
|
||||
<kbd>C</kbd> next collision.<br>
|
||||
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
|
||||
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
|
||||
is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
|
||||
marks where it came from. 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>
|
||||
@@ -364,7 +486,8 @@ input[type=range]{flex:1;min-width:180px}
|
||||
<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="3000">extra slow</option>
|
||||
<option value="1200">slow</option>
|
||||
<option value="250" selected>normal</option>
|
||||
<option value="90">fast</option>
|
||||
<option value="20">very fast</option>
|
||||
@@ -377,6 +500,17 @@ let i = 0, timer = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
|
||||
// The green / MEN|AT|WORK / red boxes drawn on the card itself, so a Freight Agent action or a
|
||||
// Porter boarding shows up where it happened rather than only in a side panel.
|
||||
function miniFacility(x) {
|
||||
let h = '<div class="mini">';
|
||||
if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
|
||||
if (x.trackCap > 0) h += '<br><span class="dim">work </span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
|
||||
if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
|
||||
return h + '</div>';
|
||||
}
|
||||
|
||||
function boxes(items, cap, cls) {
|
||||
let h = '';
|
||||
for (let k = 0; k < Math.max(cap, items.length); k++) {
|
||||
@@ -394,15 +528,27 @@ function render() {
|
||||
$('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;
|
||||
$('hand').innerHTML = f.hand.length
|
||||
? f.hand.map((c) => '<span class="card">' + esc(c) + '</span>').join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
$('depts').innerHTML = f.departments.map((c) => '<span class="card">' + esc(c) + '</span>').join('');
|
||||
$('tt').innerHTML = '<div class="tt">' + f.timetable.map((t, k) =>
|
||||
'<div class="' + (t !== null ? 'due ' : '') + (k + 1 === f.stage ? 'now' : '') + '">' +
|
||||
'S' + (k + 1) + '<br>' + (t !== null ? 'T' + t : '·') + '</div>').join('') + '</div>' +
|
||||
'<div class="dim" style="margin-top:5px">The NUMBER of a train is its seniority and direction ' +
|
||||
'(odd = westbound, even = eastbound). The Stage it departs is set by a 1D12 roll when its card ' +
|
||||
'was played — so Train 8 leaving at Stage 6 is normal.</div>';
|
||||
$('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 class="region">' + r.map((t) =>
|
||||
'<span class="chip">' + esc(t.label) + '</span>' +
|
||||
'<div class="consist">' + (t.consist.length ? esc(t.consist.join(', ')) : 'empty') + '</div>'
|
||||
).join('') + '</div>').join('') +
|
||||
'</div></div>').join('');
|
||||
|
||||
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
|
||||
@@ -415,11 +561,14 @@ function render() {
|
||||
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' : '') + '">' +
|
||||
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
||||
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
|
||||
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
|
||||
'<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>' : '') +
|
||||
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
|
||||
(cell.facility ? miniFacility(cell.facility) : '') +
|
||||
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
@@ -476,8 +625,13 @@ $('play').onclick = () => {
|
||||
$('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(); }
|
||||
else if (e.key === 'ArrowLeft') go(i - 1);
|
||||
else if (e.key === 'Home') go(0);
|
||||
else if (e.key === 'End') go(FRAMES.length - 1);
|
||||
else if (e.key === 's' || e.key === 'S') $('stage').click();
|
||||
else if (e.key === 'r' || e.key === 'R') $('money').click();
|
||||
else if (e.key === 'c' || e.key === 'C') $('crash').click();
|
||||
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
||||
};
|
||||
render();
|
||||
</script></body></html>`;
|
||||
|
||||
+10
-6
@@ -120,12 +120,11 @@ export function summarize(
|
||||
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++;
|
||||
}
|
||||
// Train lifecycle now has proper event types; it used to be smuggled through `phaseBegan`
|
||||
// free text, which made the replay unable to say anything useful about it.
|
||||
if (e.type === 'trainHighballed') highballed++;
|
||||
else if (e.type === 'trainArrived') arrived++;
|
||||
else if (e.type === 'trainCompleted') completed++;
|
||||
}
|
||||
|
||||
for (const i of intents) inc(intentCounts, i);
|
||||
@@ -213,6 +212,11 @@ export function summarize(
|
||||
*/
|
||||
const EXPECTED_EVENTS = [
|
||||
'trainScheduled',
|
||||
'extraQueued',
|
||||
'trainMadeUp',
|
||||
'trainHighballed',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'carPlacedOnTrain',
|
||||
'trayMoved',
|
||||
'carsCoupled',
|
||||
|
||||
Reference in New Issue
Block a user