/** * Game setup — rules §4. * * Builds the initial state deterministically from a seed. Part of component 2; the D12 rolls * come from component 7. */ 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, OPENING_DEALS, MAINLINE_DECK, houseRules, mainlineProfile, TRACK_CARDS, ROLLING_STOCK_SUPPLY, STAGES_PER_DAY, TIMETABLED_TRAINS, crewTrayCount, mainlineCardCount, officeProfile, } from './content.ts'; import type { Rng } from './rng.ts'; import { createRng } from './rng.ts'; import type { Card, CardId, DivisionNode, SeatIndex, GameConfig, GameState, OfficeArea, PlayerIndex, RollingStock, TrackCard, TrayId, } from './state.ts'; import { coordKey, freshTurns } 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(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] { /** * THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now. * * Q6 took them out of solitaire because they have no legal target in a one-player game. They are * out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`: * leaving them in would make ~9% of draws reject outright, which is worse than not dealing them. * `pvpCardsAllowed` (`GameConfig`, wired 2026-08-20) is the player-facing toggle, but ANDing it * with `cardsImplemented` keeps it inert until Phase 5 actually builds the cards — flipping * `pvpCardsAllowed` on today must not turn a working game into one where ~9% of draws reject. * * AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant, * which is the same dead draw by another name. Recorded in TODO.md as multiplayer work. * * `mode` is still taken so the signature does not move when they return; it is deliberately unused * for these two categories today. */ void mode; const cardsImplemented = false; const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented; 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) { for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind }); } if (opponentCardsInDeck) { for (const c of SPACE_USE_CARDS) { for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key }); } } /** * A DEFENCE GOES OUT WITH THE ATTACK IT ANSWERS. * * Facing Point Locks (both kinds), the Water Column and the Overpass exist only to answer Derail, * a Watertower and a Railroad Crossing — all of which are among the 22 opponent-directed cards * held out above. Left in, they are dead draws for exactly the same reason the attacks would be, * and there are 7 of them. `SimpleCard.answers` names the pairing on the card itself, so they come * back together the moment `opponentCardsInDeck` does. */ const dealt = (c: { answers?: string }): boolean => opponentCardsInDeck || !c.answers; for (const c of ENHANCEMENT_CARDS) { if (!dealt(c)) continue; for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key }); } for (const c of MAINLINE_MODIFIER_CARDS) { if (!dealt(c)) continue; 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 (opponentCardsInDeck) { for (const c of ACTION_CARDS) { for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key }); } } // Track is IN the deck, 96 cards of it — the single largest category, and the reason building a // district costs you the industry or train you did not draw instead. for (const t of TRACK_CARDS) { for (let i = 0; i < t.copiesInDeck; i++) push({ kind: 'track', geometry: t.geometry, hand: t.hand }); } 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(seat: SeatIndex): 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: [], standingWest: 0, facility: buildPassengerFacility('whistlePost'), modifiers: [], enhancements: [], }; const limitsCard = (): TrackCard => ({ geometry: { kind: 'limits' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [], }); const grid = new Map(); grid.set(coordKey(officeCoord), officeCard); grid.set(coordKey(limitsWest), limitsCard()); grid.set(coordKey(limitsEast), limitsCard()); return { seat, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [], heldAtLimits: [], dispatchUsedToday: [], }; } /** * 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[0]): NonNullable { const p = officeProfile(tier); return { kind: 'passenger', subtype: 'office', allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility }, outboundBox: [], inboundBox: [], capacity: { outbound: p.passengerOut, inbound: p.passengerIn }, // No freight pipeline: a Depot, Station and Terminal work passengers only, and the MEN | AT | // WORK sign is printed "For Freight Facilities" (rules-v0.2.md:452). This was a three-slot array // of nulls, which read as "three empty boxes" to every renderer that looked. menAtWork: null, industryTrack: { 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. * * 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. */ /** * THE MAINLINE CARDS ARE DEALT FROM A DECK, NOT ROLLED. * * `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so * a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the * deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory * (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the * deal is now a deal: take cards out of it and do not put them back. * * The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is * on the board" only reads as a rule if the board can hold at most one. * * A Division needs `players + 1` cards, so four players draw five from ten and the deck is never * close to exhausted; the throw is there because a silent short Division would be very hard to see. */ function buildDivision(players: number, rng: Rng): DivisionNode[] { const nodes: DivisionNode[] = []; const deck = [...MAINLINE_DECK]; const mainline = (): DivisionNode => { if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it'); const card = deck.splice(rng.nextInt(deck.length), 1)[0]!; const node: DivisionNode = { kind: 'mainline', card, transits: [] }; if (mainlineProfile(card).speed.kind === 'grade') { /** * SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed * "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two * players (§4.2), or beyond an end Division Point next to one — never inside one player's own * district — so whichever direction climbs advantages one neighbour over the other, and there * is no single player who owns that call fairly. Orientation is rolled from the seed instead, * identically for solitaire and multiplayer, and this is not expected to change when setup * eventually gains an interactive phase for other decisions. See implications.md §10 Q11. */ node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west'; } return node; }; nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] }); for (let p = 0; p < players; p++) { nodes.push(mainline()); nodes.push({ kind: 'office', seat: 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 })); // Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to // the players occupying them, and starts as the identity mapping, which is what makes the // seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else. const officeAreas = new Map(); for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat)); // §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); /** * §4.4 — THE OPENING D12 DECIDES WHO SITS WHERE. * * `seating[seat] = player`, and seat 0 is the WESTERN end of the chain (`buildDivision` lays the * Western Division Point, then office 0, and finishes at the Eastern one). So the highest roll * takes the last seat — "highest is the Eastern Division Point" — and the lowest ends up beside * the Western Division Point, which is the rule's other named position. * * The rule names only those two ends, because at a table the players are already sitting in a * chain and the roll only says which way round it is. There is no physical table here, so the * roll orders everybody: ascending by roll, west to east. It uses a number every player is * already told to roll, and it makes the roll matter to more than the winner. * * Ties break toward the LOWER player index sitting further east, which is the same convention * `argmax` uses for the Superintendent roll on the line above — first max wins. * * This was `void divisionRolls` until v0.4.1: the roll was drawn and discarded, and seating was * the identity mapping. Turning it on is what makes seat and player genuinely different at * runtime rather than only in the type names. */ const seating: PlayerIndex[] = players .map((_, p) => p) .sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a); /** * §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with. * * `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track * separately and deals three of each, then shuffles the remainder back together so the game runs * off one deck from the first draw onward either way. `content.ts` says what each option is for. * * A player dealt six holds six against a limit of three, on purpose: the first turn is spent * choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on * `draw.end`, which needs no special case for this). A player dealt three is already at the limit. * * §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and * is therefore SEAT order, not player order. That is the same in all three shapes. */ const rules = houseRules(config); const deal = OPENING_DEALS[rules.startingHand]; const deck = buildDeck(config.mode, config.pvpCardsAllowed); const cards = new Map(); for (const c of deck) cards.set(c.id, c); const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track'; const ids = deck.map((c) => c.id); const superSeat = seating.indexOf(superintendent); const hands = new Map(); const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!; let shuffled: CardId[]; if (deal.any > 0) { // One deck, one shuffle, the top cards off it — the prototype's own deal. const single = rng.shuffle(ids); let cut = 0; for (let i = 0; i < playerCount; i++) { hands.set(dealtTo(i), single.slice(cut, cut + deal.any)); cut += deal.any; } shuffled = single.slice(cut); } else { // Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed. const trackPile = rng.shuffle(ids.filter(isTrack)); const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id))); let trackCursor = 0; let otherCursor = 0; for (let i = 0; i < playerCount; i++) { hands.set(dealtTo(i), [ ...trackPile.slice(trackCursor, trackCursor + deal.track), ...otherPile.slice(otherCursor, otherCursor + deal.other), ]); trackCursor += deal.track; otherCursor += deal.other; } // The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal // device only, so track competes for the draw exactly as before from the first draw onward. shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]); } // §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile // that grows as players discard onto it. Taken after the recombination, so a Department slot can // show track like any other card. let cursor = 0; const departments: CardId[][] = [[], [], []]; for (const pile of departments) { const card = shuffled[cursor++]; if (card) pile.push(card); } const homeOffice = shuffled.slice(cursor); const redFlags = new Map(); 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, seating, openingRolls: { division: divisionRolls, superintendent: superRolls }, division: { nodes: buildDivision(playerCount, rng) }, officeAreas, trays: new Map(), freeTrays, cards, decks: { homeOffice, departments, salvageYard: [], hands, redFlags }, yards: { divisionYard: buildRollingStock(), classificationYard: [] }, timetable, pendingExtras: [], pendingSecondSections: [], clock: { day: 1, stage: 1, phase: 'localOps', currentActor: superintendent, pendingDecision: null, clearanceRuling: null, superintendent, actorOffset: 0, }, turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS), movedThisPhase: new Set(), collisionsToday: 0, collisionsTotal: 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 };