722 lines
30 KiB
TypeScript
722 lines
30 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,
|
|
HouseRuleOverrides,
|
|
ModifierKind,
|
|
OfficeTier,
|
|
TrackGeometry,
|
|
} from './content.ts';
|
|
import { officeProfile } from './content.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identifiers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type PlayerIndex = number;
|
|
|
|
/**
|
|
* A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3).
|
|
*
|
|
* NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An
|
|
* Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat,
|
|
* and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while
|
|
* their Revenue and the Fedora travel with them.
|
|
*
|
|
* So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent
|
|
* and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain
|
|
* numbers, so the distinction is carried by naming and by the accessors rather than by the type
|
|
* system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them
|
|
* rather than reaching into `officeAreas` directly.
|
|
*/
|
|
export type SeatIndex = 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 = {
|
|
/** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */
|
|
seat: SeatIndex;
|
|
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 }
|
|
/** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */
|
|
| { at: 'grid'; seat: SeatIndex; 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';
|
|
/**
|
|
* WHICH WAY THE ENGINE POINTS IN RAILROAD TERMS — east or west, and never anything else.
|
|
*
|
|
* `facing` is a PORT, because movement needs one: a crew on a north-south spur must be able to
|
|
* leave by 'n' or 's'. But a Division runs east and west, and a player reads a train the way a
|
|
* railroader does — "the engine is on the west end" — so a ▲ on a crew that had turned onto a
|
|
* spur read as a train that had somehow stood itself on end. Worse, nothing resets `facing` when
|
|
* a train leaves the district, so a crew that shunted onto a north-south spur and then departed
|
|
* carried its 'n' out onto the Division map and drew ▲ there too.
|
|
*
|
|
* So this is the DISPLAY facing, and it is a separate field because it cannot be derived: on
|
|
* north-south track the east-west sense is not in the current port, it is in the last one. It
|
|
* holds its value across north-south track and updates whenever `facing` becomes 'e' or 'w' —
|
|
* which is exactly the railroad's own convention, where compass north on a branch is still
|
|
* timetable east. A train that runs forward through 180° of curves genuinely does come out
|
|
* pointing the other way, and this follows it; backing up does not change it, because a train
|
|
* that backs up has not turned around.
|
|
*
|
|
* NOT `direction`: that is the timetable direction of the RUN and does not move when a run-around
|
|
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
|
*/
|
|
railFacing?: '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;
|
|
/**
|
|
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
|
|
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
|
|
* Load/Unload, which is what gives the Porters their one Stage with the train.
|
|
*/
|
|
departsThisStage?: 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'; seat: SeatIndex };
|
|
|
|
/** 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;
|
|
};
|
|
/**
|
|
* The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`).
|
|
*
|
|
* Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the
|
|
* engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place
|
|
* a default is written down. A caller that cares names only the dials it is setting.
|
|
*/
|
|
houseRules?: HouseRuleOverrides;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* One turn per player, created together at phase entry.
|
|
*
|
|
* This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the
|
|
* table. That is indistinguishable from this while only the player at the cursor may act — which is
|
|
* exactly the case today, and every existing test still describes the same game.
|
|
*
|
|
* It is per-player now because making it so later would mean the same change PLUS reworking a client
|
|
* built around "wait your turn". What it enables is Local Operations work that no other player can
|
|
* observe — switching inside your own district — happening off-cursor, which is a change to
|
|
* `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19.
|
|
*/
|
|
export function freshTurns(players: number, moves: number): Map<PlayerIndex, TurnState> {
|
|
const turns = new Map<PlayerIndex, TurnState>();
|
|
for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves));
|
|
return turns;
|
|
}
|
|
|
|
/**
|
|
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
|
*
|
|
* The single place the display facing is decided, so the Division map, the Office cards and the
|
|
* tooltips can never disagree about which end of a train the engine is on. Three sources, in the
|
|
* order they can be trusted: the carried east-west sense; the current port, when it happens to be
|
|
* an east-west one (a tray placed straight onto the board has no history yet); and failing both,
|
|
* the direction of the run.
|
|
*/
|
|
export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'direction'>): 'e' | 'w' {
|
|
if (tray.railFacing) return tray.railFacing;
|
|
if (tray.facing === 'e' || tray.facing === 'w') return tray.facing;
|
|
return tray.direction === 'west' ? 'w' : 'e';
|
|
}
|
|
|
|
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
|
const t = s.turns.get(player);
|
|
if (!t) throw new Error(`no turn state for player ${player}`);
|
|
return t;
|
|
}
|
|
|
|
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[];
|
|
/**
|
|
* Who is sitting where: `seating[seat] = player`, seat 0 at the WESTERN end of the chain.
|
|
*
|
|
* Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity,
|
|
* except in solitaire where one player means one seat. Employee Rotation would rotate this array
|
|
* and nothing else.
|
|
*/
|
|
seating: PlayerIndex[];
|
|
/**
|
|
* The two opening D12s, per player, kept so a client can show the rolls rather than only their
|
|
* outcome — it is the game's first moment of drama (`lobby-and-sessions.md` §4). Indexed by
|
|
* PLAYER, since that is who rolls.
|
|
*/
|
|
openingRolls: { division: number[]; superintendent: number[] };
|
|
division: Division;
|
|
officeAreas: Map<SeatIndex, 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;
|
|
/** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */
|
|
turns: Map<PlayerIndex, 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.seat));
|
|
|
|
if (isBoundary) {
|
|
if (current.length > 0) out.push(current);
|
|
current = [];
|
|
} else {
|
|
current.push(i);
|
|
}
|
|
});
|
|
|
|
if (current.length > 0) out.push(current);
|
|
return out;
|
|
}
|
|
|
|
/** Who is sitting at this seat. */
|
|
export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
|
const p = state.seating[seat];
|
|
if (p === undefined) throw new Error(`no player at seat ${seat}`);
|
|
return p;
|
|
}
|
|
|
|
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
|
/**
|
|
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
|
*
|
|
* Acting order, the deal and the Fedora are all "starting here and proceeding left" (Gap 1, §4.7,
|
|
* §5), which is a statement about the physical chain of Offices — so it is seat arithmetic, not
|
|
* player arithmetic. All three used to do `(player + n) % players`, which was the same thing only
|
|
* while seating was the identity mapping. It stopped being that when §4.4's D12 started deciding
|
|
* who sits where.
|
|
*
|
|
* "Left" is increasing seat index, i.e. eastward along the chain, matching what the shift-change
|
|
* tests have always asserted.
|
|
*/
|
|
export function playerLeftOf(state: GameState, player: PlayerIndex, n = 1): PlayerIndex {
|
|
return playerAtSeat(state, (seatOf(state, player) + n) % state.seating.length);
|
|
}
|
|
|
|
export function seatOf(state: GameState, player: PlayerIndex): SeatIndex {
|
|
const seat = state.seating.indexOf(player);
|
|
if (seat < 0) throw new Error(`player ${player} is not seated`);
|
|
return seat;
|
|
}
|
|
|
|
export function isControlPoint(state: GameState, seat: SeatIndex): boolean {
|
|
const area = state.officeAreas.get(seat);
|
|
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
|
return officeProfile(area.tier).isControlPoint;
|
|
}
|
|
|
|
export function adTrackCount(state: GameState, seat: SeatIndex): number {
|
|
const area = state.officeAreas.get(seat);
|
|
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
|
return officeProfile(area.tier).adTracks;
|
|
}
|
|
|
|
export function totalRevenue(state: GameState): number {
|
|
return state.players.reduce((n, p) => n + p.revenue, 0);
|
|
}
|