The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
/**
|
|
* 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,
|
|
TRACK_CARDS,
|
|
ROLLING_STOCK_SUPPLY,
|
|
STAGES_PER_DAY,
|
|
SECOND_SECTION,
|
|
TIMETABLED_TRAINS,
|
|
crewTrayCount,
|
|
mainlineCardCount,
|
|
officeProfile,
|
|
} from './content.ts';
|
|
import type { StartingOffice } 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, emptyTally, 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,
|
|
startingOffice: StartingOffice = 'whistlePost',
|
|
/** False only for a save that predates the card — see `HouseRules.secondSectionCard`. */
|
|
secondSectionCard = true,
|
|
): 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 });
|
|
/**
|
|
* OFFICE UPGRADES, MINUS THE TIER EVERYBODY ALREADY HAS.
|
|
*
|
|
* A table that starts on Depots has no use for a Depot card: `check` refuses it, because an
|
|
* upgrade must be to the NEXT tier and a Depot is not an upgrade on a Depot. Leaving them in
|
|
* would deal four dead cards into a 90-odd card deck — the same dead draw the opponent-directed
|
|
* cards are held out for. Station and Terminal are still upgrades and stay.
|
|
*/
|
|
for (const o of OFFICE_PROFILES) {
|
|
if (o.tier === startingOffice) continue;
|
|
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 });
|
|
}
|
|
/**
|
|
* Q9 — THE SECOND SECTION CARD, WHICH WAS DECLARED AND NEVER DEALT.
|
|
*
|
|
* `content.ts` has carried `SECOND_SECTION` (1 copy) all along and `setup.ts` never built it into
|
|
* the deck, while `newTrain.secondSection` was offered free on every train due out — so the one
|
|
* card that is supposed to gate the action did not exist and the action cost nothing. The bot ran
|
|
* 26 accidental Second Sections in one measured round because of it.
|
|
*
|
|
* Jesse's ruling, 2026-09-23: the action requires the card. Dealing it is the other half — gating
|
|
* on a card the deck never holds would delete the mechanic rather than fix it.
|
|
*/
|
|
if (secondSectionCard) for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
|
|
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, tier: StartingOffice): 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(tier),
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
|
|
const limitsCard = (): TrackCard => ({
|
|
geometry: { kind: 'limits' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const grid = new Map<string, TrackCard>();
|
|
grid.set(coordKey(officeCoord), officeCard);
|
|
grid.set(coordKey(limitsWest), limitsCard());
|
|
grid.set(coordKey(limitsEast), limitsCard());
|
|
|
|
return {
|
|
seat,
|
|
tier,
|
|
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<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.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/mainline-deck.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 (card === 'heavyGrade') {
|
|
/**
|
|
* 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.
|
|
*
|
|
* RE-CONFIRMED 2026-08-23, when the question was raised again and giving the choice to the
|
|
* Superintendent was considered and rejected — the office rotates every three Stages, the
|
|
* advantage it would hand out does not. 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;
|
|
// Resolved once, here, because it decides BOTH the Office every area is built on and which office
|
|
// cards the deck holds — and the two must not be able to disagree.
|
|
const startingOffice = houseRules(config).startingOffice;
|
|
|
|
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<SeatIndex, OfficeArea>();
|
|
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat, startingOffice));
|
|
// §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, rules.startingOffice, rules.secondSectionCard);
|
|
const cards = new Map<CardId, Card>();
|
|
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<PlayerIndex, CardId[]>();
|
|
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<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,
|
|
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,
|
|
decisionAnswer: null,
|
|
superintendent,
|
|
actorOffset: 0,
|
|
},
|
|
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
|
movedThisPhase: new Set(),
|
|
collisionsToday: 0,
|
|
collisionsPrevDay: 0,
|
|
collisionsTotal: 0,
|
|
status: 'active',
|
|
outcome: null,
|
|
extraDays: 0,
|
|
extensionVotes: players.map(() => null),
|
|
official: null,
|
|
tally: emptyTally(playerCount),
|
|
};
|
|
}
|
|
|
|
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 };
|