* refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
646 lines
26 KiB
TypeScript
646 lines
26 KiB
TypeScript
/**
|
||
* PDC World Darts Championship Simulator
|
||
*
|
||
* Monte Carlo simulation of the PDC World Darts Championship.
|
||
* The tournament is a 128-player single-elimination bracket with 7 rounds,
|
||
* using best-of-sets formats that increase in length each round.
|
||
*
|
||
* Algorithm:
|
||
* 1. Load all participants and their Elo + world ranking from participantExpectedValues.
|
||
* 2. Two simulation paths:
|
||
* a. Bracket populated: simulate from actual draw, respecting completed matches.
|
||
* b. Pre-bracket: top 32 seeds placed into fixed balanced bracket positions;
|
||
* remaining 96 players randomly drawn into unseeded slots each simulation.
|
||
* 3. Compute per-set win probability using the logistic sigmoid:
|
||
* p_set = 1 / (1 + e^(-(Elo1 - Elo2) / ELO_DIVISOR))
|
||
* 4. Compute match win probability using the Bernoulli sets model:
|
||
* P(win) = sum_{w2=0}^{S-1} C(S-1+w2, w2) * p^S * (1-p)^w2
|
||
* where S = sets to win, which varies by round.
|
||
* 5. Track integer placement counts per tier across 50,000 simulations.
|
||
* 6. Convert to probability distributions using exact denominators (column sums = 1.0).
|
||
*
|
||
* Round format (PDC World Championship):
|
||
* R1 (R128): best-of-3 sets, first to 2
|
||
* R2 (R64): best-of-5 sets, first to 3
|
||
* R3 (R32): best-of-5 sets, first to 3
|
||
* R4 (R16): best-of-7 sets, first to 4
|
||
* QF: best-of-7 sets, first to 4
|
||
* SF: best-of-11 sets, first to 6
|
||
* Final: best-of-13 sets, first to 7
|
||
*
|
||
* Seeding (pre-bracket path):
|
||
* Top 32 players (by world ranking) are seeded into fixed bracket positions
|
||
* using the standard balanced bracket structure:
|
||
* R1 seeds: 1v32, 16v17, 9v24, 8v25 (top half) + 5v28, 12v21, 13v20, 4v29
|
||
* 3v30, 14v19, 11v22, 6v27 (bottom half) + 7v26, 10v23, 15v18, 2v31
|
||
* Each seed's unseeded opponent slot is randomly filled from the 96 unseeded players
|
||
* in each simulation run — spreading the draw uncertainty across all simulations.
|
||
*
|
||
* Placement bucketing (8-slot probability model):
|
||
* probFirst → Champion
|
||
* probSecond → Finalist
|
||
* probThird/Fourth → SF losers (2/sim)
|
||
* probFifth–Eighth → QF losers (4/sim)
|
||
* Earlier rounds → all 0
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq, and } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50000;
|
||
|
||
/**
|
||
* Controls how much Elo gaps affect per-set win probability.
|
||
* Higher = softer probabilities (more randomness).
|
||
* Lower = sharper (Elo differences matter more).
|
||
*
|
||
* Standard chess uses 400. Snooker (more random than chess) uses 700.
|
||
* Darts at the elite level is highly skill-dominated; 200 gives:
|
||
* 100-pt gap → ~62% per set
|
||
* 200-pt gap → ~73% per set
|
||
* 300-pt gap → ~83% per set
|
||
*
|
||
* With 200, a real PDC field (top players 1800–1970 Elo, unseeded at 1400)
|
||
* produces EV ≈ 65–70 for the world #1 (≈2080 Elo) in a 128-player bracket.
|
||
*/
|
||
const ELO_DIVISOR = 200;
|
||
|
||
/**
|
||
* Sets needed to win per round, in bracket order (R1 first, Final last).
|
||
* Index 0 = R1/R128 (64 matches, best-of-3, need 2)
|
||
* Index 1 = R2/R64 (32 matches, best-of-5, need 3)
|
||
* Index 2 = R3/R32 (16 matches, best-of-5, need 3)
|
||
* Index 3 = R4/R16 (8 matches, best-of-7, need 4)
|
||
* Index 4 = QF (4 matches, best-of-7, need 4)
|
||
* Index 5 = SF (2 matches, best-of-11, need 6)
|
||
* Index 6 = Final (1 match, best-of-13, need 7)
|
||
*/
|
||
const SETS_TO_WIN = [2, 3, 3, 4, 4, 6, 7] as const;
|
||
|
||
/**
|
||
* Number of seeds that get fixed bracket positions.
|
||
* The remaining (128 - TOP_SEEDS) players are randomly drawn.
|
||
*/
|
||
const TOP_SEEDS = 32;
|
||
|
||
// ─── Math helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Per-set win probability for player 1 vs player 2 based on Elo.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function setWinProb(elo1: number, elo2: number): number {
|
||
return 1 / (1 + Math.exp(-(elo1 - elo2) / ELO_DIVISOR));
|
||
}
|
||
|
||
/**
|
||
* Match win probability for player 1 using the Bernoulli sets model.
|
||
* For a best-of-(2S-1) match (first to S sets):
|
||
* P(win) = sum_{w2=0}^{S-1} C(S-1+w2, w2) * p^S * (1-p)^w2
|
||
* Exported for unit testing.
|
||
*/
|
||
export function matchWinProb(p: number, setsToWin: number): number {
|
||
const S = setsToWin;
|
||
let prob = 0;
|
||
for (let w2 = 0; w2 < S; w2++) {
|
||
prob += binomialCoeff(S - 1 + w2, w2) * Math.pow(p, S) * Math.pow(1 - p, w2);
|
||
}
|
||
return prob;
|
||
}
|
||
|
||
/** Binomial coefficient C(n, k) via iterative multiplication. */
|
||
function binomialCoeff(n: number, k: number): number {
|
||
if (k === 0) return 1;
|
||
if (k > n - k) k = n - k;
|
||
let result = 1;
|
||
for (let i = 0; i < k; i++) {
|
||
result = (result * (n - i)) / (i + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Returns the 128-player seeded bracket R1 pair list.
|
||
* Each entry is [participantIdA, participantIdB].
|
||
* Top 32 seeded players fill fixed positions; 96 unseeded players are randomly
|
||
* shuffled and assigned to the remaining slots.
|
||
*
|
||
* Structure:
|
||
* - 32 seeded-vs-unseeded matches (seeds 1–32 each face a randomly drawn unseeded opponent)
|
||
* - 32 unseeded-vs-unseeded matches (remaining 64 unseeded players paired randomly)
|
||
* Total: 64 R1 matches ✓ (128 players)
|
||
*
|
||
* Note: in the hot simulation loop, seeded positions are pre-computed via getSeededMatchOrder
|
||
* and inlined directly — this function is used for testing and bracket-draw path only.
|
||
*
|
||
* Exported for unit testing.
|
||
*/
|
||
export function buildR1Bracket(
|
||
seededIds: string[], // exactly 32, index 0 = seed 1
|
||
unseededIds: string[] // exactly 96, shuffled
|
||
): Array<[string, string]> {
|
||
// The 32 seeded players each face one of the first 32 unseeded opponents.
|
||
// Seeds are arranged in bracket order using the standard balanced structure
|
||
// for 32 seeds (same algorithm as snooker's R32_BRACKET but generalised).
|
||
const seededMatchOrder = getSeededMatchOrder(32); // returns 32 seed positions in bracket order
|
||
|
||
const pairs: Array<[string, string]> = [];
|
||
|
||
// Interleave seeded and unseeded pairs so each seed's R1 match is immediately
|
||
// followed by an unseeded-vs-unseeded match. This ensures the two types of
|
||
// match converge in R2 rather than running as separate sub-brackets until the Final.
|
||
for (let i = 0; i < 32; i++) {
|
||
const seedPos = seededMatchOrder[i] - 1; // 0-indexed
|
||
// Even slot: seed vs. unseeded[i]
|
||
pairs.push([seededIds[seedPos], unseededIds[i]]);
|
||
// Odd slot: unseeded vs. unseeded (indices 32 + 2i and 32 + 2i + 1)
|
||
pairs.push([unseededIds[32 + i * 2], unseededIds[32 + i * 2 + 1]]);
|
||
}
|
||
|
||
return pairs;
|
||
}
|
||
|
||
/**
|
||
* Returns seed positions in standard balanced bracket order for N seeds.
|
||
* Guarantees seed 1 and seed 2 can only meet in the Final.
|
||
* E.g. for N=4: [1, 4, 3, 2] → match order 1v4, 3v2 in the top/bottom halves.
|
||
*
|
||
* Algorithm: start with [1, 2], repeatedly interleave (n+1 - seed) complements.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function getSeededMatchOrder(n: number): number[] {
|
||
let order = [1, 2];
|
||
while (order.length < n) {
|
||
const size = order.length;
|
||
const newOrder: number[] = [];
|
||
for (const seed of order) {
|
||
newOrder.push(seed);
|
||
newOrder.push(2 * size + 1 - seed);
|
||
}
|
||
order = newOrder;
|
||
}
|
||
return order;
|
||
}
|
||
|
||
/** Fisher-Yates shuffle (in-place, returns array). */
|
||
function shuffle<T>(arr: T[]): T[] {
|
||
for (let i = arr.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class DartsSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// 1. Find the bracket scoring event (if it exists).
|
||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||
where: and(
|
||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||
),
|
||
});
|
||
|
||
// 2. Load playoff matches (empty if bracket hasn't been drawn yet).
|
||
const allMatches = bracketEvent
|
||
? await db.query.playoffMatches.findMany({
|
||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
||
})
|
||
: [];
|
||
|
||
// 3. Load Elo ratings and world rankings.
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
worldRanking: schema.seasonParticipantExpectedValues.worldRanking,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
const eloMap = new Map<string, number>();
|
||
const rankingMap = new Map<string, number>();
|
||
for (const r of evRows) {
|
||
if (r.sourceElo !== null && r.sourceElo !== undefined) {
|
||
eloMap.set(r.participantId, r.sourceElo);
|
||
}
|
||
if (r.worldRanking !== null && r.worldRanking !== undefined) {
|
||
rankingMap.set(r.participantId, r.worldRanking);
|
||
}
|
||
}
|
||
|
||
// Determine simulation path.
|
||
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
|
||
|
||
if (bracketPopulated) {
|
||
return this.simulateBracket(allMatches, eloMap);
|
||
} else {
|
||
return this.simulatePreBracket(sportsSeasonId, eloMap, rankingMap, db);
|
||
}
|
||
}
|
||
|
||
// ─── Path A: Bracket drawn ────────────────────────────────────────────────
|
||
|
||
private async simulateBracket(
|
||
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
|
||
eloMap: Map<string, number>
|
||
): Promise<SimulationResult[]> {
|
||
// Group matches by round, sorted by match count descending (R1 first = most matches).
|
||
const byRound = new Map<string, typeof allMatches>();
|
||
for (const m of allMatches) {
|
||
if (!byRound.has(m.round)) byRound.set(m.round, []);
|
||
byRound.get(m.round)?.push(m);
|
||
}
|
||
|
||
const sortedRounds = [...byRound.values()]
|
||
.toSorted((a, b) => b.length - a.length)
|
||
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
|
||
|
||
if (sortedRounds.length !== 7) {
|
||
throw new Error(
|
||
`Expected 7 rounds for PDC World Darts Championship, found ${sortedRounds.length}. ` +
|
||
`Rounds: ${[...byRound.keys()].join(", ")}`
|
||
);
|
||
}
|
||
|
||
const [r1Matches, r2Matches, r3Matches, r4Matches, qfMatches, sfMatches, finalMatches] = sortedRounds;
|
||
|
||
if (r1Matches.length !== 64) {
|
||
throw new Error(
|
||
`Expected 64 R1 matches (128-player bracket), found ${r1Matches.length}.`
|
||
);
|
||
}
|
||
|
||
// Collect all 128 participant IDs from R1.
|
||
const participantIds: string[] = [];
|
||
for (const m of r1Matches) {
|
||
if (!m.participant1Id || !m.participant2Id) {
|
||
throw new Error(
|
||
`R1 match ${m.matchNumber} is missing participants. ` +
|
||
`Assign all 128 players to the bracket before running simulation.`
|
||
);
|
||
}
|
||
participantIds.push(m.participant1Id, m.participant2Id);
|
||
}
|
||
|
||
// 1400 reflects the typical strength of unseeded PDC World Championship
|
||
// qualifiers (regional/Q-School players), who are significantly weaker than
|
||
// the seeded tour players.
|
||
const fallbackElo = 1400;
|
||
|
||
// Cache matchWinProb — Elo values are fixed across simulations.
|
||
const matchProbCache = new Map<string, number>();
|
||
const simMatch = (p1: string, p2: string, setsToWin: number): { winner: string; loser: string } => {
|
||
const elo1 = eloMap.get(p1) ?? fallbackElo;
|
||
const elo2 = eloMap.get(p2) ?? fallbackElo;
|
||
const cacheKey = `${elo1},${elo2},${setsToWin}`;
|
||
let winProb = matchProbCache.get(cacheKey);
|
||
if (winProb === undefined) {
|
||
winProb = matchWinProb(setWinProb(elo1, elo2), setsToWin);
|
||
matchProbCache.set(cacheKey, winProb);
|
||
}
|
||
const winner = Math.random() < winProb ? p1 : p2;
|
||
return { winner, loser: winner === p1 ? p2 : p1 };
|
||
};
|
||
|
||
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
|
||
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
|
||
const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m]));
|
||
const r4ByNum = new Map(r4Matches.map((m) => [m.matchNumber, m]));
|
||
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
|
||
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
|
||
const finalMatch = finalMatches[0];
|
||
|
||
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
// R1 (64 matches)
|
||
const r1Winners: string[] = [];
|
||
for (let i = 1; i <= 64; i++) {
|
||
const m = r1ByNum.get(i);
|
||
if (!m) continue;
|
||
if (m.isComplete && m.winnerId) {
|
||
r1Winners.push(m.winnerId);
|
||
} else {
|
||
const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "", SETS_TO_WIN[0]);
|
||
r1Winners.push(winner);
|
||
}
|
||
}
|
||
|
||
// R2 (32 matches)
|
||
const r2Winners: string[] = [];
|
||
for (let i = 1; i <= 32; i++) {
|
||
const dbMatch = r2ByNum.get(i);
|
||
let winner: string;
|
||
if (dbMatch?.isComplete && dbMatch.winnerId) {
|
||
winner = dbMatch.winnerId;
|
||
} else {
|
||
const p1 = r1Winners[(i - 1) * 2];
|
||
const p2 = r1Winners[(i - 1) * 2 + 1];
|
||
({ winner } = simMatch(p1, p2, SETS_TO_WIN[1]));
|
||
}
|
||
r2Winners.push(winner);
|
||
}
|
||
|
||
// R3 (16 matches)
|
||
const r3Winners: string[] = [];
|
||
for (let i = 1; i <= 16; i++) {
|
||
const dbMatch = r3ByNum.get(i);
|
||
let winner: string;
|
||
if (dbMatch?.isComplete && dbMatch.winnerId) {
|
||
winner = dbMatch.winnerId;
|
||
} else {
|
||
const p1 = r2Winners[(i - 1) * 2];
|
||
const p2 = r2Winners[(i - 1) * 2 + 1];
|
||
({ winner } = simMatch(p1, p2, SETS_TO_WIN[2]));
|
||
}
|
||
r3Winners.push(winner);
|
||
}
|
||
|
||
// R4 (8 matches)
|
||
const r4Winners: string[] = [];
|
||
for (let i = 1; i <= 8; i++) {
|
||
const dbMatch = r4ByNum.get(i);
|
||
let winner: string;
|
||
if (dbMatch?.isComplete && dbMatch.winnerId) {
|
||
winner = dbMatch.winnerId;
|
||
} else {
|
||
const p1 = r3Winners[(i - 1) * 2];
|
||
const p2 = r3Winners[(i - 1) * 2 + 1];
|
||
({ winner } = simMatch(p1, p2, SETS_TO_WIN[3]));
|
||
}
|
||
r4Winners.push(winner);
|
||
}
|
||
|
||
// QF (4 matches)
|
||
const qfWinners: string[] = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const dbMatch = qfByNum.get(i);
|
||
let winner: string;
|
||
let loser: string;
|
||
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
|
||
winner = dbMatch.winnerId;
|
||
loser = dbMatch.loserId;
|
||
} else {
|
||
const p1 = r4Winners[(i - 1) * 2];
|
||
const p2 = r4Winners[(i - 1) * 2 + 1];
|
||
({ winner, loser } = simMatch(p1, p2, SETS_TO_WIN[4]));
|
||
}
|
||
qfWinners.push(winner);
|
||
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// SF (2 matches)
|
||
const sfWinners: string[] = [];
|
||
for (let i = 1; i <= 2; i++) {
|
||
const dbMatch = sfByNum.get(i);
|
||
let winner: string;
|
||
let loser: string;
|
||
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
|
||
winner = dbMatch.winnerId;
|
||
loser = dbMatch.loserId;
|
||
} else {
|
||
const p1 = qfWinners[(i - 1) * 2];
|
||
const p2 = qfWinners[(i - 1) * 2 + 1];
|
||
({ winner, loser } = simMatch(p1, p2, SETS_TO_WIN[5]));
|
||
}
|
||
sfWinners.push(winner);
|
||
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// Final
|
||
let champion: string;
|
||
let finalist: string;
|
||
if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
|
||
champion = finalMatch.winnerId;
|
||
finalist = finalMatch.loserId;
|
||
} else {
|
||
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1], SETS_TO_WIN[6]));
|
||
}
|
||
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
|
||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||
}
|
||
|
||
return buildResults(participantIds, NUM_SIMULATIONS, {
|
||
championCounts,
|
||
finalistCounts,
|
||
sfLoserCounts,
|
||
qfLoserCounts,
|
||
});
|
||
}
|
||
|
||
// ─── Path B: Pre-bracket simulation ──────────────────────────────────────────
|
||
// Top 32 seeds are placed into fixed bracket positions.
|
||
// Remaining 96 players are randomly drawn into unseeded slots each simulation.
|
||
|
||
private async simulatePreBracket(
|
||
sportsSeasonId: string,
|
||
eloMap: Map<string, number>,
|
||
rankingMap: Map<string, number>,
|
||
db: ReturnType<typeof database>
|
||
): Promise<SimulationResult[]> {
|
||
const allParticipants = await db
|
||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
|
||
|
||
if (allParticipants.length < 2) {
|
||
throw new Error(
|
||
`Pre-bracket simulation requires at least 2 participants (got ${allParticipants.length}). ` +
|
||
`Add players to this sports season first.`
|
||
);
|
||
}
|
||
|
||
// 1400 reflects the typical strength of unseeded PDC World Championship
|
||
// qualifiers (regional/Q-School players), who are significantly weaker than
|
||
// the seeded tour players.
|
||
const fallbackElo = 1400;
|
||
|
||
// Sort participants by world ranking (ascending). Fall back to Elo order (descending) for
|
||
// any without a ranking, then alphabetical as a final tiebreak.
|
||
const sorted = [...allParticipants].toSorted((a, b) => {
|
||
const rankA = rankingMap.get(a.id);
|
||
const rankB = rankingMap.get(b.id);
|
||
if (rankA !== undefined && rankB !== undefined) return rankA - rankB;
|
||
if (rankA !== undefined) return -1; // ranked before unranked
|
||
if (rankB !== undefined) return 1;
|
||
// Both unranked — sort by Elo descending
|
||
return (eloMap.get(b.id) ?? fallbackElo) - (eloMap.get(a.id) ?? fallbackElo);
|
||
});
|
||
|
||
const topSeeds = sorted.slice(0, TOP_SEEDS).map((p) => p.id); // seeds 1–32
|
||
const unseeded = sorted.slice(TOP_SEEDS).map((p) => p.id); // remaining players
|
||
|
||
const allParticipantIds = allParticipants.map((p) => p.id);
|
||
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const sfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
const qfLoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
|
||
|
||
// Cache set-level probabilities — fixed across all simulations.
|
||
const matchProbCache = new Map<string, number>();
|
||
const simMatch = (p1Id: string, p2Id: string, setsToWin: number): string => {
|
||
const elo1 = eloMap.get(p1Id) ?? fallbackElo;
|
||
const elo2 = eloMap.get(p2Id) ?? fallbackElo;
|
||
const cacheKey = `${elo1},${elo2},${setsToWin}`;
|
||
let winProb = matchProbCache.get(cacheKey);
|
||
if (winProb === undefined) {
|
||
winProb = matchWinProb(setWinProb(elo1, elo2), setsToWin);
|
||
matchProbCache.set(cacheKey, winProb);
|
||
}
|
||
return Math.random() < winProb ? p1Id : p2Id;
|
||
};
|
||
|
||
// Pre-compute fixed seeded bracket positions once — only the unseeded draw changes per sim.
|
||
const seededMatchOrder = getSeededMatchOrder(TOP_SEEDS);
|
||
const seededSlots = seededMatchOrder.map(seed => topSeeds[seed - 1]);
|
||
|
||
// Pad unseeded pool to 96 once before the loop.
|
||
// In practice the admin should always load 128 players; this guards against edge cases.
|
||
const unseededPool = [...unseeded];
|
||
while (unseededPool.length + topSeeds.length < 128) {
|
||
unseededPool.push(`__bye_${unseededPool.length}`);
|
||
}
|
||
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
// Draw: shuffle the unseeded pool — seeded positions are pre-computed.
|
||
const drawnUnseeded = shuffle([...unseededPool]);
|
||
|
||
// Build R1 pairs inline using pre-computed seeded slots.
|
||
// IMPORTANT: interleave each seeded match with its adjacent unseeded match.
|
||
// Without interleaving, all 32 seeded matches come first (pairs 0–31) and
|
||
// all 32 unseeded matches come last (pairs 32–63). Because R2 pairs adjacent
|
||
// R1 winners, this creates two completely separate sub-brackets (seeded vs.
|
||
// unseeded) that only converge at the Final — producing absurd results like
|
||
// unseeded 1800-Elo players having a 20% finalist probability.
|
||
// Interleaving ensures seeded and unseeded regions mix from R2 onwards.
|
||
const r1Pairs: Array<[string, string]> = [];
|
||
for (let i = 0; i < TOP_SEEDS; i++) {
|
||
// Even slot: seeded player vs. their randomly drawn unseeded opponent
|
||
r1Pairs.push([seededSlots[i], drawnUnseeded[i]]);
|
||
// Odd slot (adjacent): unseeded vs. unseeded pair that feeds into the
|
||
// same R2 match as the seeded slot above
|
||
r1Pairs.push([drawnUnseeded[TOP_SEEDS + i * 2], drawnUnseeded[TOP_SEEDS + i * 2 + 1]]);
|
||
}
|
||
|
||
// R1 (64 matches)
|
||
const r1Winners: string[] = [];
|
||
for (const [p1, p2] of r1Pairs) {
|
||
r1Winners.push(simMatch(p1, p2, SETS_TO_WIN[0]));
|
||
}
|
||
|
||
// R2–R4 (32 / 16 / 8 matches)
|
||
const r2Winners: string[] = [];
|
||
for (let i = 0; i < r1Winners.length; i += 2) {
|
||
r2Winners.push(simMatch(r1Winners[i], r1Winners[i + 1], SETS_TO_WIN[1]));
|
||
}
|
||
const r3Winners: string[] = [];
|
||
for (let i = 0; i < r2Winners.length; i += 2) {
|
||
r3Winners.push(simMatch(r2Winners[i], r2Winners[i + 1], SETS_TO_WIN[2]));
|
||
}
|
||
const r4Winners: string[] = [];
|
||
for (let i = 0; i < r3Winners.length; i += 2) {
|
||
r4Winners.push(simMatch(r3Winners[i], r3Winners[i + 1], SETS_TO_WIN[3]));
|
||
}
|
||
|
||
// QF (4 matches)
|
||
const qfWinners: string[] = [];
|
||
for (let i = 0; i < r4Winners.length; i += 2) {
|
||
const p1 = r4Winners[i], p2 = r4Winners[i + 1];
|
||
const winner = simMatch(p1, p2, SETS_TO_WIN[4]);
|
||
const loser = winner === p1 ? p2 : p1;
|
||
qfWinners.push(winner);
|
||
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// SF (2 matches)
|
||
const sfWinners: string[] = [];
|
||
for (let i = 0; i < qfWinners.length; i += 2) {
|
||
const p1 = qfWinners[i], p2 = qfWinners[i + 1];
|
||
const winner = simMatch(p1, p2, SETS_TO_WIN[5]);
|
||
const loser = winner === p1 ? p2 : p1;
|
||
sfWinners.push(winner);
|
||
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// Final
|
||
const champion = simMatch(sfWinners[0], sfWinners[1], SETS_TO_WIN[6]);
|
||
const finalist = champion === sfWinners[0] ? sfWinners[1] : sfWinners[0];
|
||
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
|
||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||
}
|
||
|
||
return buildResults(allParticipantIds, NUM_SIMULATIONS, {
|
||
championCounts,
|
||
finalistCounts,
|
||
sfLoserCounts,
|
||
qfLoserCounts,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Shared result builder ─────────────────────────────────────────────────────
|
||
|
||
function buildResults(
|
||
participantIds: string[],
|
||
N: number,
|
||
counts: {
|
||
championCounts: Map<string, number>;
|
||
finalistCounts: Map<string, number>;
|
||
sfLoserCounts: Map<string, number>;
|
||
qfLoserCounts: Map<string, number>;
|
||
}
|
||
): SimulationResult[] {
|
||
const { championCounts, finalistCounts, sfLoserCounts, qfLoserCounts } = counts;
|
||
|
||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||
const c = championCounts.get(participantId) ?? 0;
|
||
const f = finalistCounts.get(participantId) ?? 0;
|
||
const sf = sfLoserCounts.get(participantId) ?? 0;
|
||
const qf = qfLoserCounts.get(participantId) ?? 0;
|
||
return {
|
||
participantId,
|
||
probabilities: {
|
||
probFirst: c / N,
|
||
probSecond: f / N,
|
||
probThird: sf / (2 * N),
|
||
probFourth: sf / (2 * N),
|
||
probFifth: qf / (4 * N),
|
||
probSixth: qf / (4 * N),
|
||
probSeventh: qf / (4 * N),
|
||
probEighth: qf / (4 * N),
|
||
},
|
||
source: "darts_world_championship_monte_carlo",
|
||
};
|
||
});
|
||
|
||
// Per-position column normalisation — ensures sums are exactly 1.0.
|
||
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
|
||
"probFirst", "probSecond", "probThird", "probFourth",
|
||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||
];
|
||
for (const key of positionKeys) {
|
||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||
const residual = 1.0 - colSum;
|
||
if (residual !== 0) {
|
||
const maxResult = results.reduce((best, r) =>
|
||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||
);
|
||
maxResult.probabilities[key] += residual;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|