* Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
1438 lines
49 KiB
TypeScript
1438 lines
49 KiB
TypeScript
import { eq, and } from "drizzle-orm";
|
||
import { database } from "~/database/context";
|
||
import * as schema from "~/database/schema";
|
||
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
||
import {
|
||
getBracketTemplate,
|
||
buildNCAA68SlotMap,
|
||
matchIndexForSeedSlot,
|
||
STANDARD_BRACKET_SEEDING,
|
||
} 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,
|
||
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
||
odds: { with: { participant: 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,
|
||
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
||
odds: { with: { participant: 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 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> {
|
||
// 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 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[],
|
||
regionOverride?: BracketRegion[]
|
||
): 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`
|
||
);
|
||
}
|
||
|
||
// FIFA 48 generates an empty knockout bracket (participants assigned later via groups)
|
||
if (templateId === "fifa_48") {
|
||
return await generateFIFA48Bracket(eventId, template);
|
||
}
|
||
|
||
// NCAA 68 requires special handling for First Four and Round of 64
|
||
// Use regionOverride if supplied (per-event config), otherwise fall back to template.regions
|
||
if (templateId === "ncaa_68") {
|
||
const effectiveTemplate = regionOverride
|
||
? { ...template, regions: regionOverride }
|
||
: template;
|
||
return await generateNCAA68Bracket(eventId, effectiveTemplate, participantIds);
|
||
}
|
||
|
||
// NFL 14 requires special handling for bye weeks
|
||
if (templateId === "nfl_14") {
|
||
return await generateNFL14Bracket(eventId, template, participantIds);
|
||
}
|
||
|
||
// AFL 10 requires special handling for double-chance finals system
|
||
if (templateId === "afl_10") {
|
||
return await generateAFL10Bracket(eventId, template, participantIds);
|
||
}
|
||
|
||
// CFP 12 requires special handling for bye weeks (seeds 1–4 skip First Round)
|
||
if (templateId === "cfp_12") {
|
||
return await generateCFP12Bracket(eventId, template, participantIds);
|
||
}
|
||
|
||
// NBA 20 requires special handling for the play-in tournament
|
||
if (templateId === "nba_20") {
|
||
return await generateNBA20Bracket(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 using the template's regions config.
|
||
*
|
||
* Participant array layout (driven by buildNCAA68SlotMap):
|
||
* [region 0 direct seeds] [region 1 direct seeds] ...
|
||
* [play-in teams in region/play-in order, 2 per game]
|
||
*/
|
||
async function generateNCAA68Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
if (!template.regions || template.regions.length === 0) {
|
||
throw new Error("NCAA 68 template requires a regions config");
|
||
}
|
||
|
||
const matches: NewPlayoffMatch[] = [];
|
||
const slotMap = buildNCAA68SlotMap(template.regions);
|
||
|
||
// Per-region map: region index → FF labels for that region's play-ins (e.g. "FF1")
|
||
const regionFFLabels = new Map<number, string[]>();
|
||
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
||
const { regionIndex, playInIndex } = slotMap.playInOffsets[i];
|
||
if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []);
|
||
const labels = regionFFLabels.get(regionIndex);
|
||
if (labels) labels[playInIndex] = `FF${i + 1}`;
|
||
}
|
||
|
||
// ── First Four ────────────────────────────────────────────────────────────
|
||
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
||
const { startIndex, seedSlot, regionIndex } = slotMap.playInOffsets[i];
|
||
const regionName = template.regions[regionIndex].name;
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "First Four",
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
|
||
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "First Four",
|
||
seedInfo: `${regionName} ${seedSlot}-seed play-in`,
|
||
});
|
||
}
|
||
|
||
// ── Round of 64: 8 matches per region ────────────────────────────────────
|
||
let r64MatchNumber = 1;
|
||
for (let r = 0; r < template.regions.length; r++) {
|
||
const region = template.regions[r];
|
||
const directOffset = slotMap.directOffsets[r];
|
||
const labels = regionFFLabels.get(r) ?? [];
|
||
|
||
// Map seed rank → participant array index or FF label
|
||
const seedToSlot = new Map<number, number | string>();
|
||
for (let i = 0; i < region.directSeeds.length; i++) {
|
||
seedToSlot.set(region.directSeeds[i], directOffset + i);
|
||
}
|
||
for (let p = 0; p < region.playIns.length; p++) {
|
||
seedToSlot.set(region.playIns[p].seedSlot, labels[p]);
|
||
}
|
||
|
||
for (const [hiSeed, loSeed] of STANDARD_BRACKET_SEEDING) {
|
||
const slot1 = seedToSlot.get(hiSeed);
|
||
const slot2 = seedToSlot.get(loSeed);
|
||
|
||
if (slot1 === undefined || slot2 === undefined) {
|
||
throw new Error(
|
||
`Region "${region.name}" missing seed ${slot1 === undefined ? hiSeed : loSeed}`
|
||
);
|
||
}
|
||
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Round of 64",
|
||
matchNumber: r64MatchNumber++,
|
||
participant1Id:
|
||
participantIds && typeof slot1 === "number"
|
||
? (participantIds[slot1] ?? null)
|
||
: null,
|
||
participant2Id:
|
||
participantIds && typeof slot2 === "number"
|
||
? (participantIds[slot2] ?? null)
|
||
: null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "Round of 64",
|
||
seedInfo: `${region.name}: ${hiSeed} vs ${typeof slot2 === "string" ? slot2 : loSeed}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Round of 32 → Championship (empty match shells) ──────────────────────
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Generate NFL 14 bracket with bye weeks for top 2 seeds
|
||
* Phase 2.9: Special handling for Wild Card round (12 teams) and Divisional byes
|
||
*/
|
||
async function generateNFL14Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
|
||
// Actually for 14 teams (seeds 1-14), Wild Card should be:
|
||
// Match 1: #7 (index 6) vs #10 (index 9)
|
||
// Match 2: #6 (index 5) vs #11 (index 10)
|
||
// Match 3: #5 (index 4) vs #12 (index 11)
|
||
// Match 4: #8 (index 7) vs #9 (index 8)
|
||
// Match 5: #4 (index 3) vs #13 (index 12)
|
||
// Match 6: #3 (index 2) vs #14 (index 13)
|
||
const wildCardSeeding = [
|
||
[6, 9], // #7 vs #10
|
||
[5, 10], // #6 vs #11
|
||
[4, 11], // #5 vs #12
|
||
[7, 8], // #8 vs #9
|
||
[3, 12], // #4 vs #13
|
||
[2, 13], // #3 vs #14
|
||
];
|
||
|
||
for (let i = 0; i < wildCardSeeding.length; i++) {
|
||
const [seed1, seed2] = wildCardSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Wild Card",
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[seed1] : null,
|
||
participant2Id: participantIds ? participantIds[seed2] : null,
|
||
isComplete: false,
|
||
isScoring: false, // Wild Card doesn't score fantasy points
|
||
templateRound: "Wild Card",
|
||
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
||
});
|
||
}
|
||
|
||
// Divisional: 4 games
|
||
// Top 2 seeds (#1 and #2) get byes and are placed in Divisional automatically
|
||
// The other 2 Divisional matches will be filled by Wild Card winners
|
||
const divisionalRound = template.rounds.find((r) => r.name === "Divisional");
|
||
if (divisionalRound) {
|
||
for (let i = 0; i < divisionalRound.matchCount; i++) {
|
||
let participant1Id: string | null = null;
|
||
const participant2Id: string | null = null;
|
||
let seedInfo: string | null = null;
|
||
|
||
// Assign bye teams to first two Divisional matches
|
||
if (participantIds) {
|
||
if (i === 0) {
|
||
// Match 1: #1 seed (index 0) vs TBD (Wild Card winner)
|
||
participant1Id = participantIds[0];
|
||
seedInfo = "1 vs TBD";
|
||
} else if (i === 1) {
|
||
// Match 2: #2 seed (index 1) vs TBD (Wild Card winner)
|
||
participant1Id = participantIds[1];
|
||
seedInfo = "2 vs TBD";
|
||
}
|
||
// Matches 3 and 4 are TBD vs TBD (Wild Card winners)
|
||
}
|
||
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Divisional",
|
||
matchNumber: i + 1,
|
||
participant1Id,
|
||
participant2Id,
|
||
isComplete: false,
|
||
isScoring: divisionalRound.isScoring,
|
||
templateRound: "Divisional",
|
||
seedInfo,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Generate remaining rounds (Conference Championship, Super Bowl)
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Generate AFL 10 bracket with Wildcard Round (2026+ format)
|
||
* Phase 3.3: Special handling for AFL's double-chance finals system
|
||
*
|
||
* Structure:
|
||
* - Wildcard Round: 7v10, 8v9
|
||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||
* - Semi-Finals: QF losers vs EF winners
|
||
* - Preliminary Finals: QF winners vs SF winners
|
||
* - Grand Final: PF winners
|
||
*/
|
||
async function generateAFL10Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
|
||
// Wildcard Round: 7th vs 10th, 8th vs 9th
|
||
const wildcardSeeding = [
|
||
[6, 9], // #7 (index 6) vs #10 (index 9) - 7th hosts
|
||
[7, 8], // #8 (index 7) vs #9 (index 8) - 8th hosts
|
||
];
|
||
|
||
for (let i = 0; i < wildcardSeeding.length; i++) {
|
||
const [seed1, seed2] = wildcardSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Wildcard Round",
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[seed1] : null,
|
||
participant2Id: participantIds ? participantIds[seed2] : null,
|
||
isComplete: false,
|
||
isScoring: false, // Losers get 0 points (9th-10th place)
|
||
templateRound: "Wildcard Round",
|
||
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
||
});
|
||
}
|
||
|
||
// Qualifying Finals: 1st vs 4th, 2nd vs 3rd
|
||
const qualifyingSeeding = [
|
||
[0, 3], // #1 (index 0) vs #4 (index 3)
|
||
[1, 2], // #2 (index 1) vs #3 (index 2)
|
||
];
|
||
|
||
for (let i = 0; i < qualifyingSeeding.length; i++) {
|
||
const [seed1, seed2] = qualifyingSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Qualifying Finals",
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[seed1] : null,
|
||
participant2Id: participantIds ? participantIds[seed2] : null,
|
||
isComplete: false,
|
||
isScoring: false, // Winners get bye, losers get second chance
|
||
templateRound: "Qualifying Finals",
|
||
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
||
});
|
||
}
|
||
|
||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||
const eliminationSeeding = [
|
||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||
];
|
||
|
||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||
const { higher, wildcard } = eliminationSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Elimination Finals",
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[higher] : null,
|
||
participant2Id: null, // Will be filled by wildcard winner
|
||
isComplete: false,
|
||
isScoring: true, // Losers share 7th-8th
|
||
templateRound: "Elimination Finals",
|
||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||
});
|
||
}
|
||
|
||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||
for (let i = 0; i < 2; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Semi-Finals",
|
||
matchNumber: i + 1,
|
||
participant1Id: null, // Will be filled by QF loser
|
||
participant2Id: null, // Will be filled by EF winner
|
||
isComplete: false,
|
||
isScoring: true, // Losers share 5th-6th
|
||
templateRound: "Semi-Finals",
|
||
seedInfo: null,
|
||
});
|
||
}
|
||
|
||
// Preliminary Finals: QF winners vs SF winners (TBD vs TBD)
|
||
for (let i = 0; i < 2; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Preliminary Finals",
|
||
matchNumber: i + 1,
|
||
participant1Id: null, // Will be filled by QF winner
|
||
participant2Id: null, // Will be filled by SF winner
|
||
isComplete: false,
|
||
isScoring: true, // Losers share 3rd-4th
|
||
templateRound: "Preliminary Finals",
|
||
seedInfo: null,
|
||
});
|
||
}
|
||
|
||
// Grand Final: PF winners (TBD vs TBD)
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Grand Final",
|
||
matchNumber: 1,
|
||
participant1Id: null,
|
||
participant2Id: null,
|
||
isComplete: false,
|
||
isScoring: true, // Winner 1st, Loser 2nd
|
||
templateRound: "Grand Final",
|
||
seedInfo: null,
|
||
});
|
||
|
||
return await createManyPlayoffMatches(matches);
|
||
}
|
||
|
||
/**
|
||
* AFL-specific advancement logic for the complex double-chance system
|
||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||
*
|
||
* Advancement rules:
|
||
* - Wildcard Round: Winner → Elimination Finals
|
||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||
* - Elimination Finals: Winner → Semi-Finals
|
||
* - Semi-Finals: Winner → Preliminary Finals
|
||
* - Preliminary Finals: Winner → Grand Final
|
||
*/
|
||
async function advanceAFLWinner(
|
||
match: PlayoffMatch,
|
||
winnerId: string,
|
||
loserId: string
|
||
): Promise<void> {
|
||
const eventId = match.scoringEventId;
|
||
|
||
// Wildcard Round: Winner advances to Elimination Finals
|
||
if (match.round === "Wildcard Round") {
|
||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||
|
||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||
|
||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
// Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||
if (match.round === "Qualifying Finals") {
|
||
// QF Match 1: Winner → PF1 participant1, Loser → SF1 participant1
|
||
// QF Match 2: Winner → PF2 participant1, Loser → SF2 participant1
|
||
const pfMatchNumber = match.matchNumber;
|
||
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
||
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
||
|
||
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
||
if (pfMatch.participant1Id) throw new Error(`PF ${pfMatchNumber} participant1 already filled`);
|
||
|
||
await updatePlayoffMatch(pfMatch.id, { participant1Id: winnerId });
|
||
|
||
// Also advance loser to Semi-Finals
|
||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||
const sfMatch = sfMatches.find((m) => m.matchNumber === match.matchNumber);
|
||
|
||
if (!sfMatch) throw new Error(`Semi-Finals match ${match.matchNumber} not found`);
|
||
if (sfMatch.participant1Id) throw new Error(`SF ${match.matchNumber} participant1 already filled`);
|
||
|
||
await updatePlayoffMatch(sfMatch.id, { participant1Id: loserId });
|
||
return;
|
||
}
|
||
|
||
// Elimination Finals: Winner → Semi-Finals
|
||
if (match.round === "Elimination Finals") {
|
||
// EF Match 1 winner → SF2 participant2
|
||
// EF Match 2 winner → SF1 participant2
|
||
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||
|
||
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||
|
||
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
// Semi-Finals: Winner → Preliminary Finals
|
||
if (match.round === "Semi-Finals") {
|
||
// SF Match 1 winner → PF2 participant2
|
||
// SF Match 2 winner → PF1 participant2
|
||
const pfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
||
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
||
|
||
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
||
if (pfMatch.participant2Id) throw new Error(`PF ${pfMatchNumber} participant2 already filled`);
|
||
|
||
await updatePlayoffMatch(pfMatch.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
// Preliminary Finals: Winner → Grand Final
|
||
if (match.round === "Preliminary Finals") {
|
||
const gfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Grand Final");
|
||
const gfMatch = gfMatches[0];
|
||
|
||
if (!gfMatch) throw new Error("Grand Final match not found");
|
||
|
||
const participantSlot: 'participant1Id' | 'participant2Id' =
|
||
match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
||
|
||
if (gfMatch[participantSlot]) throw new Error(`GF ${participantSlot} already filled`);
|
||
|
||
await updatePlayoffMatch(gfMatch.id, { [participantSlot]: winnerId });
|
||
return;
|
||
}
|
||
|
||
// Grand Final: No advancement
|
||
if (match.round === "Grand Final") {
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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> {
|
||
// Get the match
|
||
const match = await findPlayoffMatchById(matchId);
|
||
if (!match) throw new Error("Match not found");
|
||
|
||
// Special handling for NBA 20 play-in tournament
|
||
if (template.id === "nba_20" && (match.round === "Play-In Round 1" || match.round === "Play-In Round 2")) {
|
||
const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||
if (!loserId) throw new Error("Cannot determine loser for NBA play-in advancement");
|
||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
||
}
|
||
|
||
// Special handling for AFL 10 double-chance system
|
||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||
if (template.id === "afl_10") {
|
||
const loserId = match.winnerId === match.participant1Id ? match.participant2Id : match.participant1Id;
|
||
if (!loserId) throw new Error("Cannot determine loser for AFL advancement");
|
||
return await advanceAFLWinner(match, winnerId, loserId);
|
||
}
|
||
|
||
// Special handling for NCAA 68 First Four
|
||
// First Four winners advance to Round of 64 slots derived from the regions config
|
||
if (template.id === "ncaa_68" && match.round === "First Four") {
|
||
return await advanceFirstFourWinner(match, winnerId, template);
|
||
}
|
||
|
||
// 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 });
|
||
|
||
// If this round has a loserFeedsInto, also advance the loser to that round
|
||
if (currentRound.loserFeedsInto) {
|
||
const loserId =
|
||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||
if (loserId) {
|
||
const loserRound = template.rounds.find((r) => r.name === currentRound.loserFeedsInto);
|
||
if (loserRound) {
|
||
const loserMatches = await findPlayoffMatchesByEventIdAndRound(
|
||
match.scoringEventId,
|
||
loserRound.name
|
||
);
|
||
const loserMatch = loserMatches[0];
|
||
if (loserMatch) {
|
||
// SF match 1 loser → participant1Id, SF match 2 loser → participant2Id
|
||
const loserSlot: "participant1Id" | "participant2Id" =
|
||
match.matchNumber === 1 ? "participant1Id" : "participant2Id";
|
||
if (!loserMatch[loserSlot]) {
|
||
await updatePlayoffMatch(loserMatch.id, { [loserSlot]: loserId });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Advance a First Four winner to their target Round of 64 slot.
|
||
*
|
||
* The target match number is computed dynamically from the template's regions config:
|
||
* r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
|
||
*
|
||
* The winner always fills participant2Id (they are the lower seed in their matchup).
|
||
*/
|
||
async function advanceFirstFourWinner(
|
||
firstFourMatch: PlayoffMatch,
|
||
winnerId: string,
|
||
template: BracketTemplate
|
||
): Promise<void> {
|
||
// Prefer the per-event stored region config (set at bracket generation time);
|
||
// fall back to the template's built-in regions for backwards compatibility.
|
||
const db = database();
|
||
const eventRow = await db.query.scoringEvents.findFirst({
|
||
where: eq(schema.scoringEvents.id, firstFourMatch.scoringEventId),
|
||
columns: { bracketRegionConfig: true },
|
||
});
|
||
const regions =
|
||
(eventRow?.bracketRegionConfig as BracketRegion[] | null) ?? template.regions;
|
||
|
||
if (!regions) {
|
||
throw new Error("NCAA 68 template requires regions config for First Four advancement");
|
||
}
|
||
|
||
const slotMap = buildNCAA68SlotMap(regions);
|
||
const ffIndex = firstFourMatch.matchNumber - 1; // 0-indexed
|
||
|
||
if (ffIndex < 0 || ffIndex >= slotMap.playInOffsets.length) {
|
||
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
|
||
}
|
||
|
||
const { regionIndex, seedSlot } = slotMap.playInOffsets[ffIndex];
|
||
const matchIndexInRegion = matchIndexForSeedSlot(seedSlot);
|
||
// Each region contributes exactly 8 matches to the Round of 64, in region order
|
||
const r64MatchNumber = regionIndex * 8 + matchIndexInRegion + 1;
|
||
|
||
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
|
||
firstFourMatch.scoringEventId,
|
||
"Round of 64"
|
||
);
|
||
|
||
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
|
||
if (!targetMatch) {
|
||
throw new Error(`Round of 64 match ${r64MatchNumber} not found`);
|
||
}
|
||
|
||
if (targetMatch.participant2Id) {
|
||
throw new Error(`Round of 64 match ${r64MatchNumber} participant2Id is already filled`);
|
||
}
|
||
|
||
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
||
}
|
||
|
||
/**
|
||
* Generate FIFA 48 knockout bracket (empty, no participants assigned)
|
||
* Creates 31 matches across 5 rounds identical to simple_32 structure.
|
||
* Participants are assigned later via assignParticipantsToKnockout after group stage.
|
||
*/
|
||
async function generateFIFA48Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
|
||
for (const round of template.rounds) {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Assign participants to Round of 32 knockout matches
|
||
* Used after group stage to populate the empty knockout bracket
|
||
*
|
||
* @param eventId - The scoring event ID
|
||
* @param assignments - Array of { matchNumber, slot, participantId }
|
||
*/
|
||
export async function assignParticipantsToKnockout(
|
||
eventId: string,
|
||
assignments: Array<{
|
||
matchNumber: number;
|
||
slot: "participant1Id" | "participant2Id";
|
||
participantId: string;
|
||
}>
|
||
): Promise<void> {
|
||
// Get Round of 32 matches
|
||
const r32Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Round of 32");
|
||
|
||
for (const assignment of assignments) {
|
||
const match = r32Matches.find((m) => m.matchNumber === assignment.matchNumber);
|
||
if (!match) {
|
||
throw new Error(`Round of 32 match ${assignment.matchNumber} not found`);
|
||
}
|
||
|
||
if (match[assignment.slot]) {
|
||
throw new Error(
|
||
`Round of 32 match ${assignment.matchNumber} ${assignment.slot} is already filled`
|
||
);
|
||
}
|
||
|
||
await updatePlayoffMatch(match.id, {
|
||
[assignment.slot]: assignment.participantId,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate College Football Playoff 12-team bracket.
|
||
*
|
||
* First Round (seeds 5–12, 4 games, not scoring):
|
||
* Match 1: 5 vs 12
|
||
* Match 2: 6 vs 11
|
||
* Match 3: 7 vs 10
|
||
* Match 4: 8 vs 9
|
||
*
|
||
* Quarterfinals (4 games, scoring starts):
|
||
* Match 1: 1 vs First Round match 4 winner (8/9)
|
||
* Match 2: 4 vs First Round match 1 winner (5/12)
|
||
* Match 3: 3 vs First Round match 2 winner (6/11)
|
||
* Match 4: 2 vs First Round match 3 winner (7/10)
|
||
*
|
||
* Semifinals and National Championship: TBD vs TBD
|
||
*/
|
||
async function generateCFP12Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
|
||
// First Round: seeds 5–12 (indices 4–11), top 4 (indices 0–3) have byes
|
||
const firstRoundSeeding: [number, number][] = [
|
||
[4, 11], // 5 vs 12
|
||
[5, 10], // 6 vs 11
|
||
[6, 9], // 7 vs 10
|
||
[7, 8], // 8 vs 9
|
||
];
|
||
|
||
const firstRound = template.rounds[0]; // "First Round"
|
||
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
||
const [s1, s2] = firstRoundSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: firstRound.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[s1] : null,
|
||
participant2Id: participantIds ? participantIds[s2] : null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: firstRound.name,
|
||
seedInfo: `${s1 + 1} vs ${s2 + 1}`,
|
||
});
|
||
}
|
||
|
||
// Quarterfinals: seeds 1–4 (indices 0–3) receive byes, face First Round winners
|
||
// Matchup order mirrors CFP bracket: 1 vs 8/9 winner, 4 vs 5/12 winner, etc.
|
||
const quarterfinalsSeeding: [number, string][] = [
|
||
[0, "1 vs TBD"], // 1 seed vs First Round match 4 winner (8v9)
|
||
[3, "4 vs TBD"], // 4 seed vs First Round match 1 winner (5v12)
|
||
[2, "3 vs TBD"], // 3 seed vs First Round match 2 winner (6v11)
|
||
[1, "2 vs TBD"], // 2 seed vs First Round match 3 winner (7v10)
|
||
];
|
||
|
||
const quarterfinalsRound = template.rounds[1]; // "Quarterfinals"
|
||
for (let i = 0; i < quarterfinalsSeeding.length; i++) {
|
||
const [byeSeedIndex, seedInfo] = quarterfinalsSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: quarterfinalsRound.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[byeSeedIndex] : null,
|
||
participant2Id: null, // Filled when First Round winner is known
|
||
isComplete: false,
|
||
isScoring: true,
|
||
templateRound: quarterfinalsRound.name,
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// Semifinals and National Championship: all TBD
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Generate NBA 20-team bracket (Play-In + Playoffs).
|
||
*
|
||
* Participant array layout (indices 0-19):
|
||
* [0–9] East seeds 1–10 (E1=0, E2=1, ..., E10=9)
|
||
* [10–19] West seeds 1–10 (W1=10, W2=11, ..., W10=19)
|
||
*
|
||
* Play-In Round 1 (4 matches):
|
||
* M1: E7(6) vs E8(7) M2: E9(8) vs E10(9)
|
||
* M3: W7(16) vs W8(17) M4: W9(18) vs W10(19)
|
||
*
|
||
* Play-In Round 2 (2 matches, TBD):
|
||
* M1: East loser(7v8) vs East winner(9v10) → E8 seed
|
||
* M2: West loser(7v8) vs West winner(9v10) → W8 seed
|
||
*
|
||
* First Round (8 series — bracket order for correct Conference Semis matchups via ceil logic):
|
||
* M1: E1 vs E8 (PIR2-M1 winner) M2: E4 vs E5
|
||
* M3: E2 vs E7 (PIR1-M1 winner) M4: E3 vs E6
|
||
* M5: W1 vs W8 (PIR2-M2 winner) M6: W4 vs W5
|
||
* M7: W2 vs W7 (PIR1-M3 winner) M8: W3 vs W6
|
||
*/
|
||
async function generateNBA20Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
const p = (idx: number): string | null =>
|
||
participantIds ? (participantIds[idx] ?? null) : null;
|
||
|
||
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
|
||
const pir1Seeding: [number, number, string][] = [
|
||
[6, 7, "East: 7 vs 8"],
|
||
[8, 9, "East: 9 vs 10"],
|
||
[16, 17, "West: 7 vs 8"],
|
||
[18, 19, "West: 9 vs 10"],
|
||
];
|
||
for (let i = 0; i < pir1Seeding.length; i++) {
|
||
const [idx1, idx2, seedInfo] = pir1Seeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Play-In Round 1",
|
||
matchNumber: i + 1,
|
||
participant1Id: p(idx1),
|
||
participant2Id: p(idx2),
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "Play-In Round 1",
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
|
||
const pir2SeedInfo = [
|
||
"East: 7/8 loser vs 9/10 winner",
|
||
"West: 7/8 loser vs 9/10 winner",
|
||
];
|
||
for (let i = 0; i < 2; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Play-In Round 2",
|
||
matchNumber: i + 1,
|
||
participant1Id: null,
|
||
participant2Id: null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "Play-In Round 2",
|
||
seedInfo: pir2SeedInfo[i],
|
||
});
|
||
}
|
||
|
||
// ── First Round (8 series) ────────────────────────────────────────────────────
|
||
// IMPORTANT: This match ordering is load-bearing.
|
||
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
|
||
// seed slots: FR M1 p2 = E8, FR M3 p2 = E7, FR M5 p2 = W8, FR M7 p2 = W7.
|
||
// Do not change the match order without updating the simulator's resolveGame/
|
||
// resolveSeries calls and the corresponding test fixtures.
|
||
const firstRoundSeeding: [number | null, number | null, string][] = [
|
||
[0, null, "East: 1 vs 8"],
|
||
[3, 4, "East: 4 vs 5"],
|
||
[1, null, "East: 2 vs 7"],
|
||
[2, 5, "East: 3 vs 6"],
|
||
[10, null, "West: 1 vs 8"],
|
||
[13, 14, "West: 4 vs 5"],
|
||
[11, null, "West: 2 vs 7"],
|
||
[12, 15, "West: 3 vs 6"],
|
||
];
|
||
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
||
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "First Round",
|
||
matchNumber: i + 1,
|
||
participant1Id: idx1 !== null ? p(idx1) : null,
|
||
participant2Id: idx2 !== null ? p(idx2) : null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "First Round",
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
|
||
for (let roundIndex = 3; 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);
|
||
}
|
||
|
||
/**
|
||
* Returns true if the loser of the given match advances to another round rather
|
||
* than being eliminated. Use this to avoid recording a premature elimination result.
|
||
*
|
||
* Currently only applies to NBA Play-In Round 1 matches 1 and 3 (the 7v8 games),
|
||
* whose losers go on to Play-In Round 2.
|
||
*/
|
||
export function doesLoserAdvance(
|
||
round: string,
|
||
matchNumber: number,
|
||
templateId: string
|
||
): boolean {
|
||
// NBA Play-In Round 1: 7v8 games (M1, M3) — loser gets a second chance in Round 2.
|
||
// 9v10 games (M2, M4) — loser is immediately eliminated.
|
||
if (templateId === "nba_20" && round === "Play-In Round 1") {
|
||
return matchNumber === 1 || matchNumber === 3;
|
||
}
|
||
// AFL Qualifying Finals: both losers (1v4 and 2v3) advance to Semi-Finals.
|
||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* NBA play-in advancement logic.
|
||
*
|
||
* Play-In Round 1:
|
||
* M1 (E7v8): winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
|
||
* M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
|
||
* M3 (W7v8): winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
|
||
* M4 (W9v10): winner → PIR2 M2 p2; loser eliminated
|
||
*
|
||
* Play-In Round 2:
|
||
* M1: winner → First Round M1 p2 (E8 seed); loser eliminated
|
||
* M2: winner → First Round M5 p2 (W8 seed); loser eliminated
|
||
*/
|
||
async function advanceNBAPlayInWinner(
|
||
match: PlayoffMatch,
|
||
winnerId: string,
|
||
loserId: string
|
||
): Promise<void> {
|
||
const eventId = match.scoringEventId;
|
||
|
||
if (match.round === "Play-In Round 1") {
|
||
if (match.matchNumber === 1) {
|
||
// E7v8: winner → First Round M3 p2 (E7 seed); loser → PIR2 M1 p1
|
||
// FR M3 is "E2 vs E7": E2 is already at p1, E7 fills p2.
|
||
const [frMatches, pir2Matches] = await Promise.all([
|
||
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
||
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
||
]);
|
||
const frM3 = frMatches.find((m) => m.matchNumber === 3);
|
||
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
||
if (!frM3) throw new Error("First Round match 3 not found");
|
||
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
||
if (frM3.participant2Id) throw new Error("First Round M3 participant2 already filled");
|
||
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
|
||
await Promise.all([
|
||
updatePlayoffMatch(frM3.id, { participant2Id: winnerId }),
|
||
updatePlayoffMatch(pir2M1.id, { participant1Id: loserId }),
|
||
]);
|
||
return;
|
||
}
|
||
|
||
if (match.matchNumber === 2) {
|
||
// E9v10: winner → PIR2 M1 p2; loser eliminated
|
||
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
||
const pir2M1 = pir2Matches.find((m) => m.matchNumber === 1);
|
||
if (!pir2M1) throw new Error("Play-In Round 2 match 1 not found");
|
||
if (pir2M1.participant2Id) throw new Error("Play-In Round 2 M1 participant2 already filled");
|
||
await updatePlayoffMatch(pir2M1.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
if (match.matchNumber === 3) {
|
||
// W7v8: winner → First Round M7 p2 (W7 seed); loser → PIR2 M2 p1
|
||
// FR M7 is "W2 vs W7": W2 is already at p1, W7 fills p2.
|
||
const [frMatches, pir2Matches] = await Promise.all([
|
||
findPlayoffMatchesByEventIdAndRound(eventId, "First Round"),
|
||
findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2"),
|
||
]);
|
||
const frM7 = frMatches.find((m) => m.matchNumber === 7);
|
||
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
||
if (!frM7) throw new Error("First Round match 7 not found");
|
||
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
||
if (frM7.participant2Id) throw new Error("First Round M7 participant2 already filled");
|
||
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
|
||
await Promise.all([
|
||
updatePlayoffMatch(frM7.id, { participant2Id: winnerId }),
|
||
updatePlayoffMatch(pir2M2.id, { participant1Id: loserId }),
|
||
]);
|
||
return;
|
||
}
|
||
|
||
if (match.matchNumber === 4) {
|
||
// W9v10: winner → PIR2 M2 p2; loser eliminated
|
||
const pir2Matches = await findPlayoffMatchesByEventIdAndRound(eventId, "Play-In Round 2");
|
||
const pir2M2 = pir2Matches.find((m) => m.matchNumber === 2);
|
||
if (!pir2M2) throw new Error("Play-In Round 2 match 2 not found");
|
||
if (pir2M2.participant2Id) throw new Error("Play-In Round 2 M2 participant2 already filled");
|
||
await updatePlayoffMatch(pir2M2.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
throw new Error(`Unknown Play-In Round 1 match number: ${match.matchNumber}`);
|
||
}
|
||
|
||
if (match.round === "Play-In Round 2") {
|
||
if (match.matchNumber === 1) {
|
||
// E8 seed: winner → First Round M1 p2; loser eliminated
|
||
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
||
const frM1 = frMatches.find((m) => m.matchNumber === 1);
|
||
if (!frM1) throw new Error("First Round match 1 not found");
|
||
if (frM1.participant2Id) throw new Error("First Round M1 participant2 already filled");
|
||
await updatePlayoffMatch(frM1.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
if (match.matchNumber === 2) {
|
||
// W8 seed: winner → First Round M5 p2; loser eliminated
|
||
const frMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "First Round");
|
||
const frM5 = frMatches.find((m) => m.matchNumber === 5);
|
||
if (!frM5) throw new Error("First Round match 5 not found");
|
||
if (frM5.participant2Id) throw new Error("First Round M5 participant2 already filled");
|
||
await updatePlayoffMatch(frM5.id, { participant2Id: winnerId });
|
||
return;
|
||
}
|
||
|
||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||
}
|
||
}
|