Compare commits
3 commits
b85f387c79
...
81d813d3f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 81d813d3f3 | |||
|
|
37af2f1fc3 | ||
|
|
1bd23a4419 |
13 changed files with 1648 additions and 244 deletions
|
|
@ -196,16 +196,50 @@ export interface ConsolationRound {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the template's consolation round, if it has one.
|
* Find the template's consolation round, if it has one.
|
||||||
|
*
|
||||||
|
* A consolation round must be TERMINAL — its winner plays no further game, which is
|
||||||
|
* what lets its result split two exact positions. `loserFeedsInto` alone is not
|
||||||
|
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
|
||||||
|
* round to route losers into the elimination bracket, and those targets are ordinary
|
||||||
|
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
|
||||||
|
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
|
||||||
|
*
|
||||||
* Exported for unit testing.
|
* Exported for unit testing.
|
||||||
*/
|
*/
|
||||||
export function findConsolationRound(
|
export function findConsolationRound(
|
||||||
template: BracketTemplate | undefined
|
template: BracketTemplate | undefined
|
||||||
): ConsolationRound | undefined {
|
): ConsolationRound | undefined {
|
||||||
const feeder = template?.rounds.find((r) => r.loserFeedsInto);
|
const isTerminal = (roundName: string) =>
|
||||||
|
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
|
||||||
|
|
||||||
|
const feeder = template?.rounds.find(
|
||||||
|
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
|
||||||
|
);
|
||||||
if (!feeder?.loserFeedsInto) return undefined;
|
if (!feeder?.loserFeedsInto) return undefined;
|
||||||
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round names whose losers are placed by some LATER round rather than finishing where
|
||||||
|
* they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into
|
||||||
|
* the elimination bracket.
|
||||||
|
*
|
||||||
|
* The consolation feeder is deliberately excluded: its losers do finish at that tier
|
||||||
|
* (the consolation game splits their two positions), so it still consumes them.
|
||||||
|
*
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function roundsWithLosersPlacedLater(
|
||||||
|
template: BracketTemplate | undefined,
|
||||||
|
consolation: ConsolationRound | undefined
|
||||||
|
): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
(template?.rounds ?? [])
|
||||||
|
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
|
||||||
|
.map((r) => r.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the ordered final-rankings list from completed matches.
|
* Build the ordered final-rankings list from completed matches.
|
||||||
*
|
*
|
||||||
|
|
@ -228,7 +262,9 @@ export function computeRankedEntries(
|
||||||
rounds: string[],
|
rounds: string[],
|
||||||
matchesByRound: Map<string, Match[]>,
|
matchesByRound: Map<string, Match[]>,
|
||||||
consolation: ConsolationRound | undefined,
|
consolation: ConsolationRound | undefined,
|
||||||
ownershipMap: Map<string, TeamOwnership>
|
ownershipMap: Map<string, TeamOwnership>,
|
||||||
|
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||||
|
losersPlacedLater: Set<string> = new Set()
|
||||||
): EliminatedEntry[] {
|
): EliminatedEntry[] {
|
||||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||||
|
|
||||||
|
|
@ -300,6 +336,14 @@ export function computeRankedEntries(
|
||||||
// consumes none of its own.
|
// consumes none of its own.
|
||||||
if (consolationActive && roundName === consolation?.round) continue;
|
if (consolationActive && roundName === consolation?.round) continue;
|
||||||
|
|
||||||
|
// A round normally consumes one position per match — its losers finish here,
|
||||||
|
// whether or not the games have been played yet (four semifinalists occupy 1–4
|
||||||
|
// regardless). But in a double-elimination bracket a winners-bracket loss places
|
||||||
|
// nobody: the loser drops into the elimination bracket and is ranked by whatever
|
||||||
|
// knocks them out later. Those rounds must consume nothing, or every position
|
||||||
|
// below inflates (a 20-team llws_20 bracket would end at "T23").
|
||||||
|
if (losersPlacedLater.has(roundName)) continue;
|
||||||
|
|
||||||
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,7 +415,8 @@ export function PlayoffBracket({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
consolation,
|
consolation,
|
||||||
ownershipMap
|
ownershipMap,
|
||||||
|
roundsWithLosersPlacedLater(template, consolation)
|
||||||
);
|
);
|
||||||
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,22 @@ export function TabbedBracketLayout({
|
||||||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||||
|
|
||||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
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 sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||||
|
|
||||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import {
|
||||||
computeEliminatedByRound,
|
computeEliminatedByRound,
|
||||||
computeRankedEntries,
|
computeRankedEntries,
|
||||||
findConsolationRound,
|
findConsolationRound,
|
||||||
|
roundsWithLosersPlacedLater,
|
||||||
type Match,
|
type Match,
|
||||||
} from "../PlayoffBracket";
|
} from "../PlayoffBracket";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import { resolveLLWSAdvancement } from "~/models/playoff-match";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -426,6 +428,129 @@ function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// llws_20 — double elimination, where a winners-bracket loss places nobody
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Stable participant id for an llws_20 bracket slot. */
|
||||||
|
function llwsTeam(i: number): string {
|
||||||
|
return `t${String(i).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
|
||||||
|
* participant id so the outcome is deterministic, and return every match.
|
||||||
|
* Routing comes from the real advancement map rather than being hand-listed.
|
||||||
|
*/
|
||||||
|
function llwsMatches(): Match[] {
|
||||||
|
const template = getBracketTemplate("llws_20");
|
||||||
|
if (!template) throw new Error("llws_20 template missing");
|
||||||
|
|
||||||
|
// round → matchNumber → [p1, p2]
|
||||||
|
const slots = new Map<string, Map<number, [string | null, string | null]>>();
|
||||||
|
for (const round of template.rounds) {
|
||||||
|
const byNumber = new Map<number, [string | null, string | null]>();
|
||||||
|
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
|
||||||
|
slots.set(round.name, byNumber);
|
||||||
|
}
|
||||||
|
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
|
||||||
|
const pair = slots.get(round)?.get(n);
|
||||||
|
if (pair) pair[slot] = id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
|
||||||
|
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
|
||||||
|
for (let local = 0; local < 4; local++) {
|
||||||
|
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
|
||||||
|
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("Winners Round 2", 1, 0, llwsTeam(8));
|
||||||
|
put("Winners Round 2", 2, 0, llwsTeam(9));
|
||||||
|
put("Winners Round 2", 3, 0, llwsTeam(18));
|
||||||
|
put("Winners Round 2", 4, 0, llwsTeam(19));
|
||||||
|
|
||||||
|
const matches: Match[] = [];
|
||||||
|
for (const round of template.rounds) {
|
||||||
|
for (let n = 1; n <= round.matchCount; n++) {
|
||||||
|
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
|
||||||
|
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
|
||||||
|
// Deterministic: the lower id always wins.
|
||||||
|
const winnerId = p1 < p2 ? p1 : p2;
|
||||||
|
const loserId = p1 < p2 ? p2 : p1;
|
||||||
|
matches.push(
|
||||||
|
makeRankedMatch(round.name, n, winnerId, loserId, {
|
||||||
|
winnerSlot: winnerId === p1 ? 1 : 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||||
|
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
|
||||||
|
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeRankedEntries — llws_20 double elimination", () => {
|
||||||
|
const template = getBracketTemplate("llws_20");
|
||||||
|
const rounds = template?.rounds.map((r) => r.name) ?? [];
|
||||||
|
|
||||||
|
function rankLlws() {
|
||||||
|
const matches = llwsMatches();
|
||||||
|
const consolation = findConsolationRound(template);
|
||||||
|
return computeRankedEntries(
|
||||||
|
matches,
|
||||||
|
rounds,
|
||||||
|
groupMatchesByRound(matches),
|
||||||
|
consolation,
|
||||||
|
new Map(),
|
||||||
|
roundsWithLosersPlacedLater(template, consolation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("ranks all 19 non-champions exactly once", () => {
|
||||||
|
const entries = rankLlws();
|
||||||
|
expect(entries).toHaveLength(19);
|
||||||
|
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the top 8 the positions the scoring tiers depend on", () => {
|
||||||
|
const entries = rankLlws();
|
||||||
|
const labels = entries.map((e) => e.rankLabel);
|
||||||
|
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
|
||||||
|
// game, then the two 5–6 and two 7–8 tier teams.
|
||||||
|
expect(labels[0]).toBe("T2");
|
||||||
|
expect(labels.filter((l) => l === "3")).toHaveLength(1);
|
||||||
|
expect(labels.filter((l) => l === "4")).toHaveLength(1);
|
||||||
|
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
|
||||||
|
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inflate positions below the top 8", () => {
|
||||||
|
// Winners-bracket losses place nobody — those teams are ranked by the
|
||||||
|
// elimination-bracket game that actually knocks them out. If the winners
|
||||||
|
// rounds consumed positions, the last tier would read T23 in a 20-team field.
|
||||||
|
const entries = rankLlws();
|
||||||
|
const labels = entries.map((e) => e.rankLabel);
|
||||||
|
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
|
||||||
|
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
|
||||||
|
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
|
||||||
|
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
|
||||||
|
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never ranks a winners-bracket loser at the round they first lost", () => {
|
||||||
|
const entries = rankLlws();
|
||||||
|
// t00 wins every game it plays (lowest id), so take a team that loses in the
|
||||||
|
// winners bracket but survives: the Opening Round M1 loser, t01.
|
||||||
|
const t01 = entries.find((e) => e.participant.id === "t01");
|
||||||
|
expect(t01).toBeDefined();
|
||||||
|
// Losing the opening game must not park them in the bottom tier — they got a
|
||||||
|
// second life in the elimination bracket.
|
||||||
|
expect(t01?.rankLabel).not.toBe("T17");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("findConsolationRound", () => {
|
describe("findConsolationRound", () => {
|
||||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||||
|
|
@ -441,6 +566,17 @@ describe("findConsolationRound", () => {
|
||||||
it("returns undefined when there is no template", () => {
|
it("returns undefined when there is no template", () => {
|
||||||
expect(findConsolationRound(undefined)).toBeUndefined();
|
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores double-elimination loser routing and finds the real consolation game", () => {
|
||||||
|
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
|
||||||
|
// into the elimination bracket. Only the Bracket Championship feeds a terminal
|
||||||
|
// round; taking the first loserFeedsInto instead would mistake "Elimination
|
||||||
|
// Round 1" for a third-place game and corrupt the final rankings.
|
||||||
|
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
|
||||||
|
round: "Consolation Third Place",
|
||||||
|
feederRound: "Bracket Championship",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeRankedEntries", () => {
|
describe("computeRankedEntries", () => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export interface BracketRound {
|
||||||
* When set, the loser of each match in this round is placed into the target round.
|
* When set, the loser of each match in this round is placed into the target round.
|
||||||
*/
|
*/
|
||||||
loserFeedsInto?: string | null;
|
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 {
|
export interface GroupStageConfig {
|
||||||
|
|
@ -934,6 +946,258 @@ 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. Kept short — the admin
|
||||||
|
// form renders them in a narrow fixed-width column alongside each participant picker.
|
||||||
|
participantLabels: [
|
||||||
|
"US G1 Home", "US G1 Away",
|
||||||
|
"US G2 Home", "US G2 Away",
|
||||||
|
"US G3 Home", "US G3 Away",
|
||||||
|
"US G4 Home", "US G4 Away",
|
||||||
|
"US Bye 1", "US Bye 2",
|
||||||
|
"Intl G1 Home", "Intl G1 Away",
|
||||||
|
"Intl G2 Home", "Intl G2 Away",
|
||||||
|
"Intl G3 Home", "Intl G3 Away",
|
||||||
|
"Intl G4 Home", "Intl G4 Away",
|
||||||
|
"Intl Bye 1", "Intl Bye 2",
|
||||||
|
],
|
||||||
|
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
|
* All available bracket templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -951,6 +1215,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
||||||
tennis_128: TENNIS_128,
|
tennis_128: TENNIS_128,
|
||||||
cfp_12: CFP_12,
|
cfp_12: CFP_12,
|
||||||
nba_20: NBA_20,
|
nba_20: NBA_20,
|
||||||
|
llws_20: LLWS_20,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1006,6 +1271,18 @@ export function getScoringRoundType(
|
||||||
const round = template.rounds.find((r) => r.name === roundName);
|
const round = template.rounds.find((r) => r.name === roundName);
|
||||||
if (!round || !round.isScoring) return null;
|
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
|
// Special handling for AFL finals
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||||
|
|
|
||||||
589
app/models/__tests__/llws-20-bracket.test.ts
Normal file
589
app/models/__tests__/llws-20-bracket.test.ts
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
doesLoserAdvance,
|
||||||
|
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("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
|
||||||
|
// doesLoserAdvance is what stops the scoring engine writing a 0-point
|
||||||
|
// elimination (and announcing a knockout) for a team that is still alive.
|
||||||
|
// Winners Final and Bracket Championship are scoring rounds and are covered
|
||||||
|
// by loserIsPartial instead, so they are deliberately not listed here.
|
||||||
|
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
|
||||||
|
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
|
||||||
|
}
|
||||||
|
for (const round of [
|
||||||
|
"Elimination Round 1",
|
||||||
|
"Elimination Round 2",
|
||||||
|
"Elimination Round 3",
|
||||||
|
"Elimination Round 4",
|
||||||
|
"Elimination Final",
|
||||||
|
]) {
|
||||||
|
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply LLWS loser routing to other templates", () => {
|
||||||
|
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
|
||||||
|
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
getBracketTemplate,
|
||||||
buildNCAA68SlotMap,
|
buildNCAA68SlotMap,
|
||||||
matchIndexForSeedSlot,
|
matchIndexForSeedSlot,
|
||||||
|
llwsMatchNumber,
|
||||||
|
llwsSideAndLocal,
|
||||||
STANDARD_BRACKET_SEEDING,
|
STANDARD_BRACKET_SEEDING,
|
||||||
} from "~/lib/bracket-templates";
|
} from "~/lib/bracket-templates";
|
||||||
|
|
||||||
|
|
@ -467,6 +469,11 @@ export async function generateBracketFromTemplate(
|
||||||
return await generateNBA20Bracket(eventId, template, participantIds);
|
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[] = [];
|
const matches: NewPlayoffMatch[] = [];
|
||||||
|
|
||||||
// Generate matches for each round in the template
|
// Generate matches for each round in the template
|
||||||
|
|
@ -980,6 +987,15 @@ export async function advanceWinnerTemplate(
|
||||||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
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
|
// Special handling for AFL 10 double-chance system
|
||||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
|
|
@ -1427,6 +1443,12 @@ export function doesLoserAdvance(
|
||||||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1538,3 +1560,320 @@ async function advanceNBAPlayInWinner(
|
||||||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
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.
|
// 3rd place game finalizes both positions distinctly.
|
||||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
"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: {
|
tennis_128: {
|
||||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
"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
|
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
* to bank nothing.
|
||||||
*
|
*
|
||||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
* 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,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined
|
bracketTemplateId: string | null | undefined
|
||||||
): boolean {
|
): number | null {
|
||||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
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);
|
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;
|
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);
|
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||||
return nextRound?.isScoring === true;
|
return nextRound?.isScoring === true ? 5 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -281,19 +305,18 @@ export async function processPlayoffEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
// provisional floor only when this round guarantees them one — see
|
||||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||||
if (match.loserId && !loserAdvances) {
|
if (match.loserId && !loserAdvances) {
|
||||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (match.winnerId && awardFloor) {
|
if (match.winnerId && winnerFloor !== null) {
|
||||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -353,7 +376,7 @@ export async function processPlayoffEvent(
|
||||||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||||
// returns null (winners were given floor 5 inline above).
|
// returns null; those winners were given their floor inline above.
|
||||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||||
round,
|
round,
|
||||||
event.bracketTemplateId,
|
event.bracketTemplateId,
|
||||||
|
|
@ -441,8 +464,9 @@ export async function processMatchResult(
|
||||||
if (!loserAdvances) {
|
if (!loserAdvances) {
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
if (nonScoringFloor !== null) {
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||||
}
|
}
|
||||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -134,14 +134,21 @@ export function calculateSharedPlacementPoints(
|
||||||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
* 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])
|
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
* - 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
|
* 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.
|
* (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.
|
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||||
|
|
|
||||||
|
|
@ -889,7 +889,7 @@ export default function EventBracket({
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||||
{slotLabel}
|
{slotLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -126,22 +126,48 @@ describe("LLWSSimulator", () => {
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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));
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
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);
|
const total = results.reduce((s, r) => s + r.probabilities.probFifth, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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));
|
setupMockDb(defaultParticipants(), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const p = r.probabilities;
|
const p = r.probabilities;
|
||||||
expect(p.probFifth).toBeCloseTo(p.probSixth, 10);
|
// Each sim assigns a team at most one placement, so summing the distinct
|
||||||
expect(p.probSixth).toBeCloseTo(p.probSeventh, 10);
|
// tiers (5th/6th and 7th/8th each count once) cannot exceed 1.
|
||||||
expect(p.probSeventh).toBeCloseTo(p.probEighth, 10);
|
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", () => {
|
describe("legacy pool-suffix externalIds", () => {
|
||||||
it("fixed pools (US:A / US:B / Intl:A / Intl:B) produce valid results", async () => {
|
it("accepts US:A / US:B / Intl:A / Intl:B, ignoring the pool part", async () => {
|
||||||
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
setupMockDb(defaultParticipants("fixed"), makeEvRows(ALL_IDS));
|
||||||
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
const results = await new LLWSSimulator(1_000).simulate("season-1");
|
||||||
expect(results).toHaveLength(20);
|
expect(results).toHaveLength(20);
|
||||||
|
|
@ -190,10 +219,10 @@ describe("LLWSSimulator", () => {
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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 = [
|
const participants = [
|
||||||
...US_IDS.slice(0, 5).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
...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" })),
|
...INTL_IDS.map((id) => ({ id, name: `Team ${id}`, externalId: "Intl" })),
|
||||||
];
|
];
|
||||||
setupMockDb(participants, makeEvRows(ALL_IDS));
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||||
|
|
@ -202,6 +231,17 @@ describe("LLWSSimulator", () => {
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 1);
|
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 ───────────────────────────────────────────────────────────
|
// ── Error cases ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -262,26 +302,13 @@ describe("LLWSSimulator", () => {
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
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 = [
|
const participants = [
|
||||||
// 6 in Pool A, 4 in Pool B
|
...Array.from({ length: 9 }, (_, i) => ({ id: `us-${i + 1}`, name: `US Team ${i + 1}`, externalId: "US" })),
|
||||||
...US_IDS.slice(0, 6).map((id) => ({ id, name: `US Team ${id}`, externalId: "US:A" })),
|
...Array.from({ length: 11 }, (_, i) => ({ id: `intl-${i + 1}`, name: `Team ${i + 1}`, externalId: "Intl" })),
|
||||||
...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));
|
setupMockDb(participants, makeEvRows(ALL_IDS));
|
||||||
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/exactly 5 teams each/);
|
await expect(new LLWSSimulator(1_000).simulate("season-1")).rejects.toThrow(/10 US teams/);
|
||||||
});
|
|
||||||
|
|
||||||
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/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* Little League World Series (LLWS) Bracket Simulator
|
* 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:
|
* Algorithm:
|
||||||
* 1. Load all 20 participants for the sports season from DB
|
* 1. Load all 20 participants for the sports season from DB
|
||||||
|
|
@ -10,48 +16,36 @@
|
||||||
* (entered via Admin → Futures Odds; American format)
|
* (entered via Admin → Futures Odds; American format)
|
||||||
* 3. Convert odds to normalized championship probabilities (vig removed).
|
* 3. Convert odds to normalized championship probabilities (vig removed).
|
||||||
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
* These drive per-game win probability: p1 / (p1 + p2). Falls back to 50/50.
|
||||||
* 4. Determine pool assignment mode from externalId:
|
* 4. Per simulation:
|
||||||
* - Fixed pools: externalId is "US:A", "US:B", "Intl:A", or "Intl:B"
|
* a. Shuffle each side's 10 teams into the 10 bracket slots (8 opening-round
|
||||||
* → use these exact pool assignments every simulation.
|
* teams + 2 byes). The draw is modelled as random — a specific known draw
|
||||||
* - Randomized pools: externalId is "US" or "Intl" only
|
* is not yet expressible in participant config.
|
||||||
* → randomly shuffle each side into Pool A / Pool B each simulation.
|
* b. Simulate the 10-team double-elimination bracket for each side
|
||||||
* 5. Per simulation:
|
* (see simulateSideBracket for the exact game-by-game structure)
|
||||||
* a. Assign pools (fixed or random)
|
* c. Consolation game: US side loser vs Intl side loser → 3rd / 4th
|
||||||
* b. Simulate pool play round-robin within each pool (10 games/pool)
|
* d. World Championship: US champion vs Intl champion → 1st / 2nd
|
||||||
* Top 2 by W-L record advance. Ties broken randomly.
|
* 5. Track placement counts across all simulations.
|
||||||
* c. Simulate 4-team double-elimination bracket per side:
|
* 6. Convert counts to probability distributions.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Pool assignment (externalId format):
|
* Side assignment (externalId): "US" or "Intl". The legacy pool suffixes
|
||||||
* "US:A" / "US:B" / "Intl:A" / "Intl:B" → fixed pools (post-draw mode)
|
* ("US:A", "US:B", "Intl:A", "Intl:B") are still accepted and read as the side
|
||||||
* "US" / "Intl" → randomized pools (pre-draw mode)
|
* alone, so seasons configured for the old pool-play format keep working — pools
|
||||||
* Mixed: if ANY US or Intl team has a pool suffix, ALL teams on that side must
|
* no longer exist, so the suffix has no effect.
|
||||||
* have one (throws otherwise). Sides can differ — US fixed while Intl randomized.
|
|
||||||
*
|
*
|
||||||
* Placement tiers → SimulationProbabilities mapping:
|
* Placement tiers → SimulationProbabilities mapping (matches llws_20's scoring):
|
||||||
* probFirst = World Series Champion (1 per sim)
|
* probFirst = World Championship winner (1 per sim)
|
||||||
* probSecond = World Series Runner-up (1 per sim)
|
* probSecond = World Championship loser (1 per sim)
|
||||||
* probThird = Consolation game winner / 3rd place (1 per sim)
|
* probThird = Consolation winner (1 per sim)
|
||||||
* probFourth = Consolation game loser / 4th place (1 per sim)
|
* probFourth = Consolation loser (1 per sim)
|
||||||
* probFifth–probEighth = Double-elim bracket losers before side championships
|
* probFifth/probSixth = Elimination Final losers (2 per sim — 1 per side)
|
||||||
* (4 per sim — split evenly: 2 US + 2 Intl)
|
* probSeventh/probEighth = Elimination Round 4 losers (2 per sim — 1 per side)
|
||||||
* Pool play losers → all 0 (12 teams, did not advance from pool play)
|
* Everyone else → all 0 (12 teams out in Elimination Rounds 1–3)
|
||||||
*
|
*
|
||||||
* Admin setup:
|
* Admin setup:
|
||||||
* 1. Create a Sport with simulatorType = "llws_bracket"
|
* 1. Create a Sport with simulatorType = "llws_bracket"
|
||||||
* 2. Create a Sports Season and add exactly 20 participants (10 US, 10 International)
|
* 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):
|
* 3. Set externalId on each participant via Admin → Manage Participants to "US" or
|
||||||
* Pre-draw: "US" or "Intl" (or leave null — names starting with "US " infer US, all others infer Intl)
|
* "Intl" (optional — names starting with "US " infer US, all others infer Intl)
|
||||||
* Post-draw: "US:A", "US:B", "Intl:A", or "Intl:B"
|
|
||||||
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
* 4. Enter championship futures odds via Admin → Futures Odds (sourceOdds)
|
||||||
* 5. Run simulation via Admin → Simulate
|
* 5. Run simulation via Admin → Simulate
|
||||||
*/
|
*/
|
||||||
|
|
@ -68,7 +62,6 @@ import { positiveConfigNumber } from "./config-access";
|
||||||
const NUM_SIMULATIONS = 50_000;
|
const NUM_SIMULATIONS = 50_000;
|
||||||
const US_TEAM_COUNT = 10;
|
const US_TEAM_COUNT = 10;
|
||||||
const INTL_TEAM_COUNT = 10;
|
const INTL_TEAM_COUNT = 10;
|
||||||
const POOL_SIZE = 5; // teams per pool within each side
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -77,8 +70,6 @@ type Side = "US" | "Intl";
|
||||||
interface Team {
|
interface Team {
|
||||||
participantId: string;
|
participantId: string;
|
||||||
side: Side;
|
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). */
|
/** Normalized championship win probability (0–1, vig removed). */
|
||||||
oddsProb: number;
|
oddsProb: number;
|
||||||
}
|
}
|
||||||
|
|
@ -88,11 +79,21 @@ interface PlacementCounts {
|
||||||
finalist: number;
|
finalist: number;
|
||||||
thirdPlace: number;
|
thirdPlace: number;
|
||||||
fourthPlace: 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 ─────────────────────────────────────────────────────────────────
|
// ─── 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 } {
|
function simGame(t1: Team, t2: Team): { winner: Team; loser: Team } {
|
||||||
// If either team has no odds entered, treat the game as a coin flip.
|
// 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)
|
// 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.
|
* Simulate one side's 10-team double-elimination bracket.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Seeds (pool results):
|
* `slots` holds the side's teams in bracket order, matching the llws_20 participant
|
||||||
* poolA1 = Pool A winner, poolA2 = Pool A runner-up
|
* layout: slots[0..7] are the four opening-round games (two teams each) and
|
||||||
* poolB1 = Pool B winner, poolB2 = Pool B runner-up
|
* slots[8], slots[9] are the two bye teams entering Winners Round 2.
|
||||||
*
|
*
|
||||||
* Bracket:
|
* Structure (side-local, mirroring LLWS_ADVANCEMENT in models/playoff-match):
|
||||||
* G1 (WB): A1 vs B2
|
* Winners bracket
|
||||||
* G2 (WB): B1 vs A2
|
* OP1 s0 v s1 OP2 s2 v s3 OP3 s4 v s5 OP4 s6 v s7
|
||||||
* G3 (WB Final): G1W vs G2W
|
* WR2-1 s8 v OP1w WR2-2 s9 v OP2w
|
||||||
* G4 (LB R1): G1L vs G2L → loser eliminated (bracketLoser)
|
* WSF1 OP3w v WR2-1w WSF2 WR2-2w v OP4w
|
||||||
* G5 (LB Final): G3L vs G4W → loser eliminated (bracketLoser)
|
* WF WSF1w v WSF2w → winner to the side championship
|
||||||
* G6 (Side Championship): G3W vs G5W → loser eliminated (sideLoser)
|
* 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 }
|
* Note the double-chance path: the Winners Final loser is NOT out, it drops to the
|
||||||
* bracketLosers (2) are bumped into counts directly.
|
* 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(
|
function simulateSideBracket(
|
||||||
poolA1: Team,
|
slots: Team[],
|
||||||
poolA2: Team,
|
|
||||||
poolB1: Team,
|
|
||||||
poolB2: Team,
|
|
||||||
bump: (id: string, key: keyof PlacementCounts) => void
|
bump: (id: string, key: keyof PlacementCounts) => void
|
||||||
): { sideChampion: Team; sideLoser: Team } {
|
): { 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 wr21 = simGame(slots[8], op1.winner);
|
||||||
const g1 = simGame(poolA1, poolB2);
|
const wr22 = simGame(slots[9], op2.winner);
|
||||||
const g2 = simGame(poolB1, poolA2);
|
|
||||||
const g3 = simGame(g1.winner, g2.winner); // WB Final
|
|
||||||
|
|
||||||
// Losers bracket
|
const wsf1 = simGame(op3.winner, wr21.winner);
|
||||||
const g4 = simGame(g1.loser, g2.loser); // LB R1 — g4.loser eliminated
|
const wsf2 = simGame(wr22.winner, op4.winner);
|
||||||
bump(g4.loser.participantId, "bracketLoser");
|
|
||||||
|
|
||||||
const g5 = simGame(g3.loser, g4.winner); // LB Final — g5.loser eliminated
|
const wf = simGame(wsf1.winner, wsf2.winner);
|
||||||
bump(g5.loser.participantId, "bracketLoser");
|
|
||||||
|
|
||||||
// Side championship
|
// ── Elimination bracket ────────────────────────────────────────────────────
|
||||||
const g6 = simGame(g3.winner, g5.winner);
|
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 ───────────────────────────────────────────────────────
|
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
type PoolSuffix = "A" | "B" | null;
|
/**
|
||||||
|
* Parse a participant's externalId into a side.
|
||||||
function parseExternalId(raw: string | null): { side: Side; pool: PoolSuffix } | null {
|
*
|
||||||
|
* 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;
|
if (!raw) return null;
|
||||||
const upper = raw.toUpperCase();
|
const side = raw.toUpperCase().split(":")[0];
|
||||||
if (upper === "US") return { side: "US", pool: null };
|
if (side === "US") return { side: "US" };
|
||||||
if (upper === "INTL") return { side: "Intl", pool: null };
|
if (side === "INTL") return { side: "Intl" };
|
||||||
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" };
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,36 +216,6 @@ function inferExternalIdFromName(name: string): string {
|
||||||
return upper === "US" || upper.startsWith("US ") ? "US" : "Intl";
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class LLWSSimulator implements 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[] = [];
|
const teams: Team[] = [];
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
const raw = p.externalId ?? inferExternalIdFromName(p.name);
|
||||||
|
|
@ -312,13 +271,12 @@ export class LLWSSimulator implements Simulator {
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Participant ${p.id} has invalid externalId "${p.externalId}". ` +
|
`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({
|
teams.push({
|
||||||
participantId: p.id,
|
participantId: p.id,
|
||||||
side: parsed.side,
|
side: parsed.side,
|
||||||
fixedPool: parsed.pool,
|
|
||||||
oddsProb: normalizedOddsMap.get(p.id) ?? 0,
|
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}.`);
|
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.
|
// 5. Initialise placement count accumulators for all participants.
|
||||||
const allIds = participants.map((p) => p.id);
|
const allIds = participants.map((p) => p.id);
|
||||||
const counts = new Map<string, PlacementCounts>(
|
const counts = new Map<string, PlacementCounts>(allIds.map((id) => [id, zeroCounts()]));
|
||||||
allIds.map((id) => [id, { champion: 0, finalist: 0, thirdPlace: 0, fourthPlace: 0, bracketLoser: 0 }])
|
|
||||||
);
|
|
||||||
const bump = (id: string, key: keyof PlacementCounts) => {
|
const bump = (id: string, key: keyof PlacementCounts) => {
|
||||||
const entry = counts.get(id);
|
const entry = counts.get(id);
|
||||||
if (entry) entry[key]++;
|
if (entry) entry[key]++;
|
||||||
|
|
@ -350,42 +302,35 @@ export class LLWSSimulator implements Simulator {
|
||||||
|
|
||||||
// 6. Run Monte Carlo simulations.
|
// 6. Run Monte Carlo simulations.
|
||||||
for (let s = 0; s < numSimulations; s++) {
|
for (let s = 0; s < numSimulations; s++) {
|
||||||
// Assign pools for this simulation.
|
// The draw is modelled as random: shuffle each side into the 10 bracket slots
|
||||||
const [usPoolA, usPoolB] = assignPools(usTeams, usRandomized);
|
// (8 opening-round teams, then the 2 bye teams).
|
||||||
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.
|
|
||||||
const { sideChampion: usChamp, sideLoser: usLose } =
|
const { sideChampion: usChamp, sideLoser: usLose } =
|
||||||
simulateSideBracket(usA1, usA2, usB1, usB2, bump);
|
simulateSideBracket(shuffle([...usTeams]), bump);
|
||||||
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
const { sideChampion: intlChamp, sideLoser: intlLose } =
|
||||||
simulateSideBracket(intlA1, intlA2, intlB1, intlB2, bump);
|
simulateSideBracket(shuffle([...intlTeams]), bump);
|
||||||
|
|
||||||
// Consolation game: 3rd / 4th place.
|
// Consolation game: 3rd / 4th place.
|
||||||
const consolation = simGame(usLose, intlLose);
|
const consolation = simGame(usLose, intlLose);
|
||||||
bump(consolation.winner.participantId, "thirdPlace");
|
bump(consolation.winner.participantId, "thirdPlace");
|
||||||
bump(consolation.loser.participantId, "fourthPlace");
|
bump(consolation.loser.participantId, "fourthPlace");
|
||||||
|
|
||||||
// World Series: 1st / 2nd place.
|
// World Championship: 1st / 2nd place.
|
||||||
const ws = simGame(usChamp, intlChamp);
|
const ws = simGame(usChamp, intlChamp);
|
||||||
bump(ws.winner.participantId, "champion");
|
bump(ws.winner.participantId, "champion");
|
||||||
bump(ws.loser.participantId, "finalist");
|
bump(ws.loser.participantId, "finalist");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Convert counts to probability distributions.
|
// 7. Convert counts to probability distributions.
|
||||||
// bracketLosers: 4 per sim (2 US + 2 Intl) → split evenly.
|
// Each of the two 5–8 tiers takes exactly 2 teams per sim (one per side), and
|
||||||
const bracketLosersPerSim = 4;
|
// the teams within a tier are tied, so the tier probability is split across
|
||||||
const bracketDivisor = bracketLosersPerSim * numSimulations;
|
// 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) => {
|
return allIds.map((id) => {
|
||||||
const c = counts.get(id) ?? zeroCounts;
|
const c = counts.get(id) ?? empty;
|
||||||
const bracketProb = c.bracketLoser / bracketDivisor;
|
const upperTier = c.elimFinalLoser / tierDivisor; // 5th–6th
|
||||||
|
const lowerTier = c.elimRound4Loser / tierDivisor; // 7th–8th
|
||||||
return {
|
return {
|
||||||
participantId: id,
|
participantId: id,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
|
|
@ -393,10 +338,10 @@ export class LLWSSimulator implements Simulator {
|
||||||
probSecond: c.finalist / numSimulations,
|
probSecond: c.finalist / numSimulations,
|
||||||
probThird: c.thirdPlace / numSimulations,
|
probThird: c.thirdPlace / numSimulations,
|
||||||
probFourth: c.fourthPlace / numSimulations,
|
probFourth: c.fourthPlace / numSimulations,
|
||||||
probFifth: bracketProb,
|
probFifth: upperTier,
|
||||||
probSixth: bracketProb,
|
probSixth: upperTier,
|
||||||
probSeventh: bracketProb,
|
probSeventh: lowerTier,
|
||||||
probEighth: bracketProb,
|
probEighth: lowerTier,
|
||||||
},
|
},
|
||||||
source: "llws_monte_carlo",
|
source: "llws_monte_carlo",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
setupSections: ["participants", "eloRatings", "futuresOdds", "bracket"],
|
||||||
},
|
},
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10, poolSize: 5 },
|
defaultConfig: { ...BASE_CONFIG, usTeamCount: 10, internationalTeamCount: 10 },
|
||||||
requiredInputs: ["sourceOdds"],
|
requiredInputs: ["sourceOdds"],
|
||||||
optionalInputs: ["metadata"],
|
optionalInputs: ["metadata"],
|
||||||
setupSections: ["participants", "futuresOdds"],
|
setupSections: ["participants", "futuresOdds"],
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
||||||
llws_bracket: {
|
llws_bracket: {
|
||||||
info: {
|
info: {
|
||||||
name: "LLWS Bracket Monte Carlo",
|
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(),
|
create: () => new LLWSSimulator(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue