brackt/app/models/playoff-match.ts

656 lines
21 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 });
}
/**
* NCAA 68 First Four seeding
* Returns matchups for the 4 play-in games
*
* First Four structure (using 0-indexed participant array):
* - Games 1-2: Participants 64-67 (16 seeds, lowest auto-qualifiers)
* - Winners are 16 seeds and face 1 seeds in Round of 64
* - Games 3-4: Participants 60-63 (11/12 seeds, last at-large bids)
* - Winners are 11 seeds and face 6 seeds in Round of 64
*/
function generateNCAA68FirstFourSeeding(): [number, number][] {
return [
[64, 65], // First Four Game 1: 16-seed play-in A (winner is 16 seed, faces 1 seed)
[66, 67], // First Four Game 2: 16-seed play-in B (winner is 16 seed, faces 1 seed)
[60, 61], // First Four Game 3: 11/12-seed play-in A (winner is 11 seed, faces 6 seed)
[62, 63], // First Four Game 4: 11/12-seed play-in B (winner is 11 seed, faces 6 seed)
];
}
/**
* NCAA 68 Round of 64 seeding
* Returns matchups for the Round of 64, with 4 TBD slots from First Four
*
* 68 teams total: 60 directly seeded (0-59) + 8 in First Four (60-67)
* Round of 64 needs 64 participants: 60 direct + 4 First Four winners
*
* Distribution: Each region has 15 direct seeds + 1 FF winner = 16 per region
* - Region 1: Seeds 0-14 + FF1 (FF1 winner is 16 seed, faces 1 seed)
* - Region 2: Seeds 15-29 + FF2 (FF2 winner is 16 seed, faces 1 seed)
* - Region 3: Seeds 30-44 + FF3 (FF3 winner is 11 seed, faces 6 seed)
* - Region 4: Seeds 45-59 + FF4 (FF4 winner is 11 seed, faces 6 seed)
*
* @returns Array of [seed1, seed2 or "FF{gameNum}"] pairs for 32 matches
*/
function generateNCAA68RoundOf64Seeding(): ([number, number] | [number, string] | [string, number])[] {
const matchups: ([number, number] | [number, string] | [string, number])[] = [];
// Region 1: Seeds 0-14 + FF1 (16 total)
// Standard bracket order: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
matchups.push([0, "FF1"]); // #1 vs #16 (FF1 winner)
matchups.push([7, 8]); // #8 vs #9
matchups.push([4, 11]); // #5 vs #12
matchups.push([3, 12]); // #4 vs #13
matchups.push([5, 10]); // #6 vs #11
matchups.push([2, 13]); // #3 vs #14
matchups.push([6, 9]); // #7 vs #10
matchups.push([1, 14]); // #2 vs #15
// Region 2: Seeds 15-29 + FF2 (16 total)
matchups.push([15, "FF2"]); // #1 vs #16 (FF2 winner)
matchups.push([22, 23]); // #8 vs #9
matchups.push([19, 26]); // #5 vs #12
matchups.push([18, 27]); // #4 vs #13
matchups.push([20, 25]); // #6 vs #11
matchups.push([17, 28]); // #3 vs #14
matchups.push([21, 24]); // #7 vs #10
matchups.push([16, 29]); // #2 vs #15
// Region 3: Seeds 30-44 + FF3 (16 total)
matchups.push([30, 44]); // #1 vs #16
matchups.push([37, 38]); // #8 vs #9
matchups.push([34, 41]); // #5 vs #12
matchups.push([33, 42]); // #4 vs #13
matchups.push([35, "FF3"]); // #6 vs #11 (FF3 winner = 11 seed)
matchups.push([32, 43]); // #3 vs #14
matchups.push([36, 39]); // #7 vs #10
matchups.push([31, 40]); // #2 vs #15
// Region 4: Seeds 45-59 + FF4 (16 total)
matchups.push([45, 59]); // #1 vs #16
matchups.push([52, 53]); // #8 vs #9
matchups.push([49, 56]); // #5 vs #12
matchups.push([48, 57]); // #4 vs #13
matchups.push([50, "FF4"]); // #6 vs #11 (FF4 winner = 11 seed)
matchups.push([47, 58]); // #3 vs #14
matchups.push([51, 54]); // #7 vs #10
matchups.push([46, 55]); // #2 vs #15
return matchups;
}
/**
* Generate standard tournament seeding matchups
* Returns pairs of seed indices for proper bracket balance
*
* Examples:
* - 4 teams: [[0,3], [1,2]] = 1v4, 2v3
* - 8 teams: [[0,7], [3,4], [1,6], [2,5]] = 1v8, 4v5, 2v7, 3v6
* - 16 teams: [[0,15], [7,8], [4,11], [3,12], [5,10], [2,13], [6,9], [1,14]]
* = 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
*/
function generateStandardSeeding(teamCount: number): [number, number][] {
if (![4, 8, 16, 32].includes(teamCount)) {
// Non-standard sizes need sequential seeding for now
const pairs: [number, number][] = [];
for (let i = 0; i < teamCount; i += 2) {
pairs.push([i, i + 1]);
}
return pairs;
}
// Standard balanced bracket seeding
const rounds = Math.log2(teamCount);
let seeds = [0, 1]; // Start with 1 vs 2
// Build up seeding by adding rounds
for (let round = 1; round < rounds; round++) {
const newSeeds: number[] = [];
const maxSeed = Math.pow(2, round + 1) - 1;
for (const seed of seeds) {
newSeeds.push(seed);
newSeeds.push(maxSeed - seed);
}
seeds = newSeeds;
}
// Convert to pairs (odd indices vs even indices)
const pairs: [number, number][] = [];
for (let i = 0; i < seeds.length; i += 2) {
pairs.push([seeds[i], seeds[i + 1]]);
}
return pairs;
}
/**
* Generate a bracket from a template
* Phase 2.6: Template-based bracket generation
* Phase 2.7: Added proper tournament seeding
*
* @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`
);
}
// NCAA 68 requires special handling for First Four and Round of 64
if (templateId === "ncaa_68") {
return await generateNCAA68Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
for (const round of template.rounds) {
// Only assign participants to the first round if participantIds provided
let seedingPairs: [number, number][] = [];
if (participantIds && round === template.rounds[0]) {
const firstRoundTeams = round.matchCount * 2;
seedingPairs = generateStandardSeeding(firstRoundTeams);
}
for (let i = 0; i < round.matchCount; i++) {
let participant1Id: string | null = null;
let participant2Id: string | null = null;
let seedInfo: string | null = null;
if (participantIds && round === template.rounds[0] && seedingPairs[i]) {
const [seed1Index, seed2Index] = seedingPairs[i];
participant1Id = participantIds[seed1Index] || null;
participant2Id = participantIds[seed2Index] || null;
// Store seed info for display (1-indexed for users)
seedInfo = `${seed1Index + 1} vs ${seed2Index + 1}`;
}
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id,
participant2Id,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* Generate NCAA 68 bracket with First Four and Round of 64
* Phase 2.8: Special handling for First Four play-in games
*/
async function generateNCAA68Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// First Four: 4 play-in games
const firstFourSeeding = generateNCAA68FirstFourSeeding();
for (let i = 0; i < firstFourSeeding.length; i++) {
const [seed1, seed2] = firstFourSeeding[i];
matches.push({
scoringEventId: eventId,
round: "First Four",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // First Four doesn't score fantasy points
templateRound: "First Four",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Round of 64: 32 games with some TBD slots from First Four
const roundOf64Seeding = generateNCAA68RoundOf64Seeding();
for (let i = 0; i < roundOf64Seeding.length; i++) {
const matchup = roundOf64Seeding[i];
let participant1Id: string | null = null;
let participant2Id: string | null = null;
let seedInfo: string | null = null;
if (participantIds) {
const [seed1, seed2] = matchup;
// Handle TBD slots (FF1-FF4 placeholders)
if (typeof seed1 === "number") {
participant1Id = participantIds[seed1];
} // else it's "FF1", "FF2", etc. - will be filled by First Four winner
if (typeof seed2 === "number") {
participant2Id = participantIds[seed2];
}
seedInfo = `${typeof seed1 === "number" ? seed1 + 1 : seed1} vs ${typeof seed2 === "number" ? seed2 + 1 : seed2}`;
}
matches.push({
scoringEventId: eventId,
round: "Round of 64",
matchNumber: i + 1,
participant1Id,
participant2Id,
isComplete: false,
isScoring: false, // Round of 64 doesn't score
templateRound: "Round of 64",
seedInfo,
});
}
// Generate remaining rounds (Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship)
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
const round = template.rounds[roundIndex];
for (let i = 0; i < round.matchCount; i++) {
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i + 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: null,
});
}
}
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");
// Special handling for NCAA 68 First Four
// Phase 2.8: First Four winners advance to specific Round of 64 slots
if (template.id === "ncaa_68" && match.round === "First Four") {
return await advanceFirstFourWinner(match, winnerId);
}
// 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 });
}
/**
* Advance First Four winner to Round of 64
* Phase 2.8: NCAA 68 specific logic
*
* First Four match → Round of 64 match mapping:
* - FF Match 1 (16-seed) → R64 Match 1 (Region 1 vs #1 seed), participant2Id
* - FF Match 2 (16-seed) → R64 Match 9 (Region 2 vs #1 seed), participant2Id
* - FF Match 3 (11-seed) → R64 Match 21 (Region 3 vs #6 seed), participant2Id
* - FF Match 4 (11-seed) → R64 Match 29 (Region 4 vs #6 seed), participant2Id
*/
async function advanceFirstFourWinner(
firstFourMatch: PlayoffMatch,
winnerId: string
): Promise<void> {
// Map First Four match numbers to Round of 64 match numbers
const firstFourToRoundOf64: Record<number, number> = {
1: 1, // FF Game 1 (16-seed) → R64 Match 1 (vs 1 seed)
2: 9, // FF Game 2 (16-seed) → R64 Match 9 (vs 1 seed)
3: 21, // FF Game 3 (11-seed) → R64 Match 21 (vs 6 seed)
4: 29, // FF Game 4 (11-seed) → R64 Match 29 (vs 6 seed)
};
const targetMatchNumber = firstFourToRoundOf64[firstFourMatch.matchNumber];
if (!targetMatchNumber) {
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
}
// Find the target Round of 64 match
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
firstFourMatch.scoringEventId,
"Round of 64"
);
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === targetMatchNumber);
if (!targetMatch) {
throw new Error(`Round of 64 match ${targetMatchNumber} not found`);
}
// Check if participant2Id is already filled
if (targetMatch.participant2Id) {
throw new Error(`Round of 64 match ${targetMatchNumber} participant2Id is already filled`);
}
// Fill the slot
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
}