claude/llws-double-elimination-bracket-oaj093 #138
10 changed files with 1429 additions and 234 deletions
|
|
@ -152,7 +152,22 @@ export function TabbedBracketLayout({
|
|||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||
|
||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
// Restrict each round to the match numbers this phase's groups actually claim.
|
||||
// Rounds can be shared across phases (LLWS runs U.S. and International through
|
||||
// the same rounds), so without this the mobile view would merge both sides into
|
||||
// one column. No-op where a phase's groups already cover every match in the
|
||||
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
|
||||
// group filter.
|
||||
const phaseMatchesByRound = new Map(
|
||||
phaseRounds.map((r) => {
|
||||
const all = matchesByRound.get(r) ?? [];
|
||||
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
|
||||
const allowed = new Set(
|
||||
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
|
||||
);
|
||||
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
|
||||
})
|
||||
);
|
||||
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
|
||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ export interface BracketRound {
|
|||
* When set, the loser of each match in this round is placed into the target round.
|
||||
*/
|
||||
loserFeedsInto?: string | null;
|
||||
/**
|
||||
* Floor position banked by the WINNER of a *non-scoring* round.
|
||||
*
|
||||
* Omit for the default behavior: winners entering the first scoring round bank a
|
||||
* T5–T8 floor (position 5), everyone else banks nothing. Set an explicit number when
|
||||
* that default is wrong — in a double-elimination losers bracket a win can guarantee
|
||||
* a worse finish than 5th (llws_20 "Elimination Round 3" → 7). Set null to bank no
|
||||
* floor even though the next round scores.
|
||||
*
|
||||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||
*/
|
||||
nonScoringWinnerFloor?: number | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -934,6 +946,257 @@ export const NBA_20: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Side-local match numbers → global match numbers, per round shape. */
|
||||
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M1–4, Intl M5–8
|
||||
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4
|
||||
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
|
||||
|
||||
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
|
||||
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
|
||||
"Winners Round 2",
|
||||
"Elimination Round 1",
|
||||
"Winners Semifinals",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the global match number for a side-local match in an LLWS round.
|
||||
* side 0 = United States, side 1 = International.
|
||||
*/
|
||||
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
return localMatch + side * offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of llwsMatchNumber: global match number → { side, localMatch }.
|
||||
*/
|
||||
export function llwsSideAndLocal(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { side: 0 | 1; localMatch: number } {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
|
||||
return { side, localMatch: matchNumber - side * offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
|
||||
*
|
||||
* Two independent 10-team double-elimination brackets — United States and
|
||||
* International — each producing a side champion, then a World Championship game and
|
||||
* a Consolation Third Place game between the two side runners-up.
|
||||
*
|
||||
* Rounds are shared across both sides: U.S. matches take the low match numbers and
|
||||
* International the high ones (see llwsMatchNumber). The phases/groups config splits
|
||||
* them back apart for display.
|
||||
*
|
||||
* A loss in the winners bracket is NOT an elimination — it drops the team into the
|
||||
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
|
||||
* A loss in the elimination bracket is final.
|
||||
*
|
||||
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
|
||||
* it loses the Bracket Championship, dropping to the Consolation game rather than
|
||||
* forcing a rematch. This is the official LLWS modified double-elimination format.
|
||||
*
|
||||
* Placement tiers (only 8 teams score — the field is exactly 8 when Elim R4 begins):
|
||||
* 1st / 2nd World Championship
|
||||
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
|
||||
* 5th / 6th Elimination Final losers
|
||||
* 7th / 8th Elimination Round 4 losers
|
||||
* 0 pts the 12 teams eliminated in Elimination Rounds 1–3
|
||||
*
|
||||
* Participant array layout (20 slots):
|
||||
* [0–7] U.S. Opening Round teams, two per game (M1..M4)
|
||||
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
|
||||
* [10–17] International Opening Round teams, two per game (M5..M8)
|
||||
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
|
||||
*/
|
||||
export const LLWS_20: BracketTemplate = {
|
||||
id: "llws_20",
|
||||
name: "Little League World Series (20 teams)",
|
||||
totalTeams: 20,
|
||||
scoringStartsAtRound: "Winners Final",
|
||||
// Ordered by the real schedule so non-phased views read chronologically.
|
||||
rounds: [
|
||||
{
|
||||
name: "Opening Round",
|
||||
matchCount: 8,
|
||||
feedsInto: "Winners Round 2",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 1",
|
||||
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
|
||||
},
|
||||
{
|
||||
name: "Winners Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Semifinals",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 2",
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 1",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 2",
|
||||
isScoring: false, // losers finish 13th–16th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Winners Semifinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Final",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 3",
|
||||
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
|
||||
// Elimination Final). Same value as the engine default, stated explicitly.
|
||||
nonScoringWinnerFloor: 5,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 3",
|
||||
isScoring: false, // losers finish 11th–12th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 3",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 4",
|
||||
isScoring: false, // losers finish 9th–10th
|
||||
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Winners Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
|
||||
loserFeedsInto: "Elimination Final",
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 4",
|
||||
matchCount: 2,
|
||||
feedsInto: "Elimination Final",
|
||||
isScoring: true, // losers share 7th–8th
|
||||
},
|
||||
{
|
||||
name: "Elimination Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // losers share 5th–6th
|
||||
},
|
||||
{
|
||||
name: "Bracket Championship",
|
||||
matchCount: 2,
|
||||
feedsInto: "World Championship",
|
||||
isScoring: true, // loser drops to the Consolation game (provisional 4th)
|
||||
loserFeedsInto: "Consolation Third Place",
|
||||
},
|
||||
{
|
||||
name: "Consolation Third Place",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 3rd, loser 4th
|
||||
},
|
||||
{
|
||||
name: "World Championship",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 1st, loser 2nd
|
||||
},
|
||||
],
|
||||
// Region assignments rotate year to year (which region draws the bye changes), so
|
||||
// these are positional slot labels rather than region names.
|
||||
participantLabels: [
|
||||
"U.S. Opening 1 — Home", "U.S. Opening 1 — Away",
|
||||
"U.S. Opening 2 — Home", "U.S. Opening 2 — Away",
|
||||
"U.S. Opening 3 — Home", "U.S. Opening 3 — Away",
|
||||
"U.S. Opening 4 — Home", "U.S. Opening 4 — Away",
|
||||
"U.S. Bye — Winners R2 G1", "U.S. Bye — Winners R2 G2",
|
||||
"Intl Opening 1 — Home", "Intl Opening 1 — Away",
|
||||
"Intl Opening 2 — Home", "Intl Opening 2 — Away",
|
||||
"Intl Opening 3 — Home", "Intl Opening 3 — Away",
|
||||
"Intl Opening 4 — Home", "Intl Opening 4 — Away",
|
||||
"Intl Bye — Winners R2 G1", "Intl Bye — Winners R2 G2",
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
name: "United States",
|
||||
groups: [
|
||||
{
|
||||
name: "U.S. Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [1, 2, 3, 4],
|
||||
"Winners Round 2": [1, 2],
|
||||
"Winners Semifinals": [1, 2],
|
||||
"Winners Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [1, 2],
|
||||
"Elimination Round 2": [1, 2],
|
||||
"Elimination Round 3": [1, 2],
|
||||
"Elimination Round 4": [1],
|
||||
"Elimination Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [1] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "International",
|
||||
groups: [
|
||||
{
|
||||
name: "International Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [5, 6, 7, 8],
|
||||
"Winners Round 2": [3, 4],
|
||||
"Winners Semifinals": [3, 4],
|
||||
"Winners Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [3, 4],
|
||||
"Elimination Round 2": [3, 4],
|
||||
"Elimination Round 3": [3, 4],
|
||||
"Elimination Round 4": [2],
|
||||
"Elimination Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [2] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Championship",
|
||||
rounds: ["Consolation Third Place", "World Championship"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* All available bracket templates
|
||||
*/
|
||||
|
|
@ -951,6 +1214,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
llws_20: LLWS_20,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1006,6 +1270,18 @@ export function getScoringRoundType(
|
|||
const round = template.rounds.find((r) => r.name === roundName);
|
||||
if (!round || !round.isScoring) return null;
|
||||
|
||||
// Special handling for LLWS double elimination: match counts don't identify the
|
||||
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
|
||||
// the Winners Final eliminates nobody.
|
||||
if (template.id === "llws_20") {
|
||||
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
|
||||
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
|
||||
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
|
||||
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
|
||||
if (roundName === "World Championship") return "finals"; // 1st and 2nd
|
||||
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
|
||||
}
|
||||
|
||||
// Special handling for AFL finals
|
||||
if (template.id === "afl_10") {
|
||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||
|
|
|
|||
561
app/models/__tests__/llws-20-bracket.test.ts
Normal file
561
app/models/__tests__/llws-20-bracket.test.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
/**
|
||||
* LLWS 20-Team Double-Elimination Bracket Tests
|
||||
*
|
||||
* Verifies the llws_20 template against the official 2026 LLBWS bracket
|
||||
* (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear
|
||||
* throughout as `G<n>` so the routing can be checked against the printed bracket.
|
||||
*
|
||||
* The critical property under test is the double-elimination loser routing: a loss in
|
||||
* the winners bracket drops a team into the elimination bracket at a specific slot,
|
||||
* while a loss in the elimination bracket is final.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
getScoringRoundType,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { generateBracketFromTemplate, resolveLLWSAdvancement } from "../playoff-match";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
calculateAveragedPoints,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
|
||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||
// minimal stub is enough to capture the generated rows.
|
||||
const insertedRows: Record<string, unknown>[] = [];
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
insert: () => ({
|
||||
values: (rows: Record<string, unknown>[]) => ({
|
||||
returning: async () => {
|
||||
insertedRows.push(...rows);
|
||||
return rows;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 10,
|
||||
};
|
||||
|
||||
// ── PDF game number ↔ (round, match number) ──────────────────────────────────
|
||||
//
|
||||
// Transcribed directly from the 2026 LLBWS bracket. U.S. games take the low match
|
||||
// numbers in each round, International the high ones.
|
||||
const GAME_TO_MATCH: Record<number, { round: string; matchNumber: number }> = {
|
||||
// Opening Round — U.S. G2,4,6,8 (M1–4); Intl G1,3,5,7 (M5–8)
|
||||
2: { round: "Opening Round", matchNumber: 1 },
|
||||
4: { round: "Opening Round", matchNumber: 2 },
|
||||
6: { round: "Opening Round", matchNumber: 3 },
|
||||
8: { round: "Opening Round", matchNumber: 4 },
|
||||
1: { round: "Opening Round", matchNumber: 5 },
|
||||
3: { round: "Opening Round", matchNumber: 6 },
|
||||
5: { round: "Opening Round", matchNumber: 7 },
|
||||
7: { round: "Opening Round", matchNumber: 8 },
|
||||
// Winners Round 2 — U.S. G10,12; Intl G9,11
|
||||
10: { round: "Winners Round 2", matchNumber: 1 },
|
||||
12: { round: "Winners Round 2", matchNumber: 2 },
|
||||
9: { round: "Winners Round 2", matchNumber: 3 },
|
||||
11: { round: "Winners Round 2", matchNumber: 4 },
|
||||
// Elimination Round 1 — U.S. G14,16; Intl G13,15
|
||||
14: { round: "Elimination Round 1", matchNumber: 1 },
|
||||
16: { round: "Elimination Round 1", matchNumber: 2 },
|
||||
13: { round: "Elimination Round 1", matchNumber: 3 },
|
||||
15: { round: "Elimination Round 1", matchNumber: 4 },
|
||||
// Winners Semifinals — U.S. G17,19; Intl G18,20
|
||||
17: { round: "Winners Semifinals", matchNumber: 1 },
|
||||
19: { round: "Winners Semifinals", matchNumber: 2 },
|
||||
18: { round: "Winners Semifinals", matchNumber: 3 },
|
||||
20: { round: "Winners Semifinals", matchNumber: 4 },
|
||||
// Elimination Round 2 — U.S. G22,24; Intl G21,23
|
||||
22: { round: "Elimination Round 2", matchNumber: 1 },
|
||||
24: { round: "Elimination Round 2", matchNumber: 2 },
|
||||
21: { round: "Elimination Round 2", matchNumber: 3 },
|
||||
23: { round: "Elimination Round 2", matchNumber: 4 },
|
||||
// Elimination Round 3 — U.S. G26,28; Intl G25,27
|
||||
26: { round: "Elimination Round 3", matchNumber: 1 },
|
||||
28: { round: "Elimination Round 3", matchNumber: 2 },
|
||||
25: { round: "Elimination Round 3", matchNumber: 3 },
|
||||
27: { round: "Elimination Round 3", matchNumber: 4 },
|
||||
// Winners Final — U.S. G30; Intl G29
|
||||
30: { round: "Winners Final", matchNumber: 1 },
|
||||
29: { round: "Winners Final", matchNumber: 2 },
|
||||
// Elimination Round 4 — U.S. G32; Intl G31
|
||||
32: { round: "Elimination Round 4", matchNumber: 1 },
|
||||
31: { round: "Elimination Round 4", matchNumber: 2 },
|
||||
// Elimination Final — U.S. G34; Intl G33
|
||||
34: { round: "Elimination Final", matchNumber: 1 },
|
||||
33: { round: "Elimination Final", matchNumber: 2 },
|
||||
// Bracket Championship — U.S. G36; Intl G35
|
||||
36: { round: "Bracket Championship", matchNumber: 1 },
|
||||
35: { round: "Bracket Championship", matchNumber: 2 },
|
||||
// Finals
|
||||
37: { round: "Consolation Third Place", matchNumber: 1 },
|
||||
38: { round: "World Championship", matchNumber: 1 },
|
||||
};
|
||||
|
||||
const MATCH_TO_GAME = new Map<string, number>(
|
||||
Object.entries(GAME_TO_MATCH).map(([game, m]) => [
|
||||
`${m.round}#${m.matchNumber}`,
|
||||
Number(game),
|
||||
])
|
||||
);
|
||||
|
||||
function gameNumberFor(round: string, matchNumber: number): number {
|
||||
const game = MATCH_TO_GAME.get(`${round}#${matchNumber}`);
|
||||
if (game === undefined) throw new Error(`No PDF game for ${round} #${matchNumber}`);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Narrows a destination that the test expects to exist. */
|
||||
function required<T>(destination: T | null): T {
|
||||
if (destination === null) throw new Error("Expected a destination, got null");
|
||||
return destination;
|
||||
}
|
||||
|
||||
/** PDF game number a destination points at. */
|
||||
function destinationGame(
|
||||
destination: { round: string; matchNumber: number } | null
|
||||
): number {
|
||||
const d = required(destination);
|
||||
return gameNumberFor(d.round, d.matchNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* The official bracket printed as feed labels: for each game, which prior game's
|
||||
* winner (W) or loser (L) fills each slot. `null` = a team seeded in directly.
|
||||
*
|
||||
* Transcribed from the PDF. This is the source of truth the routing must reproduce.
|
||||
*/
|
||||
const EXPECTED_SLOTS: Record<number, [string | null, string | null]> = {
|
||||
// Opening Round — all directly seeded
|
||||
1: [null, null], 2: [null, null], 3: [null, null], 4: [null, null],
|
||||
5: [null, null], 6: [null, null], 7: [null, null], 8: [null, null],
|
||||
// Winners Round 2 — bye team, then an Opening Round winner
|
||||
9: [null, "W1"], 10: [null, "W2"], 11: [null, "W3"], 12: [null, "W4"],
|
||||
// Elimination Round 1
|
||||
13: ["L3", "L5"], 14: ["L4", "L6"], 15: ["L1", "L7"], 16: ["L2", "L8"],
|
||||
// Winners Semifinals
|
||||
17: ["W6", "W10"], 18: ["W5", "W9"], 19: ["W12", "W8"], 20: ["W11", "W7"],
|
||||
// Elimination Round 2
|
||||
21: ["L9", "W13"], 22: ["L10", "W14"], 23: ["L11", "W15"], 24: ["L12", "W16"],
|
||||
// Elimination Round 3 — cross-over
|
||||
25: ["L18", "W23"], 26: ["L17", "W24"], 27: ["L20", "W21"], 28: ["L19", "W22"],
|
||||
// Winners Final
|
||||
29: ["W18", "W20"], 30: ["W17", "W19"],
|
||||
// Elimination Round 4
|
||||
31: ["W27", "W25"], 32: ["W28", "W26"],
|
||||
// Elimination Final
|
||||
33: ["L29", "W31"], 34: ["L30", "W32"],
|
||||
// Bracket Championship
|
||||
35: ["W29", "W33"], 36: ["W30", "W34"],
|
||||
// Finals
|
||||
37: ["L36", "L35"], 38: ["W36", "W35"],
|
||||
};
|
||||
|
||||
describe("LLWS 20 Bracket Template", () => {
|
||||
describe("Template structure", () => {
|
||||
it("has correct identity and size", () => {
|
||||
expect(LLWS_20.id).toBe("llws_20");
|
||||
expect(LLWS_20.totalTeams).toBe(20);
|
||||
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
|
||||
});
|
||||
|
||||
it("has 12 rounds totalling 38 matches", () => {
|
||||
expect(LLWS_20.rounds).toHaveLength(12);
|
||||
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
|
||||
expect(total).toBe(38);
|
||||
});
|
||||
|
||||
it("has the expected match count per round", () => {
|
||||
const counts = Object.fromEntries(
|
||||
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
|
||||
);
|
||||
expect(counts).toEqual({
|
||||
"Opening Round": 8,
|
||||
"Winners Round 2": 4,
|
||||
"Elimination Round 1": 4,
|
||||
"Winners Semifinals": 4,
|
||||
"Elimination Round 2": 4,
|
||||
"Elimination Round 3": 4,
|
||||
"Winners Final": 2,
|
||||
"Elimination Round 4": 2,
|
||||
"Elimination Final": 2,
|
||||
"Bracket Championship": 2,
|
||||
"Consolation Third Place": 1,
|
||||
"World Championship": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks exactly the point-awarding rounds as scoring", () => {
|
||||
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
|
||||
expect(scoring).toEqual([
|
||||
"Winners Final",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
"Bracket Championship",
|
||||
"Consolation Third Place",
|
||||
"World Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rounds in chronological order", () => {
|
||||
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
|
||||
const names = LLWS_20.rounds.map((r) => r.name);
|
||||
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
|
||||
names.indexOf("Winners Semifinals")
|
||||
);
|
||||
expect(names.indexOf("Winners Final")).toBeLessThan(
|
||||
names.indexOf("Elimination Final")
|
||||
);
|
||||
});
|
||||
|
||||
it("gives elimination-bracket winners a floor matching their real worst case", () => {
|
||||
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
|
||||
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier),
|
||||
// so the engine's default floor of 5 would overstate it.
|
||||
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
|
||||
// Reaching the Winners Final guarantees 5th at worst.
|
||||
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
|
||||
// Nothing is guaranteed earlier than that.
|
||||
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
});
|
||||
|
||||
it("has 20 participant labels", () => {
|
||||
expect(LLWS_20.participantLabels).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("splits display into U.S., International and Championship phases", () => {
|
||||
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
|
||||
"United States",
|
||||
"International",
|
||||
"Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns every match to exactly one phase group", () => {
|
||||
const claimed = new Map<string, number>();
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
|
||||
for (const n of numbers) {
|
||||
const key = `${round}#${n}`;
|
||||
claimed.set(key, (claimed.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every per-side match claimed exactly once (36 games; the 2 finals live in
|
||||
// the Championship phase's plain round list, not in a group).
|
||||
expect(claimed.size).toBe(36);
|
||||
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracket generation", () => {
|
||||
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
|
||||
|
||||
async function generate() {
|
||||
insertedRows.length = 0;
|
||||
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
|
||||
return insertedRows.map((r) => ({
|
||||
round: r.round as string,
|
||||
matchNumber: r.matchNumber as number,
|
||||
participant1Id: (r.participant1Id ?? null) as string | null,
|
||||
participant2Id: (r.participant2Id ?? null) as string | null,
|
||||
isScoring: r.isScoring as boolean,
|
||||
}));
|
||||
}
|
||||
|
||||
it("creates all 38 matches", async () => {
|
||||
const rows = await generate();
|
||||
expect(rows).toHaveLength(38);
|
||||
});
|
||||
|
||||
it("creates the right number of matches per round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
expect(
|
||||
rows.filter((r) => r.round === round.name),
|
||||
`${round.name} match count`
|
||||
).toHaveLength(round.matchCount);
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers matches 1..n within each round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
const numbers = rows
|
||||
.filter((r) => r.round === round.name)
|
||||
.map((r) => r.matchNumber)
|
||||
.toSorted((a, b) => a - b);
|
||||
expect(numbers).toEqual(
|
||||
Array.from({ length: round.matchCount }, (_, i) => i + 1)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
|
||||
const rows = await generate();
|
||||
const opening = rows
|
||||
.filter((r) => r.round === "Opening Round")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
// U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8.
|
||||
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-0", "team-1"],
|
||||
["team-2", "team-3"],
|
||||
["team-4", "team-5"],
|
||||
["team-6", "team-7"],
|
||||
["team-10", "team-11"],
|
||||
["team-12", "team-13"],
|
||||
["team-14", "team-15"],
|
||||
["team-16", "team-17"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
|
||||
const rows = await generate();
|
||||
const wr2 = rows
|
||||
.filter((r) => r.round === "Winners Round 2")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-8", null],
|
||||
["team-9", null],
|
||||
["team-18", null],
|
||||
["team-19", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses each participant exactly once and leaves every other slot empty", async () => {
|
||||
const rows = await generate();
|
||||
const seeded = rows
|
||||
.flatMap((r) => [r.participant1Id, r.participant2Id])
|
||||
.filter((id): id is string => id !== null);
|
||||
expect(seeded).toHaveLength(20);
|
||||
expect(new Set(seeded).size).toBe(20);
|
||||
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
|
||||
});
|
||||
|
||||
it("stamps isScoring from the template", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
for (const row of rows.filter((r) => r.round === round.name)) {
|
||||
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a participant count other than 20", async () => {
|
||||
await expect(
|
||||
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
|
||||
).rejects.toThrow(/requires 20 participants/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Side / match-number mapping", () => {
|
||||
it("round-trips every match number through side-local form", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.matchCount === 1) continue; // shared finals have no side
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side, localMatch } = llwsSideAndLocal(round.name, n);
|
||||
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("puts U.S. matches in the low half and International in the high half", () => {
|
||||
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advancement matches the official bracket", () => {
|
||||
/**
|
||||
* Replay the whole tournament through resolveLLWSAdvancement and record which
|
||||
* feed label ends up in each slot, then compare against the printed bracket.
|
||||
*/
|
||||
const actualSlots: Record<number, [string | null, string | null]> = {};
|
||||
for (const game of Object.keys(EXPECTED_SLOTS)) {
|
||||
actualSlots[Number(game)] = [null, null];
|
||||
}
|
||||
|
||||
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
|
||||
const game = Number(gameStr);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
|
||||
for (const [dest, label] of [
|
||||
[winner, `W${game}`],
|
||||
[loser, `L${game}`],
|
||||
] as const) {
|
||||
if (!dest) continue;
|
||||
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
|
||||
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
|
||||
actualSlots[targetGame][slotIndex] = label;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
|
||||
"Game %i has the printed participants",
|
||||
(game) => {
|
||||
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
|
||||
}
|
||||
);
|
||||
|
||||
it("fills every slot in the bracket exactly once", () => {
|
||||
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
|
||||
// leaving 56 to be filled by advancement.
|
||||
const filled = Object.values(actualSlots)
|
||||
.flat()
|
||||
.filter((s) => s !== null).length;
|
||||
expect(filled).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Double-elimination loser routing", () => {
|
||||
it("routes every winners-bracket loser into the elimination bracket", () => {
|
||||
const winnersRounds = [
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
];
|
||||
for (const roundName of winnersRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
|
||||
expect(loser?.round.startsWith("Elimination")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminates every elimination-bracket loser", () => {
|
||||
const elimRounds = [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
];
|
||||
for (const roundName of elimRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
|
||||
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
|
||||
// double-elimination behavior: a first loss never eliminates.
|
||||
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
|
||||
expect(destinationGame(loser)).toBe(34);
|
||||
expect(destinationGame(winner)).toBe(36);
|
||||
});
|
||||
|
||||
it("sends the side-championship loser to the consolation game, not out", () => {
|
||||
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
|
||||
// done in the bracket, but still plays G37 for 3rd/4th.
|
||||
const us = resolveLLWSAdvancement("Bracket Championship", 1);
|
||||
expect(destinationGame(us.winner)).toBe(38);
|
||||
expect(destinationGame(us.loser)).toBe(37);
|
||||
expect(required(us.winner).slot).toBe("participant1Id");
|
||||
expect(required(us.loser).slot).toBe("participant1Id");
|
||||
|
||||
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
|
||||
expect(required(intl.winner).slot).toBe("participant2Id");
|
||||
expect(required(intl.loser).slot).toBe("participant2Id");
|
||||
});
|
||||
|
||||
it("advances nobody out of the two final games", () => {
|
||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("never crosses a team between the U.S. and International sides", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.name === "Bracket Championship") continue; // the crossover point
|
||||
if (round.matchCount === 1) continue;
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side } = llwsSideAndLocal(round.name, n);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
for (const dest of [winner, loser]) {
|
||||
if (!dest) continue;
|
||||
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
|
||||
if (!destRound || destRound.matchCount === 1) continue;
|
||||
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placement tiers", () => {
|
||||
it("classifies scoring rounds correctly", () => {
|
||||
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
|
||||
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
|
||||
// Nobody is eliminated in the Winners Final — the loser drops to the
|
||||
// elimination bracket — so it has no placement tier.
|
||||
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
|
||||
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
|
||||
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
|
||||
});
|
||||
|
||||
it("splits 5–8 into two two-team tiers", () => {
|
||||
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
|
||||
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
// Surviving Elimination Round 4 is worth more than losing it.
|
||||
expect(upper).toBeGreaterThan(lower);
|
||||
});
|
||||
|
||||
it("awards nothing below 8th", () => {
|
||||
// The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th.
|
||||
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
});
|
||||
|
||||
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
|
||||
// Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point:
|
||||
// per side the Winners Final winner, the Winners Final loser, and the two
|
||||
// Elimination Round 3 winners.
|
||||
const eliminatedBeforeElimR4 =
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
|
||||
expect(eliminatedBeforeElimR4).toBe(12);
|
||||
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@ import {
|
|||
getBracketTemplate,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
|
||||
|
|
@ -467,6 +469,11 @@ export async function generateBracketFromTemplate(
|
|||
return await generateNBA20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
// LLWS 20 requires special handling for its two double-elimination brackets
|
||||
if (templateId === "llws_20") {
|
||||
return await generateLLWS20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
|
||||
// Generate matches for each round in the template
|
||||
|
|
@ -980,6 +987,15 @@ export async function advanceWinnerTemplate(
|
|||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for LLWS 20 double elimination: winners-bracket losers route
|
||||
// into the elimination bracket instead of being knocked out.
|
||||
if (template.id === "llws_20") {
|
||||
const loserId =
|
||||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
|
||||
return await advanceLLWSWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for AFL 10 double-chance system
|
||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||
if (template.id === "afl_10") {
|
||||
|
|
@ -1427,6 +1443,12 @@ export function doesLoserAdvance(
|
|||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||
return true;
|
||||
}
|
||||
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
|
||||
// must not be recorded as an elimination. (Winners Final and Bracket Championship
|
||||
// are scoring rounds and are handled via loserIsPartial instead.)
|
||||
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner(
|
|||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||
*/
|
||||
interface LLWSDestination {
|
||||
round: string;
|
||||
localMatch: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||
*
|
||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||
* says where the winner goes and where the loser goes (null = eliminated).
|
||||
*
|
||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||
*
|
||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||
* eliminating the team; a loss in the elimination bracket is final.
|
||||
*/
|
||||
const LLWS_ADVANCEMENT: Record<
|
||||
string,
|
||||
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||
> = {
|
||||
"Opening Round": {
|
||||
1: {
|
||||
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
3: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||
},
|
||||
4: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||
},
|
||||
},
|
||||
"Winners Round 2": {
|
||||
1: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Semifinals": {
|
||||
1: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||
// A winners-bracket final loss is not an elimination — it drops to the
|
||||
// Elimination Final for a second chance at the side championship.
|
||||
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Elimination Round 1": {
|
||||
1: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 2": {
|
||||
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 3": {
|
||||
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 4": {
|
||||
1: {
|
||||
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||
const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
]);
|
||||
|
||||
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
||||
export interface LLWSResolvedDestination {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
||||
* numbers. `null` means that participant has no further game (eliminated, or the
|
||||
* tournament is over for them).
|
||||
*
|
||||
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
||||
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
||||
*/
|
||||
export function resolveLLWSAdvancement(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
||||
// Terminal rounds — nobody advances.
|
||||
if (round === "Consolation Third Place" || round === "World Championship") {
|
||||
return { winner: null, loser: null };
|
||||
}
|
||||
|
||||
// Bracket Championship is the crossover: the winner goes to the World Championship
|
||||
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
||||
// participant1, International participant2), so the two sides can't collide.
|
||||
if (round === "Bracket Championship") {
|
||||
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
||||
const slot: "participant1Id" | "participant2Id" =
|
||||
side === 0 ? "participant1Id" : "participant2Id";
|
||||
return {
|
||||
winner: { round: "World Championship", matchNumber: 1, slot },
|
||||
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
||||
};
|
||||
}
|
||||
|
||||
const roundMap = LLWS_ADVANCEMENT[round];
|
||||
if (!roundMap) {
|
||||
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
||||
}
|
||||
|
||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||
const routes = roundMap[localMatch];
|
||||
if (!routes) {
|
||||
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
||||
}
|
||||
|
||||
// Winner and loser stay on their own side, so the same side offset applies to both.
|
||||
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
||||
d === null
|
||||
? null
|
||||
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
||||
|
||||
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||
*
|
||||
* Only the Opening Round and the four bye slots receive participants up front;
|
||||
* everything else is filled by advanceLLWSWinner as games complete.
|
||||
*
|
||||
* Participant array layout (see LLWS_20 in lib/bracket-templates):
|
||||
* [0–7] U.S. Opening Round teams, two per game
|
||||
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
||||
* [10–17] International Opening Round teams, two per game
|
||||
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
|
||||
*/
|
||||
async function generateLLWS20Bracket(
|
||||
eventId: string,
|
||||
template: BracketTemplate,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const p = (idx: number): string | null =>
|
||||
participantIds ? (participantIds[idx] ?? null) : null;
|
||||
|
||||
const sides = [
|
||||
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
|
||||
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
|
||||
];
|
||||
|
||||
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
|
||||
for (const { side, label, openingBase } of sides) {
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Opening Round",
|
||||
matchNumber: llwsMatchNumber("Opening Round", side, local),
|
||||
participant1Id: p(openingBase + (local - 1) * 2),
|
||||
participant2Id: p(openingBase + (local - 1) * 2 + 1),
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Opening Round",
|
||||
seedInfo: `${label} Opening ${local}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
|
||||
for (const { side, label, byeBase } of sides) {
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Winners Round 2",
|
||||
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
|
||||
participant1Id: p(byeBase + (local - 1)),
|
||||
participant2Id: null, // Opening Round winner
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Winners Round 2",
|
||||
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Every remaining round starts empty ──────────────────────────────────────
|
||||
const remaining = template.rounds.filter(
|
||||
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
|
||||
);
|
||||
for (const round of remaining) {
|
||||
for (let i = 1; i <= round.matchCount; i++) {
|
||||
// Championship/Consolation are single shared games; everything else is per-side.
|
||||
const perSide = round.matchCount > 1;
|
||||
const label = perSide
|
||||
? llwsSideAndLocal(round.name, i).side === 0
|
||||
? "U.S."
|
||||
: "Intl"
|
||||
: null;
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: label ? `${label} ${round.name}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
|
||||
* loser into the elimination bracket rather than eliminating them.
|
||||
*
|
||||
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
|
||||
*/
|
||||
async function advanceLLWSWinner(
|
||||
match: PlayoffMatch,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
|
||||
|
||||
// Winner and loser can land in different rounds, so resolve each independently.
|
||||
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
|
||||
if (winner) moves.push({ destination: winner, participantId: winnerId });
|
||||
if (loser) moves.push({ destination: loser, participantId: loserId });
|
||||
|
||||
for (const { destination, participantId } of moves) {
|
||||
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
eventId,
|
||||
destination.round
|
||||
);
|
||||
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
|
||||
);
|
||||
}
|
||||
if (target[destination.slot]) {
|
||||
throw new Error(
|
||||
`Next match ${destination.slot} is already filled ` +
|
||||
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
|
||||
);
|
||||
}
|
||||
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,21 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
llws_20: {
|
||||
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
|
||||
// winning that game lifts them back to a 4th-place floor.
|
||||
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
|
||||
// Elimination Round 4 losers are the 7th–8th tier (8 teams alive at this point).
|
||||
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
||||
// Elimination Final losers are the 5th–6th tier; the winner reaches the side
|
||||
// championship, where the worst case is 4th (lose it, then lose the consolation).
|
||||
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
||||
// Side championship loser still has the consolation game — provisional 4th.
|
||||
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
||||
// Consolation finalizes 3rd and 4th distinctly.
|
||||
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
|
|
@ -126,28 +141,37 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
};
|
||||
|
||||
/**
|
||||
* Returns true if a non-scoring round's winners are entering the first scoring round
|
||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
||||
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||
* to bank nothing.
|
||||
*
|
||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
||||
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||
* multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet
|
||||
* Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
|
||||
*
|
||||
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
||||
* A round may override this with `nonScoringWinnerFloor` when the default is wrong —
|
||||
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
|
||||
* (llws_20 "Elimination Round 3" → 7), or nothing at all.
|
||||
*
|
||||
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
|
||||
*/
|
||||
function doesNonScoringRoundFeedIntoScoringRound(
|
||||
function nonScoringWinnerFloorFor(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): boolean {
|
||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
||||
): number | null {
|
||||
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||
if (!template) return true; // Unknown template: preserve old behavior
|
||||
if (!template) return 5; // Unknown template: preserve old behavior
|
||||
const currentRound = template.rounds.find((r) => r.name === round);
|
||||
if (!currentRound) return true; // Unknown round: preserve old behavior
|
||||
if (!currentRound) return 5; // Unknown round: preserve old behavior
|
||||
// Explicit per-round override wins, including an explicit null (bank nothing).
|
||||
if (currentRound.nonScoringWinnerFloor !== undefined) {
|
||||
return currentRound.nonScoringWinnerFloor;
|
||||
}
|
||||
const nextRoundName = currentRound.feedsInto;
|
||||
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
||||
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
|
||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||
return nextRound?.isScoring === true;
|
||||
return nextRound?.isScoring === true ? 5 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -286,14 +310,14 @@ export async function processPlayoffEvent(
|
|||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||
for (const match of matches) {
|
||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||
if (match.loserId && !loserAdvances) {
|
||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||
}
|
||||
if (match.winnerId && awardFloor) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||
if (match.winnerId && winnerFloor !== null) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -441,8 +465,9 @@ export async function processMatchResult(
|
|||
if (!loserAdvances) {
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||
}
|
||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||
if (nonScoringFloor !== null) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||
}
|
||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints(
|
|||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||||
*
|
||||
* LLWS has the same shape from its two elimination brackets:
|
||||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||||
*/
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||||
|
||||
/**
|
||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||||
*
|
||||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||||
* the two side runners-up.
|
||||
*/
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||||
|
||||
/**
|
||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||
|
|
|
|||
|
|
@ -126,22 +126,48 @@ describe("LLWSSimulator", () => {
|
|||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("sum of probFifth across participants equals ~1.0 (4 bracket losers, split evenly)", async () => {
|
||||
it("probFifth sums to ~1.0 (2 Elimination Final losers per sim, split over 5th/6th)", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
// 4 bracket losers per sim, each assigned bracketLoser/(4*N) → sum = 1.0
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("probFifth through probEighth are equal for every participant (even bracket-loser split)", async () => {
|
||||
it("probSeventh sums to ~1.0 (2 Elimination Round 4 losers per sim, split over 7th/8th)", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
const total = results.reduce((s, r) => s + r.probabilities.probSeventh, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("ties 5th with 6th and 7th with 8th, but keeps the two tiers separate", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS, { includeOdds: true }));
|
||||
const results = await new LLWSSimulator(2_000).simulate("season-1");
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
// Within a tier the two positions are tied.
|
||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
||||
}
|
||||
// The tiers are distinct outcomes (losing the Elimination Final vs losing
|
||||
// Elimination Round 4), so they must not be forced equal across the field.
|
||||
const differs = results.some(
|
||||
(r) => Math.abs(r.probabilities.probFifth - r.probabilities.probSeventh) > 1e-9
|
||||
);
|
||||
expect(differs).toBe(true);
|
||||
});
|
||||
|
||||
it("gives every team a total placement probability of at most 1", async () => {
|
||||
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
for (const r of results) {
|
||||
const p = r.probabilities;
|
||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
||||
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
|
||||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
||||
// Each sim assigns a team at most one placement, so summing the distinct
|
||||
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
|
||||
const total =
|
||||
p.probFirst + p.probSecond + p.probThird + p.probFourth +
|
||||
p.probFifth * 2 + p.probSeventh * 2;
|
||||
expect(total).toBeLessThanOrEqual(1 + 1e-9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -179,10 +205,13 @@ describe("LLWSSimulator", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Pool assignment modes ─────────────────────────────────────────────────
|
||||
// ── Legacy externalId formats ─────────────────────────────────────────────
|
||||
//
|
||||
// The tournament no longer has pool play, but seasons configured for the old
|
||||
// format still carry pool suffixes. Those must keep loading, read as the side alone.
|
||||
|
||||
describe("pool assignment modes", () => {
|
||||
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
||||
describe("legacy pool-suffix externalIds", () => {
|
||||
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
|
||||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
|
|
@ -190,10 +219,10 @@ describe("LLWSSimulator", () => {
|
|||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("mixed mode: US fixed pools, Intl randomized", async () => {
|
||||
it("accepts a mix of suffixed and bare side ids", async () => {
|
||||
const participants = [
|
||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
|
|
@ -202,6 +231,17 @@ describe("LLWSSimulator", () => {
|
|||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||
expect(total).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("accepts an uneven suffix split (pools no longer constrain anything)", async () => {
|
||||
const participants = [
|
||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||
expect(results).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error cases ───────────────────────────────────────────────────────────
|
||||
|
|
@ -262,26 +302,13 @@ describe("LLWSSimulator", () => {
|
|||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||
});
|
||||
|
||||
it("throws when fixed pools have unequal A/B split", async () => {
|
||||
it("throws when International team count is not 10", async () => {
|
||||
const participants = [
|
||||
// 6 in Pool A, 4 in Pool B
|
||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:B" })),
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||||
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
|
||||
});
|
||||
|
||||
it("throws when US externalIds mix pool suffixes and bare side", async () => {
|
||||
const participants = [
|
||||
// Some US:A, some "US" (no pool suffix) → mixed
|
||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
||||
...US_IDS.slice(5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US" })), // no pool
|
||||
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||
];
|
||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/mixed externalId formats/);
|
||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
/**
|
||||
* Little League World Series (LLWS) Bracket Simulator
|
||||
*
|
||||
* Monte Carlo simulation of the LLWS (20-team format, 2022–present).
|
||||
* Monte Carlo simulation of the LLWS (20-team double-elimination format, 2025+).
|
||||
*
|
||||
* The tournament is two independent 10-team double-elimination brackets — United
|
||||
* States and International — each producing a side champion, then a World
|
||||
* Championship game and a Consolation game between the side runners-up. There is no
|
||||
* pool play. This mirrors the llws_20 bracket template so simulated placements line
|
||||
* up with the bracket admins actually score.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Load all 20 participants for the sports season from DB
|
||||
|
|
@ -10,48 +16,36 @@
|
|||
* (entered via Admin → Futures Odds; American format)
|
||||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||
* 4. Determine pool assignment mode from externalId:
|
||||
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
|
||||
* → use these exact pool assignments every simulation.
|
||||
* - Randomized pools: externalId is "US" or "Intl" only
|
||||
* → randomly shuffle each side into Pool A / Pool B each simulation.
|
||||
* 5. Per simulation:
|
||||
* a. Assign pools (fixed or random)
|
||||
* b. Simulate pool play round-robin within each pool (10 games/pool)
|
||||
* Top 2 by W-L record advance. Ties broken randomly.
|
||||
* c. Simulate 4-team double-elimination bracket per side:
|
||||
* G1: A1 vs B2 (WB)
|
||||
* G2: B1 vs A2 (WB)
|
||||
* G3: G1W vs G2W (WB Final)
|
||||
* G4: G1L vs G2L (LB R1 — loser eliminated)
|
||||
* G5: G3L vs G4W (LB Final — loser eliminated)
|
||||
* G6: G3W vs G5W (Side Championship — loser eliminated)
|
||||
* d. Consolation game: US loser vs Intl loser → 3rd / 4th
|
||||
* e. World Series: US champion vs Intl champion → 1st / 2nd
|
||||
* 6. Track placement counts across all simulations.
|
||||
* 7. Convert counts to probability distributions.
|
||||
* 4. Per simulation:
|
||||
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||||
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||||
* is not yet expressible in participant config.
|
||||
* b. Simulate the 10-team double-elimination bracket for each side
|
||||
* (see simulateSideBracket for the exact game-by-game structure)
|
||||
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||||
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||||
* 5. Track placement counts across all simulations.
|
||||
* 6. Convert counts to probability distributions.
|
||||
*
|
||||
* Pool assignment (externalId format):
|
||||
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
|
||||
* "US" / "Intl" → randomized pools (pre-draw mode)
|
||||
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
|
||||
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
|
||||
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
||||
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
|
||||
* alone, so seasons configured for the old pool-play format keep working — pools
|
||||
* no longer exist, so the suffix has no effect.
|
||||
*
|
||||
* Placement tiers → SimulationProbabilities mapping:
|
||||
* probFirst = World Series Champion (1 per sim)
|
||||
* probSecond = World Series Runner-up (1 per sim)
|
||||
* probThird = Consolation game winner / 3rd place (1 per sim)
|
||||
* probFourth = Consolation game loser / 4th place (1 per sim)
|
||||
* probFifth–probEighth = Double-elim bracket losers before side championships
|
||||
* (4 per sim — split evenly: 2 US + 2 Intl)
|
||||
* Pool play losers → all 0 (12 teams, did not advance from pool play)
|
||||
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||||
* probFirst = World Championship winner (1 per sim)
|
||||
* probSecond = World Championship loser (1 per sim)
|
||||
* probThird = Consolation winner (1 per sim)
|
||||
* probFourth = Consolation loser (1 per sim)
|
||||
* probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side)
|
||||
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side)
|
||||
* Everyone else → all 0 (12 teams out in Elimination Rounds 1–3)
|
||||
*
|
||||
* Admin setup:
|
||||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
||||
* 3. Set externalId on each participant via Admin → Manage Participants (optional if names follow the convention):
|
||||
* Pre-draw: "US" or "Intl" (or leave null — names starting with "US " infer US, all others infer Intl)
|
||||
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
|
||||
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||||
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||
* 5. Run simulation via Admin → Simulate
|
||||
*/
|
||||
|
|
@ -68,7 +62,6 @@ import { positiveConfigNumber } from "./config-access";
|
|||
const NUM_SIMULATIONS = 50_000;
|
||||
const US_TEAM_COUNT = 10;
|
||||
const INTL_TEAM_COUNT = 10;
|
||||
const POOL_SIZE = 5; // teams per pool within each side
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -77,8 +70,6 @@ type Side = "US" | "Intl";
|
|||
interface Team {
|
||||
participantId: string;
|
||||
side: Side;
|
||||
/** Explicit pool ("A" or "B") if set in externalId; null if randomized. */
|
||||
fixedPool: "A" | "B" | null;
|
||||
/** Normalized championship win probability (0–1, vig removed). */
|
||||
oddsProb: number;
|
||||
}
|
||||
|
|
@ -88,11 +79,21 @@ interface PlacementCounts {
|
|||
finalist: number;
|
||||
thirdPlace: number;
|
||||
fourthPlace: number;
|
||||
bracketLoser: number;
|
||||
/** Lost the Elimination Final — the 5th–6th tier (1 per side per sim). */
|
||||
elimFinalLoser: number;
|
||||
/** Lost Elimination Round 4 — the 7th–8th tier (1 per side per sim). */
|
||||
elimRound4Loser: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function zeroCounts(): PlacementCounts {
|
||||
return {
|
||||
champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0,
|
||||
elimFinalLoser: 0, elimRound4Loser: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
||||
// If either team has no odds entered, treat the game as a coin flip.
|
||||
// The 50/50 fallback must cover the one-sided case (one team known, one not)
|
||||
|
|
@ -118,101 +119,89 @@ function shuffle<T>(arr: T[]): T[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Assign teams to Pool A / Pool B for one side.
|
||||
* In fixed mode, respects the pre-set pool. In randomized mode, shuffles then splits.
|
||||
*/
|
||||
function assignPools(teams: Team[], randomized: boolean): [Team[], Team[]] {
|
||||
if (!randomized) {
|
||||
return [teams.filter((t) => t.fixedPool === "A"), teams.filter((t) => t.fixedPool === "B")];
|
||||
}
|
||||
const shuffled = shuffle([...teams]);
|
||||
return [shuffled.slice(0, 5), shuffled.slice(5)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate round-robin pool play among 5 teams.
|
||||
* Returns the top 2 teams by win count (ties broken randomly).
|
||||
*/
|
||||
function simulatePoolPlay(pool: Team[]): [Team, Team] {
|
||||
const wins = new Map<string, number>(pool.map((t) => [t.participantId, 0]));
|
||||
|
||||
// Each pair plays once.
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
for (let j = i + 1; j < pool.length; j++) {
|
||||
const { winner } = simGame(pool[i], pool[j]);
|
||||
wins.set(winner.participantId, (wins.get(winner.participantId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by wins descending; pre-generate a stable random tiebreaker per team so
|
||||
// the comparator is consistent (Math.random() inside a comparator is a bug — the
|
||||
// engine may call it multiple times per pair and get contradictory results).
|
||||
const tiebreaker = new Map(pool.map((t) => [t.participantId, Math.random()]));
|
||||
const ranked = pool.toSorted((a, b) => {
|
||||
const diff = (wins.get(b.participantId) ?? 0) - (wins.get(a.participantId) ?? 0);
|
||||
return diff !== 0 ? diff : (tiebreaker.get(a.participantId) ?? 0) - (tiebreaker.get(b.participantId) ?? 0);
|
||||
});
|
||||
|
||||
return [ranked[0], ranked[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a 4-team double-elimination bracket for one side.
|
||||
* Simulate one side's 10-team double-elimination bracket.
|
||||
*
|
||||
* Seeds (pool results):
|
||||
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
|
||||
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
|
||||
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
|
||||
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
||||
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||||
*
|
||||
* Bracket:
|
||||
* G1 (WB): A1 vs B2
|
||||
* G2 (WB): B1 vs A2
|
||||
* G3 (WB Final): G1W vs G2W
|
||||
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
|
||||
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
|
||||
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
|
||||
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||||
* Winners bracket
|
||||
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
||||
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||||
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
|
||||
* WF WSF1w v WSF2w → winner to the side championship
|
||||
* Elimination bracket (a loss here is final)
|
||||
* ER1-1 OP2l v OP3l ER1-2 OP1l v OP4l
|
||||
* ER2-1 WR2-1l v ER1-1w ER2-2 WR2-2l v ER1-2w
|
||||
* ER3-1 WSF1l v ER2-2w ER3-2 WSF2l v ER2-1w (cross-over)
|
||||
* ER4 ER3-2w v ER3-1w → loser is the 7th–8th tier
|
||||
* EF WFl v ER4w → loser is the 5th–6th tier
|
||||
* Side championship: WFw v EFw → loser drops to the consolation game
|
||||
*
|
||||
* Returns: { sideChampion, sideLoser }
|
||||
* bracketLosers (2) are bumped into counts directly.
|
||||
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
|
||||
* Elimination Final. There is no "if necessary" game, so the side championship is
|
||||
* decided in one game.
|
||||
*
|
||||
* Returns { sideChampion, sideLoser }; the two scoring elimination losers are
|
||||
* bumped into the counts directly.
|
||||
*/
|
||||
function simulateSideBracket(
|
||||
poolA1: Team,
|
||||
poolA2: Team,
|
||||
poolB1: Team,
|
||||
poolB2: Team,
|
||||
slots: Team[],
|
||||
bump: (id: string, key: keyof PlacementCounts) => void
|
||||
): { sideChampion: Team; sideLoser: Team } {
|
||||
// ── Winners bracket ────────────────────────────────────────────────────────
|
||||
const op1 = simGame(slots[0], slots[1]);
|
||||
const op2 = simGame(slots[2], slots[3]);
|
||||
const op3 = simGame(slots[4], slots[5]);
|
||||
const op4 = simGame(slots[6], slots[7]);
|
||||
|
||||
// Winners bracket
|
||||
const g1 = simGame(poolA1, poolB2);
|
||||
const g2 = simGame(poolB1, poolA2);
|
||||
const g3 = simGame(g1.winner, g2.winner); // WB Final
|
||||
const wr21 = simGame(slots[8], op1.winner);
|
||||
const wr22 = simGame(slots[9], op2.winner);
|
||||
|
||||
// Losers bracket
|
||||
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
|
||||
bump(g4.loser.participantId, "bracketLoser");
|
||||
const wsf1 = simGame(op3.winner, wr21.winner);
|
||||
const wsf2 = simGame(wr22.winner, op4.winner);
|
||||
|
||||
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
|
||||
bump(g5.loser.participantId, "bracketLoser");
|
||||
const wf = simGame(wsf1.winner, wsf2.winner);
|
||||
|
||||
// Side championship
|
||||
const g6 = simGame(g3.winner, g5.winner);
|
||||
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||
const er11 = simGame(op2.loser, op3.loser);
|
||||
const er12 = simGame(op1.loser, op4.loser);
|
||||
|
||||
return { sideChampion: g6.winner, sideLoser: g6.loser };
|
||||
const er21 = simGame(wr21.loser, er11.winner);
|
||||
const er22 = simGame(wr22.loser, er12.winner);
|
||||
|
||||
// Cross-over: each semifinal loser meets the winner from the opposite half.
|
||||
const er31 = simGame(wsf1.loser, er22.winner);
|
||||
const er32 = simGame(wsf2.loser, er21.winner);
|
||||
|
||||
const er4 = simGame(er32.winner, er31.winner);
|
||||
bump(er4.loser.participantId, "elimRound4Loser"); // 7th–8th tier
|
||||
|
||||
// The Winners Final loser gets its second chance here.
|
||||
const ef = simGame(wf.loser, er4.winner);
|
||||
bump(ef.loser.participantId, "elimFinalLoser"); // 5th–6th tier
|
||||
|
||||
// ── Side championship ──────────────────────────────────────────────────────
|
||||
const sideChampionship = simGame(wf.winner, ef.winner);
|
||||
|
||||
return { sideChampion: sideChampionship.winner, sideLoser: sideChampionship.loser };
|
||||
}
|
||||
|
||||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
type PoolSuffix = "A" | "B" | null;
|
||||
|
||||
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
|
||||
/**
|
||||
* Parse a participant's externalId into a side.
|
||||
*
|
||||
* The legacy pool-play suffixes ("US:A", "Intl:B", …) are still accepted so seasons
|
||||
* configured before the format change keep loading; the pool part is ignored because
|
||||
* the tournament no longer has pools.
|
||||
*/
|
||||
function parseExternalId(raw: string | null): { side: Side } | null {
|
||||
if (!raw) return null;
|
||||
const upper = raw.toUpperCase();
|
||||
if (upper === "US") return { side: "US", pool: null };
|
||||
if (upper === "INTL") return { side: "Intl", pool: null };
|
||||
if (upper === "US:A") return { side: "US", pool: "A" };
|
||||
if (upper === "US:B") return { side: "US", pool: "B" };
|
||||
if (upper === "INTL:A") return { side: "Intl", pool: "A" };
|
||||
if (upper === "INTL:B") return { side: "Intl", pool: "B" };
|
||||
const side = raw.toUpperCase().split(":")[0];
|
||||
if (side === "US") return { side: "US" };
|
||||
if (side === "INTL") return { side: "Intl" };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -227,36 +216,6 @@ function inferExternalIdFromName(name: string): string {
|
|||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether pool assignments should be randomized for one side.
|
||||
* - If ALL teams on the side have a pool suffix → fixed pools (returns false).
|
||||
* - If NO teams have a pool suffix → randomized (returns true).
|
||||
* - Mixed → throws.
|
||||
* Also validates that fixed pools are split exactly POOL_SIZE / POOL_SIZE.
|
||||
*/
|
||||
function determineRandomized(sideTeams: Team[], sideName: string): boolean {
|
||||
const withPool = sideTeams.filter((t) => t.fixedPool !== null);
|
||||
const withoutPool = sideTeams.filter((t) => t.fixedPool === null);
|
||||
if (withPool.length > 0 && withoutPool.length > 0) {
|
||||
throw new Error(
|
||||
`${sideName} teams have mixed externalId formats: some have pool suffixes (e.g. "US:A") ` +
|
||||
`and some don't. Either all ${sideName} teams must have pool suffixes or none should.`
|
||||
);
|
||||
}
|
||||
if (withPool.length === sideTeams.length) {
|
||||
const poolA = sideTeams.filter((t) => t.fixedPool === "A");
|
||||
const poolB = sideTeams.filter((t) => t.fixedPool === "B");
|
||||
if (poolA.length !== POOL_SIZE || poolB.length !== POOL_SIZE) {
|
||||
throw new Error(
|
||||
`${sideName} fixed pools must have exactly ${POOL_SIZE} teams each. ` +
|
||||
`Found Pool A: ${poolA.length}, Pool B: ${poolB.length}.`
|
||||
);
|
||||
}
|
||||
return false; // fixed pools
|
||||
}
|
||||
return true; // randomized
|
||||
}
|
||||
|
||||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LLWSSimulator implements Simulator {
|
||||
|
|
@ -304,7 +263,7 @@ export class LLWSSimulator implements Simulator {
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Parse externalId for each participant to determine side and fixed pool.
|
||||
// 4. Parse externalId for each participant to determine which side they're on.
|
||||
const teams: Team[] = [];
|
||||
for (const p of participants) {
|
||||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||||
|
|
@ -312,13 +271,12 @@ export class LLWSSimulator implements Simulator {
|
|||
if (!parsed) {
|
||||
throw new Error(
|
||||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
||||
`Expected: "US", "Intl", "US:A", "US:B", "Intl:A", or "Intl:B".`
|
||||
`Expected: "US" or "Intl".`
|
||||
);
|
||||
}
|
||||
teams.push({
|
||||
participantId: p.id,
|
||||
side: parsed.side,
|
||||
fixedPool: parsed.pool,
|
||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
|
@ -334,15 +292,9 @@ export class LLWSSimulator implements Simulator {
|
|||
throw new Error(`Expected ${INTL_TEAM_COUNT} International teams, found ${intlTeams.length}.`);
|
||||
}
|
||||
|
||||
// Determine pool assignment mode for each side.
|
||||
const usRandomized = determineRandomized(usTeams, "US");
|
||||
const intlRandomized = determineRandomized(intlTeams, "International");
|
||||
|
||||
// 5. Initialise placement count accumulators for all participants.
|
||||
const allIds = participants.map((p) => p.id);
|
||||
const counts = new Map<string, PlacementCounts>(
|
||||
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
|
||||
);
|
||||
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
|
||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||
const entry = counts.get(id);
|
||||
if (entry) entry[key]++;
|
||||
|
|
@ -350,42 +302,35 @@ export class LLWSSimulator implements Simulator {
|
|||
|
||||
// 6. Run Monte Carlo simulations.
|
||||
for (let s = 0; s < numSimulations; s++) {
|
||||
// Assign pools for this simulation.
|
||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
||||
const [intlPoolA, intlPoolB] = assignPools(intlTeams, intlRandomized);
|
||||
|
||||
// Pool play: top 2 from each pool advance.
|
||||
const [usA1, usA2] = simulatePoolPlay(usPoolA);
|
||||
const [usB1, usB2] = simulatePoolPlay(usPoolB);
|
||||
const [intlA1, intlA2] = simulatePoolPlay(intlPoolA);
|
||||
const [intlB1, intlB2] = simulatePoolPlay(intlPoolB);
|
||||
|
||||
// Double-elimination bracket per side.
|
||||
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||||
// (8 opening-round teams, then the 2 bye teams).
|
||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
|
||||
simulateSideBracket(shuffle([...usTeams]), bump);
|
||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
|
||||
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||||
|
||||
// Consolation game: 3rd / 4th place.
|
||||
const consolation = simGame(usLose, intlLose);
|
||||
bump(consolation.winner.participantId, "thirdPlace");
|
||||
bump(consolation.loser.participantId, "fourthPlace");
|
||||
|
||||
// World Series: 1st / 2nd place.
|
||||
// World Championship: 1st / 2nd place.
|
||||
const ws = simGame(usChamp, intlChamp);
|
||||
bump(ws.winner.participantId, "champion");
|
||||
bump(ws.loser.participantId, "finalist");
|
||||
}
|
||||
|
||||
// 7. Convert counts to probability distributions.
|
||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
||||
const bracketLosersPerSim = 4;
|
||||
const bracketDivisor = bracketLosersPerSim * numSimulations;
|
||||
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||||
// the teams within a tier are tied, so the tier probability is split across
|
||||
// its two positions.
|
||||
const tierDivisor = 2 * numSimulations;
|
||||
|
||||
const zeroCounts: PlacementCounts = { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 };
|
||||
const empty = zeroCounts();
|
||||
return allIds.map((id) => {
|
||||
const c = counts.get(id) ?? zeroCounts;
|
||||
const bracketProb = c.bracketLoser / bracketDivisor;
|
||||
const c = counts.get(id) ?? empty;
|
||||
const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th
|
||||
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th
|
||||
return {
|
||||
participantId: id,
|
||||
probabilities: {
|
||||
|
|
@ -393,10 +338,10 @@ export class LLWSSimulator implements Simulator {
|
|||
probSecond: c.finalist / numSimulations,
|
||||
probThird: c.thirdPlace / numSimulations,
|
||||
probFourth: c.fourthPlace / numSimulations,
|
||||
probFifth: bracketProb,
|
||||
probSixth: bracketProb,
|
||||
probSeventh: bracketProb,
|
||||
probEighth: bracketProb,
|
||||
probFifth: upperTier,
|
||||
probSixth: upperTier,
|
||||
probSeventh: lowerTier,
|
||||
probEighth: lowerTier,
|
||||
},
|
||||
source: "llws_monte_carlo",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
|||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||
},
|
||||
llws_bracket: {
|
||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
|
||||
requiredInputs: ["sourceOdds"],
|
||||
optionalInputs: ["metadata"],
|
||||
setupSections: ["participants", "futuresOdds"],
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
llws_bracket: {
|
||||
info: {
|
||||
name: "LLWS Bracket Monte Carlo",
|
||||
description: "Simulates the 20-team Little League World Series: pool play round-robin (5 teams/pool, top 2 advance) → 4-team double-elimination bracket per side (US & International) → consolation game (3rd/4th) → World Series. Uses championship futures odds for all win probabilities. Set externalId to 'US'/'Intl' (randomized pools) or 'US:A'/'US:B'/'Intl:A'/'Intl:B' (fixed pools).",
|
||||
description: "Simulates the 20-team Little League World Series: a 10-team double-elimination bracket per side (US & International), each producing a side champion, then the consolation game (3rd/4th) and the World Championship (1st/2nd). Uses championship futures odds for all win probabilities. Set externalId to 'US' or 'Intl'.",
|
||||
},
|
||||
create: () => new LLWSSimulator(),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue