brackt/app/models/playoff-match.ts
Claude 1bd23a4419
Add LLWS 20-team double-elimination bracket
The Little League Baseball World Series runs 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 side runners-up. 38 games in
all. No existing template could express it: every one is single
elimination, at most with a bolted-on third-place game.

Adds the llws_20 template plus dedicated generation and advancement,
following the same bespoke-routing pattern afl_10 and nba_20 use rather
than the generic ceil(matchNumber / 2) advancement.

The core of the change is loser routing. In the winners bracket a loss
is not an elimination — it drops the team into the elimination bracket
at a specific slot, including the deliberate cross-overs the official
bracket uses (Elimination Round 1 pairs L4/L6 and L2/L8; Elimination
Round 3 pairs each semifinal loser with the winner from the opposite
half). In the elimination bracket a loss is final. Matching the official
modified double-elimination format, there is no "if necessary" game: the
winners-bracket champion is eliminated if it loses the side
championship, dropping to the consolation game.

Rounds are shared across both sides, U.S. taking the low match numbers
and International the high ones, so the scoring config stays one entry
per stage. The existing phases/groups display machinery splits them back
apart into United States / International / Championship tabs.

Scoring lands on exactly 8 point-earning teams, which is the field size
when Elimination Round 4 begins: the two finals decide 1st–4th,
Elimination Final losers take 5th–6th, and Elimination Round 4 losers
7th–8th. 3rd and 4th are distinct because the consolation game is real,
and 5–8 splits into two two-team tiers so surviving Elimination Round 4
is worth more than losing it.

Also:

- Adds an optional nonScoringWinnerFloor to BracketRound. The engine
  hardcoded a 5th-place floor for winners of non-scoring rounds feeding
  a scoring one, which is wrong inside a losers bracket where a win can
  guarantee only 7th. Opt-in, so no existing template changes behavior.

- Fixes TabbedBracketLayout's mobile path, which built its match map
  unfiltered and so would have merged U.S. and International games into
  one column. No-op for NCAA and NBA, whose groups already cover every
  match in their phases.

- Rewrites the LLWS Monte Carlo simulator, which still modelled the
  retired pool-play format (5 teams per pool, then a 4-team bracket per
  side) and no longer described the tournament being scored. It now runs
  the real 10-team double elimination and splits the 5–8 probabilities
  into the correct tiers instead of one even four-way split. Legacy
  "US:A"/"Intl:B" externalIds are still accepted, read as the side
  alone, so seasons configured for the old format keep loading.

Tests replay all 38 games through the pure advancement resolver and
assert each one against the feed labels printed on the official bracket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
2026-08-03 18:06:56 +00:00

1879 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { eq, and } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
import {
getBracketTemplate,
buildNCAA68SlotMap,
matchIndexForSeedSlot,
llwsMatchNumber,
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates";
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
export async function createPlayoffMatch(
data: NewPlayoffMatch
): Promise<PlayoffMatch> {
const db = database();
const [match] = await db
.insert(schema.playoffMatches)
.values(data)
.returning();
return match;
}
export async function createManyPlayoffMatches(
data: NewPlayoffMatch[]
): Promise<PlayoffMatch[]> {
const db = database();
return await db
.insert(schema.playoffMatches)
.values(data)
.returning();
}
export async function findPlayoffMatchById(
id: string
): Promise<PlayoffMatch | undefined> {
const db = database();
return await db.query.playoffMatches.findFirst({
where: eq(schema.playoffMatches.id, id),
with: {
scoringEvent: true,
participant1: true,
participant2: true,
winner: true,
loser: true,
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
odds: { with: { participant: true } },
},
});
}
export async function findPlayoffMatchesByEventId(
eventId: string
): Promise<PlayoffMatch[]> {
const db = database();
return await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
with: {
participant1: true,
participant2: true,
winner: true,
loser: true,
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
odds: { with: { participant: true } },
},
});
}
export async function findPlayoffMatchesByEventIdAndRound(
eventId: string,
round: string
): Promise<PlayoffMatch[]> {
const db = database();
return await db.query.playoffMatches.findMany({
where: and(
eq(schema.playoffMatches.scoringEventId, eventId),
eq(schema.playoffMatches.round, round)
),
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
with: {
participant1: true,
participant2: true,
winner: true,
loser: true,
},
});
}
export async function updatePlayoffMatch(
id: string,
data: Partial<NewPlayoffMatch>
): Promise<PlayoffMatch> {
const db = database();
const [match] = await db
.update(schema.playoffMatches)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.playoffMatches.id, id))
.returning();
return match;
}
export async function deletePlayoffMatch(id: string): Promise<void> {
const db = database();
await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id));
}
export async function deletePlayoffMatchesByEventId(
eventId: string
): Promise<void> {
const db = database();
await db
.delete(schema.playoffMatches)
.where(eq(schema.playoffMatches.scoringEventId, eventId));
}
/**
* Set the winner of a playoff match
*/
export async function setMatchWinner(
matchId: string,
winnerId: string,
loserId: string,
participant1Score?: number,
participant2Score?: number
): Promise<PlayoffMatch> {
const db = database();
const [match] = await db
.update(schema.playoffMatches)
.set({
winnerId,
loserId,
isComplete: true,
participant1Score: participant1Score?.toString(),
participant2Score: participant2Score?.toString(),
updatedAt: new Date(),
})
.where(eq(schema.playoffMatches.id, matchId))
.returning();
return match;
}
/** A draw match with participants/winner already resolved to ids. */
export interface ResolvedDrawMatch {
externalMatchId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isScoring: boolean;
}
/**
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
* `externalMatchId` so repeated syncs update in place rather than duplicating.
*
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
* matchups and known winners directly from the source — appropriate for a feed
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated), those carrying a result,
* and the participant ids of losers whose match *transitioned to complete on this
* run* (a row that was absent or not-yet-complete before and is complete now).
* That set is the newly-decided eliminations — used to announce knockouts once,
* idempotently across re-syncs, since playoff_matches persist between syncs.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const byExternalId = new Map(
existing
.filter((m) => m.externalMatchId)
.map((m) => [m.externalMatchId as string, m])
);
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
// Loser is "newly decided" when this match reaches completion for the first
// time: either a brand-new complete row, or an existing row that was not
// complete before. Re-syncing an already-complete match yields nothing.
if (isComplete && m.loserId && !existingRow?.isComplete) {
newlyDecidedLoserIds.push(m.loserId);
}
if (existingRow) {
await db
.update(schema.playoffMatches)
.set({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
updatedAt: new Date(),
})
.where(eq(schema.playoffMatches.id, existingRow.id));
written++;
} else {
toInsert.push({
scoringEventId: eventId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
externalMatchId: m.externalMatchId,
});
}
}
if (toInsert.length > 0) {
await createManyPlayoffMatches(toInsert);
written += toInsert.length;
}
return { written, completed, newlyDecidedLoserIds };
}
/**
* Generate a standard single elimination bracket structure
*/
export async function generateSingleEliminationBracket(
eventId: string,
participantIds: string[]
): Promise<PlayoffMatch[]> {
const numParticipants = participantIds.length;
// Validate that we have 4 or 8 participants for standard bracket
if (numParticipants !== 4 && numParticipants !== 8) {
throw new Error("Single elimination bracket requires 4 or 8 participants");
}
const matches: NewPlayoffMatch[] = [];
if (numParticipants === 8) {
// Quarterfinals (4 matches)
for (let i = 0; i < 4; i++) {
matches.push({
scoringEventId: eventId,
round: "Quarterfinals",
matchNumber: i + 1,
participant1Id: participantIds[i * 2],
participant2Id: participantIds[i * 2 + 1],
isComplete: false,
});
}
// Semifinals (2 matches)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Semifinals",
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
});
}
} else if (numParticipants === 4) {
// Semifinals (2 matches)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Semifinals",
matchNumber: i + 1,
participant1Id: participantIds[i * 2],
participant2Id: participantIds[i * 2 + 1],
isComplete: false,
});
}
}
// Finals (1 match)
matches.push({
scoringEventId: eventId,
round: "Finals",
matchNumber: 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
});
return await createManyPlayoffMatches(matches);
}
/**
* Advance the winner of a match to the next round
* Uses deterministic slot assignment to maintain bracket structure
*/
export async function advanceWinner(
matchId: string,
winnerId: string
): Promise<void> {
// Get the match
const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found");
// Determine which round this is and where to advance
const eventId = match.scoringEventId;
let nextRound: string;
let nextMatchNumber: number;
let participantSlot: 'participant1Id' | 'participant2Id';
if (match.round === "Quarterfinals") {
nextRound = "Semifinals";
// Match 1,2 -> SF1, Match 3,4 -> SF2
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
// Odd matches (1, 3) -> participant1, Even matches (2, 4) -> participant2
participantSlot = match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
} else if (match.round === "Semifinals") {
nextRound = "Finals";
nextMatchNumber = 1;
// SF Match 1 -> Finals participant1, SF Match 2 -> Finals participant2
participantSlot = match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
} else {
// Finals - no advancement needed
return;
}
// Find the next match
const nextMatches = await findPlayoffMatchesByEventIdAndRound(eventId, nextRound);
const nextMatch = nextMatches.find(m => m.matchNumber === nextMatchNumber);
if (!nextMatch) throw new Error("Next match not found");
// Check if the slot is already filled
if (nextMatch[participantSlot]) {
throw new Error(`Next match ${participantSlot} is already filled`);
}
// Fill the determined slot
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
}
/**
* Generate standard tournament seeding matchups
* Returns pairs of seed indices for proper bracket balance
*
* Examples:
* - 4 teams: [[0,3], [1,2]] = 1v4, 2v3
* - 8 teams: [[0,7], [3,4], [1,6], [2,5]] = 1v8, 4v5, 2v7, 3v6
* - 16 teams: [[0,15], [7,8], [4,11], [3,12], [5,10], [2,13], [6,9], [1,14]]
* = 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
*/
function generateStandardSeeding(teamCount: number): [number, number][] {
if (![4, 8, 16, 32].includes(teamCount)) {
// Non-standard sizes need sequential seeding for now
const pairs: [number, number][] = [];
for (let i = 0; i < teamCount; i += 2) {
pairs.push([i, i + 1]);
}
return pairs;
}
// Standard balanced bracket seeding
const rounds = Math.log2(teamCount);
let seeds = [0, 1]; // Start with 1 vs 2
// Build up seeding by adding rounds
for (let round = 1; round < rounds; round++) {
const newSeeds: number[] = [];
const maxSeed = Math.pow(2, round + 1) - 1;
for (const seed of seeds) {
newSeeds.push(seed);
newSeeds.push(maxSeed - seed);
}
seeds = newSeeds;
}
// Convert to pairs (odd indices vs even indices)
const pairs: [number, number][] = [];
for (let i = 0; i < seeds.length; i += 2) {
pairs.push([seeds[i], seeds[i + 1]]);
}
return pairs;
}
/**
* Generate a bracket from a template
* Phase 2.6: Template-based bracket generation
* Phase 2.7: Added proper tournament seeding
*
* @param eventId - The scoring event ID
* @param templateId - The bracket template ID (e.g., "simple_16", "ncaa_68")
* @param participantIds - Array of participant IDs to assign to the bracket (optional, can be assigned later)
* @returns Array of created playoff matches
*/
export async function generateBracketFromTemplate(
eventId: string,
templateId: string,
participantIds?: string[],
regionOverride?: BracketRegion[]
): Promise<PlayoffMatch[]> {
const template = getBracketTemplate(templateId);
if (!template) {
throw new Error(`Bracket template '${templateId}' not found`);
}
// Validate participant count if provided
if (participantIds && participantIds.length !== template.totalTeams) {
throw new Error(
`Template '${templateId}' requires ${template.totalTeams} participants, but ${participantIds.length} were provided`
);
}
// FIFA 48 generates an empty knockout bracket (participants assigned later via groups)
if (templateId === "fifa_48") {
return await generateFIFA48Bracket(eventId, template);
}
// NCAA 68 requires special handling for First Four and Round of 64
// Use regionOverride if supplied (per-event config), otherwise fall back to template.regions
if (templateId === "ncaa_68") {
const effectiveTemplate = regionOverride
? { ...template, regions: regionOverride }
: template;
return await generateNCAA68Bracket(eventId, effectiveTemplate, participantIds);
}
// NFL 14 requires special handling for bye weeks
if (templateId === "nfl_14") {
return await generateNFL14Bracket(eventId, template, participantIds);
}
// AFL 10 requires special handling for double-chance finals system
if (templateId === "afl_10") {
return await generateAFL10Bracket(eventId, template, participantIds);
}
// CFP 12 requires special handling for bye weeks (seeds 14 skip First Round)
if (templateId === "cfp_12") {
return await generateCFP12Bracket(eventId, template, participantIds);
}
// NBA 20 requires special handling for the play-in tournament
if (templateId === "nba_20") {
return await generateNBA20Bracket(eventId, template, participantIds);
}
// LLWS 20 requires special handling for its two double-elimination brackets
if (templateId === "llws_20") {
return await generateLLWS20Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
for (const round of template.rounds) {
// Only assign participants to the first round if participantIds provided
let seedingPairs: [number, number][] = [];
if (participantIds && round === template.rounds[0]) {
const firstRoundTeams = round.matchCount * 2;
seedingPairs = generateStandardSeeding(firstRoundTeams);
}
for (let i = 0; i < round.matchCount; i++) {
let participant1Id: string | null = null;
let participant2Id: string | null = null;
let seedInfo: string | null = null;
if (participantIds && round === template.rounds[0] && seedingPairs[i]) {
const [seed1Index, seed2Index] = seedingPairs[i];
participant1Id = participantIds[seed1Index] || null;
participant2Id = participantIds[seed2Index] || null;
// Store seed info for display (1-indexed for users)
seedInfo = `${seed1Index + 1} vs ${seed2Index + 1}`;
}
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id,
participant2Id,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Generate NCAA 68 bracket using the template's regions config.
*
* Participant array layout (driven by buildNCAA68SlotMap):
* [region 0 direct seeds] [region 1 direct seeds] ...
* [play-in teams in region/play-in order, 2 per game]
*/
async function generateNCAA68Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
if (!template.regions || template.regions.length === 0) {
throw new Error("NCAA 68 template requires a regions config");
}
const matches: NewPlayoffMatch[] = [];
const slotMap = buildNCAA68SlotMap(template.regions);
// Per-region map: region index → FF labels for that region's play-ins (e.g. "FF1")
const regionFFLabels = new Map<number, string[]>();
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
const { regionIndex, playInIndex } = slotMap.playInOffsets[i];
if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []);
const labels = regionFFLabels.get(regionIndex);
if (labels) labels[playInIndex] = `FF${i + 1}`;
}
// ── First Four ────────────────────────────────────────────────────────────
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
const { startIndex, seedSlot, regionIndex } = slotMap.playInOffsets[i];
const regionName = template.regions[regionIndex].name;
matches.push({
scoringEventId: eventId,
round: "First Four",
matchNumber: i + 1,
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
isComplete: false,
isScoring: false,
templateRound: "First Four",
seedInfo: `${regionName} ${seedSlot}-seed play-in`,
});
}
// ── Round of 64: 8 matches per region ────────────────────────────────────
let r64MatchNumber = 1;
for (let r = 0; r < template.regions.length; r++) {
const region = template.regions[r];
const directOffset = slotMap.directOffsets[r];
const labels = regionFFLabels.get(r) ?? [];
// Map seed rank → participant array index or FF label
const seedToSlot = new Map<number, number | string>();
for (let i = 0; i < region.directSeeds.length; i++) {
seedToSlot.set(region.directSeeds[i], directOffset + i);
}
for (let p = 0; p < region.playIns.length; p++) {
seedToSlot.set(region.playIns[p].seedSlot, labels[p]);
}
for (const [hiSeed, loSeed] of STANDARD_BRACKET_SEEDING) {
const slot1 = seedToSlot.get(hiSeed);
const slot2 = seedToSlot.get(loSeed);
if (slot1 === undefined || slot2 === undefined) {
throw new Error(
`Region "${region.name}" missing seed ${slot1 === undefined ? hiSeed : loSeed}`
);
}
matches.push({
scoringEventId: eventId,
round: "Round of 64",
matchNumber: r64MatchNumber++,
participant1Id:
participantIds && typeof slot1 === "number"
? (participantIds[slot1] ?? null)
: null,
participant2Id:
participantIds && typeof slot2 === "number"
? (participantIds[slot2] ?? null)
: null,
isComplete: false,
isScoring: false,
templateRound: "Round of 64",
seedInfo: `${region.name}: ${hiSeed} vs ${typeof slot2 === "string" ? slot2 : loSeed}`,
});
}
}
// ── Round of 32 → Championship (empty match shells) ──────────────────────
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Generate NFL 14 bracket with bye weeks for top 2 seeds
* Phase 2.9: Special handling for Wild Card round (12 teams) and Divisional byes
*/
async function generateNFL14Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// Actually for 14 teams (seeds 1-14), Wild Card should be:
// Match 1: #7 (index 6) vs #10 (index 9)
// Match 2: #6 (index 5) vs #11 (index 10)
// Match 3: #5 (index 4) vs #12 (index 11)
// Match 4: #8 (index 7) vs #9 (index 8)
// Match 5: #4 (index 3) vs #13 (index 12)
// Match 6: #3 (index 2) vs #14 (index 13)
const wildCardSeeding = [
[6, 9], // #7 vs #10
[5, 10], // #6 vs #11
[4, 11], // #5 vs #12
[7, 8], // #8 vs #9
[3, 12], // #4 vs #13
[2, 13], // #3 vs #14
];
for (let i = 0; i < wildCardSeeding.length; i++) {
const [seed1, seed2] = wildCardSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Wild Card",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // Wild Card doesn't score fantasy points
templateRound: "Wild Card",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Divisional: 4 games
// Top 2 seeds (#1 and #2) get byes and are placed in Divisional automatically
// The other 2 Divisional matches will be filled by Wild Card winners
const divisionalRound = template.rounds.find((r) => r.name === "Divisional");
if (divisionalRound) {
for (let i = 0; i < divisionalRound.matchCount; i++) {
let participant1Id: string | null = null;
const participant2Id: string | null = null;
let seedInfo: string | null = null;
// Assign bye teams to first two Divisional matches
if (participantIds) {
if (i === 0) {
// Match 1: #1 seed (index 0) vs TBD (Wild Card winner)
participant1Id = participantIds[0];
seedInfo = "1 vs TBD";
} else if (i === 1) {
// Match 2: #2 seed (index 1) vs TBD (Wild Card winner)
participant1Id = participantIds[1];
seedInfo = "2 vs TBD";
}
// Matches 3 and 4 are TBD vs TBD (Wild Card winners)
}
matches.push({
scoringEventId: eventId,
round: "Divisional",
matchNumber: i + 1,
participant1Id,
participant2Id,
isComplete: false,
isScoring: divisionalRound.isScoring,
templateRound: "Divisional",
seedInfo,
});
}
}
// Generate remaining rounds (Conference Championship, Super Bowl)
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Generate AFL 10 bracket with Wildcard Round (2026+ format)
* Phase 3.3: Special handling for AFL's double-chance finals system
*
* Structure:
* - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
* - Semi-Finals: QF losers vs EF winners
* - Preliminary Finals: QF winners vs SF winners
* - Grand Final: PF winners
*/
async function generateAFL10Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// Wildcard Round: 7th vs 10th, 8th vs 9th
const wildcardSeeding = [
[6, 9], // #7 (index 6) vs #10 (index 9) - 7th hosts
[7, 8], // #8 (index 7) vs #9 (index 8) - 8th hosts
];
for (let i = 0; i < wildcardSeeding.length; i++) {
const [seed1, seed2] = wildcardSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Wildcard Round",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // Losers get 0 points (9th-10th place)
templateRound: "Wildcard Round",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Qualifying Finals: 1st vs 4th, 2nd vs 3rd
const qualifyingSeeding = [
[0, 3], // #1 (index 0) vs #4 (index 3)
[1, 2], // #2 (index 1) vs #3 (index 2)
];
for (let i = 0; i < qualifyingSeeding.length; i++) {
const [seed1, seed2] = qualifyingSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Qualifying Finals",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // Winners get bye, losers get second chance
templateRound: "Qualifying Finals",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
const eliminationSeeding = [
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
];
for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, wildcard } = eliminationSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Elimination Finals",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[higher] : null,
participant2Id: null, // Will be filled by wildcard winner
isComplete: false,
isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
});
}
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Semi-Finals",
matchNumber: i + 1,
participant1Id: null, // Will be filled by QF loser
participant2Id: null, // Will be filled by EF winner
isComplete: false,
isScoring: true, // Losers share 5th-6th
templateRound: "Semi-Finals",
seedInfo: null,
});
}
// Preliminary Finals: QF winners vs SF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Preliminary Finals",
matchNumber: i + 1,
participant1Id: null, // Will be filled by QF winner
participant2Id: null, // Will be filled by SF winner
isComplete: false,
isScoring: true, // Losers share 3rd-4th
templateRound: "Preliminary Finals",
seedInfo: null,
});
}
// Grand Final: PF winners (TBD vs TBD)
matches.push({
scoringEventId: eventId,
round: "Grand Final",
matchNumber: 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: true, // Winner 1st, Loser 2nd
templateRound: "Grand Final",
seedInfo: null,
});
return await createManyPlayoffMatches(matches);
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner → Elimination Finals
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
* - Elimination Finals: Winner → Semi-Finals
* - Semi-Finals: Winner → Preliminary Finals
* - Preliminary Finals: Winner → Grand Final
*/
async function advanceAFLWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
// Wildcard Round: Winner advances to Elimination Finals
if (match.round === "Wildcard Round") {
// Wildcard Match 1 winner → EF Match 2, participant2Id
// Wildcard Match 2 winner → EF Match 1, participant2Id
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
return;
}
// Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
if (match.round === "Qualifying Finals") {
// QF Match 1: Winner → PF1 participant1, Loser → SF1 participant1
// QF Match 2: Winner → PF2 participant1, Loser → SF2 participant1
const pfMatchNumber = match.matchNumber;
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
if (pfMatch.participant1Id) throw new Error(`PF ${pfMatchNumber} participant1 already filled`);
await updatePlayoffMatch(pfMatch.id, { participant1Id: winnerId });
// Also advance loser to Semi-Finals
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === match.matchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${match.matchNumber} not found`);
if (sfMatch.participant1Id) throw new Error(`SF ${match.matchNumber} participant1 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant1Id: loserId });
return;
}
// Elimination Finals: Winner → Semi-Finals
if (match.round === "Elimination Finals") {
// EF Match 1 winner → SF2 participant2
// EF Match 2 winner → SF1 participant2
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
return;
}
// Semi-Finals: Winner → Preliminary Finals
if (match.round === "Semi-Finals") {
// SF Match 1 winner → PF2 participant2
// SF Match 2 winner → PF1 participant2
const pfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
if (pfMatch.participant2Id) throw new Error(`PF ${pfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(pfMatch.id, { participant2Id: winnerId });
return;
}
// Preliminary Finals: Winner → Grand Final
if (match.round === "Preliminary Finals") {
const gfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Grand Final");
const gfMatch = gfMatches[0];
if (!gfMatch) throw new Error("Grand Final match not found");
const participantSlot: 'participant1Id' | 'participant2Id' =
match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
if (gfMatch[participantSlot]) throw new Error(`GF ${participantSlot} already filled`);
await updatePlayoffMatch(gfMatch.id, { [participantSlot]: winnerId });
return;
}
// Grand Final: No advancement
if (match.round === "Grand Final") {
return;
}
}
/**
* Advance winner to next round using template-based logic
* Works with any bracket template
*/
export async function advanceWinnerTemplate(
matchId: string,
winnerId: string,
template: BracketTemplate
): Promise<void> {
// Get the match
const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found");
// Special handling for NBA 20 play-in tournament
if (template.id === "nba_20" && (match.round === "Play-In Round 1" || match.round === "Play-In Round 2")) {
const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for NBA play-in advancement");
return await advanceNBAPlayInWinner(match, winnerId, loserId);
}
// Special handling for LLWS 20 double elimination: winners-bracket losers route
// into the elimination bracket instead of being knocked out.
if (template.id === "llws_20") {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
return await advanceLLWSWinner(match, winnerId, loserId);
}
// Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") {
const loserId = match.winnerId === match.participant1Id ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for AFL advancement");
return await advanceAFLWinner(match, winnerId, loserId);
}
// Special handling for NCAA 68 First Four
// First Four winners advance to Round of 64 slots derived from the regions config
if (template.id === "ncaa_68" && match.round === "First Four") {
return await advanceFirstFourWinner(match, winnerId, template);
}
// Find current round in template
const currentRoundIndex = template.rounds.findIndex(
(r) => r.name === match.round
);
if (currentRoundIndex === -1) {
throw new Error(`Round '${match.round}' not found in template`);
}
const currentRound = template.rounds[currentRoundIndex];
// Check if there's a next round
if (!currentRound.feedsInto) {
// Championship game - no advancement needed
return;
}
const nextRound = template.rounds.find((r) => r.name === currentRound.feedsInto);
if (!nextRound) {
throw new Error(`Next round '${currentRound.feedsInto}' not found in template`);
}
// Determine next match number and slot
// This uses the same logic as before: matches advance in sequential pairs
const nextMatchNumber = Math.ceil(match.matchNumber / 2);
const participantSlot: 'participant1Id' | 'participant2Id' =
match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
// Find the next match
const nextMatches = await findPlayoffMatchesByEventIdAndRound(
match.scoringEventId,
nextRound.name
);
const nextMatch = nextMatches.find((m) => m.matchNumber === nextMatchNumber);
if (!nextMatch) {
throw new Error(
`Next match not found: round=${nextRound.name}, matchNumber=${nextMatchNumber}`
);
}
// Check if the slot is already filled
if (nextMatch[participantSlot]) {
throw new Error(`Next match ${participantSlot} is already filled`);
}
// Fill the determined slot
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
// If this round has a loserFeedsInto, also advance the loser to that round
if (currentRound.loserFeedsInto) {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (loserId) {
const loserRound = template.rounds.find((r) => r.name === currentRound.loserFeedsInto);
if (loserRound) {
const loserMatches = await findPlayoffMatchesByEventIdAndRound(
match.scoringEventId,
loserRound.name
);
const loserMatch = loserMatches[0];
if (loserMatch) {
// SF match 1 loser → participant1Id, SF match 2 loser → participant2Id
const loserSlot: "participant1Id" | "participant2Id" =
match.matchNumber === 1 ? "participant1Id" : "participant2Id";
if (!loserMatch[loserSlot]) {
await updatePlayoffMatch(loserMatch.id, { [loserSlot]: loserId });
}
}
}
}
}
}
/**
* Advance a First Four winner to their target Round of 64 slot.
*
* The target match number is computed dynamically from the template's regions config:
* r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
*
* The winner always fills participant2Id (they are the lower seed in their matchup).
*/
async function advanceFirstFourWinner(
firstFourMatch: PlayoffMatch,
winnerId: string,
template: BracketTemplate
): Promise<void> {
// Prefer the per-event stored region config (set at bracket generation time);
// fall back to the template's built-in regions for backwards compatibility.
const db = database();
const eventRow = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, firstFourMatch.scoringEventId),
columns: { bracketRegionConfig: true },
});
const regions =
(eventRow?.bracketRegionConfig as BracketRegion[] | null) ?? template.regions;
if (!regions) {
throw new Error("NCAA 68 template requires regions config for First Four advancement");
}
const slotMap = buildNCAA68SlotMap(regions);
const ffIndex = firstFourMatch.matchNumber - 1; // 0-indexed
if (ffIndex < 0 || ffIndex >= slotMap.playInOffsets.length) {
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
}
const { regionIndex, seedSlot } = slotMap.playInOffsets[ffIndex];
const matchIndexInRegion = matchIndexForSeedSlot(seedSlot);
// Each region contributes exactly 8 matches to the Round of 64, in region order
const r64MatchNumber = regionIndex * 8 + matchIndexInRegion + 1;
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
firstFourMatch.scoringEventId,
"Round of 64"
);
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
if (!targetMatch) {
throw new Error(`Round of 64 match ${r64MatchNumber} not found`);
}
if (targetMatch.participant2Id) {
throw new Error(`Round of 64 match ${r64MatchNumber} participant2Id is already filled`);
}
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
}
/**
* Generate FIFA 48 knockout bracket (empty, no participants assigned)
* Creates 31 matches across 5 rounds identical to simple_32 structure.
* Participants are assigned later via assignParticipantsToKnockout after group stage.
*/
async function generateFIFA48Bracket(
eventId: string,
template: BracketTemplate
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
for (const round of template.rounds) {
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Assign participants to Round of 32 knockout matches
* Used after group stage to populate the empty knockout bracket
*
* @param eventId - The scoring event ID
* @param assignments - Array of { matchNumber, slot, participantId }
*/
export async function assignParticipantsToKnockout(
eventId: string,
assignments: Array<{
matchNumber: number;
slot: "participant1Id" | "participant2Id";
participantId: string;
}>
): Promise<void> {
// Get Round of 32 matches
const r32Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Round of 32");
for (const assignment of assignments) {
const match = r32Matches.find((m) => m.matchNumber === assignment.matchNumber);
if (!match) {
throw new Error(`Round of 32 match ${assignment.matchNumber} not found`);
}
if (match[assignment.slot]) {
throw new Error(
`Round of 32 match ${assignment.matchNumber} ${assignment.slot} is already filled`
);
}
await updatePlayoffMatch(match.id, {
[assignment.slot]: assignment.participantId,
});
}
}
/**
* Generate College Football Playoff 12-team bracket.
*
* First Round (seeds 512, 4 games, not scoring):
* Match 1: 5 vs 12
* Match 2: 6 vs 11
* Match 3: 7 vs 10
* Match 4: 8 vs 9
*
* Quarterfinals (4 games, scoring starts):
* Match 1: 1 vs First Round match 4 winner (8/9)
* Match 2: 4 vs First Round match 1 winner (5/12)
* Match 3: 3 vs First Round match 2 winner (6/11)
* Match 4: 2 vs First Round match 3 winner (7/10)
*
* Semifinals and National Championship: TBD vs TBD
*/
async function generateCFP12Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// First Round: seeds 512 (indices 411), top 4 (indices 03) have byes
const firstRoundSeeding: [number, number][] = [
[4, 11], // 5 vs 12
[5, 10], // 6 vs 11
[6, 9], // 7 vs 10
[7, 8], // 8 vs 9
];
const firstRound = template.rounds[0]; // "First Round"
for (let i = 0; i < firstRoundSeeding.length; i++) {
const [s1, s2] = firstRoundSeeding[i];
matches.push({
scoringEventId: eventId,
round: firstRound.name,
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[s1] : null,
participant2Id: participantIds ? participantIds[s2] : null,
isComplete: false,
isScoring: false,
templateRound: firstRound.name,
seedInfo: `${s1 + 1} vs ${s2 + 1}`,
});
}
// Quarterfinals: seeds 14 (indices 03) receive byes, face First Round winners
// Matchup order mirrors CFP bracket: 1 vs 8/9 winner, 4 vs 5/12 winner, etc.
const quarterfinalsSeeding: [number, string][] = [
[0, "1 vs TBD"], // 1 seed vs First Round match 4 winner (8v9)
[3, "4 vs TBD"], // 4 seed vs First Round match 1 winner (5v12)
[2, "3 vs TBD"], // 3 seed vs First Round match 2 winner (6v11)
[1, "2 vs TBD"], // 2 seed vs First Round match 3 winner (7v10)
];
const quarterfinalsRound = template.rounds[1]; // "Quarterfinals"
for (let i = 0; i < quarterfinalsSeeding.length; i++) {
const [byeSeedIndex, seedInfo] = quarterfinalsSeeding[i];
matches.push({
scoringEventId: eventId,
round: quarterfinalsRound.name,
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[byeSeedIndex] : null,
participant2Id: null, // Filled when First Round winner is known
isComplete: false,
isScoring: true,
templateRound: quarterfinalsRound.name,
seedInfo,
});
}
// Semifinals and National Championship: all TBD
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Generate NBA 20-team bracket (Play-In + Playoffs).
*
* Participant array layout (indices 0-19):
* [09] East seeds 110 (E1=0, E2=1, ..., E10=9)
* [1019] West seeds 110 (W1=10, W2=11, ..., W10=19)
*
* Play-In Round 1 (4 matches):
* M1: E7(6) vs E8(7) M2: E9(8) vs E10(9)
* M3: W7(16) vs W8(17) M4: W9(18) vs W10(19)
*
* Play-In Round 2 (2 matches, TBD):
* M1: East loser(7v8) vs East winner(9v10) → E8 seed
* M2: West loser(7v8) vs West winner(9v10) → W8 seed
*
* First Round (8 series — bracket order for correct Conference Semis matchups via ceil logic):
* M1: E1 vs E8 (PIR2-M1 winner) M2: E4 vs E5
* M3: E2 vs E7 (PIR1-M1 winner) M4: E3 vs E6
* M5: W1 vs W8 (PIR2-M2 winner) M6: W4 vs W5
* M7: W2 vs W7 (PIR1-M3 winner) M8: W3 vs W6
*/
async function generateNBA20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
const pir1Seeding: [number, number, string][] = [
[6, 7, "East: 7 vs 8"],
[8, 9, "East: 9 vs 10"],
[16, 17, "West: 7 vs 8"],
[18, 19, "West: 9 vs 10"],
];
for (let i = 0; i < pir1Seeding.length; i++) {
const [idx1, idx2, seedInfo] = pir1Seeding[i];
matches.push({
scoringEventId: eventId,
round: "Play-In Round 1",
matchNumber: i + 1,
participant1Id: p(idx1),
participant2Id: p(idx2),
isComplete: false,
isScoring: false,
templateRound: "Play-In Round 1",
seedInfo,
});
}
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
const pir2SeedInfo = [
"East: 7/8 loser vs 9/10 winner",
"West: 7/8 loser vs 9/10 winner",
];
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Play-In Round 2",
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: false,
templateRound: "Play-In Round 2",
seedInfo: pir2SeedInfo[i],
});
}
// ── First Round (8 series) ────────────────────────────────────────────────────
// IMPORTANT: This match ordering is load-bearing.
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
// seed slots: FR M1 p2 = E8, FR M3 p2 = E7, FR M5 p2 = W8, FR M7 p2 = W7.
// Do not change the match order without updating the simulator's resolveGame/
// resolveSeries calls and the corresponding test fixtures.
const firstRoundSeeding: [number | null, number | null, string][] = [
[0, null, "East: 1 vs 8"],
[3, 4, "East: 4 vs 5"],
[1, null, "East: 2 vs 7"],
[2, 5, "East: 3 vs 6"],
[10, null, "West: 1 vs 8"],
[13, 14, "West: 4 vs 5"],
[11, null, "West: 2 vs 7"],
[12, 15, "West: 3 vs 6"],
];
for (let i = 0; i < firstRoundSeeding.length; i++) {
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
matches.push({
scoringEventId: eventId,
round: "First Round",
matchNumber: i + 1,
participant1Id: idx1 !== null ? p(idx1) : null,
participant2Id: idx2 !== null ? p(idx2) : null,
isComplete: false,
isScoring: false,
templateRound: "First Round",
seedInfo,
});
}
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
for (let roundIndex = 3; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Returns true if the loser of the given match advances to another round rather
* than being eliminated. Use this to avoid recording a premature elimination result.
*
* Currently only applies to NBA Play-In Round 1 matches 1 and 3 (the 7v8 games),
* whose losers go on to Play-In Round 2.
*/
export function doesLoserAdvance(
round: string,
matchNumber: number,
templateId: string
): boolean {
// NBA Play-In Round 1: 7v8 games (M1, M3) — loser gets a second chance in Round 2.
// 9v10 games (M2, M4) — loser is immediately eliminated.
if (templateId === "nba_20" && round === "Play-In Round 1") {
return matchNumber === 1 || matchNumber === 3;
}
// AFL Qualifying Finals: both losers (1v4 and 2v3) advance to Semi-Finals.
if (templateId === "afl_10" && round === "Qualifying Finals") {
return true;
}
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
// must not be recorded as an elimination. (Winners Final and Bracket Championship
// are scoring rounds and are handled via loserIsPartial instead.)
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
return true;
}
return false;
}
/**
* NBA play-in advancement logic.
*
* Play-In Round 1:
* M1 (E7v8): winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
* M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
* M3 (W7v8): winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
* M4 (W9v10): winner → PIR2 M2 p2; loser eliminated
*
* Play-In Round 2:
* M1: winner → First Round M1 p2 (E8 seed); loser eliminated
* M2: winner → First Round M5 p2 (W8 seed); loser eliminated
*/
async function advanceNBAPlayInWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
if (match.round === "Play-In Round 1") {
if (match.matchNumber === 1) {
// E7v8: winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
// FR M3 is "E2 vs E7": E2 is already at p1, E7 fills p2.
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
]);
const frM3 = frMatches.find((m) => m.matchNumber === 3);
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
if (!frM3) throw new Error("First Round match 3 not found");
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
if (frM3.participant2Id) throw new Error("First Round M3 participant2 already filled");
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM3.id, { participant2Id: winnerId }),
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
]);
return;
}
if (match.matchNumber === 2) {
// E9v10: winner → PIR2 M1 p2; loser eliminated
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
if (pir2M1.participant2Id) throw new Error("Play-In Round 2 M1 participant2 already filled");
await updatePlayoffMatch(pir2M1.id, { participant2Id: winnerId });
return;
}
if (match.matchNumber === 3) {
// W7v8: winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
// FR M7 is "W2 vs W7": W2 is already at p1, W7 fills p2.
const [frMatches, pir2Matches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
]);
const frM7 = frMatches.find((m) => m.matchNumber === 7);
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
if (!frM7) throw new Error("First Round match 7 not found");
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
if (frM7.participant2Id) throw new Error("First Round M7 participant2 already filled");
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
await Promise.all([
updatePlayoffMatch(frM7.id, { participant2Id: winnerId }),
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
]);
return;
}
if (match.matchNumber === 4) {
// W9v10: winner → PIR2 M2 p2; loser eliminated
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
if (pir2M2.participant2Id) throw new Error("Play-In Round 2 M2 participant2 already filled");
await updatePlayoffMatch(pir2M2.id, { participant2Id: winnerId });
return;
}
throw new Error(`Unknown Play-In Round 1 match number: ${match.matchNumber}`);
}
if (match.round === "Play-In Round 2") {
if (match.matchNumber === 1) {
// E8 seed: winner → First Round M1 p2; loser eliminated
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
const frM1 = frMatches.find((m) => m.matchNumber === 1);
if (!frM1) throw new Error("First Round match 1 not found");
if (frM1.participant2Id) throw new Error("First Round M1 participant2 already filled");
await updatePlayoffMatch(frM1.id, { participant2Id: winnerId });
return;
}
if (match.matchNumber === 2) {
// W8 seed: winner → First Round M5 p2; loser eliminated
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
const frM5 = frMatches.find((m) => m.matchNumber === 5);
if (!frM5) throw new Error("First Round match 5 not found");
if (frM5.participant2Id) throw new Error("First Round M5 participant2 already filled");
await updatePlayoffMatch(frM5.id, { participant2Id: winnerId });
return;
}
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):
* [07] U.S. Opening Round teams, two per game
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
* [1017] 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 });
}
}