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