Expand game engine, replay, tests, and documentation
This commit is contained in:
+146
-32
@@ -18,13 +18,14 @@
|
||||
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
EXTRA_TRAINS,
|
||||
MAINLINE_PROFILES,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
TIMETABLED_TRAINS,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
@@ -33,7 +34,7 @@ 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 type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
@@ -50,10 +51,7 @@ function movesForStage(s: GameState): number {
|
||||
: 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
|
||||
@@ -173,7 +171,8 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
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.
|
||||
// "Held train" — no crew available. It waits; the Stage moves on (§7).
|
||||
events.push({ type: 'trainHeld', trainNumber: due, reason: 'no free Crew Tray' });
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
const profile = trainProfile(due, false);
|
||||
@@ -195,10 +194,78 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
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` });
|
||||
events.push({
|
||||
type: 'trainMadeUp',
|
||||
trainNumber: due,
|
||||
isExtra: false,
|
||||
at: side === 'west' ? 'West Division Point' : 'East Division Point',
|
||||
direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Q9 — a second section runs immediately behind its first, with the same number, rules and
|
||||
// consist. It needs its own Crew Tray, and because it follows a train of the same number into
|
||||
// the same Subdivision it will usually force the §8.1 clearance decision.
|
||||
if (s.pendingSecondSections.length > 0 && s.freeTrays.length > 0) {
|
||||
const number = s.pendingSecondSections.shift()!;
|
||||
const profile = trainProfile(number, false);
|
||||
if (profile) {
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
const direction: Direction = profile.direction === 'east' ? 'east' : 'west';
|
||||
const side: Direction = direction === 'east' ? 'west' : 'east';
|
||||
s.trays.set(trayId, {
|
||||
id: trayId,
|
||||
trainNumber: number,
|
||||
trainIsExtra: false,
|
||||
engineFront: true,
|
||||
consist: [],
|
||||
direction,
|
||||
position: { at: 'divisionPoint', side },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
events.push({
|
||||
type: 'trainMadeUp',
|
||||
trainNumber: number,
|
||||
isExtra: false,
|
||||
at: side === 'west' ? 'West Division Point' : 'East Division Point',
|
||||
direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// §7 — "Once all timetabled trains are created, if there is a played Extra Train card and an
|
||||
// available Crew Tray, the player who played the card may place the Crew Tray in either division
|
||||
// point for immediate departure." An Extra runs once and is gone.
|
||||
//
|
||||
// SIMPLIFICATION, flagged: the rules let the player choose the direction. This launches eastbound
|
||||
// from the West Division Point. Giving the player the choice needs its own intent.
|
||||
if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) {
|
||||
const number = s.pendingExtras.shift()!;
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
s.trays.set(trayId, {
|
||||
id: trayId,
|
||||
trainNumber: number,
|
||||
trainIsExtra: true,
|
||||
engineFront: true,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
events.push({
|
||||
type: 'trainMadeUp',
|
||||
trainNumber: number,
|
||||
isExtra: true,
|
||||
at: 'West Division Point',
|
||||
direction: 'east',
|
||||
});
|
||||
}
|
||||
|
||||
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
|
||||
const filling = trainNeedingCars(s);
|
||||
if (filling) {
|
||||
@@ -223,10 +290,13 @@ function trainNeedingCars(s: GameState): TrayId | null {
|
||||
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);
|
||||
const want = consistSize(profile.consist);
|
||||
if (tray.consist.length >= want) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it.
|
||||
const allowed = profile.consist.freightTypes;
|
||||
const suitable = s.yards.divisionYard.some(
|
||||
(c) => profile.consist.allowedTypes.includes(c.type) || c.type === 'caboose',
|
||||
(c) => c.type === 'caboose' || c.type === 'coach' || !allowed || allowed.includes(c.type),
|
||||
);
|
||||
if (suitable) return id;
|
||||
}
|
||||
@@ -255,6 +325,11 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
if (s.movedThisPhase.has(id)) continue;
|
||||
const moved = moveTrain(s, id, tray, events);
|
||||
if (moved === 'needsClearance') return { events, needsInput: true };
|
||||
// An expedited train may act twice in one Stage: it arrives and departs (Q3).
|
||||
if (moved === 'expedited') {
|
||||
const again = moveTrain(s, id, tray, events);
|
||||
if (again === 'needsClearance') return { events, needsInput: true };
|
||||
}
|
||||
s.movedThisPhase.add(id);
|
||||
}
|
||||
|
||||
@@ -262,7 +337,27 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
|
||||
}
|
||||
|
||||
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited';
|
||||
|
||||
/** Puts a train onto a Mainline card with its crossing time already computed. */
|
||||
function enterMainline(
|
||||
s: GameState,
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
index: number,
|
||||
): void {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
||||
const stages = crossingStages(node.card, profile?.speed ?? 'slow', carriesPassengers);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||
}
|
||||
|
||||
function moveTrain(
|
||||
s: GameState,
|
||||
@@ -284,9 +379,7 @@ function moveTrain(
|
||||
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 };
|
||||
enterMainline(s, node, id, tray, target);
|
||||
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` });
|
||||
@@ -317,11 +410,14 @@ function moveTrain(
|
||||
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 };
|
||||
enterMainline(s, node, id, tray, target);
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -335,23 +431,24 @@ function moveTrain(
|
||||
}
|
||||
|
||||
if (tray.position.at === 'mainline') {
|
||||
const { index, region } = tray.position;
|
||||
const { index } = 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 };
|
||||
const transit = node.transits.find((t) => t.tray === id);
|
||||
if (!transit) return 'held';
|
||||
|
||||
// Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
|
||||
// Fast/Slow class. Count it down rather than stepping through printed cells.
|
||||
if (transit.stagesRemaining > 1) {
|
||||
transit.stagesRemaining -= 1;
|
||||
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;
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -396,8 +493,12 @@ function evaluateClearance(
|
||||
const node = s.division.nodes[targetIndex];
|
||||
if (!node || node.kind !== 'mainline') return 'clear';
|
||||
|
||||
for (const region of node.regions) {
|
||||
const other = region.occupant;
|
||||
// Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block.
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card);
|
||||
if (profile?.trainsMayPass) return 'clear';
|
||||
|
||||
for (const t of node.transits) {
|
||||
const other = t.tray;
|
||||
if (!other || other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
@@ -442,7 +543,16 @@ function arriveAtOffice(
|
||||
|
||||
area.adOccupancy.push(id);
|
||||
tray.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} arrived` });
|
||||
events.push({
|
||||
type: 'trainArrived',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
consist: tray.consist.map((c) => ({ ...c })),
|
||||
office: officeProfile(area.tier).name,
|
||||
});
|
||||
|
||||
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
|
||||
// movedThisPhase and gets a second chance to move before the phase ends.
|
||||
if (isExpedited(tray)) return 'expedited';
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -491,7 +601,11 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
|
||||
(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` });
|
||||
events.push({
|
||||
type: 'trainCompleted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
consist: tray.consist.map((c) => ({ ...c })),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user