Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user