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
1826 lines
64 KiB
TypeScript
1826 lines
64 KiB
TypeScript
import { eq, and } from "drizzle-orm";
|
||
import { database } from "~/database/context";
|
||
import * as schema from "~/database/schema";
|
||
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
||
import {
|
||
getBracketTemplate,
|
||
buildNCAA68SlotMap,
|
||
matchIndexForSeedSlot,
|
||
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 1–4 skip First Round)
|
||
if (templateId === "cfp_12") {
|
||
return await generateCFP12Bracket(eventId, template, participantIds);
|
||
}
|
||
|
||
// NBA 20 requires special handling for the play-in tournament
|
||
if (templateId === "nba_20") {
|
||
return await generateNBA20Bracket(eventId, template, participantIds);
|
||
}
|
||
|
||
// 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 5–12, 4 games, not scoring):
|
||
* Match 1: 5 vs 12
|
||
* Match 2: 6 vs 11
|
||
* Match 3: 7 vs 10
|
||
* Match 4: 8 vs 9
|
||
*
|
||
* Quarterfinals (4 games, scoring starts):
|
||
* Match 1: 1 vs First Round match 4 winner (8/9)
|
||
* Match 2: 4 vs First Round match 1 winner (5/12)
|
||
* Match 3: 3 vs First Round match 2 winner (6/11)
|
||
* Match 4: 2 vs First Round match 3 winner (7/10)
|
||
*
|
||
* Semifinals and National Championship: TBD vs TBD
|
||
*/
|
||
async function generateCFP12Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
|
||
// First Round: seeds 5–12 (indices 4–11), top 4 (indices 0–3) have byes
|
||
const firstRoundSeeding: [number, number][] = [
|
||
[4, 11], // 5 vs 12
|
||
[5, 10], // 6 vs 11
|
||
[6, 9], // 7 vs 10
|
||
[7, 8], // 8 vs 9
|
||
];
|
||
|
||
const firstRound = template.rounds[0]; // "First Round"
|
||
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
||
const [s1, s2] = firstRoundSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: firstRound.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[s1] : null,
|
||
participant2Id: participantIds ? participantIds[s2] : null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: firstRound.name,
|
||
seedInfo: `${s1 + 1} vs ${s2 + 1}`,
|
||
});
|
||
}
|
||
|
||
// Quarterfinals: seeds 1–4 (indices 0–3) receive byes, face First Round winners
|
||
// Matchup order mirrors CFP bracket: 1 vs 8/9 winner, 4 vs 5/12 winner, etc.
|
||
const quarterfinalsSeeding: [number, string][] = [
|
||
[0, "1 vs TBD"], // 1 seed vs First Round match 4 winner (8v9)
|
||
[3, "4 vs TBD"], // 4 seed vs First Round match 1 winner (5v12)
|
||
[2, "3 vs TBD"], // 3 seed vs First Round match 2 winner (6v11)
|
||
[1, "2 vs TBD"], // 2 seed vs First Round match 3 winner (7v10)
|
||
];
|
||
|
||
const quarterfinalsRound = template.rounds[1]; // "Quarterfinals"
|
||
for (let i = 0; i < quarterfinalsSeeding.length; i++) {
|
||
const [byeSeedIndex, seedInfo] = quarterfinalsSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: quarterfinalsRound.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: participantIds ? participantIds[byeSeedIndex] : null,
|
||
participant2Id: null, // Filled when First Round winner is known
|
||
isComplete: false,
|
||
isScoring: true,
|
||
templateRound: quarterfinalsRound.name,
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// Semifinals and National Championship: all TBD
|
||
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
||
const round = template.rounds[roundIndex];
|
||
for (let i = 0; i < round.matchCount; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: round.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: null,
|
||
participant2Id: null,
|
||
isComplete: false,
|
||
isScoring: round.isScoring,
|
||
templateRound: round.name,
|
||
seedInfo: null,
|
||
});
|
||
}
|
||
}
|
||
|
||
return await createManyPlayoffMatches(matches);
|
||
}
|
||
|
||
/**
|
||
* Generate NBA 20-team bracket (Play-In + Playoffs).
|
||
*
|
||
* Participant array layout (indices 0-19):
|
||
* [0–9] East seeds 1–10 (E1=0, E2=1, ..., E10=9)
|
||
* [10–19] West seeds 1–10 (W1=10, W2=11, ..., W10=19)
|
||
*
|
||
* Play-In Round 1 (4 matches):
|
||
* M1: E7(6) vs E8(7) M2: E9(8) vs E10(9)
|
||
* M3: W7(16) vs W8(17) M4: W9(18) vs W10(19)
|
||
*
|
||
* Play-In Round 2 (2 matches, TBD):
|
||
* M1: East loser(7v8) vs East winner(9v10) → E8 seed
|
||
* M2: West loser(7v8) vs West winner(9v10) → W8 seed
|
||
*
|
||
* First Round (8 series — bracket order for correct Conference Semis matchups via ceil logic):
|
||
* M1: E1 vs E8 (PIR2-M1 winner) M2: E4 vs E5
|
||
* M3: E2 vs E7 (PIR1-M1 winner) M4: E3 vs E6
|
||
* M5: W1 vs W8 (PIR2-M2 winner) M6: W4 vs W5
|
||
* M7: W2 vs W7 (PIR1-M3 winner) M8: W3 vs W6
|
||
*/
|
||
async function generateNBA20Bracket(
|
||
eventId: string,
|
||
template: BracketTemplate,
|
||
participantIds?: string[]
|
||
): Promise<PlayoffMatch[]> {
|
||
const matches: NewPlayoffMatch[] = [];
|
||
const p = (idx: number): string | null =>
|
||
participantIds ? (participantIds[idx] ?? null) : null;
|
||
|
||
// ── Play-In Round 1 (4 matches) ───────────────────────────────────────────────
|
||
const pir1Seeding: [number, number, string][] = [
|
||
[6, 7, "East: 7 vs 8"],
|
||
[8, 9, "East: 9 vs 10"],
|
||
[16, 17, "West: 7 vs 8"],
|
||
[18, 19, "West: 9 vs 10"],
|
||
];
|
||
for (let i = 0; i < pir1Seeding.length; i++) {
|
||
const [idx1, idx2, seedInfo] = pir1Seeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Play-In Round 1",
|
||
matchNumber: i + 1,
|
||
participant1Id: p(idx1),
|
||
participant2Id: p(idx2),
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "Play-In Round 1",
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// ── Play-In Round 2 (2 matches, TBD) ─────────────────────────────────────────
|
||
const pir2SeedInfo = [
|
||
"East: 7/8 loser vs 9/10 winner",
|
||
"West: 7/8 loser vs 9/10 winner",
|
||
];
|
||
for (let i = 0; i < 2; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "Play-In Round 2",
|
||
matchNumber: i + 1,
|
||
participant1Id: null,
|
||
participant2Id: null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "Play-In Round 2",
|
||
seedInfo: pir2SeedInfo[i],
|
||
});
|
||
}
|
||
|
||
// ── First Round (8 series) ────────────────────────────────────────────────────
|
||
// IMPORTANT: This match ordering is load-bearing.
|
||
// NBASimulator.simulateBracketAware() hard-codes match numbers to derive play-in
|
||
// seed slots: FR M1 p2 = E8, FR M3 p2 = E7, FR M5 p2 = W8, FR M7 p2 = W7.
|
||
// Do not change the match order without updating the simulator's resolveGame/
|
||
// resolveSeries calls and the corresponding test fixtures.
|
||
const firstRoundSeeding: [number | null, number | null, string][] = [
|
||
[0, null, "East: 1 vs 8"],
|
||
[3, 4, "East: 4 vs 5"],
|
||
[1, null, "East: 2 vs 7"],
|
||
[2, 5, "East: 3 vs 6"],
|
||
[10, null, "West: 1 vs 8"],
|
||
[13, 14, "West: 4 vs 5"],
|
||
[11, null, "West: 2 vs 7"],
|
||
[12, 15, "West: 3 vs 6"],
|
||
];
|
||
for (let i = 0; i < firstRoundSeeding.length; i++) {
|
||
const [idx1, idx2, seedInfo] = firstRoundSeeding[i];
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: "First Round",
|
||
matchNumber: i + 1,
|
||
participant1Id: idx1 !== null ? p(idx1) : null,
|
||
participant2Id: idx2 !== null ? p(idx2) : null,
|
||
isComplete: false,
|
||
isScoring: false,
|
||
templateRound: "First Round",
|
||
seedInfo,
|
||
});
|
||
}
|
||
|
||
// ── Conference Semifinals, Conference Finals, NBA Finals (all TBD) ────────────
|
||
for (let roundIndex = 3; roundIndex < template.rounds.length; roundIndex++) {
|
||
const round = template.rounds[roundIndex];
|
||
for (let i = 0; i < round.matchCount; i++) {
|
||
matches.push({
|
||
scoringEventId: eventId,
|
||
round: round.name,
|
||
matchNumber: i + 1,
|
||
participant1Id: null,
|
||
participant2Id: null,
|
||
isComplete: false,
|
||
isScoring: round.isScoring,
|
||
templateRound: round.name,
|
||
seedInfo: null,
|
||
});
|
||
}
|
||
}
|
||
|
||
return await createManyPlayoffMatches(matches);
|
||
}
|
||
|
||
/**
|
||
* Returns true if the loser of the given match advances to another round rather
|
||
* than being eliminated. Use this to avoid recording a premature elimination result.
|
||
*
|
||
* Currently only applies to NBA Play-In Round 1 matches 1 and 3 (the 7v8 games),
|
||
* whose losers go on to Play-In Round 2.
|
||
*/
|
||
export function doesLoserAdvance(
|
||
round: string,
|
||
matchNumber: number,
|
||
templateId: string
|
||
): boolean {
|
||
// NBA Play-In Round 1: 7v8 games (M1, M3) — loser gets a second chance in Round 2.
|
||
// 9v10 games (M2, M4) — loser is immediately eliminated.
|
||
if (templateId === "nba_20" && round === "Play-In Round 1") {
|
||
return matchNumber === 1 || matchNumber === 3;
|
||
}
|
||
// AFL Qualifying Finals: both losers (1v4 and 2v3) advance to Semi-Finals.
|
||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||
return true;
|
||
}
|
||
// 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):
|
||
* [0–7] U.S. Opening Round teams, two per game
|
||
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
||
* [10–17] 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 });
|
||
}
|
||
}
|