Files
station-master/src/engine/state.ts
T

464 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}