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 };
|
||||
Reference in New Issue
Block a user