379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
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<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.insert(schema.playoffMatches)
|
|
.values(data)
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
export async function createManyPlayoffMatches(
|
|
data: NewPlayoffMatch[]
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db
|
|
.insert(schema.playoffMatches)
|
|
.values(data)
|
|
.returning();
|
|
}
|
|
|
|
export async function findPlayoffMatchById(
|
|
id: string
|
|
): Promise<PlayoffMatch | undefined> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findFirst({
|
|
where: eq(schema.playoffMatches.id, id),
|
|
with: {
|
|
scoringEvent: true,
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findPlayoffMatchesByEventId(
|
|
eventId: string
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findMany({
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findPlayoffMatchesByEventIdAndRound(
|
|
eventId: string,
|
|
round: string
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findMany({
|
|
where: and(
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
eq(schema.playoffMatches.round, round)
|
|
),
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updatePlayoffMatch(
|
|
id: string,
|
|
data: Partial<NewPlayoffMatch>
|
|
): Promise<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.update(schema.playoffMatches)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(schema.playoffMatches.id, id))
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
export async function deletePlayoffMatch(id: string): Promise<void> {
|
|
const db = database();
|
|
await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id));
|
|
}
|
|
|
|
export async function deletePlayoffMatchesByEventId(
|
|
eventId: string
|
|
): Promise<void> {
|
|
const db = database();
|
|
await db
|
|
.delete(schema.playoffMatches)
|
|
.where(eq(schema.playoffMatches.scoringEventId, eventId));
|
|
}
|
|
|
|
/**
|
|
* Set the winner of a playoff match
|
|
*/
|
|
export async function setMatchWinner(
|
|
matchId: string,
|
|
winnerId: string,
|
|
loserId: string,
|
|
participant1Score?: number,
|
|
participant2Score?: number
|
|
): Promise<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.update(schema.playoffMatches)
|
|
.set({
|
|
winnerId,
|
|
loserId,
|
|
isComplete: true,
|
|
participant1Score: participant1Score?.toString(),
|
|
participant2Score: participant2Score?.toString(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.playoffMatches.id, matchId))
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
/**
|
|
* Generate a standard single elimination bracket structure
|
|
*/
|
|
export async function generateSingleEliminationBracket(
|
|
eventId: string,
|
|
participantIds: string[]
|
|
): Promise<PlayoffMatch[]> {
|
|
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<void> {
|
|
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 a bracket from a template
|
|
* Phase 2.6: Template-based bracket generation
|
|
*
|
|
* @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<PlayoffMatch[]> {
|
|
const template = getBracketTemplate(templateId);
|
|
if (!template) {
|
|
throw new Error(`Bracket template '${templateId}' not found`);
|
|
}
|
|
|
|
// Validate participant count if provided
|
|
if (participantIds && participantIds.length !== template.totalTeams) {
|
|
throw new Error(
|
|
`Template '${templateId}' requires ${template.totalTeams} participants, but ${participantIds.length} were provided`
|
|
);
|
|
}
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
let participantIndex = 0;
|
|
|
|
// Generate matches for each round in the template
|
|
for (const round of template.rounds) {
|
|
const teamsInRound = round.matchCount * 2;
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
// Determine if we should assign participants to this match
|
|
let participant1Id: string | null = null;
|
|
let participant2Id: string | null = null;
|
|
|
|
// Only assign participants to the first round if participantIds provided
|
|
if (participantIds && round === template.rounds[0]) {
|
|
participant1Id = participantIds[participantIndex] || null;
|
|
participant2Id = participantIds[participantIndex + 1] || null;
|
|
participantIndex += 2;
|
|
}
|
|
|
|
matches.push({
|
|
scoringEventId: eventId,
|
|
round: round.name,
|
|
matchNumber: i + 1,
|
|
participant1Id,
|
|
participant2Id,
|
|
isComplete: false,
|
|
isScoring: round.isScoring,
|
|
templateRound: round.name,
|
|
seedInfo: null, // Can be set manually later for display (e.g., "1 vs 16")
|
|
});
|
|
}
|
|
}
|
|
|
|
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<void> {
|
|
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 });
|
|
}
|