Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in TODO.md under Play Balance. GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered, chosen by number parity. The number no longer decides an Extra's direction — the start does, which supersedes the recorded ruling that "the number decides, like everything else on the timetable". The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division on its first move having crossed nothing, and be paid for the run. Either end now runs the train away from itself; at an Interchange or a Control Point the player picks the direction. The Interchange start is a YARD, off the running line, which is what makes the Superintendent clause work: placing it can never force a collision, a guaranteed one holds it there for another Stage, and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and `ask`, so nothing new decides collisions. Where an Extra may start is now a house rule (divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The legacy `atSeat` intent field still replays as it always meant. FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested position alone, so the Control Point start has been shipping since it was added with a train that could never be given a consist. Found by playing it, not by the tests, which had only asserted where the tray landed. FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision permanently poisoned that Mainline card for every later train. THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a Division could hold two Interchanges and Plains carried the weight of a card printed once. "An Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are retired too — two of those were already dead before this release and nobody had noticed. GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The player is told on the card and on the button. GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards, so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts and the Home Deck reference carry what the game plays. CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never existed. Every Enhancement row cited its implementation by line number and every citation had rotted; they name functions now. Card counts came out of the comments, since they move with play balance; source-sheet figures and dated measurements stayed. TODO.md gains an item for a card reference generated from content.ts, in six sections, so the documentation cannot disagree with the game. 715 tests pass, tsc clean, site builds.
445 lines
17 KiB
TypeScript
445 lines
17 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,
|
|
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<string, TrackCard>();
|
|
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<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/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<SeatIndex, OfficeArea>();
|
|
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<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,
|
|
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 };
|