464 lines
16 KiB
TypeScript
464 lines
16 KiB
TypeScript
/**
|
||
* 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,
|
||
MainlineKind,
|
||
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;
|
||
/** Curves are printed left- or right-handed (design supply); not chosen on placement. */
|
||
hand?: 'left' | 'right';
|
||
}
|
||
| { kind: 'office' }
|
||
| { kind: 'limits' }
|
||
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
|
||
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
||
| { kind: 'modifier'; modifier: ModifierKind }
|
||
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
||
| { kind: 'spaceUse'; key: string };
|
||
|
||
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[];
|
||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||
enhancements: string[];
|
||
};
|
||
|
||
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[];
|
||
/**
|
||
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
|
||
* inside the player's Limits but not occupying an A/D track.
|
||
*/
|
||
heldAtLimits: TrayId[];
|
||
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
|
||
dispatchUsedToday: string[];
|
||
/**
|
||
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
|
||
* Home Office deck — it is laid from here.
|
||
*/
|
||
trackSupply: Map<string, number>;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 }
|
||
| { 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
|
||
* regions the placeholder used are gone — the cells printed on the cards are decoration.
|
||
*/
|
||
export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction };
|
||
|
||
export type DivisionNode =
|
||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
||
| {
|
||
kind: 'mainline';
|
||
card: MainlineKind;
|
||
transits: Transit[];
|
||
absSignals?: boolean;
|
||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||
modifiers?: string[];
|
||
/** Red Flags protecting a stopped train here, by tray. */
|
||
redFlagged?: TrayId[];
|
||
}
|
||
| { 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 }
|
||
/**
|
||
* The four categories recovered from the design. They are in the deck so the composition is
|
||
* right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so
|
||
* `check` rejects playing them and they can only be discarded.
|
||
*/
|
||
| { kind: 'spaceUse'; key: string }
|
||
| { kind: 'enhancement'; key: string }
|
||
| { kind: 'mainlineModifier'; key: string }
|
||
| { kind: 'maneuver'; key: string }
|
||
| { kind: 'action'; key: string };
|
||
|
||
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;
|
||
/**
|
||
* Track was a CARD before it became a per-player supply, so it was naturally limited to one play
|
||
* a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
|
||
*/
|
||
laidThisTurn: 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,
|
||
laidThisTurn: 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)[];
|
||
/**
|
||
* §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled:
|
||
* it runs once, immediately, then its card goes to the Salvage Yard (§2.3).
|
||
*/
|
||
pendingExtras: number[];
|
||
/**
|
||
* Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an
|
||
* identical train behind the first, if a Crew Tray is free.
|
||
*/
|
||
pendingSecondSections: number[];
|
||
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);
|
||
}
|