252 lines
6.7 KiB
TypeScript
252 lines
6.7 KiB
TypeScript
|
|
import { eq, and } from "drizzle-orm";
|
||
|
|
import { database } from "~/database/context";
|
||
|
|
import * as schema from "~/database/schema";
|
||
|
|
|
||
|
|
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 });
|
||
|
|
}
|