import { db } from '$lib/server/db'; import { tournament, tournamentStaff, tournamentRound, tournamentRegistration, tournamentMatch, tournamentGroup, tournamentGroupEntry, match } from '$lib/server/db/schema'; import { eq, and, inArray, asc } from 'drizzle-orm'; import { log } from '$lib/server/logger'; import { TOURNAMENT_STATES, TOURNAMENT_MATCH_STATES, TOURNAMENT_STAFF_ROLES, type TournamentConfig, type TournamentFormat } from './types'; import { generateSingleElimBracket, generateDoubleElimBracket, generateGroupAssignments, generateGroupStageMatches, nextPowerOf2, getRoundName, type BracketSlot } from './bracket'; import { processByes } from './progression'; // ── Create Tournament ────────────────────────────────────────────────── export async function createTournament(opts: { name: string; description?: string; format: TournamentFormat; config: TournamentConfig; maxSlots: number; bannerUrl?: string; createdBy: string; registrationOpenAt?: Date; registrationCloseAt?: Date; startAt?: Date; }) { const [created] = await db .insert(tournament) .values({ name: opts.name, description: opts.description, format: opts.format, config: opts.config, maxSlots: opts.maxSlots, bannerUrl: opts.bannerUrl, createdBy: opts.createdBy, registrationOpenAt: opts.registrationOpenAt, registrationCloseAt: opts.registrationCloseAt, startAt: opts.startAt, state: TOURNAMENT_STATES.DRAFT }) .returning(); // add creator as organizer await db.insert(tournamentStaff).values({ tournamentId: created.id, userId: opts.createdBy, role: TOURNAMENT_STAFF_ROLES.ORGANIZER }); log.tournament.info({ tournamentId: created.id, name: opts.name }, 'tournament created'); return created; } // ── Open Registration ────────────────────────────────────────────────── export async function openRegistration(tournamentId: string) { const t = await getTournamentOrThrow(tournamentId); assertTournamentState(t.state, TOURNAMENT_STATES.DRAFT); // validate at least one round exists const rounds = await db.query.tournamentRound.findMany({ where: eq(tournamentRound.tournamentId, tournamentId) }); if (rounds.length === 0) { throw new Error('Cannot open registration: no rounds configured'); } await db .update(tournament) .set({ state: TOURNAMENT_STATES.REGISTRATION, registrationOpenAt: new Date() }) .where(eq(tournament.id, tournamentId)); log.tournament.info({ tournamentId }, 'registration opened'); } // ── Close Registration ───────────────────────────────────────────────── export async function closeRegistration(tournamentId: string) { const t = await getTournamentOrThrow(tournamentId); assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION); const config = t.config as TournamentConfig; const nextState = config.qualifiers?.enabled ? TOURNAMENT_STATES.QUALIFIERS : TOURNAMENT_STATES.SEEDING; await db .update(tournament) .set({ state: nextState, registrationCloseAt: new Date() }) .where(eq(tournament.id, tournamentId)); log.tournament.info({ tournamentId, nextState }, 'registration closed'); } // ── Register for Tournament ──────────────────────────────────────────── export async function registerForTournament(tournamentId: string, teamId: string, userId: string) { const t = await getTournamentOrThrow(tournamentId); assertTournamentState(t.state, TOURNAMENT_STATES.REGISTRATION); // check max slots const registrations = await db.query.tournamentRegistration.findMany({ where: and( eq(tournamentRegistration.tournamentId, tournamentId), inArray(tournamentRegistration.status, ['registered', 'confirmed']) ) }); const config = t.config as TournamentConfig; const hasQualifiers = config.qualifiers?.enabled; // if no qualifiers, cap at maxSlots. with qualifiers, allow more signups if (!hasQualifiers && registrations.length >= t.maxSlots) { throw new Error('Tournament is full'); } // check not already registered const existing = await db.query.tournamentRegistration.findFirst({ where: and( eq(tournamentRegistration.tournamentId, tournamentId), eq(tournamentRegistration.teamId, teamId) ) }); if (existing) { throw new Error('Already registered for this tournament'); } const [reg] = await db .insert(tournamentRegistration) .values({ tournamentId, teamId, userId, status: 'registered' }) .returning(); log.tournament.info({ tournamentId, teamId }, 'team registered'); return reg; } // ── Withdraw from Tournament ─────────────────────────────────────────── export async function withdrawFromTournament(tournamentId: string, teamId: string) { const reg = await db.query.tournamentRegistration.findFirst({ where: and( eq(tournamentRegistration.tournamentId, tournamentId), eq(tournamentRegistration.teamId, teamId) ) }); if (!reg) throw new Error('Not registered for this tournament'); await db .update(tournamentRegistration) .set({ status: 'withdrawn' }) .where(eq(tournamentRegistration.id, reg.id)); log.tournament.info({ tournamentId, teamId }, 'team withdrew'); } // ── Finalize Seeding ─────────────────────────────────────────────────── export async function finalizeSeeding(tournamentId: string) { const t = await getTournamentOrThrow(tournamentId); if (t.state !== TOURNAMENT_STATES.SEEDING && t.state !== TOURNAMENT_STATES.QUALIFIERS) { throw new Error(`Cannot finalize seeding in state ${t.state}`); } const registrations = await db.query.tournamentRegistration.findMany({ where: and( eq(tournamentRegistration.tournamentId, tournamentId), inArray(tournamentRegistration.status, ['registered', 'confirmed']) ), orderBy: [asc(tournamentRegistration.seed), asc(tournamentRegistration.registeredAt)] }); if (registrations.length < 2) { throw new Error('Need at least 2 teams to generate bracket'); } // cap to maxSlots if needed (qualifiers should have already reduced) const activeTeams = registrations.slice(0, t.maxSlots); // assign seeds if not already set (from qualifiers) for (let i = 0; i < activeTeams.length; i++) { if (!activeTeams[i].seed) { await db .update(tournamentRegistration) .set({ seed: i + 1 }) .where(eq(tournamentRegistration.id, activeTeams[i].id)); activeTeams[i] = { ...activeTeams[i], seed: i + 1 }; } } // eliminate teams that didn't make the cut const eliminated = registrations.slice(t.maxSlots); for (const reg of eliminated) { await db .update(tournamentRegistration) .set({ status: 'eliminated' }) .where(eq(tournamentRegistration.id, reg.id)); } const format = t.format as TournamentFormat; const config = t.config as TournamentConfig; // build seed → teamId map const seedToTeam = new Map(); for (const reg of activeTeams) { seedToTeam.set(reg.seed!, reg.teamId); } if (format === 'groups_bracket' && config.groupStage) { await generateGroupsAndBracket(tournamentId, activeTeams.length, config, seedToTeam); } else if (format === 'double_elim') { await generateDoubleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam); } else { await generateSingleElimFromSeeds(tournamentId, activeTeams.length, seedToTeam); } await db .update(tournament) .set({ state: TOURNAMENT_STATES.BRACKET }) .where(eq(tournament.id, tournamentId)); // auto-advance BYE matches await processByes(tournamentId); log.tournament.info({ tournamentId, teamCount: activeTeams.length, format }, 'bracket generated'); } // ── Cancel Tournament ────────────────────────────────────────────────── export async function cancelTournament(tournamentId: string) { const t = await getTournamentOrThrow(tournamentId); if (t.state === TOURNAMENT_STATES.FINISHED || t.state === TOURNAMENT_STATES.CANCELLED) { throw new Error(`Tournament is already ${t.state}`); } // cancel any active matches const activeMatches = await db.query.tournamentMatch.findMany({ where: and( eq(tournamentMatch.tournamentId, tournamentId), inArray(tournamentMatch.state, [ TOURNAMENT_MATCH_STATES.SCHEDULED, TOURNAMENT_MATCH_STATES.LIVE ]) ) }); for (const tm of activeMatches) { if (tm.matchId) { await db .update(match) .set({ state: 'CANCELLED', finishedAt: new Date() }) .where(eq(match.id, tm.matchId)); } await db .update(tournamentMatch) .set({ state: TOURNAMENT_MATCH_STATES.FINISHED }) .where(eq(tournamentMatch.id, tm.id)); } await db .update(tournament) .set({ state: TOURNAMENT_STATES.CANCELLED, finishedAt: new Date() }) .where(eq(tournament.id, tournamentId)); log.tournament.info({ tournamentId }, 'tournament cancelled'); } // ── Check Tournament Completion ──────────────────────────────────────── export async function checkTournamentCompletion(tournamentId: string) { const allMatches = await db.query.tournamentMatch.findMany({ where: eq(tournamentMatch.tournamentId, tournamentId) }); const incomplete = allMatches.filter( (m) => m.state !== TOURNAMENT_MATCH_STATES.FINISHED && m.state !== TOURNAMENT_MATCH_STATES.BYE ); if (incomplete.length > 0) return false; // find the final match winner // for single/double elim, it's the last match with no winnerGoesToId const finalMatch = allMatches.find((m) => !m.winnerGoesToId && m.winnerId); const winnerId = finalMatch?.winnerId; await db .update(tournament) .set({ state: TOURNAMENT_STATES.FINISHED, finishedAt: new Date(), winnerId }) .where(eq(tournament.id, tournamentId)); log.tournament.info({ tournamentId, winnerId }, 'tournament finished'); return true; } // ── Internal: Generate Single Elim from Seeds ────────────────────────── async function generateSingleElimFromSeeds( tournamentId: string, teamCount: number, seedToTeam: Map ) { const bracketSlots = generateSingleElimBracket(teamCount); const bracketSize = nextPowerOf2(teamCount); const totalRounds = Math.log2(bracketSize); // get or create rounds const rounds = await getOrCreateRounds(tournamentId, totalRounds, 'winners'); // create tournament matches await insertBracketMatches(tournamentId, bracketSlots, rounds, seedToTeam, 'winners'); } // ── Internal: Generate Double Elim from Seeds ────────────────────────── async function generateDoubleElimFromSeeds( tournamentId: string, teamCount: number, seedToTeam: Map ) { const bracketSlots = generateDoubleElimBracket(teamCount); const bracketSize = nextPowerOf2(teamCount); const wbRounds = Math.log2(bracketSize); const lbRounds = 2 * (wbRounds - 1); // create rounds for WB, LB, and GF const wbRoundRecords = await getOrCreateRounds(tournamentId, wbRounds, 'winners'); const lbRoundRecords = await getOrCreateRounds(tournamentId, lbRounds, 'losers'); const gfRoundRecords = await getOrCreateRounds(tournamentId, 2, 'grand_final'); // build round map: bracketType + roundIndex → round record const roundMap = new Map(); for (let i = 0; i < wbRoundRecords.length; i++) { roundMap.set(`winners-${i}`, wbRoundRecords[i]); } for (let i = 0; i < lbRoundRecords.length; i++) { roundMap.set(`losers-${i}`, lbRoundRecords[i]); } for (let i = 0; i < gfRoundRecords.length; i++) { roundMap.set(`grand_final-${i}`, gfRoundRecords[i]); } // insert all bracket matches (in two passes: first create, then link) // first pass: create all matches without progression links const slotToMatchId = new Map(); for (const slot of bracketSlots) { const roundKey = `${slot.bracketType}-${slot.roundIndex}`; const round = roundMap.get(roundKey); if (!round) continue; const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null; const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null; const isBye = slot.isBye; let state: string = TOURNAMENT_MATCH_STATES.PENDING; if (isBye) state = TOURNAMENT_MATCH_STATES.BYE; else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY; const [created] = await db .insert(tournamentMatch) .values({ tournamentId, roundId: round.id, bracketPosition: slot.bracketPosition, team1Id: team1Id, team2Id: team2Id, state }) .returning(); const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`; slotToMatchId.set(key, created.id); } // second pass: link progression for (const slot of bracketSlots) { const key = `${slot.bracketType}-${slot.roundIndex}-${slot.bracketPosition}`; const matchId = slotToMatchId.get(key); if (!matchId) continue; const updates: Record = {}; if (slot.winnerGoesTo) { const targetKey = `${slot.winnerGoesTo.roundIndex === 0 && slot.bracketType !== 'grand_final' ? slot.bracketType : slot.winnerGoesTo.roundIndex >= 0 ? '' : ''}`; // determine target bracket type from the slot's winnerGoesTo let targetBracketType = slot.bracketType; if ( slot.bracketType === 'winners' && slot.roundIndex === Math.log2( nextPowerOf2(bracketSlots.filter((s) => s.bracketType === 'winners').length + 1) ) - 1 ) { targetBracketType = 'grand_final'; } if (slot.bracketType === 'losers') { const maxLbRound = Math.max( ...bracketSlots.filter((s) => s.bracketType === 'losers').map((s) => s.roundIndex) ); if (slot.roundIndex === maxLbRound) { targetBracketType = 'grand_final'; } } // actually, the winnerGoesTo already encodes which round/position, we just need to find the bracket type // the bracket generator already set winnerGoesTo correctly, we need to figure out the target bracket type // for WB final → GF: winnerGoesTo points to GF roundIndex 0 // for LB final → GF: winnerGoesTo points to GF roundIndex 0 // for GF1 → GF2: winnerGoesTo points to GF roundIndex 1 // let's use a simpler approach: try all bracket types const wgt = slot.winnerGoesTo; let targetId: string | undefined; for (const bt of ['winners', 'losers', 'grand_final'] as const) { const tKey = `${bt}-${wgt.roundIndex}-${wgt.bracketPosition}`; targetId = slotToMatchId.get(tKey); if (targetId) break; } if (targetId) { updates.winnerGoesToId = targetId; updates.winnerSlot = wgt.slot; } } if (slot.loserGoesTo) { const lgt = slot.loserGoesTo; let targetId: string | undefined; for (const bt of ['winners', 'losers', 'grand_final'] as const) { const tKey = `${bt}-${lgt.roundIndex}-${lgt.bracketPosition}`; targetId = slotToMatchId.get(tKey); if (targetId) break; } if (targetId) { updates.loserGoesToId = targetId; updates.loserSlot = lgt.slot; } } if (Object.keys(updates).length > 0) { await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId)); } } } // ── Internal: Generate Groups + Bracket ──────────────────────────────── async function generateGroupsAndBracket( tournamentId: string, teamCount: number, config: TournamentConfig, seedToTeam: Map ) { const { groupCount, advanceCount } = config.groupStage!; const { groups, matches } = generateGroupStageMatches(teamCount, groupCount); // get or create group stage round const existingRounds = await db.query.tournamentRound.findMany({ where: and( eq(tournamentRound.tournamentId, tournamentId), eq(tournamentRound.bracketType, 'group') ) }); let groupRound: { id: string }; if (existingRounds.length > 0) { groupRound = existingRounds[0]; } else { const [created] = await db .insert(tournamentRound) .values({ tournamentId, name: 'Group Stage', abbreviation: 'GS', roundOrder: 0, bestOf: 5, // default, can be configured per round bracketType: 'group' }) .returning(); groupRound = created; } // create groups and entries for (const group of groups) { const [createdGroup] = await db .insert(tournamentGroup) .values({ tournamentId, name: `Group ${String.fromCharCode(65 + group.groupIndex)}`, groupOrder: group.groupIndex }) .returning(); for (let i = 0; i < group.teamSeeds.length; i++) { const teamId = seedToTeam.get(group.teamSeeds[i]); if (!teamId) continue; await db.insert(tournamentGroupEntry).values({ groupId: createdGroup.id, teamId, seed: i + 1 }); } } // create group stage matches let position = 0; for (const gm of matches) { const team1Id = seedToTeam.get(gm.team1Seed) ?? null; const team2Id = seedToTeam.get(gm.team2Seed) ?? null; await db.insert(tournamentMatch).values({ tournamentId, roundId: groupRound.id, bracketPosition: position++, team1Id, team2Id, state: team1Id && team2Id ? TOURNAMENT_MATCH_STATES.READY : TOURNAMENT_MATCH_STATES.PENDING }); } // bracket stage will be generated after groups finish // (via progression.ts when all group matches are done) } // ── Internal: Get or Create Rounds ───────────────────────────────────── async function getOrCreateRounds( tournamentId: string, count: number, bracketType: string ): Promise<{ id: string }[]> { const existing = await db.query.tournamentRound.findMany({ where: and( eq(tournamentRound.tournamentId, tournamentId), eq(tournamentRound.bracketType, bracketType) ), orderBy: [asc(tournamentRound.roundOrder)] }); if (existing.length >= count) { return existing.slice(0, count); } const rounds: { id: string }[] = [...existing]; for (let i = existing.length; i < count; i++) { // calculate matches in this round for naming const bracketSize = 1 << count; // 2^count for winners const matchesInRound = bracketType === 'grand_final' ? 1 : bracketSize / (2 << i); const name = bracketType === 'grand_final' ? i === 0 ? 'Grand Finals' : 'Grand Finals (Reset)' : getRoundName( Math.max(matchesInRound, 1), bracketType as 'winners' | 'losers' | 'grand_final' ); const prefix = bracketType === 'winners' ? 'WB' : bracketType === 'losers' ? 'LB' : 'GF'; const [created] = await db .insert(tournamentRound) .values({ tournamentId, name, abbreviation: `${prefix}${i + 1}`, roundOrder: rounds.length, bestOf: 5, // default, organizer can change later bracketType }) .returning(); rounds.push(created); } return rounds; } // ── Internal: Insert Bracket Matches (for single elim) ───────────────── async function insertBracketMatches( tournamentId: string, bracketSlots: BracketSlot[], rounds: { id: string }[], seedToTeam: Map, bracketType: string ) { // first pass: create all matches const slotToMatchId = new Map(); for (const slot of bracketSlots) { if (slot.bracketType !== bracketType) continue; const round = rounds[slot.roundIndex]; if (!round) continue; const team1Id = slot.team1Seed ? (seedToTeam.get(slot.team1Seed) ?? null) : null; const team2Id = slot.team2Seed ? (seedToTeam.get(slot.team2Seed) ?? null) : null; const isBye = slot.isBye; let state: string = TOURNAMENT_MATCH_STATES.PENDING; if (isBye) state = TOURNAMENT_MATCH_STATES.BYE; else if (team1Id && team2Id) state = TOURNAMENT_MATCH_STATES.READY; const [created] = await db .insert(tournamentMatch) .values({ tournamentId, roundId: round.id, bracketPosition: slot.bracketPosition, team1Id, team2Id, state }) .returning(); const key = `${slot.roundIndex}-${slot.bracketPosition}`; slotToMatchId.set(key, created.id); } // second pass: link progression for (const slot of bracketSlots) { if (slot.bracketType !== bracketType) continue; const key = `${slot.roundIndex}-${slot.bracketPosition}`; const matchId = slotToMatchId.get(key); if (!matchId) continue; const updates: Record = {}; if (slot.winnerGoesTo) { const targetKey = `${slot.winnerGoesTo.roundIndex}-${slot.winnerGoesTo.bracketPosition}`; const targetId = slotToMatchId.get(targetKey); if (targetId) { updates.winnerGoesToId = targetId; updates.winnerSlot = slot.winnerGoesTo.slot; } } if (Object.keys(updates).length > 0) { await db.update(tournamentMatch).set(updates).where(eq(tournamentMatch.id, matchId)); } } } // ── Helpers ──────────────────────────────────────────────────────────── export async function getTournamentOrThrow(tournamentId: string) { const t = await db.query.tournament.findFirst({ where: eq(tournament.id, tournamentId) }); if (!t) throw new Error('Tournament not found'); return t; } export function assertTournamentState(current: string, expected: string) { if (current !== expected) { throw new Error(`Tournament is in state ${current}, expected ${expected}`); } } export async function getTournamentFull(tournamentId: string) { return db.query.tournament.findFirst({ where: eq(tournament.id, tournamentId), with: { rounds: { orderBy: [asc(tournamentRound.roundOrder)] }, registrations: true, matches: true, groups: { with: { entries: true } }, staff: true } }); }