Files
esports/src/lib/server/tournament/lifecycle.ts

715 lines
22 KiB
TypeScript

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<number, string>();
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<number, string>
) {
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<number, string>
) {
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<string, { id: string }>();
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<string, string>();
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<string, unknown> = {};
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<number, string>
) {
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<number, string>,
bracketType: string
) {
// first pass: create all matches
const slotToMatchId = new Map<string, string>();
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<string, unknown> = {};
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
}
});
}