feat: Implement NCAA 68 tournament structure with First Four play-in games and Round of 64 seeding logic

This commit is contained in:
Chris Parsons 2025-11-08 21:18:09 -08:00
parent dfe4b7b643
commit bbacb00d6c
3 changed files with 441 additions and 10 deletions

View file

@ -0,0 +1,205 @@
import { describe, it, expect } from "vitest";
import { NCAA_68, getScoringRoundType } from "~/lib/bracket-templates";
/**
* NCAA 68 Bracket Unit Tests - Phase 2.8
*
* Tests the NCAA 68 tournament template structure and scoring logic:
* - 68 teams total (60 direct seeds + 8 in First Four)
* - First Four: 4 play-in games (8 teams)
* - Round of 64: 32 games
* - Only Elite Eight and beyond score fantasy points
*/
describe("NCAA 68 Bracket Template - Phase 2.8", () => {
describe("Template Structure", () => {
it("has correct total teams", () => {
expect(NCAA_68.totalTeams).toBe(68);
});
it("has all 7 rounds", () => {
expect(NCAA_68.rounds).toHaveLength(7);
expect(NCAA_68.rounds[0].name).toBe("First Four");
expect(NCAA_68.rounds[1].name).toBe("Round of 64");
expect(NCAA_68.rounds[2].name).toBe("Round of 32");
expect(NCAA_68.rounds[3].name).toBe("Sweet Sixteen");
expect(NCAA_68.rounds[4].name).toBe("Elite Eight");
expect(NCAA_68.rounds[5].name).toBe("Final Four");
expect(NCAA_68.rounds[6].name).toBe("Championship");
});
it("has correct match counts per round", () => {
expect(NCAA_68.rounds[0].matchCount).toBe(4); // First Four
expect(NCAA_68.rounds[1].matchCount).toBe(32); // Round of 64
expect(NCAA_68.rounds[2].matchCount).toBe(16); // Round of 32
expect(NCAA_68.rounds[3].matchCount).toBe(8); // Sweet Sixteen
expect(NCAA_68.rounds[4].matchCount).toBe(4); // Elite Eight (Quarterfinals)
expect(NCAA_68.rounds[5].matchCount).toBe(2); // Final Four (Semifinals)
expect(NCAA_68.rounds[6].matchCount).toBe(1); // Championship (Finals)
});
it("has correct round feeding structure", () => {
expect(NCAA_68.rounds[0].feedsInto).toBe("Round of 64");
expect(NCAA_68.rounds[1].feedsInto).toBe("Round of 32");
expect(NCAA_68.rounds[2].feedsInto).toBe("Sweet Sixteen");
expect(NCAA_68.rounds[3].feedsInto).toBe("Elite Eight");
expect(NCAA_68.rounds[4].feedsInto).toBe("Final Four");
expect(NCAA_68.rounds[5].feedsInto).toBe("Championship");
expect(NCAA_68.rounds[6].feedsInto).toBeNull(); // Championship has no next round
});
});
describe("Scoring Configuration", () => {
it("marks scoring to start at Elite Eight", () => {
expect(NCAA_68.scoringStartsAtRound).toBe("Elite Eight");
});
it("marks First Four as non-scoring", () => {
const firstFour = NCAA_68.rounds[0];
expect(firstFour.name).toBe("First Four");
expect(firstFour.isScoring).toBe(false);
});
it("marks Round of 64 as non-scoring", () => {
const roundOf64 = NCAA_68.rounds[1];
expect(roundOf64.name).toBe("Round of 64");
expect(roundOf64.isScoring).toBe(false);
});
it("marks Round of 32 as non-scoring", () => {
const roundOf32 = NCAA_68.rounds[2];
expect(roundOf32.name).toBe("Round of 32");
expect(roundOf32.isScoring).toBe(false);
});
it("marks Sweet Sixteen as non-scoring", () => {
const sweetSixteen = NCAA_68.rounds[3];
expect(sweetSixteen.name).toBe("Sweet Sixteen");
expect(sweetSixteen.isScoring).toBe(false);
});
it("marks Elite Eight as scoring (Quarterfinals)", () => {
const eliteEight = NCAA_68.rounds[4];
expect(eliteEight.name).toBe("Elite Eight");
expect(eliteEight.isScoring).toBe(true);
});
it("marks Final Four as scoring (Semifinals)", () => {
const finalFour = NCAA_68.rounds[5];
expect(finalFour.name).toBe("Final Four");
expect(finalFour.isScoring).toBe(true);
});
it("marks Championship as scoring (Finals)", () => {
const championship = NCAA_68.rounds[6];
expect(championship.name).toBe("Championship");
expect(championship.isScoring).toBe(true);
});
it("has exactly 3 scoring rounds (Elite Eight, Final Four, Championship)", () => {
const scoringRounds = NCAA_68.rounds.filter((r) => r.isScoring);
expect(scoringRounds).toHaveLength(3);
expect(scoringRounds[0].name).toBe("Elite Eight");
expect(scoringRounds[1].name).toBe("Final Four");
expect(scoringRounds[2].name).toBe("Championship");
});
it("has exactly 4 non-scoring rounds (FF, R64, R32, S16)", () => {
const nonScoringRounds = NCAA_68.rounds.filter((r) => !r.isScoring);
expect(nonScoringRounds).toHaveLength(4);
});
});
describe("Scoring Round Types", () => {
it("identifies Elite Eight as quarterfinals", () => {
const type = getScoringRoundType("Elite Eight", NCAA_68);
expect(type).toBe("quarterfinals");
});
it("identifies Final Four as semifinals", () => {
const type = getScoringRoundType("Final Four", NCAA_68);
expect(type).toBe("semifinals");
});
it("identifies Championship as finals", () => {
const type = getScoringRoundType("Championship", NCAA_68);
expect(type).toBe("finals");
});
it("returns null for non-scoring rounds", () => {
expect(getScoringRoundType("First Four", NCAA_68)).toBeNull();
expect(getScoringRoundType("Round of 64", NCAA_68)).toBeNull();
expect(getScoringRoundType("Round of 32", NCAA_68)).toBeNull();
expect(getScoringRoundType("Sweet Sixteen", NCAA_68)).toBeNull();
});
});
describe("Tournament Math", () => {
it("has correct number of total matches", () => {
// 4 + 32 + 16 + 8 + 4 + 2 + 1 = 67 total matches
const totalMatches = NCAA_68.rounds.reduce((sum, round) => sum + round.matchCount, 0);
expect(totalMatches).toBe(67);
});
it("eliminates 60 teams before Elite Eight (4+32+16+8)", () => {
const nonScoringRounds = NCAA_68.rounds.filter((r) => !r.isScoring);
const teamsEliminatedBeforeEliteEight = nonScoringRounds.reduce(
(sum, round) => sum + round.matchCount,
0
);
expect(teamsEliminatedBeforeEliteEight).toBe(60); // 4+32+16+8
});
it("has 8 teams in Elite Eight (top 8 scoring)", () => {
const eliteEightRound = NCAA_68.rounds.find((r) => r.name === "Elite Eight");
const teamsInEliteEight = eliteEightRound!.matchCount * 2;
expect(teamsInEliteEight).toBe(8);
});
it("verifies only 8 teams score fantasy points (Q18)", () => {
// Elite Eight has 4 matches = 8 teams
// These are the ONLY teams that can score fantasy points
const scoringRounds = NCAA_68.rounds.filter((r) => r.isScoring);
const eliteEightMatches = scoringRounds[0].matchCount;
const scoringTeamsCount = eliteEightMatches * 2;
expect(scoringTeamsCount).toBe(8);
});
});
describe("Phase 2.8 Requirements", () => {
it("meets requirement: 68 total teams", () => {
expect(NCAA_68.totalTeams).toBe(68);
});
it("meets requirement: 4 First Four play-in games", () => {
const firstFour = NCAA_68.rounds[0];
expect(firstFour.matchCount).toBe(4);
});
it("meets requirement: Only Elite Eight and beyond score points", () => {
const scoringRoundNames = NCAA_68.rounds
.filter((r) => r.isScoring)
.map((r) => r.name);
expect(scoringRoundNames).toEqual(["Elite Eight", "Final Four", "Championship"]);
});
it("meets requirement: Early elimination = 0 points (per Q20)", () => {
const firstFour = NCAA_68.rounds[0];
const roundOf64 = NCAA_68.rounds[1];
const roundOf32 = NCAA_68.rounds[2];
const sweetSixteen = NCAA_68.rounds[3];
expect(firstFour.isScoring).toBe(false); // 0 points
expect(roundOf64.isScoring).toBe(false); // 0 points
expect(roundOf32.isScoring).toBe(false); // 0 points
expect(sweetSixteen.isScoring).toBe(false); // 0 points
});
it("verifies total participants: 60 direct + 8 First Four", () => {
const firstFourTeams = 4 * 2; // 4 games × 2 teams = 8 teams
const directSeeds = 68 - firstFourTeams; // 60 teams directly seeded
expect(directSeeds).toBe(60);
expect(firstFourTeams).toBe(8);
expect(directSeeds + firstFourTeams).toBe(68);
});
});
});

View file

@ -252,6 +252,87 @@ export async function advanceWinner(
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
}
/**
* NCAA 68 First Four seeding
* Returns matchups for the 4 play-in games
*
* First Four structure (using 0-indexed participant array):
* - Games 1-2: Participants 64-67 (16 seeds, lowest auto-qualifiers)
* - Winners are 16 seeds and face 1 seeds in Round of 64
* - Games 3-4: Participants 60-63 (11/12 seeds, last at-large bids)
* - Winners are 11 seeds and face 6 seeds in Round of 64
*/
function generateNCAA68FirstFourSeeding(): [number, number][] {
return [
[64, 65], // First Four Game 1: 16-seed play-in A (winner is 16 seed, faces 1 seed)
[66, 67], // First Four Game 2: 16-seed play-in B (winner is 16 seed, faces 1 seed)
[60, 61], // First Four Game 3: 11/12-seed play-in A (winner is 11 seed, faces 6 seed)
[62, 63], // First Four Game 4: 11/12-seed play-in B (winner is 11 seed, faces 6 seed)
];
}
/**
* NCAA 68 Round of 64 seeding
* Returns matchups for the Round of 64, with 4 TBD slots from First Four
*
* 68 teams total: 60 directly seeded (0-59) + 8 in First Four (60-67)
* Round of 64 needs 64 participants: 60 direct + 4 First Four winners
*
* Distribution: Each region has 15 direct seeds + 1 FF winner = 16 per region
* - Region 1: Seeds 0-14 + FF1 (FF1 winner is 16 seed, faces 1 seed)
* - Region 2: Seeds 15-29 + FF2 (FF2 winner is 16 seed, faces 1 seed)
* - Region 3: Seeds 30-44 + FF3 (FF3 winner is 11 seed, faces 6 seed)
* - Region 4: Seeds 45-59 + FF4 (FF4 winner is 11 seed, faces 6 seed)
*
* @returns Array of [seed1, seed2 or "FF{gameNum}"] pairs for 32 matches
*/
function generateNCAA68RoundOf64Seeding(): ([number, number] | [number, string] | [string, number])[] {
const matchups: ([number, number] | [number, string] | [string, number])[] = [];
// Region 1: Seeds 0-14 + FF1 (16 total)
// Standard bracket order: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
matchups.push([0, "FF1"]); // #1 vs #16 (FF1 winner)
matchups.push([7, 8]); // #8 vs #9
matchups.push([4, 11]); // #5 vs #12
matchups.push([3, 12]); // #4 vs #13
matchups.push([5, 10]); // #6 vs #11
matchups.push([2, 13]); // #3 vs #14
matchups.push([6, 9]); // #7 vs #10
matchups.push([1, 14]); // #2 vs #15
// Region 2: Seeds 15-29 + FF2 (16 total)
matchups.push([15, "FF2"]); // #1 vs #16 (FF2 winner)
matchups.push([22, 23]); // #8 vs #9
matchups.push([19, 26]); // #5 vs #12
matchups.push([18, 27]); // #4 vs #13
matchups.push([20, 25]); // #6 vs #11
matchups.push([17, 28]); // #3 vs #14
matchups.push([21, 24]); // #7 vs #10
matchups.push([16, 29]); // #2 vs #15
// Region 3: Seeds 30-44 + FF3 (16 total)
matchups.push([30, 44]); // #1 vs #16
matchups.push([37, 38]); // #8 vs #9
matchups.push([34, 41]); // #5 vs #12
matchups.push([33, 42]); // #4 vs #13
matchups.push([35, "FF3"]); // #6 vs #11 (FF3 winner = 11 seed)
matchups.push([32, 43]); // #3 vs #14
matchups.push([36, 39]); // #7 vs #10
matchups.push([31, 40]); // #2 vs #15
// Region 4: Seeds 45-59 + FF4 (16 total)
matchups.push([45, 59]); // #1 vs #16
matchups.push([52, 53]); // #8 vs #9
matchups.push([49, 56]); // #5 vs #12
matchups.push([48, 57]); // #4 vs #13
matchups.push([50, "FF4"]); // #6 vs #11 (FF4 winner = 11 seed)
matchups.push([47, 58]); // #3 vs #14
matchups.push([51, 54]); // #7 vs #10
matchups.push([46, 55]); // #2 vs #15
return matchups;
}
/**
* Generate standard tournament seeding matchups
* Returns pairs of seed indices for proper bracket balance
@ -264,8 +345,7 @@ export async function advanceWinner(
*/
function generateStandardSeeding(teamCount: number): [number, number][] {
if (![4, 8, 16, 32].includes(teamCount)) {
// For non-standard sizes (like NCAA 68, NFL 14), return sequential for now
// Those will need custom seeding logic in their respective phases
// 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]);
@ -325,6 +405,11 @@ export async function generateBracketFromTemplate(
);
}
// NCAA 68 requires special handling for First Four and Round of 64
if (templateId === "ncaa_68") {
return await generateNCAA68Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
@ -368,6 +453,91 @@ export async function generateBracketFromTemplate(
return await createManyPlayoffMatches(matches);
}
/**
* Generate NCAA 68 bracket with First Four and Round of 64
* Phase 2.8: Special handling for First Four play-in games
*/
async function generateNCAA68Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// First Four: 4 play-in games
const firstFourSeeding = generateNCAA68FirstFourSeeding();
for (let i = 0; i < firstFourSeeding.length; i++) {
const [seed1, seed2] = firstFourSeeding[i];
matches.push({
scoringEventId: eventId,
round: "First Four",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // First Four doesn't score fantasy points
templateRound: "First Four",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Round of 64: 32 games with some TBD slots from First Four
const roundOf64Seeding = generateNCAA68RoundOf64Seeding();
for (let i = 0; i < roundOf64Seeding.length; i++) {
const matchup = roundOf64Seeding[i];
let participant1Id: string | null = null;
let participant2Id: string | null = null;
let seedInfo: string | null = null;
if (participantIds) {
const [seed1, seed2] = matchup;
// Handle TBD slots (FF1-FF4 placeholders)
if (typeof seed1 === "number") {
participant1Id = participantIds[seed1];
} // else it's "FF1", "FF2", etc. - will be filled by First Four winner
if (typeof seed2 === "number") {
participant2Id = participantIds[seed2];
}
seedInfo = `${typeof seed1 === "number" ? seed1 + 1 : seed1} vs ${typeof seed2 === "number" ? seed2 + 1 : seed2}`;
}
matches.push({
scoringEventId: eventId,
round: "Round of 64",
matchNumber: i + 1,
participant1Id,
participant2Id,
isComplete: false,
isScoring: false, // Round of 64 doesn't score
templateRound: "Round of 64",
seedInfo,
});
}
// Generate remaining rounds (Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship)
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);
}
/**
* Advance winner to next round using template-based logic
* Works with any bracket template
@ -383,6 +553,12 @@ export async function advanceWinnerTemplate(
const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found");
// Special handling for NCAA 68 First Four
// Phase 2.8: First Four winners advance to specific Round of 64 slots
if (template.id === "ncaa_68" && match.round === "First Four") {
return await advanceFirstFourWinner(match, winnerId);
}
// Find current round in template
const currentRoundIndex = template.rounds.findIndex(
(r) => r.name === match.round
@ -431,3 +607,50 @@ export async function advanceWinnerTemplate(
// Fill the determined slot
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
}
/**
* Advance First Four winner to Round of 64
* Phase 2.8: NCAA 68 specific logic
*
* First Four match Round of 64 match mapping:
* - FF Match 1 (16-seed) R64 Match 1 (Region 1 vs #1 seed), participant2Id
* - FF Match 2 (16-seed) R64 Match 9 (Region 2 vs #1 seed), participant2Id
* - FF Match 3 (11-seed) R64 Match 21 (Region 3 vs #6 seed), participant2Id
* - FF Match 4 (11-seed) R64 Match 29 (Region 4 vs #6 seed), participant2Id
*/
async function advanceFirstFourWinner(
firstFourMatch: PlayoffMatch,
winnerId: string
): Promise<void> {
// Map First Four match numbers to Round of 64 match numbers
const firstFourToRoundOf64: Record<number, number> = {
1: 1, // FF Game 1 (16-seed) → R64 Match 1 (vs 1 seed)
2: 9, // FF Game 2 (16-seed) → R64 Match 9 (vs 1 seed)
3: 21, // FF Game 3 (11-seed) → R64 Match 21 (vs 6 seed)
4: 29, // FF Game 4 (11-seed) → R64 Match 29 (vs 6 seed)
};
const targetMatchNumber = firstFourToRoundOf64[firstFourMatch.matchNumber];
if (!targetMatchNumber) {
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
}
// Find the target Round of 64 match
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
firstFourMatch.scoringEventId,
"Round of 64"
);
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === targetMatchNumber);
if (!targetMatch) {
throw new Error(`Round of 64 match ${targetMatchNumber} not found`);
}
// Check if participant2Id is already filled
if (targetMatch.participant2Id) {
throw new Error(`Round of 64 match ${targetMatchNumber} participant2Id is already filled`);
}
// Fill the slot
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
}

View file

@ -1120,14 +1120,17 @@ scoring_events {
- [x] Verify Elite Eight scoring starts correctly
- [x] All 207 tests passing including new bracket integration tests
- [ ] **2.8** March Madness Template (68 teams)
- [ ] Create NCAA_68 template definition
- [ ] Build First Four matchup assignment UI (4 play-in games)
- [ ] Build Round of 64 with TBD slot handling (winners from First Four)
- [ ] Implement winner advancement from First Four to Round of 64
- [ ] Test complete 68-team bracket flow
- [ ] Verify only Elite Eight and beyond score points
- [ ] Test early elimination = 0 points (per Q20)
- [x] **2.8** March Madness Template (68 teams) - Logic Complete, UI Pending ✅
- [x] Create NCAA_68 template definition
- [x] Implement NCAA tournament seeding with First Four play-in logic
- [x] Build generateNCAA68Bracket() function with proper seeding
- [x] Implement TBD slot handling for Round of 64 (winners from First Four)
- [x] Implement advanceFirstFourWinner() for First Four → Round of 64 advancement
- [x] Create comprehensive unit tests (27 tests covering NCAA 68 structure and logic)
- [x] Verify only Elite Eight and beyond score points
- [x] Verify early elimination = 0 points (per Q20)
- [x] All 76 bracket tests passing
- [ ] Build First Four matchup assignment UI (admin interface) - **UI PENDING**
- [ ] **2.9** NFL/NBA Templates
- [ ] Create NFL_14 template (with bye weeks)