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

583 lines
22 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,
Hand,
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.
*
* The through track is ALWAYS east-west (`{stem, through}` is `{e,w}`); the diverging leg is the
* 45° one and always reaches north or south. Which of the two the card can be turned to is decided
* by its printed handedness — see `Slope`.
*/
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };
/**
* A curve joins two ADJACENT edges: it runs along the card's centre line from the east or west edge
* to a frog, then leaves at 45° through the MIDDLE of the north or south edge. Four such arcs
* exist, but a printed card reaches only two of them (`Slope`).
*/
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
/**
* WHICH DIAGONAL A 45° LEG LIES ON.
*
* The printed cards (docs/tracks.png) put the through rail dead centre and send every diverging leg
* out at 45° through the middle of the north or south edge. A card can be turned 180° but not
* flipped over, so its leg never changes diagonal: the slope is printed, not chosen.
*
* Each name is the pair of arcs that MATE across a horizontal card edge — an `sw` card sitting
* above an `ne` card is one unbroken rail, whereas `sw` above `nw` is a V and joins nothing. That
* is the whole matching rule, and it is why the name is spelt this way.
*
* On screen `ne_sw` descends to the right and `nw_se` descends to the left.
*/
export type Slope = 'ne_sw' | 'nw_se';
export type CardGeometry =
| {
kind: 'track';
geometry: TrackGeometry;
turnout?: TurnoutOrientation;
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
arc?: TrackArc;
/** Curves and turnouts are printed left- or right-handed, which fixes their `Slope`. */
hand?: 'left' | 'right';
}
| { kind: 'office' }
| { kind: 'limits' }
| { kind: 'facility'; facility: FreightKind }
/** 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[];
};
// ---------------------------------------------------------------------------
// 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 — `null` on a Passenger Facility, which is what makes that unrepresentable rather
* than merely unused.
*
* One load per box; three boxes, so it is a pipeline (§9.1). §9.2's passenger work is porters
* boarding and detraining with no pipeline at all, and the sign is printed "For Freight
* Facilities" (rules-v0.2.md:452). Every Office got a three-slot array anyway, and because it was
* an array the renderers happily drew three boxes on a Depot that has no Laborer to work them —
* reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug.
*/
menAtWork: [Load | null, Load | null, Load | null] | 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 (isLockedByWork(card)) return false;
return true;
}
/**
* §9.3 — "While ANY loads are in the MEN | AT | WORK track, the industry's track is locked down for
* safety reasons. It loses its status as Operational Rail. No cars can be picked up or dropped off,
* and **no trains may occupy or move on it**."
*
* That last clause is stronger than losing Operational Rail status. A turnout is not Operational
* Rail either, and a train runs straight through one — so "cannot stop here" and "cannot pass
* through here" are different properties, and only a locked industry has both. Kept separate from
* `isOperationalRail` for exactly that reason.
*/
export function isLockedByWork(card: TrackCard): boolean {
// A Passenger Facility has no pipeline, so it is never locked by freight work.
return !!card.facility?.menAtWork?.some((slot) => slot !== null);
}
// ---------------------------------------------------------------------------
// 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;
/**
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
*
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead
* of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle
* doing both at once. That last case is why this is an index and not the boolean it replaced —
* `engineFront` was written in three places and read in none, so the engine had a position the
* game recorded and never used.
*
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
*/
engineAt: number;
/**
* ORDERED NOSE FIRST: index 0 is the end nearest the front of the train, the last index is the
* tail. Max 4 including any caboose (§A.4); the engine is not one of them.
*
* Both diagrams in Appendix A read this way. A train drawn `[<ENG][yellow][brown][blue][red]`
* drops "red as he starts, then brown and blue", ending with only yellow — cars come off the TAIL,
* which is the end of the array. And a train that picks cars up on its nose gets them "in the
* order that they were in, pushing them into the Facility" — ahead of the engine, which is why
* `engineAt` moves when it happens.
*/
consist: RollingStock[];
direction: Direction;
/**
* Which way the engine points, as an actual port on the card beneath it.
*
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
* without one falls back to `direction`.
*/
facing?: 'n' | 's' | 'e' | 'w';
position: NodeRef;
movesUsed: number;
/**
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
*
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
* runs once and is gone; there is no second visit to claim it on.
*/
stopPointClaimed?: boolean;
/**
* X17 Campaign Train — "one turn at station (speeches) then expedite".
*
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
* once, so there is no later visit to hang it on.
*/
speechMade?: boolean;
};
// ---------------------------------------------------------------------------
// 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.
*/
/**
* A train crossing a Mainline card.
*
* `stagesTotal` is what the crossing cost when the train ENTERED, and it exists so the Division map
* can say where on the card a train is. §2.1 divides a Mainline card into two regions and §8.2 has
* a train move one region per Stage; the engine models crossing as a countdown of Stages instead,
* so position has to be derived — and it cannot be recomputed later, because a modifier played onto
* the card mid-crossing would change the answer and make the train appear to jump backwards.
*
* Read by nothing that decides anything: no legality check, no movement, no bot. It records what
* already happened so the picture can be drawn.
*/
export type Transit = {
tray: TrayId;
stagesRemaining: number;
stagesTotal: 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[];
/**
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
* "Player sets orientation", so the direction is chosen when the card is placed.
*/
gradeUp?: Direction;
/** 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 }
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */
| { kind: 'track'; geometry: TrackGeometry; hand: Hand }
/**
* 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[];
/**
* THE THREE DEPARTMENT DECKS — shared by every player, and stacks rather than single slots.
*
* §6.2: a discard goes "face up ON TOP of one of the three Department slots", and a draw takes
* "the TOP face-up card". Both phrases only mean anything if a Department is a pile, and modelling
* them as one-card slots silently DESTROYED whatever was underneath — discarding onto an occupied
* slot overwrote it, leaking cards out of a closed deck.
*
* Last element is the top of the pile: the card that is face up, the only one that may be drawn,
* and the one a discarding player is choosing to offer or to bury.
*/
departments: CardId[][];
/** 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;
/**
* Trains 3/4 Express — "may drop or pick up ONE freight car at every location".
*
* Keyed `trayId@row,col`, counting freight cars that train has exchanged on that square this turn.
* Per LOCATION rather than per turn, so the Express can work its way along a district a car at a
* time — which is what makes it an Express rather than a train that may move one car a Stage.
* Cleared with the rest of the turn.
*/
freightWorked: Record<string, number>;
/** 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,
freightWorked: {},
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);
}