import { eq, and } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import type { BracketTemplate } from "~/lib/bracket-templates"; import { getBracketTemplate } 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, }, }); } 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, }, }); } 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; } /** * Generate a standard single elimination bracket structure */ export async function generateSingleEliminationBracket( eventId: string, participantIds: string[] ): Promise { const db = database(); 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 { const db = database(); // 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)) { // For non-standard sizes (like NCAA 68, NFL 14), return sequential for now // Those will need custom seeding logic in their respective phases 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[] ): 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` ); } 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); } /** * 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 { const db = database(); // Get the match const match = await findPlayoffMatchById(matchId); if (!match) throw new Error("Match not found"); // 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 }); }