2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Bracket Template System
|
|
|
|
|
|
*
|
|
|
|
|
|
* Defines pre-configured tournament bracket structures for various sports.
|
|
|
|
|
|
* Templates specify rounds, match counts, and which rounds contribute to fantasy scoring.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export interface BracketRound {
|
|
|
|
|
|
/** Display name for this round (e.g., "First Four", "Elite Eight") */
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
/** Number of matches in this round */
|
|
|
|
|
|
matchCount: number;
|
|
|
|
|
|
/** Name of the round that winners advance to (null for championship) */
|
|
|
|
|
|
feedsInto: string | null;
|
|
|
|
|
|
/** Whether this round affects fantasy points (false = 0 points per Q20) */
|
|
|
|
|
|
isScoring: boolean;
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Name of the round that *losers* advance to (e.g., "Third Place Game" for SF losers).
|
|
|
|
|
|
* When set, the loser of each match in this round is placed into the target round.
|
|
|
|
|
|
*/
|
|
|
|
|
|
loserFeedsInto?: string | null;
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
|
export interface GroupStageConfig {
|
|
|
|
|
|
/** Number of groups */
|
|
|
|
|
|
groupCount: number;
|
|
|
|
|
|
/** Teams per group */
|
|
|
|
|
|
teamsPerGroup: number;
|
|
|
|
|
|
/** Group labels (e.g., ["A", "B", ..., "L"]) */
|
|
|
|
|
|
groupLabels: string[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* A play-in game within a bracket region (e.g., NCAA First Four).
|
|
|
|
|
|
* Two teams compete; the winner fills a specific seed slot in the main bracket.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface BracketPlayIn {
|
|
|
|
|
|
/** Which seed slot the winner fills (e.g., 11 or 16) */
|
|
|
|
|
|
seedSlot: number;
|
|
|
|
|
|
/** Number of teams in this play-in game (always 2) */
|
|
|
|
|
|
teams: 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* A named region within a bracket (e.g., NCAA "East", "South").
|
|
|
|
|
|
* Each region contributes exactly 16 teams to the Round of 64.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface BracketRegion {
|
|
|
|
|
|
/** Display name (e.g., "East", "South") */
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Seed numbers that enter the region directly (no play-in).
|
|
|
|
|
|
* Must be ascending and contain every seed 1–16 except those covered by playIns.
|
|
|
|
|
|
* Length = 16 - playIns.length.
|
|
|
|
|
|
*/
|
|
|
|
|
|
directSeeds: number[];
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Play-in games for this region. Order here determines the order their teams
|
|
|
|
|
|
* appear in the participant array (after all direct-seed teams across all regions).
|
|
|
|
|
|
*/
|
|
|
|
|
|
playIns: BracketPlayIn[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
export interface BracketTemplate {
|
|
|
|
|
|
/** Unique identifier for this template */
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
/** Human-readable name */
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
/** Total number of teams in the bracket */
|
|
|
|
|
|
totalTeams: number;
|
|
|
|
|
|
/** Ordered list of rounds from earliest to championship */
|
|
|
|
|
|
rounds: BracketRound[];
|
|
|
|
|
|
/** Round name where fantasy scoring begins */
|
|
|
|
|
|
scoringStartsAtRound: string;
|
2026-02-14 22:30:12 -08:00
|
|
|
|
/** Optional group stage configuration (e.g., FIFA World Cup) */
|
|
|
|
|
|
groupStage?: GroupStageConfig;
|
|
|
|
|
|
/** Number of teams advancing to knockout stage (when groupStage is defined) */
|
|
|
|
|
|
knockoutTeams?: number;
|
2026-03-15 21:52:47 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Optional named region config. When present, the participant array for bracket
|
|
|
|
|
|
* generation is structured as: all direct seeds region-by-region in order, then
|
|
|
|
|
|
* all play-in teams grouped by region (and play-in order within each region).
|
|
|
|
|
|
*/
|
|
|
|
|
|
regions?: BracketRegion[];
|
2026-04-13 00:51:53 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Optional human-readable labels for each participant slot (0-indexed).
|
|
|
|
|
|
* When provided, the admin bracket UI shows these labels instead of "Seed N".
|
|
|
|
|
|
* Length must equal totalTeams.
|
|
|
|
|
|
*/
|
|
|
|
|
|
participantLabels?: string[];
|
2026-03-15 21:52:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** All seed numbers in a standard 16-team regional bracket (1–16) */
|
|
|
|
|
|
export const ALL_16_SEEDS: number[] = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16];
|
|
|
|
|
|
|
|
|
|
|
|
// Standard seeding order for a 16-team regional bracket
|
|
|
|
|
|
// Each entry is [higherSeed, lowerSeed] (lower number = better seed)
|
|
|
|
|
|
export const STANDARD_BRACKET_SEEDING: [number, number][] = [
|
|
|
|
|
|
[1, 16], [8, 9], [5, 12], [4, 13], [6, 11], [3, 14], [7, 10], [2, 15],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Returns the 0-indexed position within the standard 8-match bracket order
|
|
|
|
|
|
* for a given seed slot. Used to compute which Round of 64 match a play-in
|
|
|
|
|
|
* winner advances to.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Standard order: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function matchIndexForSeedSlot(seedSlot: number): number {
|
|
|
|
|
|
for (let i = 0; i < STANDARD_BRACKET_SEEDING.length; i++) {
|
|
|
|
|
|
if (STANDARD_BRACKET_SEEDING[i].includes(seedSlot)) return i;
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`Seed slot ${seedSlot} not found in standard bracket seeding`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface NCAA68SlotMap {
|
|
|
|
|
|
/** Start index in participantIds for each region's direct seeds */
|
|
|
|
|
|
directOffsets: number[];
|
|
|
|
|
|
/** Ordered list of all play-ins, with participant index info and region context */
|
|
|
|
|
|
playInOffsets: Array<{
|
|
|
|
|
|
regionIndex: number;
|
|
|
|
|
|
playInIndex: number;
|
|
|
|
|
|
/** Index of the first team in this play-in within participantIds */
|
|
|
|
|
|
startIndex: number;
|
|
|
|
|
|
seedSlot: number;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
totalDirect: number;
|
|
|
|
|
|
totalPlayIn: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Computes the participant array slot mapping for an NCAA-style bracket with regions.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout:
|
|
|
|
|
|
* [region 0 direct seeds] [region 1 direct seeds] ... [region N direct seeds]
|
|
|
|
|
|
* [region 0 play-in 0 team A, team B] [region 0 play-in 1 team A, team B] ...
|
|
|
|
|
|
* [region 1 play-in 0 team A, team B] ...
|
|
|
|
|
|
*
|
|
|
|
|
|
* This layout is shared between the server (bracket generation) and the client
|
|
|
|
|
|
* (form rendering) so both sides agree on which participant{i} maps where.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function buildNCAA68SlotMap(regions: BracketRegion[]): NCAA68SlotMap {
|
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
|
const directOffsets: number[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (const region of regions) {
|
|
|
|
|
|
directOffsets.push(cursor);
|
|
|
|
|
|
cursor += region.directSeeds.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalDirect = cursor;
|
|
|
|
|
|
const playInOffsets: NCAA68SlotMap["playInOffsets"] = [];
|
|
|
|
|
|
|
|
|
|
|
|
for (let r = 0; r < regions.length; r++) {
|
|
|
|
|
|
for (let p = 0; p < regions[r].playIns.length; p++) {
|
|
|
|
|
|
playInOffsets.push({
|
|
|
|
|
|
regionIndex: r,
|
|
|
|
|
|
playInIndex: p,
|
|
|
|
|
|
startIndex: cursor,
|
|
|
|
|
|
seedSlot: regions[r].playIns[p].seedSlot,
|
|
|
|
|
|
});
|
|
|
|
|
|
cursor += 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const totalPlayIn = cursor - totalDirect;
|
|
|
|
|
|
|
|
|
|
|
|
return { directOffsets, playInOffsets, totalDirect, totalPlayIn };
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simple 4-team bracket
|
|
|
|
|
|
* Semifinals → Finals
|
|
|
|
|
|
* All rounds score fantasy points
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const SIMPLE_4: BracketTemplate = {
|
|
|
|
|
|
id: "simple_4",
|
|
|
|
|
|
name: "Simple 4-Team Bracket",
|
|
|
|
|
|
totalTeams: 4,
|
|
|
|
|
|
scoringStartsAtRound: "Semifinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simple 8-team bracket
|
|
|
|
|
|
* Quarterfinals → Semifinals → Finals
|
|
|
|
|
|
* All rounds score fantasy points
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const SIMPLE_8: BracketTemplate = {
|
|
|
|
|
|
id: "simple_8",
|
|
|
|
|
|
name: "Simple 8-Team Bracket",
|
|
|
|
|
|
totalTeams: 8,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semifinals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simple 16-team bracket
|
|
|
|
|
|
* Round of 16 → Quarterfinals → Semifinals → Finals
|
|
|
|
|
|
* Only Quarterfinals and beyond score points (top 8)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const SIMPLE_16: BracketTemplate = {
|
|
|
|
|
|
id: "simple_16",
|
|
|
|
|
|
name: "Simple 16-Team Bracket",
|
|
|
|
|
|
totalTeams: 16,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 16",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Quarterfinals",
|
|
|
|
|
|
isScoring: false, // Early elimination = 0 points
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semifinals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Simple 32-team bracket
|
|
|
|
|
|
* Round of 32 → Round of 16 → Quarterfinals → Semifinals → Finals
|
|
|
|
|
|
* Only Quarterfinals and beyond score points (top 8)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const SIMPLE_32: BracketTemplate = {
|
|
|
|
|
|
id: "simple_32",
|
|
|
|
|
|
name: "Simple 32-Team Bracket",
|
|
|
|
|
|
totalTeams: 32,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 32",
|
|
|
|
|
|
matchCount: 16,
|
|
|
|
|
|
feedsInto: "Round of 16",
|
|
|
|
|
|
isScoring: false, // Early elimination = 0 points
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 16",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Quarterfinals",
|
|
|
|
|
|
isScoring: false, // Early elimination = 0 points
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semifinals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* PDC World Darts Championship (128 players)
|
|
|
|
|
|
* R1 (128) → R2 (64) → R3 (32) → R4 (16) → Quarterfinals → Semifinals → Final
|
|
|
|
|
|
* Only Quarterfinals and beyond score points.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Match formats:
|
|
|
|
|
|
* R1: best-of-3 sets (first to 2)
|
|
|
|
|
|
* R2: best-of-5 sets (first to 3)
|
|
|
|
|
|
* R3: best-of-5 sets (first to 3)
|
|
|
|
|
|
* R4: 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: top 32 seeds placed in fixed positions; remaining 96 randomly drawn.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const DARTS_128: BracketTemplate = {
|
|
|
|
|
|
id: "darts_128",
|
|
|
|
|
|
name: "PDC World Darts Championship (128 Players)",
|
|
|
|
|
|
totalTeams: 128,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round 1",
|
|
|
|
|
|
matchCount: 64,
|
|
|
|
|
|
feedsInto: "Round 2",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round 2",
|
|
|
|
|
|
matchCount: 32,
|
|
|
|
|
|
feedsInto: "Round 3",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round 3",
|
|
|
|
|
|
matchCount: 16,
|
|
|
|
|
|
feedsInto: "Round 4",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round 4",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Quarterfinals",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semi-Finals",
|
|
|
|
|
|
isScoring: true, // QF losers share 5th–8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semi-Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Final",
|
|
|
|
|
|
isScoring: true, // SF losers share 3rd–4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Final",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* NCAA March Madness (68 teams)
|
|
|
|
|
|
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
|
|
|
|
|
* Only Elite Eight and beyond score points per Q18
|
2026-03-15 21:52:47 -07:00
|
|
|
|
*
|
|
|
|
|
|
* 2026 region config:
|
|
|
|
|
|
* East — 16 direct seeds, no play-ins
|
|
|
|
|
|
* South — 15 direct seeds, 16-seed play-in
|
|
|
|
|
|
* West — 15 direct seeds, 11-seed play-in
|
|
|
|
|
|
* Midwest — 14 direct seeds, 11-seed play-in + 16-seed play-in
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout (68 slots):
|
|
|
|
|
|
* [0–15] East seeds 1–16
|
|
|
|
|
|
* [16–30] South seeds 1–15
|
|
|
|
|
|
* [31–45] West seeds 1–10, 12–16
|
|
|
|
|
|
* [46–59] Midwest seeds 1–10, 12–15
|
|
|
|
|
|
* [60–61] South 16-seed play-in (2 teams)
|
|
|
|
|
|
* [62–63] West 11-seed play-in (2 teams)
|
|
|
|
|
|
* [64–65] Midwest 11-seed play-in (2 teams)
|
|
|
|
|
|
* [66–67] Midwest 16-seed play-in (2 teams)
|
2025-11-03 13:16:37 -08:00
|
|
|
|
*/
|
|
|
|
|
|
export const NCAA_68: BracketTemplate = {
|
|
|
|
|
|
id: "ncaa_68",
|
|
|
|
|
|
name: "NCAA March Madness (68 teams)",
|
|
|
|
|
|
totalTeams: 68,
|
|
|
|
|
|
scoringStartsAtRound: "Elite Eight",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "First Four",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Round of 64",
|
|
|
|
|
|
isScoring: false, // Play-in games don't score
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 64",
|
|
|
|
|
|
matchCount: 32,
|
|
|
|
|
|
feedsInto: "Round of 32",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 32",
|
|
|
|
|
|
matchCount: 16,
|
|
|
|
|
|
feedsInto: "Sweet Sixteen",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Sweet Sixteen",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Elite Eight",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Elite Eight",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Final Four",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Final Four",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Championship",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Championship",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2026-03-15 21:52:47 -07:00
|
|
|
|
regions: [
|
|
|
|
|
|
{
|
|
|
|
|
|
// East: all 16 seeds enter directly — no play-in games
|
|
|
|
|
|
name: "East",
|
|
|
|
|
|
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
|
|
|
|
|
playIns: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
// South: seeds 1–15 direct; 16-seed determined by First Four play-in
|
|
|
|
|
|
name: "South",
|
|
|
|
|
|
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
|
|
|
|
playIns: [{ seedSlot: 16, teams: 2 }],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
// West: seeds 1–10 and 12–16 direct; 11-seed determined by First Four play-in
|
|
|
|
|
|
name: "West",
|
|
|
|
|
|
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16],
|
|
|
|
|
|
playIns: [{ seedSlot: 11, teams: 2 }],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
// Midwest: seeds 1–10 and 12–15 direct; both 11-seed and 16-seed via play-ins
|
|
|
|
|
|
name: "Midwest",
|
|
|
|
|
|
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15],
|
|
|
|
|
|
playIns: [
|
|
|
|
|
|
{ seedSlot: 11, teams: 2 },
|
|
|
|
|
|
{ seedSlot: 16, teams: 2 },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2025-11-03 13:16:37 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* NFL Playoffs (14 teams)
|
|
|
|
|
|
* Wild Card → Divisional → Conference Championship → Super Bowl
|
|
|
|
|
|
* Top 2 seeds get byes (skip Wild Card)
|
|
|
|
|
|
* Scoring starts at Divisional (top 8)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const NFL_14: BracketTemplate = {
|
|
|
|
|
|
id: "nfl_14",
|
|
|
|
|
|
name: "NFL Playoffs (14 teams)",
|
|
|
|
|
|
totalTeams: 14,
|
|
|
|
|
|
scoringStartsAtRound: "Divisional",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Wild Card",
|
|
|
|
|
|
matchCount: 6,
|
|
|
|
|
|
feedsInto: "Divisional",
|
|
|
|
|
|
isScoring: false, // 12 teams play, 2 have byes
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Divisional",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Conference Championship",
|
|
|
|
|
|
isScoring: true, // Quarterfinals - Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Conference Championship",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Super Bowl",
|
|
|
|
|
|
isScoring: true, // Semifinals - Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Super Bowl",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Finals - Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* AFL Finals (10 teams with Wildcard Round from 2026)
|
|
|
|
|
|
* Complex bracket with double-chance system for top 6 teams
|
|
|
|
|
|
*
|
|
|
|
|
|
* Structure:
|
|
|
|
|
|
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
|
|
|
|
|
* - Week 1 Finals:
|
|
|
|
|
|
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
|
|
|
|
|
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
|
|
|
|
|
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
|
|
|
|
|
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
|
|
|
|
|
* - Week 4: Grand Final (1st vs 2nd)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Note: This is NOT a simple single-elimination tree - it has double-chance paths
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const AFL_10: BracketTemplate = {
|
|
|
|
|
|
id: "afl_10",
|
|
|
|
|
|
name: "AFL Finals (10 teams with Wildcard)",
|
|
|
|
|
|
totalTeams: 10,
|
|
|
|
|
|
scoringStartsAtRound: "Elimination Finals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Wildcard Round",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Elimination Finals",
|
|
|
|
|
|
isScoring: false, // Losers get 0 points (9th-10th)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Qualifying Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Preliminary Finals", // Winners get bye
|
|
|
|
|
|
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Elimination Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Semi-Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 7th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semi-Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Preliminary Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-6th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Preliminary Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Grand Final",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Grand Final",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* FIFA World Cup 48-team tournament (2026+)
|
|
|
|
|
|
* Group stage: 12 groups of 4 teams play round-robin
|
|
|
|
|
|
* Knockout stage: 32 teams advance to single-elimination bracket
|
|
|
|
|
|
*
|
|
|
|
|
|
* Group-to-knockout advancement is fully manual.
|
|
|
|
|
|
* Admin marks teams as eliminated from groups, then assigns 32 advancing
|
|
|
|
|
|
* teams into knockout bracket slots.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Only knockout stage awards fantasy points.
|
|
|
|
|
|
* Teams eliminated in groups get finalPosition = 0.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const FIFA_48: BracketTemplate = {
|
|
|
|
|
|
id: "fifa_48",
|
|
|
|
|
|
name: "FIFA World Cup (48 teams)",
|
|
|
|
|
|
totalTeams: 48,
|
|
|
|
|
|
knockoutTeams: 32,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
groupStage: {
|
|
|
|
|
|
groupCount: 12,
|
|
|
|
|
|
teamsPerGroup: 4,
|
|
|
|
|
|
groupLabels: ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"],
|
|
|
|
|
|
},
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 32",
|
|
|
|
|
|
matchCount: 16,
|
|
|
|
|
|
feedsInto: "Round of 16",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Round of 16",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Quarterfinals",
|
|
|
|
|
|
isScoring: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semifinals",
|
|
|
|
|
|
isScoring: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "Finals",
|
|
|
|
|
|
isScoring: true,
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
loserFeedsInto: "Third Place Game",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Third Place Game",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true,
|
2026-02-14 22:30:12 -08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* College Football Playoff (12 teams, 2024–present format)
|
|
|
|
|
|
* First Round: 5v12, 6v11, 7v10, 8v9 (campus sites, no fantasy points)
|
|
|
|
|
|
* Quarterfinals: Seeds 1–4 (bye) vs first-round winners (scoring starts here)
|
|
|
|
|
|
* Semifinals: 4 → 2
|
|
|
|
|
|
* National Championship: 1 game
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const CFP_12: BracketTemplate = {
|
|
|
|
|
|
id: "cfp_12",
|
|
|
|
|
|
name: "College Football Playoff (12 teams)",
|
|
|
|
|
|
totalTeams: 12,
|
|
|
|
|
|
scoringStartsAtRound: "Quarterfinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "First Round",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Quarterfinals",
|
|
|
|
|
|
isScoring: false, // 8 seeds play, top 4 have byes
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Quarterfinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Semifinals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th–8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Semifinals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "National Championship",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd–4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "National Championship",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* NBA Playoffs with Play-In Tournament (20 teams)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Structure:
|
|
|
|
|
|
* Play-In Round 1 (4 games, not scoring):
|
|
|
|
|
|
* East: 7 vs 8 (winner = E7 seed), 9 vs 10 (winner advances; loser eliminated)
|
|
|
|
|
|
* West: 7 vs 8 (winner = W7 seed), 9 vs 10 (winner advances; loser eliminated)
|
|
|
|
|
|
* Play-In Round 2 (2 games, not scoring):
|
|
|
|
|
|
* East: loser of 7/8 vs winner of 9/10 → winner = E8 seed; loser eliminated
|
|
|
|
|
|
* West: loser of 7/8 vs winner of 9/10 → winner = W8 seed; loser eliminated
|
|
|
|
|
|
* First Round (8 series, not scoring):
|
|
|
|
|
|
* East: 1v8, 4v5, 2v7, 3v6
|
|
|
|
|
|
* West: 1v8, 4v5, 2v7, 3v6
|
|
|
|
|
|
* Conference Semifinals (4 series, scoring starts — 5th-8th for losers)
|
|
|
|
|
|
* Conference Finals (2 series, scoring)
|
|
|
|
|
|
* NBA Finals (1 series, scoring)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Participant array layout (20 slots, indices 0-19):
|
|
|
|
|
|
* [0–9] East seeds 1–10
|
|
|
|
|
|
* [10–19] West seeds 1–10
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-in loser paths (custom advancement logic):
|
|
|
|
|
|
* PIR1 M1 (E7v8): winner → First Round M3 p1; loser → PIR2 M1 p1
|
|
|
|
|
|
* PIR1 M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
|
|
|
|
|
|
* PIR1 M3 (W7v8): winner → First Round M7 p1; loser → PIR2 M2 p1
|
|
|
|
|
|
* PIR1 M4 (W9v10): winner → PIR2 M2 p2; loser eliminated
|
|
|
|
|
|
* PIR2 M1: winner → First Round M1 p2; loser eliminated
|
|
|
|
|
|
* PIR2 M2: winner → First Round M5 p2; loser eliminated
|
|
|
|
|
|
*
|
|
|
|
|
|
* First Round bracket order (enables correct Conference Semis matchups via standard ceil logic):
|
|
|
|
|
|
* M1: E1 vs E8 (play-in winner) → CS M1 p1
|
|
|
|
|
|
* M2: E4 vs E5 → CS M1 p2
|
|
|
|
|
|
* M3: E2 vs E7 (play-in winner) → CS M2 p1
|
|
|
|
|
|
* M4: E3 vs E6 → CS M2 p2
|
|
|
|
|
|
* M5: W1 vs W8 (play-in winner) → CS M3 p1
|
|
|
|
|
|
* M6: W4 vs W5 → CS M3 p2
|
|
|
|
|
|
* M7: W2 vs W7 (play-in winner) → CS M4 p1
|
|
|
|
|
|
* M8: W3 vs W6 → CS M4 p2
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const NBA_20: BracketTemplate = {
|
|
|
|
|
|
id: "nba_20",
|
|
|
|
|
|
name: "NBA Playoffs with Play-In (20 teams)",
|
|
|
|
|
|
totalTeams: 20,
|
|
|
|
|
|
scoringStartsAtRound: "Conference Semifinals",
|
|
|
|
|
|
rounds: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Play-In Round 1",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Play-In Round 2",
|
|
|
|
|
|
isScoring: false, // Play-in games don't score
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Play-In Round 2",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "First Round",
|
|
|
|
|
|
isScoring: false, // Still determining playoff field
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "First Round",
|
|
|
|
|
|
matchCount: 8,
|
|
|
|
|
|
feedsInto: "Conference Semifinals",
|
|
|
|
|
|
isScoring: false, // First Round losers score 0 points
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Conference Semifinals",
|
|
|
|
|
|
matchCount: 4,
|
|
|
|
|
|
feedsInto: "Conference Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 5th-8th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "Conference Finals",
|
|
|
|
|
|
matchCount: 2,
|
|
|
|
|
|
feedsInto: "NBA Finals",
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "NBA Finals",
|
|
|
|
|
|
matchCount: 1,
|
|
|
|
|
|
feedsInto: null,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
participantLabels: [
|
|
|
|
|
|
"East 1", "East 2", "East 3", "East 4", "East 5",
|
|
|
|
|
|
"East 6", "East 7", "East 8", "East 9", "East 10",
|
|
|
|
|
|
"West 1", "West 2", "West 3", "West 4", "West 5",
|
|
|
|
|
|
"West 6", "West 7", "West 8", "West 9", "West 10",
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* All available bracket templates
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|
|
|
|
|
simple_4: SIMPLE_4,
|
|
|
|
|
|
simple_8: SIMPLE_8,
|
|
|
|
|
|
simple_16: SIMPLE_16,
|
|
|
|
|
|
simple_32: SIMPLE_32,
|
|
|
|
|
|
ncaa_68: NCAA_68,
|
|
|
|
|
|
nfl_14: NFL_14,
|
2025-11-12 23:21:22 -08:00
|
|
|
|
afl_10: AFL_10,
|
2026-02-14 22:30:12 -08:00
|
|
|
|
fifa_48: FIFA_48,
|
Add PDC World Darts Championship simulator with Elo-based bracket (#248)
Fixes #123
* Add PDC World Darts Championship simulator (128-player bracket)
- New DartsSimulator: 128-player single-elimination, 7 rounds with
PDC-accurate best-of-sets formats (bo3/bo5/bo5/bo7/bo7/bo11/bo13).
ELO_DIVISOR=500 via set-level Bernoulli model. Two simulation paths:
Path A (bracket drawn) simulates from actual DB matches; Path B
(pre-bracket) seeds top 32 by world ranking in fixed positions and
randomly draws the remaining 96 per simulation run (50,000 iterations).
- darts_bracket added to simulatorTypeEnum and simulator registry.
- world_ranking nullable integer column added to participant_expected_values
(migration 0067); batchSaveSourceElos now accepts and persists it.
- Admin route /admin/sports-seasons/:id/darts-elo: bulk import with format
"Player Name, 2099, 1" (name, Elo, optional world ranking), fuzzy name
matching, auto-runs simulation and updates EV/snapshots on save.
- DARTS_128 bracket template added (scoring starts at Quarterfinals).
- 30 unit tests: math helpers, bracket seeding structure, Path A/B integration.
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Review fixes: pre-compute hot-loop invariants, import normalizeName
- Pre-compute seededSlots and getSeededMatchOrder(32) before the 50k
simulation loop — was being recomputed every iteration
- Pad unseeded pool to 96 once before the loop instead of inside it
- Inline bracket-building in the hot loop using pre-computed seededSlots;
removes the per-iteration call to buildR1Bracket/getSeededMatchOrder
- Remove dead SEEDED_R1_PAIRS constant (was never referenced)
- Import normalizeName from ~/lib/fuzzy-match instead of redefining it locally
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix lint failures: unused vars, eqeqeq, toSorted
- Remove unused seededSet/unseededSet variables in test
- Replace != null with !== null && !== undefined (eqeqeq rule)
- Replace .sort() with .toSorted() in simulator and route (unicorn/no-array-sort)
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
* Fix TS2552: restore seededSet declaration removed during lint fix
https://claude.ai/code/session_01WZ6FjMmC2eBeZ1564M9Ruz
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-31 15:37:28 -07:00
|
|
|
|
darts_128: DARTS_128,
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
cfp_12: CFP_12,
|
2026-04-13 00:51:53 -04:00
|
|
|
|
nba_20: NBA_20,
|
2025-11-03 13:16:37 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get a bracket template by ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getBracketTemplate(id: string): BracketTemplate | undefined {
|
|
|
|
|
|
return BRACKET_TEMPLATES[id];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get all bracket templates as an array
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getAllBracketTemplates(): BracketTemplate[] {
|
|
|
|
|
|
return Object.values(BRACKET_TEMPLATES);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 12:27:43 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns round names in chronological order (earliest first) for the given matches.
|
|
|
|
|
|
* When a template is provided its defined order is used; otherwise rounds are sorted
|
|
|
|
|
|
* by descending match count (more matches = earlier round).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getOrderedRoundsFromMatches(
|
|
|
|
|
|
matches: Array<{ round: string }>,
|
|
|
|
|
|
template?: BracketTemplate
|
|
|
|
|
|
): string[] {
|
|
|
|
|
|
const roundsInMatches = new Set(matches.map((m) => m.round));
|
|
|
|
|
|
|
|
|
|
|
|
if (template) {
|
|
|
|
|
|
return template.rounds
|
|
|
|
|
|
.map((r) => r.name)
|
|
|
|
|
|
.filter((name) => roundsInMatches.has(name));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const roundMatchCounts = new Map<string, number>();
|
|
|
|
|
|
for (const m of matches) {
|
|
|
|
|
|
roundMatchCounts.set(m.round, (roundMatchCounts.get(m.round) || 0) + 1);
|
|
|
|
|
|
}
|
2026-03-21 09:44:05 -07:00
|
|
|
|
return Array.from(roundMatchCounts.keys()).toSorted(
|
2026-03-16 12:27:43 -07:00
|
|
|
|
(a, b) => (roundMatchCounts.get(b) || 0) - (roundMatchCounts.get(a) || 0)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Helper to determine scoring round type based on match count in scoring rounds
|
|
|
|
|
|
* Used for determining placement sharing (5-8th, 3-4th, 1-2nd)
|
2025-11-12 23:21:22 -08:00
|
|
|
|
*
|
|
|
|
|
|
* Special handling for AFL finals system which has specific placement rules
|
2025-11-03 13:16:37 -08:00
|
|
|
|
*/
|
|
|
|
|
|
export function getScoringRoundType(
|
|
|
|
|
|
roundName: string,
|
|
|
|
|
|
template: BracketTemplate
|
|
|
|
|
|
): "quarterfinals" | "semifinals" | "finals" | null {
|
|
|
|
|
|
const round = template.rounds.find((r) => r.name === roundName);
|
|
|
|
|
|
if (!round || !round.isScoring) return null;
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
// Special handling for AFL finals
|
|
|
|
|
|
if (template.id === "afl_10") {
|
|
|
|
|
|
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
|
|
|
|
|
if (roundName === "Semi-Finals") return "quarterfinals"; // Losers share 5-6th (custom mapping)
|
|
|
|
|
|
if (roundName === "Preliminary Finals") return "semifinals"; // Losers share 3-4th
|
|
|
|
|
|
if (roundName === "Grand Final") return "finals"; // 1st and 2nd
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Standard logic for other templates
|
2025-11-03 13:16:37 -08:00
|
|
|
|
// Determine type by match count in scoring round
|
|
|
|
|
|
if (round.matchCount === 4) return "quarterfinals"; // 8 teams, losers share 5-8th
|
|
|
|
|
|
if (round.matchCount === 2) return "semifinals"; // 4 teams, losers share 3-4th
|
|
|
|
|
|
if (round.matchCount === 1) return "finals"; // 2 teams, 1st and 2nd
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|