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 };
+1045
View File
File diff suppressed because it is too large Load Diff
+465
View File
@@ -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 },
];
}
+60
View File
@@ -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'];
+95
View File
@@ -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 };
+193
View File
@@ -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;
}
+75
View File
@@ -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);
}
+252
View File
@@ -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 };
+404
View File
@@ -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);
}
+339
View File
@@ -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;
}