brackt/app/models/playoff-match.ts

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 });
}