Expand game engine, replay, tests, and documentation
This commit is contained in:
+52
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user