brackt/app/models/playoff-match.ts
Chris Parsons 4d5fea05ab
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124

* Add NCAA Football CFP simulator (12-team bracket)

Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).

- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
  in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
  Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
  probability distributions, seeding, edge cases, futures blending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix lint errors in NCAA Football CFP simulator

Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix TS2345 in NCAA football simulator test

Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00

1170 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 14 skip First Round)
if (templateId === "cfp_12") {
return await generateCFP12Bracket(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 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 512, 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 512 (indices 411), top 4 (indices 03) 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 14 (indices 03) 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);
}