Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+404
View File
@@ -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);
}