feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
import { eq, and } from "drizzle-orm";
|
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
2026-03-15 21:52:47 -07:00
|
|
|
|
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
|
|
|
|
|
import {
|
|
|
|
|
|
getBracketTemplate,
|
|
|
|
|
|
buildNCAA68SlotMap,
|
|
|
|
|
|
matchIndexForSeedSlot,
|
|
|
|
|
|
STANDARD_BRACKET_SEEDING,
|
|
|
|
|
|
} from "~/lib/bracket-templates";
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
|
|
|
|
|
|
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,
|
Add playoff match game scheduling and odds management (#135)
* Add playoff match games and odds storage
Introduces two new tables for bracket matchup detail storage:
- `playoff_match_games`: tracks individual game schedules within a
series matchup (game number, scheduledAt, status, per-game scores,
winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
matchup (single upsert record, no isLatest complexity).
Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
* Code review fixes: type safety, abstraction, and React correctness
- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
<Fragment key={match.id}> so React can correctly track rows
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
|
|
|
|
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
|
|
|
|
|
odds: { with: { participant: true } },
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
Add playoff match game scheduling and odds management (#135)
* Add playoff match games and odds storage
Introduces two new tables for bracket matchup detail storage:
- `playoff_match_games`: tracks individual game schedules within a
series matchup (game number, scheduledAt, status, per-game scores,
winner). Supports scheduled/complete/postponed status enum.
- `playoff_match_odds`: stores moneyline odds per participant per
matchup (single upsert record, no isLatest complexity).
Includes:
- Drizzle schema + relations with CASCADE deletes from playoff_matches
- Migration 0040_fat_puma.sql
- playoff-match-game.ts model with pure helpers: computeSeriesScore,
isSeriesComplete, getSeriesLeader — plus full CRUD
- playoff-match-odds.ts model with pure helpers: americanToImpliedProbability,
impliedProbabilityToAmerican, normalizeOdds — plus upsert/read/delete
- findPlayoffMatchesByEventId and findPlayoffMatchById updated to
include games and odds in their query results
- Bracket server route: add-game, update-game, delete-game,
upsert-odds, delete-odds actions
- Bracket admin UI: expandable per-match panel for game schedule
management and moneyline odds entry
- 41 new unit tests (18 game + 23 odds), all 810 tests passing
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
* Code review fixes: type safety, abstraction, and React correctness
- Derive PlayoffMatchGameStatus from schema enum instead of hardcoding
the string union, eliminating the duplicate source of truth
- updateGame now returns PlayoffMatchGame | undefined to reflect reality
when no row matches the ID
- Remove TOCTOU check-then-act in update-game action: call updateGame
directly and check the return value instead of a pre-flight findGameById
- Add status enum validation before the cast in update-game action
- Move impliedProbability computation inside upsertMatchOdds so callers
only provide moneylineOdds; the model owns the derivation
- Remove unnecessary dynamic import of americanToImpliedProbability in
the upsert-odds action (was already imported from the same module)
- Fix React list reconciliation bug: replace bare <> fragment with
<Fragment key={match.id}> so React can correctly track rows
https://claude.ai/code/session_01Twt3D1bsEK3eXUhMaz6ee7
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-11 14:17:43 -07:00
|
|
|
|
games: { orderBy: (g, { asc }) => [asc(g.gameNumber)] },
|
|
|
|
|
|
odds: { with: { participant: true } },
|
feat: Implement bracket expansion plan to support various tournament structures
- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats.
- Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets.
- Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly.
- Enhanced database schema to accommodate new scoring rules and bracket templates.
- Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly.
- Documented implementation phases for gradual rollout of new features.
- Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
2025-11-03 09:36:16 -08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
round: string
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
return await db.query.playoffMatches.findMany({
|
|
|
|
|
|
where: and(
|
|
|
|
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
|
|
|
|
eq(schema.playoffMatches.round, round)
|
|
|
|
|
|
),
|
|
|
|
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
|
|
|
|
with: {
|
|
|
|
|
|
participant1: true,
|
|
|
|
|
|
participant2: true,
|
|
|
|
|
|
winner: true,
|
|
|
|
|
|
loser: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updatePlayoffMatch(
|
|
|
|
|
|
id: string,
|
|
|
|
|
|
data: Partial<NewPlayoffMatch>
|
|
|
|
|
|
): Promise<PlayoffMatch> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const [match] = await db
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({ ...data, updatedAt: new Date() })
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, id))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
return match;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deletePlayoffMatch(id: string): Promise<void> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function deletePlayoffMatchesByEventId(
|
|
|
|
|
|
eventId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db
|
|
|
|
|
|
.delete(schema.playoffMatches)
|
|
|
|
|
|
.where(eq(schema.playoffMatches.scoringEventId, eventId));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Set the winner of a playoff match
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function setMatchWinner(
|
|
|
|
|
|
matchId: string,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string,
|
|
|
|
|
|
participant1Score?: number,
|
|
|
|
|
|
participant2Score?: number
|
|
|
|
|
|
): Promise<PlayoffMatch> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
const [match] = await db
|
|
|
|
|
|
.update(schema.playoffMatches)
|
|
|
|
|
|
.set({
|
|
|
|
|
|
winnerId,
|
|
|
|
|
|
loserId,
|
|
|
|
|
|
isComplete: true,
|
|
|
|
|
|
participant1Score: participant1Score?.toString(),
|
|
|
|
|
|
participant2Score: participant2Score?.toString(),
|
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
|
})
|
|
|
|
|
|
.where(eq(schema.playoffMatches.id, matchId))
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
return match;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate a standard single elimination bracket structure
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function generateSingleEliminationBracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
participantIds: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const numParticipants = participantIds.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Validate that we have 4 or 8 participants for standard bracket
|
|
|
|
|
|
if (numParticipants !== 4 && numParticipants !== 8) {
|
|
|
|
|
|
throw new Error("Single elimination bracket requires 4 or 8 participants");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (numParticipants === 8) {
|
|
|
|
|
|
// Quarterfinals (4 matches)
|
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Quarterfinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds[i * 2],
|
|
|
|
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semifinals (2 matches)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semifinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (numParticipants === 4) {
|
|
|
|
|
|
// Semifinals (2 matches)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semifinals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds[i * 2],
|
|
|
|
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Finals (1 match)
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Finals",
|
|
|
|
|
|
matchNumber: 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Advance the winner of a match to the next round
|
|
|
|
|
|
* Uses deterministic slot assignment to maintain bracket structure
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function advanceWinner(
|
|
|
|
|
|
matchId: string,
|
|
|
|
|
|
winnerId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
// Get the match
|
|
|
|
|
|
const match = await findPlayoffMatchById(matchId);
|
|
|
|
|
|
if (!match) throw new Error("Match not found");
|
|
|
|
|
|
|
|
|
|
|
|
// Determine which round this is and where to advance
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
let nextRound: string;
|
|
|
|
|
|
let nextMatchNumber: number;
|
|
|
|
|
|
let participantSlot: 'participant1Id' | 'participant2Id';
|
|
|
|
|
|
|
|
|
|
|
|
if (match.round === "Quarterfinals") {
|
|
|
|
|
|
nextRound = "Semifinals";
|
|
|
|
|
|
// Match 1,2 -> SF1, Match 3,4 -> SF2
|
|
|
|
|
|
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
|
|
|
|
|
|
// Odd matches (1, 3) -> participant1, Even matches (2, 4) -> participant2
|
|
|
|
|
|
participantSlot = match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
} else if (match.round === "Semifinals") {
|
|
|
|
|
|
nextRound = "Finals";
|
|
|
|
|
|
nextMatchNumber = 1;
|
|
|
|
|
|
// SF Match 1 -> Finals participant1, SF Match 2 -> Finals participant2
|
|
|
|
|
|
participantSlot = match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Finals - no advancement needed
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find the next match
|
|
|
|
|
|
const nextMatches = await findPlayoffMatchesByEventIdAndRound(eventId, nextRound);
|
|
|
|
|
|
const nextMatch = nextMatches.find(m => m.matchNumber === nextMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!nextMatch) throw new Error("Next match not found");
|
|
|
|
|
|
|
|
|
|
|
|
// Check if the slot is already filled
|
|
|
|
|
|
if (nextMatch[participantSlot]) {
|
|
|
|
|
|
throw new Error(`Next match ${participantSlot} is already filled`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fill the determined slot
|
|
|
|
|
|
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)) {
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// Non-standard sizes need sequential seeding for now
|
2025-11-04 22:09:44 -08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate a bracket from a template
|
|
|
|
|
|
* Phase 2.6: Template-based bracket generation
|
2025-11-04 22:09:44 -08:00
|
|
|
|
* Phase 2.7: Added proper tournament seeding
|
2025-11-03 13:16:37 -08:00
|
|
|
|
*
|
|
|
|
|
|
* @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,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
participantIds?: string[],
|
|
|
|
|
|
regionOverride?: BracketRegion[]
|
2025-11-03 13:16:37 -08:00
|
|
|
|
): 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`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 22:30:12 -08:00
|
|
|
|
// FIFA 48 generates an empty knockout bracket (participants assigned later via groups)
|
|
|
|
|
|
if (templateId === "fifa_48") {
|
|
|
|
|
|
return await generateFIFA48Bracket(eventId, template);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// NCAA 68 requires special handling for First Four and Round of 64
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// Use regionOverride if supplied (per-event config), otherwise fall back to template.regions
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (templateId === "ncaa_68") {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const effectiveTemplate = regionOverride
|
|
|
|
|
|
? { ...template, regions: regionOverride }
|
|
|
|
|
|
: template;
|
|
|
|
|
|
return await generateNCAA68Bracket(eventId, effectiveTemplate, participantIds);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:56:57 -08:00
|
|
|
|
// NFL 14 requires special handling for bye weeks
|
|
|
|
|
|
if (templateId === "nfl_14") {
|
|
|
|
|
|
return await generateNFL14Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
// AFL 10 requires special handling for double-chance finals system
|
|
|
|
|
|
if (templateId === "afl_10") {
|
|
|
|
|
|
return await generateAFL10Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
// CFP 12 requires special handling for bye weeks (seeds 1–4 skip First Round)
|
|
|
|
|
|
if (templateId === "cfp_12") {
|
|
|
|
|
|
return await generateCFP12Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
// NBA 20 requires special handling for the play-in tournament
|
|
|
|
|
|
if (templateId === "nba_20") {
|
|
|
|
|
|
return await generateNBA20Bracket(eventId, template, participantIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Generate matches for each round in the template
|
|
|
|
|
|
for (const round of template.rounds) {
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < round.matchCount; i++) {
|
|
|
|
|
|
let participant1Id: string | null = null;
|
|
|
|
|
|
let participant2Id: string | null = null;
|
2025-11-04 22:09:44 -08:00
|
|
|
|
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;
|
2025-11-03 13:16:37 -08:00
|
|
|
|
|
2025-11-04 22:09:44 -08:00
|
|
|
|
// Store seed info for display (1-indexed for users)
|
|
|
|
|
|
seedInfo = `${seed1Index + 1} vs ${seed2Index + 1}`;
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: round.name,
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id,
|
|
|
|
|
|
participant2Id,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: round.isScoring,
|
|
|
|
|
|
templateRound: round.name,
|
2025-11-04 22:09:44 -08:00
|
|
|
|
seedInfo,
|
2025-11-03 13:16:37 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
/**
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* 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]
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*/
|
|
|
|
|
|
async function generateNCAA68Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
if (!template.regions || template.regions.length === 0) {
|
|
|
|
|
|
throw new Error("NCAA 68 template requires a regions config");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
2026-03-15 21:52:47 -07:00
|
|
|
|
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, []);
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
const labels = regionFFLabels.get(regionIndex);
|
|
|
|
|
|
if (labels) labels[playInIndex] = `FF${i + 1}`;
|
2026-03-15 21:52:47 -07:00
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── First Four ────────────────────────────────────────────────────────────
|
|
|
|
|
|
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
|
|
|
|
|
const { startIndex, seedSlot, regionIndex } = slotMap.playInOffsets[i];
|
|
|
|
|
|
const regionName = template.regions[regionIndex].name;
|
2025-11-08 21:18:09 -08:00
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "First Four",
|
|
|
|
|
|
matchNumber: i + 1,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
|
|
|
|
|
|
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
isComplete: false,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
isScoring: false,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
templateRound: "First Four",
|
2026-03-15 21:52:47 -07:00
|
|
|
|
seedInfo: `${regionName} ${seedSlot}-seed play-in`,
|
2025-11-08 21:18:09 -08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── 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]);
|
|
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
for (const [hiSeed, loSeed] of STANDARD_BRACKET_SEEDING) {
|
|
|
|
|
|
const slot1 = seedToSlot.get(hiSeed);
|
|
|
|
|
|
const slot2 = seedToSlot.get(loSeed);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
if (slot1 === undefined || slot2 === undefined) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Region "${region.name}" missing seed ${slot1 === undefined ? hiSeed : loSeed}`
|
|
|
|
|
|
);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
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}`,
|
|
|
|
|
|
});
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// ── Round of 32 → Championship (empty match shells) ──────────────────────
|
2025-11-08 21:56:57 -08:00
|
|
|
|
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)
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const wildCardSeeding = [
|
2025-11-08 21:56:57 -08:00
|
|
|
|
[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
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-03-21 09:44:05 -07:00
|
|
|
|
for (let i = 0; i < wildCardSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = wildCardSeeding[i];
|
2025-11-08 21:56:57 -08:00
|
|
|
|
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;
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const participant2Id: string | null = null;
|
2025-11-08 21:56:57 -08:00
|
|
|
|
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)
|
2025-11-08 21:18:09 -08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Generate AFL 10 bracket with Wildcard Round (2026+ format)
|
|
|
|
|
|
* Phase 3.3: Special handling for AFL's double-chance finals system
|
|
|
|
|
|
*
|
|
|
|
|
|
* Structure:
|
|
|
|
|
|
* - Wildcard Round: 7v10, 8v9
|
|
|
|
|
|
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
|
|
|
|
|
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
|
|
|
|
|
* - Semi-Finals: QF losers vs EF winners
|
|
|
|
|
|
* - Preliminary Finals: QF winners vs SF winners
|
|
|
|
|
|
* - Grand Final: PF winners
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function generateAFL10Bracket(
|
|
|
|
|
|
eventId: string,
|
|
|
|
|
|
template: BracketTemplate,
|
|
|
|
|
|
participantIds?: string[]
|
|
|
|
|
|
): Promise<PlayoffMatch[]> {
|
|
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
// Wildcard Round: 7th vs 10th, 8th vs 9th
|
|
|
|
|
|
const wildcardSeeding = [
|
|
|
|
|
|
[6, 9], // #7 (index 6) vs #10 (index 9) - 7th hosts
|
|
|
|
|
|
[7, 8], // #8 (index 7) vs #9 (index 8) - 8th hosts
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < wildcardSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = wildcardSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Wildcard Round",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[seed1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[seed2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false, // Losers get 0 points (9th-10th place)
|
|
|
|
|
|
templateRound: "Wildcard Round",
|
|
|
|
|
|
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Qualifying Finals: 1st vs 4th, 2nd vs 3rd
|
|
|
|
|
|
const qualifyingSeeding = [
|
|
|
|
|
|
[0, 3], // #1 (index 0) vs #4 (index 3)
|
|
|
|
|
|
[1, 2], // #2 (index 1) vs #3 (index 2)
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < qualifyingSeeding.length; i++) {
|
|
|
|
|
|
const [seed1, seed2] = qualifyingSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Qualifying Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[seed1] : null,
|
|
|
|
|
|
participant2Id: participantIds ? participantIds[seed2] : null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: false, // Winners get bye, losers get second chance
|
|
|
|
|
|
templateRound: "Qualifying Finals",
|
|
|
|
|
|
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
|
|
|
|
|
const eliminationSeeding = [
|
|
|
|
|
|
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
|
|
|
|
|
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
|
|
|
|
|
const { higher, wildcard } = eliminationSeeding[i];
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Elimination Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: participantIds ? participantIds[higher] : null,
|
|
|
|
|
|
participant2Id: null, // Will be filled by wildcard winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 7th-8th
|
|
|
|
|
|
templateRound: "Elimination Finals",
|
|
|
|
|
|
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Semi-Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null, // Will be filled by QF loser
|
|
|
|
|
|
participant2Id: null, // Will be filled by EF winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 5th-6th
|
|
|
|
|
|
templateRound: "Semi-Finals",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Preliminary Finals: QF winners vs SF winners (TBD vs TBD)
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Preliminary Finals",
|
|
|
|
|
|
matchNumber: i + 1,
|
|
|
|
|
|
participant1Id: null, // Will be filled by QF winner
|
|
|
|
|
|
participant2Id: null, // Will be filled by SF winner
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Losers share 3rd-4th
|
|
|
|
|
|
templateRound: "Preliminary Finals",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Grand Final: PF winners (TBD vs TBD)
|
|
|
|
|
|
matches.push({
|
|
|
|
|
|
scoringEventId: eventId,
|
|
|
|
|
|
round: "Grand Final",
|
|
|
|
|
|
matchNumber: 1,
|
|
|
|
|
|
participant1Id: null,
|
|
|
|
|
|
participant2Id: null,
|
|
|
|
|
|
isComplete: false,
|
|
|
|
|
|
isScoring: true, // Winner 1st, Loser 2nd
|
|
|
|
|
|
templateRound: "Grand Final",
|
|
|
|
|
|
seedInfo: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* AFL-specific advancement logic for the complex double-chance system
|
|
|
|
|
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
|
|
|
|
|
*
|
|
|
|
|
|
* Advancement rules:
|
|
|
|
|
|
* - Wildcard Round: Winner → Elimination Finals
|
|
|
|
|
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
|
|
|
|
|
* - Elimination Finals: Winner → Semi-Finals
|
|
|
|
|
|
* - Semi-Finals: Winner → Preliminary Finals
|
|
|
|
|
|
* - Preliminary Finals: Winner → Grand Final
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function advanceAFLWinner(
|
|
|
|
|
|
match: PlayoffMatch,
|
|
|
|
|
|
winnerId: string,
|
|
|
|
|
|
loserId: string
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const eventId = match.scoringEventId;
|
|
|
|
|
|
|
|
|
|
|
|
// Wildcard Round: Winner advances to Elimination Finals
|
|
|
|
|
|
if (match.round === "Wildcard Round") {
|
|
|
|
|
|
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
|
|
|
|
|
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
|
|
|
|
|
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
|
|
|
|
|
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
|
|
|
|
|
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
|
|
|
|
|
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
|
|
|
|
|
if (match.round === "Qualifying Finals") {
|
|
|
|
|
|
// QF Match 1: Winner → PF1 participant1, Loser → SF1 participant1
|
|
|
|
|
|
// QF Match 2: Winner → PF2 participant1, Loser → SF2 participant1
|
|
|
|
|
|
const pfMatchNumber = match.matchNumber;
|
|
|
|
|
|
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
|
|
|
|
|
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
|
|
|
|
|
if (pfMatch.participant1Id) throw new Error(`PF ${pfMatchNumber} participant1 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(pfMatch.id, { participant1Id: winnerId });
|
|
|
|
|
|
|
|
|
|
|
|
// Also advance loser to Semi-Finals
|
|
|
|
|
|
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
|
|
|
|
|
const sfMatch = sfMatches.find((m) => m.matchNumber === match.matchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!sfMatch) throw new Error(`Semi-Finals match ${match.matchNumber} not found`);
|
|
|
|
|
|
if (sfMatch.participant1Id) throw new Error(`SF ${match.matchNumber} participant1 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(sfMatch.id, { participant1Id: loserId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Elimination Finals: Winner → Semi-Finals
|
|
|
|
|
|
if (match.round === "Elimination Finals") {
|
|
|
|
|
|
// EF Match 1 winner → SF2 participant2
|
|
|
|
|
|
// EF Match 2 winner → SF1 participant2
|
|
|
|
|
|
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
|
|
|
|
|
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
|
|
|
|
|
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
|
|
|
|
|
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Semi-Finals: Winner → Preliminary Finals
|
|
|
|
|
|
if (match.round === "Semi-Finals") {
|
|
|
|
|
|
// SF Match 1 winner → PF2 participant2
|
|
|
|
|
|
// SF Match 2 winner → PF1 participant2
|
|
|
|
|
|
const pfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
|
|
|
|
|
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
|
|
|
|
|
|
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
|
|
|
|
|
|
|
|
|
|
|
|
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
|
|
|
|
|
|
if (pfMatch.participant2Id) throw new Error(`PF ${pfMatchNumber} participant2 already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(pfMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Preliminary Finals: Winner → Grand Final
|
|
|
|
|
|
if (match.round === "Preliminary Finals") {
|
|
|
|
|
|
const gfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Grand Final");
|
|
|
|
|
|
const gfMatch = gfMatches[0];
|
|
|
|
|
|
|
|
|
|
|
|
if (!gfMatch) throw new Error("Grand Final match not found");
|
|
|
|
|
|
|
|
|
|
|
|
const participantSlot: 'participant1Id' | 'participant2Id' =
|
|
|
|
|
|
match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
|
|
|
|
|
|
|
|
|
|
|
if (gfMatch[participantSlot]) throw new Error(`GF ${participantSlot} already filled`);
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(gfMatch.id, { [participantSlot]: winnerId });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Grand Final: No advancement
|
|
|
|
|
|
if (match.round === "Grand Final") {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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");
|
|
|
|
|
|
|
2026-04-13 00:51:53 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-12 23:21:22 -08:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
// Special handling for NCAA 68 First Four
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// First Four winners advance to Round of 64 slots derived from the regions config
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (template.id === "ncaa_68" && match.round === "First Four") {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
return await advanceFirstFourWinner(match, winnerId, template);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 13:16:37 -08:00
|
|
|
|
// 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 });
|
Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator (#242)
* Add FIFA World Cup 2026 support with group stage display and Monte Carlo simulator, fixes #127
- New `groupStageMatches` table for recording group play results (W/D/L, scores, matchday, schedule)
- `computeGroupStandings()` model function: pts → GD → GF → name tiebreaker ordering
- `GroupStageStandings` component showing all 12 groups with standings table and manager column
- Admin bracket UI: group match score entry, per-group standings, "Recalculate Floors" action
- `WorldCupSimulator`: 50k Monte Carlo covering group stage + best-8 3rd-place + knockout + 3rd place game
- Fuzzy name matching for national team Elo lookup (exact → substring → word-overlap), warns on miss
- Partial group completion: completed matches replayed with real scores, remaining matches simulated
- Elo priority: admin-entered sourceElo > futures odds converted to Elo > hardcoded national team ratings
- `fifa_48` bracket template: added Third Place Game round with `loserFeedsInto` on Semifinals
- Scoring rules: distinct 3rd/4th place for `fifa_48` (not averaged), QF losers share 5th–8th equally
- Floor scoring: SF participants guaranteed 4th (provisional), finalized after 3rd place game
- `recalculate-floors` admin action deletes and replays all results from scratch (fixes stale guard bug)
- Unique index on `(tournamentGroupId, participant1Id, participant2Id)` to prevent duplicate pairings
- Batch `findMatchesByGroupIds()` replacing N sequential queries in the sport season loader
- League home mini-standings now shows `actualPoints` (includes floor) instead of `totalPoints` only
- Elo ratings admin page supports World Cup (same bulk-import flow as snooker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors: update GroupStandingData type to use findMatchesByGroupIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Increase Node heap to 4GB for unit tests in CI to prevent OOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix OOM in CI: make WorldCupSimulator simulation count configurable for tests
Tests now pass numSimulations=500 instead of the production default of 50,000.
Six simulator tests × 50k iterations each was exhausting the 4GB heap on GitHub
Actions runners. Also reduce simGroupMatch stat tests from 50k to 5k iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 10:27:47 -07:00
|
|
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-03 13:16:37 -08:00
|
|
|
|
}
|
2025-11-08 21:18:09 -08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* Advance a First Four winner to their target Round of 64 slot.
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*
|
2026-03-15 21:52:47 -07:00
|
|
|
|
* 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).
|
2025-11-08 21:18:09 -08:00
|
|
|
|
*/
|
|
|
|
|
|
async function advanceFirstFourWinner(
|
|
|
|
|
|
firstFourMatch: PlayoffMatch,
|
2026-03-15 21:52:47 -07:00
|
|
|
|
winnerId: string,
|
|
|
|
|
|
template: BracketTemplate
|
2025-11-08 21:18:09 -08:00
|
|
|
|
): Promise<void> {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
// 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) {
|
2025-11-08 21:18:09 -08:00
|
|
|
|
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
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;
|
|
|
|
|
|
|
2025-11-08 21:18:09 -08:00
|
|
|
|
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
|
|
|
|
|
|
firstFourMatch.scoringEventId,
|
|
|
|
|
|
"Round of 64"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-15 21:52:47 -07:00
|
|
|
|
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
if (!targetMatch) {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
throw new Error(`Round of 64 match ${r64MatchNumber} not found`);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (targetMatch.participant2Id) {
|
2026-03-15 21:52:47 -07:00
|
|
|
|
throw new Error(`Round of 64 match ${r64MatchNumber} participant2Id is already filled`);
|
2025-11-08 21:18:09 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
|
|
|
|
|
}
|
2026-02-14 22:30:12 -08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Add NCAA Football CFP simulator (12-team bracket) (#278)
Fixes #124
* Add NCAA Football CFP simulator (12-team bracket)
Implements a Monte Carlo simulator for the College Football Playoff using
the 2024-present 12-team format. Elo/FPI ratings are entered manually via
the existing admin Elo Ratings page; championship futures odds can
optionally be blended in (60% Elo / 40% odds).
- Add CFP_12 bracket template (First Round not scoring, QFs onward score)
- Add generateCFP12Bracket() with correct seeding: 5v12, 6v11, 7v10, 8v9
in First Round; seeds 1–4 receive QF byes
- Add NCAAFootballSimulator: 50k Monte Carlo sims, seeds teams by blended
Elo+odds strength, tracks champion/finalist/SF/QF placement tiers
- Register ncaa_football_bracket simulator type in registry and schema enum
- Add migration 0071: ALTER TYPE simulator_type ADD VALUE 'ncaa_football_bracket'
- Add tests: 30 tests covering bracket template structure and simulator
probability distributions, seeding, edge cases, futures blending
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors in NCAA Football CFP simulator
Replace non-null assertions with optional chaining, change let to const,
use toSorted() instead of sort(), and add a bump() helper to avoid
repeated map lookups with non-null assertions in simulateBracket.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TS2345 in NCAA football simulator test
Add ?? 0 fallback so optional-chained probFirst is number, not number | undefined.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:48:32 -04:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
2026-04-13 00:51:53 -04:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 p1 = E7, FR M5 p2 = W8, FR M7 p1 = 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* NBA play-in advancement logic.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Play-In Round 1:
|
|
|
|
|
|
* M1 (E7v8): winner → First Round M3 p1; loser → PIR2 M1 p1
|
|
|
|
|
|
* M2 (E9v10): winner → PIR2 M1 p2; loser eliminated
|
|
|
|
|
|
* M3 (W7v8): winner → First Round M7 p1; 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 p1; loser → PIR2 M1 p1
|
|
|
|
|
|
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.participant1Id) throw new Error("First Round M3 participant1 already filled");
|
|
|
|
|
|
if (pir2M1.participant1Id) throw new Error("Play-In Round 2 M1 participant1 already filled");
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
updatePlayoffMatch(frM3.id, { participant1Id: 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 p1; loser → PIR2 M2 p1
|
|
|
|
|
|
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.participant1Id) throw new Error("First Round M7 participant1 already filled");
|
|
|
|
|
|
if (pir2M2.participant1Id) throw new Error("Play-In Round 2 M2 participant1 already filled");
|
|
|
|
|
|
await Promise.all([
|
|
|
|
|
|
updatePlayoffMatch(frM7.id, { participant1Id: 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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|