Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+52 -17
View File
@@ -6,20 +6,25 @@
*/
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
EXTRA_TRAINS,
FREIGHT_PROFILES,
MAINLINE_MODIFIER_CARDS,
MANEUVER_CARDS,
SPACE_USE_CARDS,
MOVES_PER_LOCAL_OPS,
MODIFIER_PROFILES,
OFFICE_PROFILES,
REGIONS_PER_MAINLINE_CARD,
MAINLINE_PROFILES,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
TRACK_PROFILES,
crewTrayCount,
mainlineCardCount,
officeProfile,
} from './content.ts';
import type { Rng } from './rng.ts';
import { createRng } from './rng.ts';
import type {
Card,
@@ -29,7 +34,6 @@ import type {
GameState,
OfficeArea,
PlayerIndex,
Region,
RollingStock,
TrackCard,
TrayId,
@@ -44,7 +48,10 @@ export type SetupOptions = {
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(): Card[] {
export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
// Q6 — the 22 opponent-directed cards have no legal target in a one-player game, so a solitaire
// deck omits them entirely rather than leaving 19% of draws dead.
const solitaire = mode === 'solitaire';
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
@@ -59,10 +66,29 @@ export function buildDeck(): Card[] {
for (const f of FREIGHT_PROFILES) {
for (let i = 0; i < f.copies; i++) push({ kind: 'freightFacility', facility: f.kind });
}
for (const m of MODIFIER_PROFILES) push({ kind: 'modifier', modifier: m.kind });
for (const t of TRACK_PROFILES) {
for (let i = 0; i < t.copies; i++) push({ kind: 'track', geometry: t.geometry });
for (const m of MODIFIER_PROFILES) {
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
}
if (!solitaire) {
for (const c of SPACE_USE_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
}
}
for (const c of ENHANCEMENT_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key });
}
for (const c of MAINLINE_MODIFIER_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'mainlineModifier', key: c.key });
}
for (const c of MANEUVER_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'maneuver', key: c.key });
}
if (!solitaire) {
for (const c of ACTION_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key });
}
}
// Track is NOT in the deck any more — it is a per-player supply (content.ts TRACK_SUPPLY).
return cards;
}
@@ -127,7 +153,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility },
outboundBox: [],
inboundBox: [],
capacity: { outbound: p.greenSlots, inbound: p.redSlots },
capacity: { outbound: p.passengerOut, inbound: p.passengerIn },
menAtWork: [null, null, null],
industryTrack: { length: 0, cars: [] },
laborers: 0,
@@ -136,14 +162,21 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
};
}
/** §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. */
function buildDivision(players: number): DivisionNode[] {
/**
* §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end.
*
* Each Mainline card is now a TYPE drawn at random (§4.2, "a Mainline card is drawn at random and
* placed between each player"), which is what gives the Division its terrain and therefore its
* crossing times.
*/
function buildDivision(players: number, rng: Rng): DivisionNode[] {
const nodes: DivisionNode[] = [];
const mainline = (): DivisionNode => {
const regions: Region[] = [];
for (let i = 0; i < REGIONS_PER_MAINLINE_CARD; i++) regions.push({ occupant: null });
return { kind: 'mainline', regions };
};
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
const mainline = (): DivisionNode => ({
kind: 'mainline',
card: kinds[rng.nextInt(kinds.length)]!,
transits: [],
});
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
@@ -179,7 +212,7 @@ export function createGame(opts: SetupOptions): GameState {
void divisionRolls; // seating is fixed by array order; the roll is recorded for the event log
// §4.6-4.7 - shuffle, deal three each, then turn three face-up as the Department slots.
const deck = buildDeck();
const deck = buildDeck(config.mode);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
@@ -215,7 +248,7 @@ export function createGame(opts: SetupOptions): GameState {
seed,
rngState: rng.getState(),
players,
division: { nodes: buildDivision(playerCount) },
division: { nodes: buildDivision(playerCount, rng) },
officeAreas,
trays: new Map(),
freeTrays,
@@ -223,6 +256,8 @@ export function createGame(opts: SetupOptions): GameState {
decks: { homeOffice, departments, salvageYard: [], hands, redFlags },
yards: { divisionYard: buildRollingStock(), classificationYard: [] },
timetable,
pendingExtras: [],
pendingSecondSections: [],
clock: {
day: 1,
stage: 1,