Initial commit
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* Component 5 — The phase driver.
|
||||
*
|
||||
* `advance(state) -> { events, needsInput }`. Everything the game does WITHOUT a player acting:
|
||||
* Mainline movement, highball evaluation, collisions, train make-up, the Stage and Day clock,
|
||||
* Superintendent rotation, and victory checks.
|
||||
*
|
||||
* See architecture/components.md §2 A.5. This lives inside the engine rather than the session
|
||||
* layer so that all rules knowledge stays in one pure deterministic place — which is also what
|
||||
* makes the balance harness a plain loop over `advance` and `applyIntent`, with no server.
|
||||
*
|
||||
* USAGE:
|
||||
* while (true) {
|
||||
* const r = advance(state);
|
||||
* if (r.needsInput || state.status === 'finished') break;
|
||||
* }
|
||||
*/
|
||||
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
EXTRA_TRAINS,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
TIMETABLED_TRAINS,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { areaOf } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
/** True when the game is waiting on a player. The caller should stop pumping. */
|
||||
needsInput: boolean;
|
||||
};
|
||||
|
||||
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
|
||||
|
||||
function movesForStage(s: GameState): number {
|
||||
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
|
||||
? MOVES_PER_LOCAL_OPS_NIGHT
|
||||
: MOVES_PER_LOCAL_OPS;
|
||||
}
|
||||
|
||||
function trainProfile(number: number, isExtra: boolean) {
|
||||
const pool = isExtra ? EXTRA_TRAINS : TIMETABLED_TRAINS;
|
||||
return pool.find((t) => t.number === number) ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Division navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number {
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
|
||||
}
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function advance(s: GameState): AdvanceResult {
|
||||
const events: GameEvent[] = [];
|
||||
if (s.status === 'finished') return { events, needsInput: false };
|
||||
|
||||
// The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
|
||||
if (s.clock.pendingDecision !== null) return { events, needsInput: true };
|
||||
|
||||
switch (s.clock.phase) {
|
||||
case 'localOps':
|
||||
return playerPhase(s, events, 'localOps');
|
||||
case 'newTrain':
|
||||
return newTrainPhase(s, events);
|
||||
case 'mainline':
|
||||
return mainlinePhase(s, events);
|
||||
case 'loadUnload':
|
||||
return playerPhase(s, events, 'loadUnload');
|
||||
case 'shiftChange':
|
||||
return shiftChange(s, events);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Player-driven phases (Local Ops, Load/Unload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function playerPhase(
|
||||
s: GameState,
|
||||
events: GameEvent[],
|
||||
phase: 'localOps' | 'loadUnload',
|
||||
): AdvanceResult {
|
||||
// A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3).
|
||||
if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) {
|
||||
s.turn.done = true;
|
||||
}
|
||||
// Spending the last Move ends a switching turn without needing an explicit end (§6.1).
|
||||
if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) {
|
||||
s.turn.done = true;
|
||||
}
|
||||
|
||||
if (!s.turn.done) {
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
|
||||
|
||||
// SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking.
|
||||
// This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) —
|
||||
// but a rules gap that stranded a player would otherwise hang the game rather than fail
|
||||
// visibly, and a hung game is far harder to diagnose than a forfeited turn.
|
||||
if (legalActions(s, s.clock.currentActor).length === 0) {
|
||||
s.turn.done = true;
|
||||
} else {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
}
|
||||
|
||||
s.clock.actorOffset += 1;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
|
||||
if (s.clock.actorOffset >= s.players.length) {
|
||||
return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false };
|
||||
}
|
||||
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
|
||||
events.push({ type: 'actorChanged', player: s.clock.currentActor });
|
||||
return { events, needsInput: false };
|
||||
}
|
||||
|
||||
function actorAt(s: GameState, offset: number): PlayerIndex {
|
||||
return (s.clock.superintendent + offset) % s.players.length;
|
||||
}
|
||||
|
||||
function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] {
|
||||
switch (p) {
|
||||
case 'localOps':
|
||||
return 'newTrain';
|
||||
case 'newTrain':
|
||||
return 'mainline';
|
||||
case 'mainline':
|
||||
return 'loadUnload';
|
||||
case 'loadUnload':
|
||||
return 'shiftChange';
|
||||
default:
|
||||
return 'localOps';
|
||||
}
|
||||
}
|
||||
|
||||
function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
|
||||
s.clock.phase = phase;
|
||||
s.clock.actorOffset = 0;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
|
||||
return [
|
||||
{ type: 'phaseBegan', phase },
|
||||
{ type: 'actorChanged', player: s.clock.currentActor },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New Train Phase (§7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
// Make up any Timetabled Train due this Stage, if a Crew Tray is free (§7).
|
||||
const due = s.timetable[s.clock.stage - 1];
|
||||
if (due !== null && due !== undefined && !trainRunning(s, due, false)) {
|
||||
if (s.freeTrays.length === 0) {
|
||||
// "Held train" — no crew available. It waits; the Stage moves on.
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
const profile = trainProfile(due, false);
|
||||
if (profile) {
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
const direction: Direction = profile.direction === 'east' ? 'east' : 'west';
|
||||
// An eastbound train starts at the Western Division Point and runs east.
|
||||
const side: Direction = direction === 'east' ? 'west' : 'east';
|
||||
const tray: CrewTray = {
|
||||
id: trayId,
|
||||
trainNumber: due,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
consist: [],
|
||||
direction,
|
||||
position: { at: 'divisionPoint', side },
|
||||
movesUsed: 0,
|
||||
};
|
||||
s.trays.set(trayId, tray);
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
if (dp && dp.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${due} made up` });
|
||||
}
|
||||
}
|
||||
|
||||
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
|
||||
const filling = trainNeedingCars(s);
|
||||
if (filling) {
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
|
||||
function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.trainNumber === number && t.trainIsExtra === isExtra) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A made-up train still short of its consist spec, with a suitable car available. */
|
||||
function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.trainNumber === null) continue;
|
||||
if (tray.position.at !== 'divisionPoint') continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const want = profile.consist.count + (profile.consist.requiresCaboose ? 1 : 0);
|
||||
if (tray.consist.length >= want) continue;
|
||||
const suitable = s.yards.divisionYard.some(
|
||||
(c) => profile.consist.allowedTypes.includes(c.type) || c.type === 'caboose',
|
||||
);
|
||||
if (suitable) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
s.clock.currentActor = null;
|
||||
|
||||
// §8 — trains move in numeric order, lowest first. Timetabled outranks an Extra of the same
|
||||
// number (Gap 5): sort by (number, isExtra) ascending.
|
||||
const order = [...s.trays.entries()]
|
||||
// §8 — "Any train holding at a player's Office ... or a Division Point must attempt to move."
|
||||
// Trains standing on an A/D track are included: they are exactly the ones due to highball.
|
||||
.filter(([, t]) => t.trainNumber !== null)
|
||||
.sort(([, a], [, b]) => {
|
||||
const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
|
||||
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
||||
});
|
||||
|
||||
for (const [id, tray] of order) {
|
||||
if (s.movedThisPhase.has(id)) continue;
|
||||
const moved = moveTrain(s, id, tray, events);
|
||||
if (moved === 'needsClearance') return { events, needsInput: true };
|
||||
s.movedThisPhase.add(id);
|
||||
}
|
||||
|
||||
s.movedThisPhase = new Set();
|
||||
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
|
||||
}
|
||||
|
||||
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
|
||||
function moveTrain(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
): MoveOutcome {
|
||||
const dir = step(tray.direction);
|
||||
|
||||
if (tray.position.at === 'divisionPoint') {
|
||||
const dpIndex = s.division.nodes.findIndex(
|
||||
(n) => n.kind === 'divisionPoint' && n.side === (tray.position as { side: Direction }).side,
|
||||
);
|
||||
const target = dpIndex + dir;
|
||||
const node = s.division.nodes[target];
|
||||
if (!node || node.kind !== 'mainline') return 'held';
|
||||
|
||||
const clearance = evaluateClearance(s, id, tray, target, events);
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const region = dir > 0 ? 0 : REGIONS_PER_MAINLINE_CARD - 1;
|
||||
node.regions[region]!.occupant = id;
|
||||
tray.position = { at: 'mainline', index: target, region };
|
||||
const dp = s.division.nodes[dpIndex];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// §8.1 — a train standing on an A/D track attempts to highball onto the next Mainline card.
|
||||
// Without this a train that arrives at an Office never leaves, holding an A/D track forever and
|
||||
// colliding with every train that follows it.
|
||||
if (tray.position.at === 'grid') {
|
||||
const owner = tray.position.owner;
|
||||
const area = areaOf(s, owner);
|
||||
// Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
|
||||
if (
|
||||
tray.position.coord.row !== area.officeCoord.row ||
|
||||
tray.position.coord.col !== area.officeCoord.col
|
||||
) {
|
||||
return 'held';
|
||||
}
|
||||
|
||||
const officeIndex = nodeIndexOfOffice(s, owner);
|
||||
const target = officeIndex + dir;
|
||||
const node = s.division.nodes[target];
|
||||
if (!node) return 'held';
|
||||
|
||||
if (node.kind === 'mainline') {
|
||||
const clearance = evaluateClearance(s, id, tray, target, events);
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const region = dir > 0 ? 0 : REGIONS_PER_MAINLINE_CARD - 1;
|
||||
node.regions[region]!.occupant = id;
|
||||
tray.position = { at: 'mainline', index: target, region };
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
if (node.kind === 'divisionPoint') {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
retireTrain(s, id, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
return 'held';
|
||||
}
|
||||
|
||||
if (tray.position.at === 'mainline') {
|
||||
const { index, region } = tray.position;
|
||||
const node = s.division.nodes[index];
|
||||
if (!node || node.kind !== 'mainline') return 'held';
|
||||
|
||||
const nextRegion = region + dir;
|
||||
if (nextRegion >= 0 && nextRegion < REGIONS_PER_MAINLINE_CARD) {
|
||||
// §8.2 — one region per Stage.
|
||||
node.regions[region]!.occupant = null;
|
||||
node.regions[nextRegion]!.occupant = id;
|
||||
tray.position = { at: 'mainline', index, region: nextRegion };
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
node.regions[region]!.occupant = null;
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
if (dest.kind === 'divisionPoint') {
|
||||
// The train has run the length of the Division and leaves the game (§2.3).
|
||||
dest.holding.push(id);
|
||||
tray.position = { at: 'divisionPoint', side: dest.side };
|
||||
retireTrain(s, id, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
if (dest.kind === 'office') {
|
||||
return arriveAtOffice(s, id, tray, dest.owner, events);
|
||||
}
|
||||
}
|
||||
|
||||
return 'held';
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.1 — the highball conditions that involve the next Subdivision.
|
||||
*
|
||||
* A train moving TOWARDS the considered train is an absolute bar. A train moving the SAME
|
||||
* direction is the Superintendent's judgment call — and Gap 2 made the consequences automatic
|
||||
* precisely so that this decision carries full weight.
|
||||
*/
|
||||
function evaluateClearance(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
targetIndex: number,
|
||||
events: GameEvent[] = [],
|
||||
): 'clear' | 'blocked' | 'ask' {
|
||||
// A ruling already given for this train is consumed here — this is what stops the driver from
|
||||
// re-asking the same question every time it re-evaluates the train.
|
||||
const ruling = s.clock.clearanceRuling;
|
||||
if (ruling && ruling.train === id) {
|
||||
s.clock.clearanceRuling = null;
|
||||
return ruling.allow ? 'clear' : 'blocked';
|
||||
}
|
||||
|
||||
const node = s.division.nodes[targetIndex];
|
||||
if (!node || node.kind !== 'mainline') return 'clear';
|
||||
|
||||
for (const region of node.regions) {
|
||||
const other = region.occupant;
|
||||
if (!other || other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
|
||||
if (otherTray.direction !== tray.direction) return 'blocked';
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
|
||||
return 'ask';
|
||||
}
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
|
||||
* the collision happens, with no die roll and no judgment.
|
||||
*/
|
||||
function arriveAtOffice(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
owner: PlayerIndex,
|
||||
events: GameEvent[],
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
if (area.adOccupancy.length >= capacity) {
|
||||
collide(s, owner, [id], events, 'no free A/D track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard && officeCard.standing.length > 0) {
|
||||
collide(s, owner, [id], events, 'cars fouling the Running Track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
area.adOccupancy.push(id);
|
||||
tray.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} arrived` });
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
function collide(
|
||||
s: GameState,
|
||||
faultPlayer: PlayerIndex,
|
||||
trains: TrayId[],
|
||||
events: GameEvent[],
|
||||
reason: string,
|
||||
): void {
|
||||
for (const id of trains) {
|
||||
const tray = s.trays.get(id);
|
||||
if (!tray) continue;
|
||||
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
}
|
||||
|
||||
s.collisionsToday += 1;
|
||||
const player = s.players[faultPlayer];
|
||||
if (player) {
|
||||
player.revenue -= COLLISION_PENALTY;
|
||||
events.push({
|
||||
type: 'revenueChanged',
|
||||
player: faultPlayer,
|
||||
delta: -COLLISION_PENALTY,
|
||||
total: player.revenue,
|
||||
reason: `collision: ${reason}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
|
||||
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
const dp = s.division.nodes.find(
|
||||
(n) => n.kind === 'divisionPoint' && n.holding.includes(id),
|
||||
);
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} completed` });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shift change, Stage and Day advance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
|
||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||
s.clock.superintendent = (s.clock.superintendent + 1) % s.players.length;
|
||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||
}
|
||||
|
||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||
for (const area of s.officeAreas.values()) {
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.facility) card.facility.usedThisStage = { laborers: 0, porters: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
const finished = checkVictory(s, events);
|
||||
if (finished) return { events, needsInput: false };
|
||||
} else {
|
||||
s.clock.stage += 1;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §3.4 — Competitive only: three collisions in one Day and everyone loses.
|
||||
if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
|
||||
s.status = 'finished';
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||
return { events, needsInput: false };
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
|
||||
}
|
||||
|
||||
/** §3.3 — evaluated at the end of a Day. */
|
||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const daysElapsed = s.clock.day - 1;
|
||||
|
||||
if (s.config.victory === 'firstToTarget') {
|
||||
const target =
|
||||
s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
|
||||
const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
|
||||
if (score >= target) {
|
||||
const winner =
|
||||
s.config.mode === 'coop'
|
||||
? null
|
||||
: s.players.findIndex((p) => p.revenue === score);
|
||||
s.status = 'finished';
|
||||
s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (daysElapsed < profile.days) return false;
|
||||
|
||||
s.status = 'finished';
|
||||
|
||||
if (s.config.mode === 'competitive') {
|
||||
// §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
|
||||
if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
}
|
||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||
s.outcome = {
|
||||
result: 'win',
|
||||
winner: s.players.findIndex((p) => p.revenue === best),
|
||||
reason: 'daysElapsed',
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
|
||||
const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
|
||||
const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
|
||||
s.outcome =
|
||||
score >= target
|
||||
? { result: 'win', winner: null, reason: 'daysElapsed' }
|
||||
: { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pump helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Runs automatic work until a player must act or the game ends. */
|
||||
export function pump(s: GameState, maxSteps = 10_000): GameEvent[] {
|
||||
const all: GameEvent[] = [];
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
const r = advance(s);
|
||||
all.push(...r.events);
|
||||
if (r.needsInput || s.status === 'finished') return all;
|
||||
}
|
||||
throw new Error('phase driver failed to settle — probable infinite loop');
|
||||
}
|
||||
|
||||
export { nodeIndexOfOffice };
|
||||
+1045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Component 1 — Card catalogue / static content.
|
||||
*
|
||||
* The printed values of every card. Source of truth: docs/rules/card-reference.md.
|
||||
* See architecture/components.md §2 A.1.
|
||||
*
|
||||
* This is DATA, not logic. Every number here is provisional and will be retuned repeatedly
|
||||
* against playtesting — keep tuning confined to this file so it never requires touching the
|
||||
* engine.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §2.2. A coloured car is loaded; a white car is empty. */
|
||||
export type CarType = 'coach' | 'boxcar' | 'reefer' | 'hopper' | 'tank' | 'caboose';
|
||||
|
||||
export type Direction = 'east' | 'west';
|
||||
|
||||
/** §9 — some freight facilities load only, some unload only, some both. */
|
||||
export type FlowDirection = 'outbound' | 'inbound' | 'both';
|
||||
|
||||
export type OfficeTier = 'whistlePost' | 'depot' | 'station' | 'terminal';
|
||||
|
||||
export type FreightKind =
|
||||
| 'mineTipple'
|
||||
| 'produceShed'
|
||||
| 'grocersWarehouse'
|
||||
| 'oilRefinery'
|
||||
| 'powerPlant';
|
||||
|
||||
export type ModifierKind =
|
||||
| 'teamTrack'
|
||||
| 'loadingDock'
|
||||
| 'storageShed'
|
||||
| 'extraPlatform'
|
||||
| 'sectionGang';
|
||||
|
||||
export type TrackGeometry = 'turnout' | 'straight' | 'runAround';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freight facilities — card-reference.md §2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FreightProfile = {
|
||||
kind: FreightKind;
|
||||
name: string;
|
||||
carType: CarType;
|
||||
flow: FlowDirection;
|
||||
laborers: number;
|
||||
/** Green box capacity; 0 when the facility does not load. */
|
||||
outboundCapacity: number;
|
||||
/** Red box capacity; 0 when the facility does not unload. */
|
||||
inboundCapacity: number;
|
||||
/** Cars that fit on the industry track. Never above 4 (§9.3). */
|
||||
industryTrackLength: number;
|
||||
/** Copies in the deck. Identical pairs — Gap 10b. */
|
||||
copies: number;
|
||||
};
|
||||
|
||||
export const FREIGHT_PROFILES: readonly FreightProfile[] = [
|
||||
{
|
||||
kind: 'mineTipple',
|
||||
name: 'Mine Tipple',
|
||||
carType: 'hopper',
|
||||
flow: 'outbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 3,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'produceShed',
|
||||
name: 'Produce Shed',
|
||||
carType: 'reefer',
|
||||
flow: 'outbound',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 0,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'grocersWarehouse',
|
||||
name: "Grocer's Warehouse",
|
||||
carType: 'boxcar',
|
||||
flow: 'both',
|
||||
laborers: 2,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 3,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'oilRefinery',
|
||||
name: 'Oil Refinery',
|
||||
carType: 'tank',
|
||||
flow: 'both',
|
||||
laborers: 3,
|
||||
outboundCapacity: 2,
|
||||
inboundCapacity: 2,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
{
|
||||
kind: 'powerPlant',
|
||||
name: 'Power Plant',
|
||||
carType: 'hopper',
|
||||
flow: 'inbound',
|
||||
laborers: 3,
|
||||
outboundCapacity: 0,
|
||||
inboundCapacity: 3,
|
||||
industryTrackLength: 4,
|
||||
copies: 2,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* "Freight House" (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
||||
* facility permitting both directions. Gap 10d.
|
||||
*/
|
||||
export function isFreightHouse(profile: FreightProfile): boolean {
|
||||
return profile.flow === 'both';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — card-reference.md §4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
tier: OfficeTier;
|
||||
name: string;
|
||||
isControlPoint: boolean;
|
||||
isPassengerFacility: boolean;
|
||||
adTracks: number;
|
||||
porters: number;
|
||||
greenSlots: number;
|
||||
redSlots: number;
|
||||
/** Copies in the deck. Whistle Posts are a fixed supply outside it (§12.2). */
|
||||
copiesInDeck: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gap 8: all four tiers carry IDENTICAL track geometry — a through track plus a plain junction
|
||||
* stub above and below. They differ only in the three flags and the counts below, which is what
|
||||
* makes an upgrade a drop-in replacement that never disturbs a connection.
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{
|
||||
tier: 'whistlePost',
|
||||
name: 'Whistle Post',
|
||||
isControlPoint: false,
|
||||
isPassengerFacility: false,
|
||||
adTracks: 1,
|
||||
porters: 0,
|
||||
greenSlots: 0,
|
||||
redSlots: 0,
|
||||
copiesInDeck: 0,
|
||||
},
|
||||
{
|
||||
tier: 'depot',
|
||||
name: 'Depot',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 2,
|
||||
porters: 1,
|
||||
greenSlots: 2,
|
||||
redSlots: 2,
|
||||
copiesInDeck: 4,
|
||||
},
|
||||
{
|
||||
tier: 'station',
|
||||
name: 'Station',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 3,
|
||||
porters: 2,
|
||||
greenSlots: 3,
|
||||
redSlots: 3,
|
||||
copiesInDeck: 3,
|
||||
},
|
||||
{
|
||||
tier: 'terminal',
|
||||
name: 'Terminal',
|
||||
isControlPoint: true,
|
||||
isPassengerFacility: true,
|
||||
adTracks: 4,
|
||||
porters: 3,
|
||||
greenSlots: 4,
|
||||
redSlots: 4,
|
||||
copiesInDeck: 2,
|
||||
},
|
||||
];
|
||||
|
||||
/** Upgrade order is strict — no skipping (Gap 3b). */
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
|
||||
export function officeProfile(tier: OfficeTier): OfficeProfile {
|
||||
const found = OFFICE_PROFILES.find((p) => p.tier === tier);
|
||||
if (!found) throw new Error(`unknown office tier: ${tier}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The next tier up, or null at Terminal. Strict sequence, no skipping. */
|
||||
export function nextOfficeTier(tier: OfficeTier): OfficeTier | null {
|
||||
const i = OFFICE_ORDER.indexOf(tier);
|
||||
return i >= 0 && i + 1 < OFFICE_ORDER.length ? OFFICE_ORDER[i + 1]! : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — card-reference.md §5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModifierProfile = {
|
||||
kind: ModifierKind;
|
||||
name: string;
|
||||
effect: string;
|
||||
appliesTo: 'freight' | 'passenger' | 'either';
|
||||
};
|
||||
|
||||
export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
|
||||
{
|
||||
kind: 'teamTrack',
|
||||
name: 'Team Track',
|
||||
effect: '+1 car on the industry track',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'loadingDock',
|
||||
name: 'Loading Dock',
|
||||
effect: '+1 green Outbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'storageShed',
|
||||
name: 'Storage Shed',
|
||||
effect: '+1 red Inbound capacity',
|
||||
appliesTo: 'freight',
|
||||
},
|
||||
{
|
||||
kind: 'extraPlatform',
|
||||
name: 'Extra Platform',
|
||||
effect: '+1 green and +1 red slot',
|
||||
appliesTo: 'passenger',
|
||||
},
|
||||
{
|
||||
kind: 'sectionGang',
|
||||
name: 'Section Gang',
|
||||
effect: '+1 Laborer or +1 Porter',
|
||||
appliesTo: 'either',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains — card-reference.md §3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConsistSpec = {
|
||||
/** Revenue cars, excluding the caboose. */
|
||||
count: number;
|
||||
allowedTypes: readonly CarType[];
|
||||
requiresCaboose: boolean;
|
||||
};
|
||||
|
||||
export type TrainProfile = {
|
||||
/** 1..12. For an Extra this is the number following the "X" (§2.3). */
|
||||
number: number;
|
||||
isExtra: boolean;
|
||||
className: string;
|
||||
/** Extras are head-on: the player choosing determines direction (§2.3). */
|
||||
direction: Direction | 'playerChoice';
|
||||
consist: ConsistSpec;
|
||||
};
|
||||
|
||||
const ALL_CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank'];
|
||||
|
||||
/**
|
||||
* Odd numbers run westbound, even eastbound (§2.3). Pairs are sister trains under the optional
|
||||
* rule. Seniority runs passenger-first: low numbers are the varnish, high the drags and locals.
|
||||
*
|
||||
* NOTE the four-slot Crew Tray limit INCLUDES the caboose (§A.4), which is why no caboose train
|
||||
* carries more than three revenue cars.
|
||||
*/
|
||||
function timetabledPair(
|
||||
odd: number,
|
||||
className: string,
|
||||
consist: ConsistSpec,
|
||||
): readonly TrainProfile[] {
|
||||
return [
|
||||
{ number: odd, isExtra: false, className, direction: 'west', consist },
|
||||
{ number: odd + 1, isExtra: false, className, direction: 'east', consist },
|
||||
];
|
||||
}
|
||||
|
||||
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
||||
...timetabledPair(1, 'Limited', {
|
||||
count: 4,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(3, 'Mail-Express', {
|
||||
count: 3,
|
||||
allowedTypes: ['coach'],
|
||||
requiresCaboose: false,
|
||||
}),
|
||||
...timetabledPair(5, 'Manifest Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ['boxcar', 'reefer'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(7, 'Coal Drag', {
|
||||
count: 3,
|
||||
allowedTypes: ['hopper'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(9, 'Oil Train', {
|
||||
count: 3,
|
||||
allowedTypes: ['tank'],
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
...timetabledPair(11, 'Way Freight', {
|
||||
count: 3,
|
||||
allowedTypes: ALL_CAR_TYPES,
|
||||
requiresCaboose: true,
|
||||
}),
|
||||
];
|
||||
|
||||
/** Gap 4a: Extras carry high numbers, i.e. low seniority. They yield to every scheduled train. */
|
||||
export const EXTRA_TRAINS: readonly TrainProfile[] = [9, 10, 11, 12].map((n) => ({
|
||||
number: n,
|
||||
isExtra: true,
|
||||
className: 'Extra',
|
||||
direction: 'playerChoice' as const,
|
||||
consist: { count: 3, allowedTypes: ALL_CAR_TYPES, requiresCaboose: true },
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track — card-reference.md §6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrackProfile = {
|
||||
geometry: TrackGeometry;
|
||||
name: string;
|
||||
/** Turnouts carry no wheel icon: a train may pass through but not stop (§A.1). */
|
||||
isOperationalRail: boolean;
|
||||
copies: number;
|
||||
};
|
||||
|
||||
export const TRACK_PROFILES: readonly TrackProfile[] = [
|
||||
{ geometry: 'turnout', name: 'Turnout', isOperationalRail: false, copies: 6 },
|
||||
{ geometry: 'straight', name: 'Straight', isOperationalRail: true, copies: 3 },
|
||||
{ geometry: 'runAround', name: 'Run-around', isOperationalRail: true, copies: 3 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock supply — card-reference.md §8
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
{ type: 'hopper', loaded: 6, empty: 6 },
|
||||
{ type: 'reefer', loaded: 4, empty: 4 },
|
||||
{ type: 'tank', loaded: 4, empty: 4 },
|
||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
||||
];
|
||||
|
||||
export const TOTAL_ROLLING_STOCK = ROLLING_STOCK_SUPPLY.reduce(
|
||||
(n, s) => n + s.loaded + s.empty,
|
||||
0,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixed supplies and scale — card-reference.md §8, rules §12.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §4.3 — one Mainline card between each adjacent pair, and one beyond each end. */
|
||||
export function mainlineCardCount(players: number): number {
|
||||
return players + 1;
|
||||
}
|
||||
|
||||
/** Gap 4b — scarcity is an explicit mechanic (§7). */
|
||||
export function crewTrayCount(players: number): number {
|
||||
return players + 3;
|
||||
}
|
||||
|
||||
/** Each Mainline card is divided into two regions (Gap 4b). */
|
||||
export const REGIONS_PER_MAINLINE_CARD = 2;
|
||||
|
||||
export const STAGES_PER_DAY = 12;
|
||||
|
||||
/** §5 — the Fedora passes every three Stages; shift changes at Stages 3, 6, 9, 12. */
|
||||
export const STAGES_PER_SHIFT = 3;
|
||||
|
||||
/** §6.2 — hand limit, before the optional Red Flag. */
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/** §A.4 — a Crew Tray may hold at most four Rolling Stock, cabooses included. */
|
||||
export const MAX_CONSIST = 4;
|
||||
|
||||
/** §6.1 — Moves per Local Operations Phase; five during night Stages under Reduced Visibility. */
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
|
||||
/** §9.3 — Green -> MEN -> AT -> WORK -> car. */
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Victory — rules §3.2, revised by Gap 10e
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GameLength = 'short' | 'standard' | 'campaign';
|
||||
|
||||
export type LengthProfile = { length: GameLength; target: number; days: number };
|
||||
|
||||
export const LENGTH_PROFILES: readonly LengthProfile[] = [
|
||||
{ length: 'short', target: 10, days: 3 },
|
||||
{ length: 'standard', target: 20, days: 5 },
|
||||
{ length: 'campaign', target: 45, days: 10 },
|
||||
];
|
||||
|
||||
export function lengthProfile(length: GameLength): LengthProfile {
|
||||
const found = LENGTH_PROFILES.find((p) => p.length === length);
|
||||
if (!found) throw new Error(`unknown game length: ${length}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** §10 — a collision costs the party at fault 5 Revenue. */
|
||||
export const COLLISION_PENALTY = 5;
|
||||
|
||||
/** §3.4 — Competitive only: three collisions in one Day and everyone loses. */
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
|
||||
/** §3.5 — Competitive timed games: all players' Revenue combined must reach this. */
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — card-reference.md §1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DECK_SIZE = 52;
|
||||
|
||||
/**
|
||||
* The Home Office deck is a SINGLE deck containing every card type (§2.6, Gap 4a). The three
|
||||
* Department slots are face-up market slots fed from it, not decks with their own contents.
|
||||
*/
|
||||
export function deckComposition(): { category: string; count: number }[] {
|
||||
const office = OFFICE_PROFILES.reduce((n, p) => n + p.copiesInDeck, 0);
|
||||
const freight = FREIGHT_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
const track = TRACK_PROFILES.reduce((n, p) => n + p.copies, 0);
|
||||
return [
|
||||
{ category: 'timetabledTrain', count: TIMETABLED_TRAINS.length },
|
||||
{ category: 'extraTrain', count: EXTRA_TRAINS.length },
|
||||
{ category: 'office', count: office },
|
||||
{ category: 'freightFacility', count: freight },
|
||||
{ category: 'modifier', count: MODIFIER_PROFILES.length },
|
||||
{ category: 'track', count: track },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Events — protocol.md §3.
|
||||
*
|
||||
* An event is a FACT. Events are ordered, append-only, and fully determine state:
|
||||
* `state = fold(events)`. That one property gives reconnection, restart recovery and post-game
|
||||
* replay together.
|
||||
*
|
||||
* DESIGN RULE (overview.md, post-game replay): events must render STANDALONE. Carry the from/to,
|
||||
* not just an id the renderer has to resolve against live state — otherwise a replay viewer has to
|
||||
* reconstruct the whole board to draw one frame.
|
||||
*/
|
||||
|
||||
import type { CarType, OfficeTier } from './content.ts';
|
||||
import type { LocalOpsOption } from './intents.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
|
||||
export type GameEvent =
|
||||
// -- clock
|
||||
| { type: 'stageBegan'; day: number; stage: number }
|
||||
| { type: 'phaseBegan'; phase: string }
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
|
||||
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
||||
| { type: 'deckReshuffled' }
|
||||
| { type: 'departmentRefilled'; slot: number; cardId: CardId }
|
||||
// -- freight agent
|
||||
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'inboundCleared'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
|
||||
| { type: 'facilityUnjammed'; player: PlayerIndex; at: GridCoord; from: string; stock: RollingStock }
|
||||
// -- trains
|
||||
/**
|
||||
* §7 — a Timetabled Train card played from hand is scheduled by a 1D12 roll. `rngState` carries
|
||||
* the advanced RNG so that folding events reproduces the draw exactly.
|
||||
*/
|
||||
| { type: 'trainScheduled'; player: PlayerIndex; trainNumber: number; roll: number; slot: number; rngState: number }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- load / unload
|
||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
|
||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
|
||||
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
// -- consequences
|
||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||
|
||||
export type EventType = GameEvent['type'];
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The intent vocabulary — every legal player choice, from docs/architecture/protocol.md §1.
|
||||
*
|
||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local Operations Phase (§6) — a three-way exclusive choice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
|
||||
|
||||
export type Intent =
|
||||
| { type: 'localOps.choose'; option: LocalOpsOption }
|
||||
// -- switch (§6.1, Appendix A)
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
| { type: 'draw.fromDepartment'; slot: number }
|
||||
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||
| { type: 'draw.end' }
|
||||
// -- freight agent (§6.3)
|
||||
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
|
||||
| { type: 'freightAgent.clearInbound'; at: GridCoord; index: number }
|
||||
| { type: 'freightAgent.unjam'; at: GridCoord; from: 'outbound' | 'inbound' | 'menAtWork'; index: number }
|
||||
// -- New Train Phase (§7)
|
||||
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
|
||||
| { type: 'newTrain.passCar'; trayId: TrayId }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
| { type: 'mainline.clearance'; allow: boolean }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- Load/Unload Phase (§9)
|
||||
| { type: 'porter.board'; at: GridCoord }
|
||||
| { type: 'porter.detrain'; at: GridCoord }
|
||||
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
|
||||
| { type: 'loadUnload.end' };
|
||||
|
||||
export type IntentType = Intent['type'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rejections — protocol.md §2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RejectionCode =
|
||||
| 'NOT_YOUR_TURN'
|
||||
| 'WRONG_PHASE'
|
||||
| 'OPTION_ALREADY_CHOSEN'
|
||||
| 'OPTION_NOT_CHOSEN'
|
||||
| 'NO_MOVES_REMAINING'
|
||||
| 'ILLEGAL_MOVE'
|
||||
| 'NO_SUCH_TRAY'
|
||||
| 'NO_SUCH_CARD'
|
||||
| 'NO_SUCH_FACILITY'
|
||||
| 'CANNOT_DROP_HERE'
|
||||
| 'CONSIST_EMPTY'
|
||||
| 'CONSIST_FULL'
|
||||
| 'HAND_LIMIT'
|
||||
| 'CARD_NOT_IN_HAND'
|
||||
| 'NO_PLACEMENT'
|
||||
| 'NOT_CONNECTED'
|
||||
| 'DECK_EMPTY'
|
||||
| 'SLOT_EMPTY'
|
||||
| 'RESOURCE_SPENT'
|
||||
| 'BOX_FULL'
|
||||
| 'BOX_EMPTY'
|
||||
| 'NO_SUITABLE_CAR'
|
||||
| 'SUITABLE_CAR_EXISTS'
|
||||
| 'WRONG_CAR_TYPE'
|
||||
| 'NOT_SUPERINTENDENT'
|
||||
| 'NO_PENDING_DECISION'
|
||||
| 'NOT_UPGRADEABLE'
|
||||
| 'FACILITY_LOCKED'
|
||||
| 'NO_TRAIN_AT_OFFICE';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
export function reject(code: RejectionCode, message: string): Rejection {
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports used by handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type { CardId, Direction, GridCoord, OfficeTier, PlayerIndex, TrayId };
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Component 6 — Legal-action enumeration.
|
||||
*
|
||||
* `legalActions(state, actor) -> Intent[]` — what the UI greys out, and what a bot picks from.
|
||||
* See architecture/components.md §2 A.6.
|
||||
*
|
||||
* THE DISCIPLINE. This module enumerates *candidate* intents and then filters them through
|
||||
* component 4's `check`. It contains no rules of its own. That is deliberate: two independent
|
||||
* implementations of turnout directionality or the four-slot limit would eventually disagree, and
|
||||
* the failure mode is a legal move the UI refuses or an illegal one it offers.
|
||||
*
|
||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||||
*/
|
||||
|
||||
import type { CarType } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
|
||||
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
return check(s, player, i) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
|
||||
* turns out to be illegal simply gets filtered. Being generous here is what stops this module
|
||||
* from quietly acquiring rules.
|
||||
*/
|
||||
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
|
||||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||||
if (s.clock.pendingDecision !== null) {
|
||||
out.push({ type: 'mainline.clearance', allow: true });
|
||||
out.push({ type: 'mainline.clearance', allow: false });
|
||||
}
|
||||
|
||||
switch (s.clock.phase) {
|
||||
case 'localOps':
|
||||
out.push(...localOpsCandidates(s, player));
|
||||
break;
|
||||
case 'newTrain':
|
||||
out.push(...newTrainCandidates(s));
|
||||
break;
|
||||
case 'loadUnload':
|
||||
out.push(...loadUnloadCandidates(s, player));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
return out;
|
||||
}
|
||||
|
||||
function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
|
||||
// §6 — the three-way exclusive choice.
|
||||
out.push({ type: 'localOps.choose', option: 'switch' });
|
||||
out.push({ type: 'localOps.choose', option: 'draw' });
|
||||
out.push({ type: 'localOps.choose', option: 'freightAgent' });
|
||||
|
||||
const area = areaOf(s, player);
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
|
||||
// -- draw (§6.2)
|
||||
out.push({ type: 'draw.fromHomeOffice' });
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
|
||||
|
||||
const placements = placementCandidates(s, player);
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
out.push({ type: 'card.play', cardId });
|
||||
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
|
||||
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
|
||||
for (const placement of placements) {
|
||||
for (let variant = 0; variant < 6; variant++) {
|
||||
out.push({ type: 'card.play', cardId, placement, variant });
|
||||
}
|
||||
}
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
|
||||
}
|
||||
out.push({ type: 'draw.end' });
|
||||
|
||||
// -- freight agent (§6.3)
|
||||
for (const coord of facilityCoords(s, player)) {
|
||||
for (const carType of CAR_TYPES) {
|
||||
out.push({ type: 'freightAgent.stockOutbound', at: coord, carType });
|
||||
}
|
||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
if (f) {
|
||||
for (let idx = 0; idx < f.inboundBox.length; idx++) {
|
||||
out.push({ type: 'freightAgent.clearInbound', at: coord, index: idx });
|
||||
}
|
||||
for (const from of ['outbound', 'inbound', 'menAtWork'] as const) {
|
||||
for (let idx = 0; idx < 3; idx++) {
|
||||
out.push({ type: 'freightAgent.unjam', at: coord, from, index: idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function newTrainCandidates(s: GameState): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId] of s.trays) {
|
||||
for (const carType of CAR_TYPES) {
|
||||
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: true });
|
||||
out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: false });
|
||||
}
|
||||
out.push({ type: 'newTrain.passCar', trayId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
const area = areaOf(s, player);
|
||||
|
||||
for (const coord of facilityCoords(s, player)) {
|
||||
out.push({ type: 'porter.board', at: coord });
|
||||
out.push({ type: 'porter.detrain', at: coord });
|
||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
if (f) {
|
||||
out.push({ type: 'laborer.startLoad', at: coord });
|
||||
for (let box = 0; box < f.menAtWork.length; box++) {
|
||||
out.push({ type: 'laborer.advanceLoad', at: coord, box });
|
||||
}
|
||||
for (let ci = 0; ci < f.industryTrack.cars.length; ci++) {
|
||||
out.push({ type: 'laborer.beginUnload', at: coord, carIndex: ci });
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'loadUnload.end' });
|
||||
return out;
|
||||
}
|
||||
|
||||
function facilityCoords(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
const out: GridCoord[] = [];
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
if (!card.facility) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Empty cells adjacent to occupied ones — `check` decides which actually connect. */
|
||||
function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
const area = areaOf(s, player);
|
||||
const seen = new Set<string>();
|
||||
const out: GridCoord[] = [];
|
||||
|
||||
for (const key of area.grid.keys()) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const around: GridCoord[] = [
|
||||
{ row: row! + 1, col: col! },
|
||||
{ row: row! - 1, col: col! },
|
||||
{ row: row!, col: col! + 1 },
|
||||
{ row: row!, col: col! - 1 },
|
||||
];
|
||||
for (const c of around) {
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (area.grid.has(k) || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Component 7 — Seeded RNG.
|
||||
*
|
||||
* Every random decision in the game derives from one stored seed, so any game is exactly
|
||||
* replayable. See architecture/components.md §2 A.7.
|
||||
*
|
||||
* IMPORTANT: never call Math.random() anywhere in the engine. A single ambient random call
|
||||
* silently breaks replay, and the failure is invisible until someone tries to reproduce a bug.
|
||||
*/
|
||||
|
||||
/** An RNG is a value, not a global. Thread it through explicitly. */
|
||||
export type Rng = {
|
||||
/** Raw 32-bit unsigned draw. */
|
||||
next(): number;
|
||||
/** Uniform integer in [0, bound). Throws if bound < 1. */
|
||||
nextInt(bound: number): number;
|
||||
/** A single twelve-sided die roll, 1..12 (§4.3, §4.4, §7). */
|
||||
d12(): number;
|
||||
/** Fisher-Yates. Returns a new array; does not mutate the input. */
|
||||
shuffle<T>(items: readonly T[]): T[];
|
||||
/** The current internal state, so a game can be snapshotted mid-play. */
|
||||
getState(): number;
|
||||
};
|
||||
|
||||
/**
|
||||
* mulberry32 — small, fast, and good enough for a board game. Chosen because its entire state
|
||||
* is one 32-bit integer, which makes snapshotting and restoring an in-progress game trivial.
|
||||
*/
|
||||
export function createRng(seed: number): Rng {
|
||||
let state = seed >>> 0;
|
||||
|
||||
const next = (): number => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
};
|
||||
|
||||
const nextInt = (bound: number): number => {
|
||||
if (!Number.isInteger(bound) || bound < 1) {
|
||||
throw new Error(`nextInt bound must be a positive integer, got ${bound}`);
|
||||
}
|
||||
// Rejection sampling, so the distribution stays uniform rather than modulo-biased.
|
||||
const limit = Math.floor(0x100000000 / bound) * bound;
|
||||
let draw = next();
|
||||
while (draw >= limit) draw = next();
|
||||
return draw % bound;
|
||||
};
|
||||
|
||||
return {
|
||||
next,
|
||||
nextInt,
|
||||
d12: () => nextInt(12) + 1,
|
||||
shuffle: <T,>(items: readonly T[]): T[] => {
|
||||
const out = items.slice();
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = nextInt(i + 1);
|
||||
const a = out[i]!;
|
||||
const b = out[j]!;
|
||||
out[i] = b;
|
||||
out[j] = a;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
getState: () => state,
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore an RNG mid-stream, for rebuilding a game from a snapshot. */
|
||||
export function restoreRng(state: number): Rng {
|
||||
// mulberry32 advances from its state before drawing, and createRng seeds state directly,
|
||||
// so restoring is just seeding with the saved state.
|
||||
return createRng(state);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Game setup — rules §4.
|
||||
*
|
||||
* Builds the initial state deterministically from a seed. Part of component 2; the D12 rolls
|
||||
* come from component 7.
|
||||
*/
|
||||
|
||||
import {
|
||||
EXTRA_TRAINS,
|
||||
FREIGHT_PROFILES,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
TIMETABLED_TRAINS,
|
||||
TRACK_PROFILES,
|
||||
crewTrayCount,
|
||||
mainlineCardCount,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
DivisionNode,
|
||||
GameConfig,
|
||||
GameState,
|
||||
OfficeArea,
|
||||
PlayerIndex,
|
||||
Region,
|
||||
RollingStock,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
} from './state.ts';
|
||||
import { coordKey, freshTurn } from './state.ts';
|
||||
|
||||
export type SetupOptions = {
|
||||
id: string;
|
||||
seed: number;
|
||||
config: GameConfig;
|
||||
playerNames: string[];
|
||||
};
|
||||
|
||||
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
||||
export function buildDeck(): Card[] {
|
||||
const cards: Card[] = [];
|
||||
let n = 0;
|
||||
const push = (kind: Card['kind']): void => {
|
||||
cards.push({ id: `c${n++}`, kind });
|
||||
};
|
||||
|
||||
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
|
||||
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
|
||||
}
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
for (let i = 0; i < f.copies; i++) push({ kind: 'freightFacility', facility: f.kind });
|
||||
}
|
||||
for (const m of MODIFIER_PROFILES) push({ kind: 'modifier', modifier: m.kind });
|
||||
for (const t of TRACK_PROFILES) {
|
||||
for (let i = 0; i < t.copies; i++) push({ kind: 'track', geometry: t.geometry });
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** The Division Yard starts with every rolling stock piece (§4.9). */
|
||||
export function buildRollingStock(): RollingStock[] {
|
||||
const out: RollingStock[] = [];
|
||||
for (const s of ROLLING_STOCK_SUPPLY) {
|
||||
for (let i = 0; i < s.loaded; i++) out.push({ type: s.type, loaded: true });
|
||||
for (let i = 0; i < s.empty; i++) out.push({ type: s.type, loaded: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's opening three cards: Limits, Whistle Post, Limits, in one horizontal row (§4.2).
|
||||
*
|
||||
* Gap 8 — the Office card carries a plain junction stub above and below, so Secondary Track can
|
||||
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
|
||||
* drawn turnout cards only.
|
||||
*/
|
||||
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
const row = 0;
|
||||
const officeCoord = { row, col: 0 };
|
||||
const limitsWest = { row, col: -1 };
|
||||
const limitsEast = { row, col: 1 };
|
||||
|
||||
const officeCard: TrackCard = {
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const limitsCard = (): TrackCard => ({
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
});
|
||||
|
||||
const grid = new Map<string, TrackCard>();
|
||||
grid.set(coordKey(officeCoord), officeCard);
|
||||
grid.set(coordKey(limitsWest), limitsCard());
|
||||
grid.set(coordKey(limitsEast), limitsCard());
|
||||
|
||||
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A Whistle Post is not a Passenger Facility (§9), so it has no Porters and no slots — but the
|
||||
* Office card still carries a facility record so an upgrade is a property change rather than a
|
||||
* card swap (game-state.md constraint 9).
|
||||
*/
|
||||
function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonNullable<TrackCard['facility']> {
|
||||
const p = officeProfile(tier);
|
||||
return {
|
||||
kind: 'passenger',
|
||||
subtype: 'office',
|
||||
allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility },
|
||||
outboundBox: [],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: p.greenSlots, inbound: p.redSlots },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
laborers: 0,
|
||||
porters: p.porters,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/** §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. */
|
||||
function buildDivision(players: number): DivisionNode[] {
|
||||
const nodes: DivisionNode[] = [];
|
||||
const mainline = (): DivisionNode => {
|
||||
const regions: Region[] = [];
|
||||
for (let i = 0; i < REGIONS_PER_MAINLINE_CARD; i++) regions.push({ occupant: null });
|
||||
return { kind: 'mainline', regions };
|
||||
};
|
||||
|
||||
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||
for (let p = 0; p < players; p++) {
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'office', owner: p });
|
||||
}
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function createGame(opts: SetupOptions): GameState {
|
||||
const { id, seed, config, playerNames } = opts;
|
||||
if (playerNames.length < 1) throw new Error('a game needs at least one player');
|
||||
if (config.mode === 'solitaire' && playerNames.length !== 1) {
|
||||
throw new Error('solitaire is a one-player mode');
|
||||
}
|
||||
|
||||
const rng = createRng(seed);
|
||||
const playerCount = playerNames.length;
|
||||
|
||||
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
|
||||
|
||||
const officeAreas = new Map<PlayerIndex, OfficeArea>();
|
||||
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
|
||||
|
||||
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
|
||||
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
|
||||
const divisionRolls = players.map(() => rng.d12());
|
||||
const superRolls = players.map(() => rng.d12());
|
||||
const superintendent = argmax(superRolls);
|
||||
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
|
||||
|
||||
// §4.6-4.7 - shuffle, deal three each, then turn three face-up as the Department slots.
|
||||
const deck = buildDeck();
|
||||
const cards = new Map<CardId, Card>();
|
||||
for (const c of deck) cards.set(c.id, c);
|
||||
|
||||
const shuffled = rng.shuffle(deck.map((c) => c.id));
|
||||
const hands = new Map<PlayerIndex, CardId[]>();
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const p = (superintendent + i) % playerCount;
|
||||
hands.set(p, shuffled.slice(cursor, cursor + 3));
|
||||
cursor += 3;
|
||||
}
|
||||
const departments: (CardId | null)[] = [
|
||||
shuffled[cursor++] ?? null,
|
||||
shuffled[cursor++] ?? null,
|
||||
shuffled[cursor++] ?? null,
|
||||
];
|
||||
const homeOffice = shuffled.slice(cursor);
|
||||
|
||||
const redFlags = new Map<PlayerIndex, boolean>();
|
||||
for (let p = 0; p < playerCount; p++) {
|
||||
redFlags.set(p, config.optionalRules.emergencyToolbox);
|
||||
}
|
||||
|
||||
// §4.9 - Crew Trays near the turn sheet. Scarcity is an explicit mechanic (§7).
|
||||
const freeTrays: TrayId[] = [];
|
||||
for (let i = 0; i < crewTrayCount(playerCount); i++) freeTrays.push(`tray${i}`);
|
||||
|
||||
const timetable: (number | null)[] = new Array(STAGES_PER_DAY).fill(null);
|
||||
|
||||
return {
|
||||
id,
|
||||
config,
|
||||
seed,
|
||||
rngState: rng.getState(),
|
||||
players,
|
||||
division: { nodes: buildDivision(playerCount) },
|
||||
officeAreas,
|
||||
trays: new Map(),
|
||||
freeTrays,
|
||||
cards,
|
||||
decks: { homeOffice, departments, salvageYard: [], hands, redFlags },
|
||||
yards: { divisionYard: buildRollingStock(), classificationYard: [] },
|
||||
timetable,
|
||||
clock: {
|
||||
day: 1,
|
||||
stage: 1,
|
||||
phase: 'localOps',
|
||||
currentActor: superintendent,
|
||||
pendingDecision: null,
|
||||
clearanceRuling: null,
|
||||
superintendent,
|
||||
actorOffset: 0,
|
||||
},
|
||||
turn: freshTurn(MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
};
|
||||
}
|
||||
|
||||
function argmax(values: number[]): number {
|
||||
let best = 0;
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
if (values[i]! > values[best]!) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export { mainlineCardCount };
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Component 2 — State model and types.
|
||||
*
|
||||
* The entity model from docs/architecture/game-state.md. Pure data; no behaviour beyond a few
|
||||
* derivations that must never be cached (see DERIVED note below).
|
||||
* See architecture/components.md §2 A.2.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CarType,
|
||||
Direction,
|
||||
FreightKind,
|
||||
GameLength,
|
||||
ModifierKind,
|
||||
OfficeTier,
|
||||
TrackGeometry,
|
||||
} from './content.ts';
|
||||
import { officeProfile } from './content.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PlayerIndex = number;
|
||||
export type CardId = string;
|
||||
export type TrayId = string;
|
||||
|
||||
/** A cell in a player's Office Area grid. Sparse — cards are placed during play. */
|
||||
export type GridCoord = { row: number; col: number };
|
||||
|
||||
export function coordKey(c: GridCoord): string {
|
||||
return `${c.row},${c.col}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rolling stock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §2.2 — a coloured car is loaded, a white car is empty. */
|
||||
export type RollingStock = { type: CarType; loaded: boolean };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track and Office Area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
|
||||
* matters is that `through` and `diverge` are NOT joined to each other.
|
||||
*
|
||||
* OPEN (Gap 11) — the rules specify "turnout ×6" without saying how many face which way. Until
|
||||
* that is settled, orientation is carried per card and defaults to stem-east / diverge-north.
|
||||
*/
|
||||
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };
|
||||
|
||||
/**
|
||||
* A straight card's axis. Also part of Gap 11 — the catalogue says "straight ×3" without saying
|
||||
* how many run each way, yet a layout with no north-south straight can never use the Office's
|
||||
* junction stubs at all.
|
||||
*/
|
||||
export type TrackAxis = 'ew' | 'ns';
|
||||
|
||||
export type CardGeometry =
|
||||
| {
|
||||
kind: 'track';
|
||||
geometry: TrackGeometry;
|
||||
turnout?: TurnoutOrientation;
|
||||
axis?: TrackAxis;
|
||||
bypass?: 'n' | 's' | 'e' | 'w';
|
||||
}
|
||||
| { kind: 'office' }
|
||||
| { kind: 'limits' }
|
||||
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis };
|
||||
|
||||
export type TrackCard = {
|
||||
geometry: CardGeometry;
|
||||
/**
|
||||
* DERIVED for facility cards — a Facility track locked by loads on MEN|AT|WORK stops being
|
||||
* Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
|
||||
*/
|
||||
baseOperationalRail: boolean;
|
||||
/** Uncoupled cars left here, in track order (§A.3). */
|
||||
standing: RollingStock[];
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
};
|
||||
|
||||
export type OfficeArea = {
|
||||
owner: PlayerIndex;
|
||||
tier: OfficeTier;
|
||||
grid: Map<string, TrackCard>;
|
||||
officeCoord: GridCoord;
|
||||
/** The grid row that is the Running Track (§2.1). */
|
||||
runningRow: number;
|
||||
limitsWest: GridCoord;
|
||||
limitsEast: GridCoord;
|
||||
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
||||
adOccupancy: TrayId[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A load in transit along the MEN | AT | WORK track (§9.3).
|
||||
*
|
||||
* Direction matters and is easy to miss. **Outbound** loading runs Green -> MEN -> AT -> WORK ->
|
||||
* onto a spotted empty car. **Inbound** unloading runs the other way: car -> WORK -> AT -> MEN ->
|
||||
* red Inbound box. Same three boxes, opposite traversal.
|
||||
*/
|
||||
export type Load = { type: CarType; dir: 'out' | 'in' };
|
||||
|
||||
export type Facility = {
|
||||
kind: 'freight' | 'passenger';
|
||||
subtype: FreightKind | 'office';
|
||||
allows: { outbound: boolean; inbound: boolean };
|
||||
outboundBox: RollingStock[];
|
||||
inboundBox: RollingStock[];
|
||||
capacity: { outbound: number; inbound: number };
|
||||
/** Freight only. One load per box; three boxes, so it is a pipeline (§9.1). */
|
||||
menAtWork: [Load | null, Load | null, Load | null];
|
||||
/** Where cars are spotted for loading and unloading. */
|
||||
industryTrack: { length: number; cars: RollingStock[] };
|
||||
laborers: number;
|
||||
porters: number;
|
||||
/** Resets at the start of each Stage (§9.1). */
|
||||
usedThisStage: { laborers: number; porters: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* §9.3 — while ANY load sits on MEN|AT|WORK the industry's track is locked down and loses its
|
||||
* Operational Rail status. This is why isOperationalRail is a function, not a stored field.
|
||||
*/
|
||||
/**
|
||||
* The cars physically standing on a card.
|
||||
*
|
||||
* On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
|
||||
* car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
|
||||
* at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
|
||||
* freight load could ever complete and freight revenue was structurally zero.
|
||||
*/
|
||||
export function carsOn(card: TrackCard): RollingStock[] {
|
||||
return card.facility && card.facility.industryTrack.length > 0
|
||||
? card.facility.industryTrack.cars
|
||||
: card.standing;
|
||||
}
|
||||
|
||||
/** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */
|
||||
export function spaceOn(card: TrackCard): number {
|
||||
if (card.facility && card.facility.industryTrack.length > 0) {
|
||||
return card.facility.industryTrack.length - card.facility.industryTrack.cars.length;
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export function isOperationalRail(card: TrackCard): boolean {
|
||||
if (!card.baseOperationalRail) return false;
|
||||
if (card.facility && card.facility.menAtWork.some((slot) => slot !== null)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NodeRef =
|
||||
| { at: 'divisionPoint'; side: Direction }
|
||||
| { at: 'mainline'; index: number; region: number }
|
||||
| { at: 'grid'; owner: PlayerIndex; coord: GridCoord };
|
||||
|
||||
export type CrewTray = {
|
||||
id: TrayId;
|
||||
/** null while a local crew is switching without a train card. */
|
||||
trainNumber: number | null;
|
||||
trainIsExtra: boolean;
|
||||
/** Which end the engine occupies (§A.3). */
|
||||
engineFront: boolean;
|
||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||
consist: RollingStock[];
|
||||
direction: Direction;
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Division
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Region = { occupant: TrayId | null };
|
||||
|
||||
export type DivisionNode =
|
||||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
||||
| { kind: 'mainline'; regions: Region[] }
|
||||
| { kind: 'office'; owner: PlayerIndex };
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
export type Division = { nodes: DivisionNode[] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decks and yards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CardKind =
|
||||
| { kind: 'timetabledTrain'; number: number }
|
||||
| { kind: 'extraTrain'; number: number }
|
||||
| { kind: 'office'; tier: OfficeTier }
|
||||
| { kind: 'freightFacility'; facility: FreightKind }
|
||||
| { kind: 'modifier'; modifier: ModifierKind }
|
||||
| { kind: 'track'; geometry: TrackGeometry };
|
||||
|
||||
export type Card = { id: CardId; kind: CardKind };
|
||||
|
||||
export type Decks = {
|
||||
/** Face down. Order is SECRET — never projected to any client. */
|
||||
homeOffice: CardId[];
|
||||
/** Three face-up market slots fed from the deck (§2.6). */
|
||||
departments: (CardId | null)[];
|
||||
/** Face up, so players can audit discards (§2.6). */
|
||||
salvageYard: CardId[];
|
||||
/** Private to the owner. Max 3, or 4 with a Red Flag. */
|
||||
hands: Map<PlayerIndex, CardId[]>;
|
||||
redFlags: Map<PlayerIndex, boolean>;
|
||||
};
|
||||
|
||||
export type Yards = {
|
||||
divisionYard: RollingStock[];
|
||||
classificationYard: RollingStock[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clock and phases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
||||
|
||||
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
||||
export type SuperintendentClearance = {
|
||||
train: TrayId;
|
||||
occupiedBy: TrayId;
|
||||
};
|
||||
|
||||
export type Clock = {
|
||||
day: number;
|
||||
/** 1..12 — the Pocket Watch. */
|
||||
stage: number;
|
||||
phase: Phase;
|
||||
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
||||
currentActor: PlayerIndex | null;
|
||||
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
|
||||
pendingDecision: SuperintendentClearance | null;
|
||||
/**
|
||||
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
|
||||
* driver would re-evaluate the same train and ask the same question forever.
|
||||
*/
|
||||
clearanceRuling: { train: TrayId; allow: boolean } | null;
|
||||
superintendent: PlayerIndex;
|
||||
/**
|
||||
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
||||
* and proceeds left (Gap 1), so `currentActor = (superintendent + actorOffset) % players`.
|
||||
* When it reaches the player count, the phase is complete.
|
||||
*/
|
||||
actorOffset: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Players and game
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Player = {
|
||||
index: PlayerIndex;
|
||||
name: string;
|
||||
/** May go negative — a collision costs 5 (§10). */
|
||||
revenue: number;
|
||||
};
|
||||
|
||||
export type GameMode = 'solitaire' | 'competitive' | 'coop';
|
||||
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';
|
||||
|
||||
export type GameConfig = {
|
||||
mode: GameMode;
|
||||
victory: VictoryCondition;
|
||||
length: GameLength;
|
||||
optionalRules: {
|
||||
reducedVisibility: boolean;
|
||||
sisterTrains: boolean;
|
||||
employeeRotation: boolean;
|
||||
emergencyToolbox: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OutcomeReason =
|
||||
| 'targetReached'
|
||||
| 'daysElapsed'
|
||||
| 'collisionFloor'
|
||||
| 'revenueFloor';
|
||||
|
||||
export type Outcome = {
|
||||
result: 'win' | 'loss';
|
||||
winner: PlayerIndex | null;
|
||||
reason: OutcomeReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
|
||||
*
|
||||
* §6 — the three Local Operations options are mutually exclusive: choosing one forecloses the
|
||||
* others for that Stage. That exclusivity lives here.
|
||||
*/
|
||||
export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
drawnThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
export function freshTurn(moves: number): TurnState {
|
||||
return {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
drawnThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
export type GameState = {
|
||||
id: string;
|
||||
config: GameConfig;
|
||||
/** All RNG derives from this. Games are exactly replayable. */
|
||||
seed: number;
|
||||
rngState: number;
|
||||
players: Player[];
|
||||
division: Division;
|
||||
officeAreas: Map<PlayerIndex, OfficeArea>;
|
||||
trays: Map<TrayId, CrewTray>;
|
||||
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */
|
||||
freeTrays: TrayId[];
|
||||
cards: Map<CardId, Card>;
|
||||
decks: Decks;
|
||||
yards: Yards;
|
||||
/** Index 0 = Stage 1. A train number, or null for an empty slot. */
|
||||
timetable: (number | null)[];
|
||||
clock: Clock;
|
||||
turn: TurnState;
|
||||
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day. */
|
||||
collisionsToday: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
outcome: Outcome | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derivations — never stored, never cached
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1).
|
||||
* Whistle Posts are NOT Control Points and sit inside a Subdivision like ordinary mainline.
|
||||
*
|
||||
* At game start every Office is a Whistle Post, so the entire railroad is ONE Subdivision (§8) —
|
||||
* which is why early traffic is so constrained. Each Office upgrade splits one in two.
|
||||
*
|
||||
* Recomputed on demand. Caching this means every Office upgrade must remember to invalidate,
|
||||
* and forgetting is a silent bug in highball legality.
|
||||
*/
|
||||
export function subdivisions(state: GameState): number[][] {
|
||||
const out: number[][] = [];
|
||||
let current: number[] = [];
|
||||
|
||||
state.division.nodes.forEach((node, i) => {
|
||||
const isBoundary =
|
||||
node.kind === 'divisionPoint' ||
|
||||
(node.kind === 'office' && isControlPoint(state, node.owner));
|
||||
|
||||
if (isBoundary) {
|
||||
if (current.length > 0) out.push(current);
|
||||
current = [];
|
||||
} else {
|
||||
current.push(i);
|
||||
}
|
||||
});
|
||||
|
||||
if (current.length > 0) out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isControlPoint(state: GameState, owner: PlayerIndex): boolean {
|
||||
const area = state.officeAreas.get(owner);
|
||||
if (!area) throw new Error(`no Office Area for player ${owner}`);
|
||||
return officeProfile(area.tier).isControlPoint;
|
||||
}
|
||||
|
||||
export function adTrackCount(state: GameState, owner: PlayerIndex): number {
|
||||
const area = state.officeAreas.get(owner);
|
||||
if (!area) throw new Error(`no Office Area for player ${owner}`);
|
||||
return officeProfile(area.tier).adTracks;
|
||||
}
|
||||
|
||||
export function totalRevenue(state: GameState): number {
|
||||
return state.players.reduce((n, p) => n + p.revenue, 0);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Component 3 — Track graph and movement.
|
||||
*
|
||||
* The Office Area grid as a traversable graph, and the rules governing a Move.
|
||||
* See architecture/components.md §2 A.3 and docs/rules/rules-v0.2.md Appendix A.
|
||||
*
|
||||
* THE CENTRAL SUBTLETY. §A.1 says a train entering a turnout from "A" may proceed to B or C, but
|
||||
* one entering through B or C may only proceed to A. That is NOT a one-way restriction — A→B and
|
||||
* B→A are both legal. What it means is that **B and C are not connected to each other**: the frog
|
||||
* offers no route between the two diverging legs. Modelling this as a directed graph would forbid
|
||||
* legal moves. It is an undirected graph over *port pairs*, and the constraint is which pairs
|
||||
* exist on each card.
|
||||
*
|
||||
* The second constraint is that a traversal may never leave a card through the port it entered by.
|
||||
* That is what "without changing direction" (§2.4) means in practice.
|
||||
*/
|
||||
|
||||
import { MAX_CONSIST } from './content.ts';
|
||||
import type {
|
||||
GridCoord,
|
||||
OfficeArea,
|
||||
RollingStock,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ports and geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The four edges of a card. East is the player's right, west their left (§2.4). */
|
||||
export type Port = 'n' | 's' | 'e' | 'w';
|
||||
|
||||
export function opposite(p: Port): Port {
|
||||
switch (p) {
|
||||
case 'n':
|
||||
return 's';
|
||||
case 's':
|
||||
return 'n';
|
||||
case 'e':
|
||||
return 'w';
|
||||
case 'w':
|
||||
return 'e';
|
||||
}
|
||||
}
|
||||
|
||||
/** The grid neighbour across a given edge. Row increases northward. */
|
||||
export function neighbour(c: GridCoord, p: Port): GridCoord {
|
||||
switch (p) {
|
||||
case 'n':
|
||||
return { row: c.row + 1, col: c.col };
|
||||
case 's':
|
||||
return { row: c.row - 1, col: c.col };
|
||||
case 'e':
|
||||
return { row: c.row, col: c.col + 1 };
|
||||
case 'w':
|
||||
return { row: c.row, col: c.col - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
type PortPair = readonly [Port, Port];
|
||||
|
||||
/**
|
||||
* Which ports a card joins internally.
|
||||
*
|
||||
* - **straight / limits** — a plain through track.
|
||||
* - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist;
|
||||
* `w-n` deliberately does not. That absence is §A.1's rule.
|
||||
* - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around
|
||||
* standing cars. §A.5's facing-point move is impossible without one.
|
||||
* - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through
|
||||
* track. These are plain junctions, so unlike a turnout there is no missing pair. North and south
|
||||
* are not joined to each other — that would be crossing the running track.
|
||||
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
|
||||
* than a separate port.
|
||||
*/
|
||||
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
switch (card.geometry.kind) {
|
||||
case 'limits':
|
||||
return [['e', 'w']];
|
||||
case 'facility':
|
||||
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
||||
case 'office':
|
||||
return [
|
||||
['e', 'w'],
|
||||
['e', 'n'],
|
||||
['w', 'n'],
|
||||
['e', 's'],
|
||||
['w', 's'],
|
||||
];
|
||||
case 'track':
|
||||
switch (card.geometry.geometry) {
|
||||
case 'straight':
|
||||
return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
|
||||
case 'turnout': {
|
||||
const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
|
||||
// stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
|
||||
return [
|
||||
[o.stem, o.through],
|
||||
[o.stem, o.diverge],
|
||||
];
|
||||
}
|
||||
case 'runAround': {
|
||||
const b = card.geometry.bypass ?? 'n';
|
||||
return [
|
||||
['e', 'w'],
|
||||
['e', b],
|
||||
['w', b],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A turnout with no stated orientation takes this one. */
|
||||
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orientation (Gap 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**:
|
||||
* a track card is generic and the player decides how to lay it.
|
||||
*
|
||||
* The measurement that settled it: with orientation fixed, an east-west straight has no north or
|
||||
* south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew
|
||||
* only sideways and downward — never upward — purely as an artifact of the default. That silently
|
||||
* undid Gap 8 and put Appendix A's switching puzzle out of reach.
|
||||
*
|
||||
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
|
||||
* rotation is free.
|
||||
*/
|
||||
export type TrackVariant = {
|
||||
axis?: 'ew' | 'ns';
|
||||
turnout?: TurnoutOrientation;
|
||||
bypass?: Port;
|
||||
};
|
||||
|
||||
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
|
||||
{ stem: 'e', through: 'w', diverge: 'n' },
|
||||
{ stem: 'e', through: 'w', diverge: 's' },
|
||||
{ stem: 'w', through: 'e', diverge: 'n' },
|
||||
{ stem: 'w', through: 'e', diverge: 's' },
|
||||
{ stem: 'n', through: 's', diverge: 'e' },
|
||||
{ stem: 's', through: 'n', diverge: 'w' },
|
||||
];
|
||||
|
||||
export function variantsFor(geometry: 'straight' | 'turnout' | 'runAround'): TrackVariant[] {
|
||||
switch (geometry) {
|
||||
case 'straight':
|
||||
return [{ axis: 'ew' }, { axis: 'ns' }];
|
||||
case 'turnout':
|
||||
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
||||
case 'runAround':
|
||||
return [{ bypass: 'n' }, { bypass: 's' }];
|
||||
}
|
||||
}
|
||||
|
||||
/** Facility and Office cards have fixed geometry; only plain track rotates. */
|
||||
export function facilityVariants(): TrackVariant[] {
|
||||
return [{ axis: 'ew' }, { axis: 'ns' }];
|
||||
}
|
||||
|
||||
/** Ports reachable from `from` within this card, never including `from` itself. */
|
||||
export function exitsFrom(card: TrackCard, from: Port): Port[] {
|
||||
const out: Port[] = [];
|
||||
for (const [a, b] of connectionsFor(card)) {
|
||||
if (a === from && b !== from) out.push(b);
|
||||
else if (b === from && a !== from) out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hasPort(card: TrackCard, p: Port): boolean {
|
||||
return connectionsFor(card).some(([a, b]) => a === p || b === p);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Occupancy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Occupancy = {
|
||||
/** Which tray sits on a given card, if any. */
|
||||
trayAt(coord: GridCoord): TrayId | null;
|
||||
/** Free A/D tracks at the Office card, for the pass-through allowance (§A.4). */
|
||||
freeAdTracks(): number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Move reachability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MoveStep = { coord: GridCoord; entry: Port; exit: Port };
|
||||
|
||||
export type MoveDestination = {
|
||||
coord: GridCoord;
|
||||
/** The port the train arrived through; its new facing is the opposite. */
|
||||
entry: Port;
|
||||
path: MoveStep[];
|
||||
/** Standing cars coupled along the way, in the order encountered (§A.4). */
|
||||
couples: RollingStock[];
|
||||
};
|
||||
|
||||
export type MoveContext = {
|
||||
area: OfficeArea;
|
||||
occupancy: Occupancy;
|
||||
/** Cars already in the tray; coupling may not push the consist past four (§A.4). */
|
||||
consistSize: number;
|
||||
/** The tray making the move, so it does not block itself. */
|
||||
self: TrayId;
|
||||
};
|
||||
|
||||
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
|
||||
return area.grid.get(coordKey(c));
|
||||
}
|
||||
|
||||
function sameCoord(a: GridCoord, b: GridCoord): boolean {
|
||||
return a.row === b.row && a.col === b.col;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card a tray may finish a single Move on, starting from `start` and leaving through
|
||||
* `initialExit`.
|
||||
*
|
||||
* A Move travels any distance without changing direction (§2.4) and must finish on Operational
|
||||
* Rail (§A.1 — a turnout carries no wheel icon, so a train may pass through but not stop). Cars
|
||||
* met along the way are coupled automatically and mandatorily; you may not go around them (§A.4).
|
||||
*
|
||||
* Direction is expressed by which port the train first leaves through. Reversing is a separate
|
||||
* Move with the opposite initial exit, which is why §A.5's worked examples spend a Move on each
|
||||
* change of direction.
|
||||
*/
|
||||
export function reachableDestinations(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
const { area, occupancy } = ctx;
|
||||
const startCard = cardAt(area, start);
|
||||
if (!startCard) return [];
|
||||
if (!hasPort(startCard, initialExit)) return [];
|
||||
|
||||
const results: MoveDestination[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||
|
||||
const first = neighbour(start, initialExit);
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
const card = cardAt(area, node.coord);
|
||||
if (!card) continue;
|
||||
|
||||
// Two trains may not share a card or move through each other (§A.4). The Office track is the
|
||||
// exception: while it has free A/D tracks you may enter and pass through.
|
||||
const occupant = occupancy.trayAt(node.coord);
|
||||
if (occupant !== null && occupant !== ctx.self) {
|
||||
const isOffice = sameCoord(node.coord, area.officeCoord);
|
||||
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
|
||||
}
|
||||
|
||||
if (!hasPort(card, node.entry)) continue;
|
||||
|
||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
const couples = [...node.couples, ...carsOn(card)];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
|
||||
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
}
|
||||
|
||||
for (const exit of exitsFrom(card, node.entry)) {
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
queue.push({
|
||||
coord: neighbour(node.coord, exit),
|
||||
entry: opposite(exit),
|
||||
path: [...node.path, step],
|
||||
couples,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Both directions at once — what the UI highlights when a tray is selected. */
|
||||
export function allReachable(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
facing: Port,
|
||||
): { forward: MoveDestination[]; reverse: MoveDestination[] } {
|
||||
return {
|
||||
forward: reachableDestinations(ctx, start, facing),
|
||||
reverse: reachableDestinations(ctx, start, opposite(facing)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gap 4a — a placed card must connect to existing track. Gap 8 gives the Office card junction
|
||||
* stubs above and below so this is satisfiable from the opening Stage.
|
||||
*/
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
if (cardAt(area, coord)) return false;
|
||||
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
if (!hasPort(card, p)) continue;
|
||||
const neighbourCard = cardAt(area, neighbour(coord, p));
|
||||
if (neighbourCard && hasPort(neighbourCard, opposite(p))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
|
||||
* An industry track also has a finite length (§9.3), so it can be full.
|
||||
*/
|
||||
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
|
||||
if (sameCoord(coord, area.officeCoord)) return false;
|
||||
const card = cardAt(area, coord);
|
||||
if (!card || !isOperationalRail(card)) return false;
|
||||
return spaceOn(card) >= count;
|
||||
}
|
||||
+458
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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) => ({'&':'&','<':'<','>':'>'}[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) => ({ '&': '&', '<': '<', '>': '>' })[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}`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user