Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+602
View File
@@ -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 };