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, 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 { const db = database(); const [match] = await db .insert(schema.playoffMatches) .values(data) .returning(); return match; } export async function createManyPlayoffMatches( data: NewPlayoffMatch[] ): Promise { const db = database(); return await db .insert(schema.playoffMatches) .values(data) .returning(); } export async function findPlayoffMatchById( id: string ): Promise { 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 { 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 { 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 ): Promise { 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 { const db = database(); await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id)); } export async function deletePlayoffMatchesByEventId( eventId: string ): Promise { 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 { 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 { 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 { // 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 { 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 1–4 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); } 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 { 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(); 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(); 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 { 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 { 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 { 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 { // 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 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 { // 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 { 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 { // 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 5–12, 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 { const matches: NewPlayoffMatch[] = []; // First Round: seeds 5–12 (indices 4–11), top 4 (indices 0–3) 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 1–4 (indices 0–3) 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): * [0–9] East seeds 1–10 (E1=0, E2=1, ..., E10=9) * [10–19] West seeds 1–10 (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 { 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; } 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 { 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}`); } }