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
+252
View File
@@ -0,0 +1,252 @@
/**
* Game setup — rules §4.
*
* Builds the initial state deterministically from a seed. Part of component 2; the D12 rolls
* come from component 7.
*/
import {
EXTRA_TRAINS,
FREIGHT_PROFILES,
MOVES_PER_LOCAL_OPS,
MODIFIER_PROFILES,
OFFICE_PROFILES,
REGIONS_PER_MAINLINE_CARD,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
TRACK_PROFILES,
crewTrayCount,
mainlineCardCount,
officeProfile,
} from './content.ts';
import { createRng } from './rng.ts';
import type {
Card,
CardId,
DivisionNode,
GameConfig,
GameState,
OfficeArea,
PlayerIndex,
Region,
RollingStock,
TrackCard,
TrayId,
} from './state.ts';
import { coordKey, freshTurn } from './state.ts';
export type SetupOptions = {
id: string;
seed: number;
config: GameConfig;
playerNames: string[];
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(): Card[] {
const cards: Card[] = [];
let n = 0;
const push = (kind: Card['kind']): void => {
cards.push({ id: `c${n++}`, kind });
};
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
for (const o of OFFICE_PROFILES) {
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
}
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 });
}
return cards;
}
/** The Division Yard starts with every rolling stock piece (§4.9). */
export function buildRollingStock(): RollingStock[] {
const out: RollingStock[] = [];
for (const s of ROLLING_STOCK_SUPPLY) {
for (let i = 0; i < s.loaded; i++) out.push({ type: s.type, loaded: true });
for (let i = 0; i < s.empty; i++) out.push({ type: s.type, loaded: false });
}
return out;
}
/**
* A player's opening three cards: Limits, Whistle Post, Limits, in one horizontal row (§4.2).
*
* Gap 8 — the Office card carries a plain junction stub above and below, so Secondary Track can
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only.
*/
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
const row = 0;
const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 };
const limitsEast = { row, col: 1 };
const officeCard: TrackCard = {
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
};
const limitsCard = (): TrackCard => ({
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
});
const grid = new Map<string, TrackCard>();
grid.set(coordKey(officeCoord), officeCard);
grid.set(coordKey(limitsWest), limitsCard());
grid.set(coordKey(limitsEast), limitsCard());
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
}
/**
* A Whistle Post is not a Passenger Facility (§9), so it has no Porters and no slots — but the
* Office card still carries a facility record so an upgrade is a property change rather than a
* card swap (game-state.md constraint 9).
*/
function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonNullable<TrackCard['facility']> {
const p = officeProfile(tier);
return {
kind: 'passenger',
subtype: 'office',
allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility },
outboundBox: [],
inboundBox: [],
capacity: { outbound: p.greenSlots, inbound: p.redSlots },
menAtWork: [null, null, null],
industryTrack: { length: 0, cars: [] },
laborers: 0,
porters: p.porters,
usedThisStage: { laborers: 0, porters: 0 },
};
}
/** §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end. */
function buildDivision(players: number): 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 };
};
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
nodes.push(mainline());
nodes.push({ kind: 'office', owner: p });
}
nodes.push(mainline());
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
return nodes;
}
export function createGame(opts: SetupOptions): GameState {
const { id, seed, config, playerNames } = opts;
if (playerNames.length < 1) throw new Error('a game needs at least one player');
if (config.mode === 'solitaire' && playerNames.length !== 1) {
throw new Error('solitaire is a one-player mode');
}
const rng = createRng(seed);
const playerCount = playerNames.length;
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
const officeAreas = new Map<PlayerIndex, OfficeArea>();
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
const divisionRolls = players.map(() => rng.d12());
const superRolls = players.map(() => rng.d12());
const superintendent = argmax(superRolls);
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 cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
const shuffled = rng.shuffle(deck.map((c) => c.id));
const hands = new Map<PlayerIndex, CardId[]>();
let cursor = 0;
for (let i = 0; i < playerCount; i++) {
const p = (superintendent + i) % playerCount;
hands.set(p, shuffled.slice(cursor, cursor + 3));
cursor += 3;
}
const departments: (CardId | null)[] = [
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
];
const homeOffice = shuffled.slice(cursor);
const redFlags = new Map<PlayerIndex, boolean>();
for (let p = 0; p < playerCount; p++) {
redFlags.set(p, config.optionalRules.emergencyToolbox);
}
// §4.9 - Crew Trays near the turn sheet. Scarcity is an explicit mechanic (§7).
const freeTrays: TrayId[] = [];
for (let i = 0; i < crewTrayCount(playerCount); i++) freeTrays.push(`tray${i}`);
const timetable: (number | null)[] = new Array(STAGES_PER_DAY).fill(null);
return {
id,
config,
seed,
rngState: rng.getState(),
players,
division: { nodes: buildDivision(playerCount) },
officeAreas,
trays: new Map(),
freeTrays,
cards,
decks: { homeOffice, departments, salvageYard: [], hands, redFlags },
yards: { divisionYard: buildRollingStock(), classificationYard: [] },
timetable,
clock: {
day: 1,
stage: 1,
phase: 'localOps',
currentActor: superintendent,
pendingDecision: null,
clearanceRuling: null,
superintendent,
actorOffset: 0,
},
turn: freshTurn(MOVES_PER_LOCAL_OPS),
movedThisPhase: new Set(),
collisionsToday: 0,
status: 'active',
outcome: null,
};
}
function argmax(values: number[]): number {
let best = 0;
for (let i = 1; i < values.length; i++) {
if (values[i]! > values[best]!) best = i;
}
return best;
}
export { mainlineCardCount };