brackt/app/models/playoff-match.ts
Claude 4e48a23f6b
Add a repair script for brackets advanced before the re-seed fix
The fix only changes how new results advance, so a bracket already advanced
under the fixed crossover keeps its wrong Elimination Finals pairings — and
the admin UI cannot re-trigger advancement, since a completed match renders
as "Complete" with no way to re-submit the winner.

Extract the reconcile step as reseedAflEliminationFinals, which resolves the
pairings from the results recorded so far (the winner being advanced is
passed in, since that row can predate the write), and add a script that runs
it over afl_10 events. It moves only Wildcard teams between the two
Elimination Final slots, leaves a correct bracket untouched, and reports
rather than rewrites an Elimination Final that has already been played.

  npx tsx scripts/fix-afl-wildcard-reseed.ts --dry

An event with no Wildcard/Elimination Finals matches now raises instead of
reporting nothing to do — a mistyped event id should not read as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:34:56 +00:00

1826 lines
64 KiB
TypeScript
Raw Permalink 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,
llwsMatchNumber,
llwsSideAndLocal,
STANDARD_BRACKET_SEEDING,
} from "~/lib/bracket-templates";
import {
LLWS_LOSER_ADVANCES_ROUNDS,
resolveLLWSAdvancement,
type LLWSResolvedDestination,
} from "~/lib/llws-bracket";
import {
resolveAflWildcardPlacements,
type AflWildcardResult,
} from "~/lib/afl-wildcard-reseed";
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;
}
/** A draw match with participants/winner already resolved to ids. */
export interface ResolvedDrawMatch {
externalMatchId: string;
round: string;
matchNumber: number;
participant1Id: string | null;
participant2Id: string | null;
winnerId: string | null;
loserId: string | null;
isScoring: boolean;
}
/**
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
* `externalMatchId` so repeated syncs update in place rather than duplicating.
*
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
* matchups and known winners directly from the source — appropriate for a feed
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
* is marked complete when both its winner and loser are known.
*
* @returns counts of rows written (inserted or updated), those carrying a result,
* and the participant ids of losers whose match *transitioned to complete on this
* run* (a row that was absent or not-yet-complete before and is complete now).
* That set is the newly-decided eliminations — used to announce knockouts once,
* idempotently across re-syncs, since playoff_matches persist between syncs.
*/
export async function populateBracketFromDraw(
eventId: string,
matches: ResolvedDrawMatch[]
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
const db = database();
const existing = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
});
const byExternalId = new Map(
existing
.filter((m) => m.externalMatchId)
.map((m) => [m.externalMatchId as string, m])
);
const toInsert: NewPlayoffMatch[] = [];
let written = 0;
let completed = 0;
const newlyDecidedLoserIds: string[] = [];
for (const m of matches) {
const isComplete = m.winnerId !== null && m.loserId !== null;
if (isComplete) completed++;
const existingRow = byExternalId.get(m.externalMatchId);
// Loser is "newly decided" when this match reaches completion for the first
// time: either a brand-new complete row, or an existing row that was not
// complete before. Re-syncing an already-complete match yields nothing.
if (isComplete && m.loserId && !existingRow?.isComplete) {
newlyDecidedLoserIds.push(m.loserId);
}
if (existingRow) {
await db
.update(schema.playoffMatches)
.set({
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
updatedAt: new Date(),
})
.where(eq(schema.playoffMatches.id, existingRow.id));
written++;
} else {
toInsert.push({
scoringEventId: eventId,
round: m.round,
matchNumber: m.matchNumber,
participant1Id: m.participant1Id,
participant2Id: m.participant2Id,
winnerId: m.winnerId,
loserId: m.loserId,
isComplete,
isScoring: m.isScoring,
templateRound: m.round,
externalMatchId: m.externalMatchId,
});
}
}
if (toInsert.length > 0) {
await createManyPlayoffMatches(toInsert);
written += toInsert.length;
}
return { written, completed, newlyDecidedLoserIds };
}
/**
* 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);
}
// NBA 20 requires special handling for the play-in tournament
if (templateId === "nba_20") {
return await generateNBA20Bracket(eventId, template, participantIds);
}
// LLWS 20 requires special handling for its two double-elimination brackets
if (templateId === "llws_20") {
return await generateLLWS20Bracket(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: 5 and 6 host the two Wildcard winners, re-seeded by ladder
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
* - 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 and 6th host the two Wildcard winners. Which winner lands
// where is decided by ladder position once both games are played (see
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
const eliminationSeeding = [
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
];
for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, opponent } = 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 ${opponent}` : 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);
}
/** What a re-seed changed, by Elimination Finals match number. */
export interface AflEliminationReseed {
vacated: number[];
filled: Array<{ matchNumber: number; participantId: string }>;
}
/**
* Put the decided Wildcard winners in the Elimination Finals they belong in.
*
* The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and
* 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That
* destination depends on both games, so this reconciles both slots against the results
* recorded so far every time it runs: it places a winner whose slot only became certain
* once the other game was decided, and moves one that an earlier (or corrected) result,
* or a bracket advanced before this rule existed, put in the other slot.
*
* `pending` supplies a result that may not be in the database yet — the row read back
* while advancing a match can predate the winner being written to it.
*
* Idempotent: pairings that are already right do no writes.
*/
export async function reseedAflEliminationFinals(
eventId: string,
pending?: { matchId: string; winnerId: string }
): Promise<AflEliminationReseed> {
const [wcMatches, efMatches] = await Promise.all([
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
]);
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
if (wcMatches.length === 0 || efMatches.length === 0) {
throw new Error(
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
);
}
const winnerByMatchNumber = new Map<number, string>();
for (const wc of wcMatches) {
const decidedWinner =
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
}
const results: AflWildcardResult[] = wcMatches.map((wc) => {
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
throw new Error(
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
);
});
const wanted = new Map<number, string>();
for (const placement of resolveAflWildcardPlacements(results)) {
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
}
// Only these teams can legitimately be moved between the two Elimination Finals;
// anyone else in a slot came from somewhere this function knows nothing about.
const wildcardParticipants = new Set<string>();
for (const wc of wcMatches) {
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
}
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
for (const efMatch of efMatches) {
const occupant = efMatch.participant2Id;
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
if (occupant === belongsHere) continue;
if (occupant !== null && !wildcardParticipants.has(occupant)) {
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
}
// Re-seeding a game that has already been played would rewrite who contested a
// recorded result. Surface that (this message is not one callers swallow) rather
// than quietly corrupting the bracket.
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
throw new Error(
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
);
}
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
if (belongsHere !== null) {
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
}
}
const reseed: AflEliminationReseed = {
vacated: slotsToClear.map((slot) => slot.matchNumber),
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
};
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
// One transaction, vacating before filling: a half-applied re-seed would leave the
// same team in both Elimination Finals.
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const slot of slotsToClear) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: null, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
for (const slot of slotsToFill) {
await tx
.update(schema.playoffMatches)
.set({ participant2Id: slot.participantId, updatedAt: now })
.where(eq(schema.playoffMatches.id, slot.id));
}
});
return reseed;
}
/**
* 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 (re-seeded by ladder position)
* - 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: winners are re-seeded into the Elimination Finals by ladder
// position, so every result re-resolves both slots.
if (match.round === "Wildcard Round") {
await reseedAflEliminationFinals(eventId, { matchId: match.id, 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 LLWS 20 double elimination: winners-bracket losers route
// into the elimination bracket instead of being knocked out.
if (template.id === "llws_20") {
const loserId =
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
return await advanceLLWSWinner(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 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);
}
/**
* Generate NBA 20-team bracket (Play-In + Playoffs).
*
* Participant array layout (indices 0-19):
* [09] East seeds 110 (E1=0, E2=1, ..., E10=9)
* [1019] West seeds 110 (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;
}
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
// must not be recorded as an elimination. (Winners Final and Bracket Championship
// are scoring rounds and are handled via loserIsPartial instead.)
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
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}`);
}
}
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
// The routing table itself is pure and lives in lib/ so the renderer can import it
// without pulling the database context into the browser bundle. Re-exported here so
// existing server-side callers and tests keep their import path.
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
/**
* Generate the 20-team LLWS double-elimination bracket (38 matches).
*
* Only the Opening Round and the four bye slots receive participants up front;
* everything else is filled by advanceLLWSWinner as games complete.
*
* Participant array layout (see LLWS_20 in lib/bracket-templates):
* [07] U.S. Opening Round teams, two per game
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
* [1017] International Opening Round teams, two per game
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
*/
async function generateLLWS20Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
const p = (idx: number): string | null =>
participantIds ? (participantIds[idx] ?? null) : null;
const sides = [
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
];
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
for (const { side, label, openingBase } of sides) {
for (let local = 1; local <= 4; local++) {
matches.push({
scoringEventId: eventId,
round: "Opening Round",
matchNumber: llwsMatchNumber("Opening Round", side, local),
participant1Id: p(openingBase + (local - 1) * 2),
participant2Id: p(openingBase + (local - 1) * 2 + 1),
isComplete: false,
isScoring: false,
templateRound: "Opening Round",
seedInfo: `${label} Opening ${local}`,
});
}
}
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
for (const { side, label, byeBase } of sides) {
for (let local = 1; local <= 2; local++) {
matches.push({
scoringEventId: eventId,
round: "Winners Round 2",
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
participant1Id: p(byeBase + (local - 1)),
participant2Id: null, // Opening Round winner
isComplete: false,
isScoring: false,
templateRound: "Winners Round 2",
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
});
}
}
// ── Every remaining round starts empty ──────────────────────────────────────
const remaining = template.rounds.filter(
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
);
for (const round of remaining) {
for (let i = 1; i <= round.matchCount; i++) {
// Championship/Consolation are single shared games; everything else is per-side.
const perSide = round.matchCount > 1;
const label = perSide
? llwsSideAndLocal(round.name, i).side === 0
? "U.S."
: "Intl"
: null;
matches.push({
scoringEventId: eventId,
round: round.name,
matchNumber: i,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: round.isScoring,
templateRound: round.name,
seedInfo: label ? `${label} ${round.name}` : null,
});
}
}
return await createManyPlayoffMatches(matches);
}
/**
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
* loser into the elimination bracket rather than eliminating them.
*
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
*/
async function advanceLLWSWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const eventId = match.scoringEventId;
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
// Winner and loser can land in different rounds, so resolve each independently.
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
if (winner) moves.push({ destination: winner, participantId: winnerId });
if (loser) moves.push({ destination: loser, participantId: loserId });
for (const { destination, participantId } of moves) {
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
eventId,
destination.round
);
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
if (!target) {
throw new Error(
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
);
}
if (target[destination.slot]) {
throw new Error(
`Next match ${destination.slot} is already filled ` +
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
);
}
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
}
}