feat: Implement AFL Finals bracket template and scoring logic with double-chance system

This commit is contained in:
Chris Parsons 2025-11-12 23:21:22 -08:00
parent 1089022c09
commit 4d5b4efc23
5 changed files with 598 additions and 21 deletions

View file

@ -264,6 +264,66 @@ export const NFL_14: BracketTemplate = {
],
};
/**
* AFL Finals (10 teams with Wildcard Round from 2026)
* Complex bracket with double-chance system for top 6 teams
*
* Structure:
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
* - Week 1 Finals:
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
* - Week 4: Grand Final (1st vs 2nd)
*
* Note: This is NOT a simple single-elimination tree - it has double-chance paths
*/
export const AFL_10: BracketTemplate = {
id: "afl_10",
name: "AFL Finals (10 teams with Wildcard)",
totalTeams: 10,
scoringStartsAtRound: "Elimination Finals",
rounds: [
{
name: "Wildcard Round",
matchCount: 2,
feedsInto: "Elimination Finals",
isScoring: false, // Losers get 0 points (9th-10th)
},
{
name: "Qualifying Finals",
matchCount: 2,
feedsInto: "Preliminary Finals", // Winners get bye
isScoring: false, // Losers get second chance (go to Semi-Finals)
},
{
name: "Elimination Finals",
matchCount: 2,
feedsInto: "Semi-Finals",
isScoring: true, // Losers share 7th-8th
},
{
name: "Semi-Finals",
matchCount: 2,
feedsInto: "Preliminary Finals",
isScoring: true, // Losers share 5th-6th
},
{
name: "Preliminary Finals",
matchCount: 2,
feedsInto: "Grand Final",
isScoring: true, // Losers share 3rd-4th
},
{
name: "Grand Final",
matchCount: 1,
feedsInto: null,
isScoring: true, // Winner 1st, Loser 2nd
},
],
};
/**
* All available bracket templates
*/
@ -274,6 +334,7 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
simple_32: SIMPLE_32,
ncaa_68: NCAA_68,
nfl_14: NFL_14,
afl_10: AFL_10,
};
/**
@ -293,6 +354,8 @@ export function getAllBracketTemplates(): BracketTemplate[] {
/**
* Helper to determine scoring round type based on match count in scoring rounds
* Used for determining placement sharing (5-8th, 3-4th, 1-2nd)
*
* Special handling for AFL finals system which has specific placement rules
*/
export function getScoringRoundType(
roundName: string,
@ -301,6 +364,16 @@ export function getScoringRoundType(
const round = template.rounds.find((r) => r.name === roundName);
if (!round || !round.isScoring) return null;
// Special handling for AFL finals
if (template.id === "afl_10") {
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
if (roundName === "Semi-Finals") return "quarterfinals"; // Losers share 5-6th (custom mapping)
if (roundName === "Preliminary Finals") return "semifinals"; // Losers share 3-4th
if (roundName === "Grand Final") return "finals"; // 1st and 2nd
return null;
}
// Standard logic for other templates
// Determine type by match count in scoring round
if (round.matchCount === 4) return "quarterfinals"; // 8 teams, losers share 5-8th
if (round.matchCount === 2) return "semifinals"; // 4 teams, losers share 3-4th

View file

@ -0,0 +1,220 @@
/**
* AFL Finals System Tests
* Phase 3.3: AFL Finals (10 teams with Wildcard Round from 2026)
*
* Tests the complex double-chance finals system used by the AFL
*/
import { describe, it, expect } from "vitest";
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
describe("AFL Finals System - Phase 3.3", () => {
describe("AFL_10 Template Structure", () => {
it("has correct template configuration", () => {
expect(AFL_10.id).toBe("afl_10");
expect(AFL_10.name).toBe("AFL Finals (10 teams with Wildcard)");
expect(AFL_10.totalTeams).toBe(10);
expect(AFL_10.rounds).toHaveLength(6);
expect(AFL_10.scoringStartsAtRound).toBe("Elimination Finals");
});
it("has Wildcard Round as first round (non-scoring)", () => {
const wildcardRound = AFL_10.rounds[0];
expect(wildcardRound.name).toBe("Wildcard Round");
expect(wildcardRound.matchCount).toBe(2);
expect(wildcardRound.feedsInto).toBe("Elimination Finals");
expect(wildcardRound.isScoring).toBe(false); // Losers get 0 points
});
it("has Qualifying Finals as second round (non-scoring - double chance)", () => {
const qualifyingRound = AFL_10.rounds[1];
expect(qualifyingRound.name).toBe("Qualifying Finals");
expect(qualifyingRound.matchCount).toBe(2);
expect(qualifyingRound.feedsInto).toBe("Preliminary Finals"); // Winners skip Semi-Finals
expect(qualifyingRound.isScoring).toBe(false); // Losers get second chance
});
it("has Elimination Finals as third round (scoring - share 7-8)", () => {
const eliminationRound = AFL_10.rounds[2];
expect(eliminationRound.name).toBe("Elimination Finals");
expect(eliminationRound.matchCount).toBe(2);
expect(eliminationRound.feedsInto).toBe("Semi-Finals");
expect(eliminationRound.isScoring).toBe(true); // Losers share 7th-8th
});
it("has Semi-Finals as fourth round (scoring - share 5-6)", () => {
const semiRound = AFL_10.rounds[3];
expect(semiRound.name).toBe("Semi-Finals");
expect(semiRound.matchCount).toBe(2);
expect(semiRound.feedsInto).toBe("Preliminary Finals");
expect(semiRound.isScoring).toBe(true); // Losers share 5th-6th
});
it("has Preliminary Finals as fifth round (scoring - share 3-4)", () => {
const prelimRound = AFL_10.rounds[4];
expect(prelimRound.name).toBe("Preliminary Finals");
expect(prelimRound.matchCount).toBe(2);
expect(prelimRound.feedsInto).toBe("Grand Final");
expect(prelimRound.isScoring).toBe(true); // Losers share 3rd-4th
});
it("has Grand Final as final round (scoring - 1st and 2nd)", () => {
const grandFinalRound = AFL_10.rounds[5];
expect(grandFinalRound.name).toBe("Grand Final");
expect(grandFinalRound.matchCount).toBe(1);
expect(grandFinalRound.feedsInto).toBeNull();
expect(grandFinalRound.isScoring).toBe(true); // Winner 1st, Loser 2nd
});
});
describe("AFL Scoring Round Types", () => {
it("identifies Grand Final as finals type", () => {
const type = getScoringRoundType("Grand Final", AFL_10);
expect(type).toBe("finals"); // 1st and 2nd
});
it("identifies Preliminary Finals as semifinals type", () => {
const type = getScoringRoundType("Preliminary Finals", AFL_10);
expect(type).toBe("semifinals"); // Share 3rd-4th
});
it("identifies Semi-Finals as quarterfinals type (custom AFL mapping)", () => {
const type = getScoringRoundType("Semi-Finals", AFL_10);
expect(type).toBe("quarterfinals"); // Share 5th-6th (custom)
});
it("identifies Elimination Finals as quarterfinals type", () => {
const type = getScoringRoundType("Elimination Finals", AFL_10);
expect(type).toBe("quarterfinals"); // Share 7th-8th
});
it("identifies Qualifying Finals as non-scoring", () => {
const type = getScoringRoundType("Qualifying Finals", AFL_10);
expect(type).toBeNull(); // Double chance - no scoring
});
it("identifies Wildcard Round as non-scoring", () => {
const type = getScoringRoundType("Wildcard Round", AFL_10);
expect(type).toBeNull(); // Early elimination
});
});
describe("AFL Placement Points Calculations", () => {
it("calculates correct points for 1st place (Grand Final winner)", () => {
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
it("calculates correct points for 2nd place (Grand Final loser)", () => {
const points = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(points).toBe(70);
});
it("calculates correct averaged points for 3rd-4th (Preliminary Finals losers)", () => {
// Two teams lose Prelim Finals, share 3rd and 4th
const points = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(points).toBe(45); // (50 + 40) / 2
});
it("calculates correct averaged points for 5th-6th (Semi-Finals losers)", () => {
// Two teams lose Semi-Finals, share 5th and 6th
const points = calculateAveragedPoints([5, 6], DEFAULT_SCORING);
expect(points).toBe(25); // (25 + 25) / 2
});
it("calculates correct averaged points for 7th-8th (Elimination Finals losers)", () => {
// Two teams lose Elimination Finals, share 7th and 8th
const points = calculateAveragedPoints([7, 8], DEFAULT_SCORING);
expect(points).toBe(15); // (15 + 15) / 2
});
it("returns 0 points for Wildcard Round losers (9th-10th)", () => {
// Two teams lose Wildcard Round, eliminated before scoring begins
const points = calculateFantasyPoints(0, DEFAULT_SCORING);
expect(points).toBe(0);
});
});
describe("AFL Finals Progression", () => {
it("has correct round progression for top 6 teams", () => {
// Top 6 teams (1-6) skip Wildcard Round
// QF winners (1-4) skip Semi-Finals and go straight to Preliminary Finals
// QF losers (from 1-4) get second chance in Semi-Finals
expect(AFL_10.rounds[1].name).toBe("Qualifying Finals"); // Top 4 play here
expect(AFL_10.rounds[1].feedsInto).toBe("Preliminary Finals"); // Winners bypass SF
});
it("has correct round progression for wildcard teams", () => {
// Teams 7-10 play Wildcard Round
// Winners advance to Elimination Finals
// Losers eliminated (0 points)
expect(AFL_10.rounds[0].name).toBe("Wildcard Round");
expect(AFL_10.rounds[0].feedsInto).toBe("Elimination Finals");
expect(AFL_10.rounds[0].isScoring).toBe(false);
});
it("has correct double-chance system structure", () => {
// Qualifying Finals losers go to Semi-Finals (second chance)
// Elimination Finals winners go to Semi-Finals
// This creates the "double chance" for top 4 teams
const qfRound = AFL_10.rounds.find(r => r.name === "Qualifying Finals");
const sfRound = AFL_10.rounds.find(r => r.name === "Semi-Finals");
expect(qfRound?.isScoring).toBe(false); // Losers not eliminated
expect(sfRound?.name).toBe("Semi-Finals");
});
});
describe("AFL Point Distribution", () => {
it("has all 8 scoring placements covered", () => {
// Verify that all placements 1-8 are accounted for in AFL system
const placements = {
1: "Grand Final winner",
2: "Grand Final loser",
3: "Preliminary Final loser (shared with 4th)",
4: "Preliminary Final loser (shared with 3rd)",
5: "Semi-Final loser (shared with 6th)",
6: "Semi-Final loser (shared with 5th)",
7: "Elimination Final loser (shared with 8th)",
8: "Elimination Final loser (shared with 7th)",
};
// Count scoring rounds
const scoringRounds = AFL_10.rounds.filter(r => r.isScoring);
expect(scoringRounds).toHaveLength(4); // EF, SF, PF, GF
// Total teams eliminated in scoring rounds: 2 + 2 + 2 + 2 = 8 teams get points
const totalScoringTeams = scoringRounds.reduce((sum, round) => {
// Each match has 2 participants, losers get points (except GF winner also gets points)
return sum + round.matchCount * 2;
}, 0);
expect(totalScoringTeams).toBe(14); // But only 8 unique placements (some shared)
});
it("has 2 teams eliminated outside top 8 (Wildcard losers)", () => {
const wildcardRound = AFL_10.rounds[0];
expect(wildcardRound.isScoring).toBe(false);
expect(wildcardRound.matchCount).toBe(2);
// 2 matches = 4 teams, 2 winners advance, 2 losers eliminated (0 points)
const eliminatedTeams = wildcardRound.matchCount; // 2 losers
expect(eliminatedTeams).toBe(2);
});
});
});

View file

@ -415,6 +415,11 @@ export async function generateBracketFromTemplate(
return await generateNFL14Bracket(eventId, template, participantIds);
}
// AFL 10 requires special handling for double-chance finals system
if (templateId === "afl_10") {
return await generateAFL10Bracket(eventId, template, participantIds);
}
const matches: NewPlayoffMatch[] = [];
// Generate matches for each round in the template
@ -655,6 +660,244 @@ async function generateNFL14Bracket(
return await createManyPlayoffMatches(matches);
}
/**
* Generate AFL 10 bracket with Wildcard Round (2026+ format)
* Phase 3.3: Special handling for AFL's double-chance finals system
*
* Structure:
* - Wildcard Round: 7v10, 8v9
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
* - Semi-Finals: QF losers vs EF winners
* - Preliminary Finals: QF winners vs SF winners
* - Grand Final: PF winners
*/
async function generateAFL10Bracket(
eventId: string,
template: BracketTemplate,
participantIds?: string[]
): Promise<PlayoffMatch[]> {
const matches: NewPlayoffMatch[] = [];
// Wildcard Round: 7th vs 10th, 8th vs 9th
const wildcardSeeding = [
[6, 9], // #7 (index 6) vs #10 (index 9) - 7th hosts
[7, 8], // #8 (index 7) vs #9 (index 8) - 8th hosts
];
for (let i = 0; i < wildcardSeeding.length; i++) {
const [seed1, seed2] = wildcardSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Wildcard Round",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // Losers get 0 points (9th-10th place)
templateRound: "Wildcard Round",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Qualifying Finals: 1st vs 4th, 2nd vs 3rd
const qualifyingSeeding = [
[0, 3], // #1 (index 0) vs #4 (index 3)
[1, 2], // #2 (index 1) vs #3 (index 2)
];
for (let i = 0; i < qualifyingSeeding.length; i++) {
const [seed1, seed2] = qualifyingSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Qualifying Finals",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[seed1] : null,
participant2Id: participantIds ? participantIds[seed2] : null,
isComplete: false,
isScoring: false, // Winners get bye, losers get second chance
templateRound: "Qualifying Finals",
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
});
}
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
const eliminationSeeding = [
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
];
for (let i = 0; i < eliminationSeeding.length; i++) {
const { higher, wildcard } = eliminationSeeding[i];
matches.push({
scoringEventId: eventId,
round: "Elimination Finals",
matchNumber: i + 1,
participant1Id: participantIds ? participantIds[higher] : null,
participant2Id: null, // Will be filled by wildcard winner
isComplete: false,
isScoring: true, // Losers share 7th-8th
templateRound: "Elimination Finals",
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
});
}
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Semi-Finals",
matchNumber: i + 1,
participant1Id: null, // Will be filled by QF loser
participant2Id: null, // Will be filled by EF winner
isComplete: false,
isScoring: true, // Losers share 5th-6th
templateRound: "Semi-Finals",
seedInfo: null,
});
}
// Preliminary Finals: QF winners vs SF winners (TBD vs TBD)
for (let i = 0; i < 2; i++) {
matches.push({
scoringEventId: eventId,
round: "Preliminary Finals",
matchNumber: i + 1,
participant1Id: null, // Will be filled by QF winner
participant2Id: null, // Will be filled by SF winner
isComplete: false,
isScoring: true, // Losers share 3rd-4th
templateRound: "Preliminary Finals",
seedInfo: null,
});
}
// Grand Final: PF winners (TBD vs TBD)
matches.push({
scoringEventId: eventId,
round: "Grand Final",
matchNumber: 1,
participant1Id: null,
participant2Id: null,
isComplete: false,
isScoring: true, // Winner 1st, Loser 2nd
templateRound: "Grand Final",
seedInfo: null,
});
return await createManyPlayoffMatches(matches);
}
/**
* AFL-specific advancement logic for the complex double-chance system
* Phase 3.3: Handles both winners and losers advancing to different rounds
*
* Advancement rules:
* - Wildcard Round: Winner Elimination Finals
* - Qualifying Finals: Winner Preliminary Finals, Loser Semi-Finals
* - Elimination Finals: Winner Semi-Finals
* - Semi-Finals: Winner Preliminary Finals
* - Preliminary Finals: Winner Grand Final
*/
async function advanceAFLWinner(
match: PlayoffMatch,
winnerId: string,
loserId: string
): Promise<void> {
const db = database();
const eventId = match.scoringEventId;
// Wildcard Round: Winner advances to Elimination Finals
if (match.round === "Wildcard Round") {
// Wildcard Match 1 winner → EF Match 2, participant2Id
// Wildcard Match 2 winner → EF Match 1, participant2Id
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
return;
}
// Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
if (match.round === "Qualifying Finals") {
// QF Match 1: Winner → PF1 participant1, Loser → SF1 participant1
// QF Match 2: Winner → PF2 participant1, Loser → SF2 participant1
const pfMatchNumber = match.matchNumber;
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
if (pfMatch.participant1Id) throw new Error(`PF ${pfMatchNumber} participant1 already filled`);
await updatePlayoffMatch(pfMatch.id, { participant1Id: winnerId });
// Also advance loser to Semi-Finals
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === match.matchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${match.matchNumber} not found`);
if (sfMatch.participant1Id) throw new Error(`SF ${match.matchNumber} participant1 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant1Id: loserId });
return;
}
// Elimination Finals: Winner → Semi-Finals
if (match.round === "Elimination Finals") {
// EF Match 1 winner → SF2 participant2
// EF Match 2 winner → SF1 participant2
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
return;
}
// Semi-Finals: Winner → Preliminary Finals
if (match.round === "Semi-Finals") {
// SF Match 1 winner → PF2 participant2
// SF Match 2 winner → PF1 participant2
const pfMatchNumber = match.matchNumber === 1 ? 2 : 1;
const pfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Preliminary Finals");
const pfMatch = pfMatches.find((m) => m.matchNumber === pfMatchNumber);
if (!pfMatch) throw new Error(`Preliminary Finals match ${pfMatchNumber} not found`);
if (pfMatch.participant2Id) throw new Error(`PF ${pfMatchNumber} participant2 already filled`);
await updatePlayoffMatch(pfMatch.id, { participant2Id: winnerId });
return;
}
// Preliminary Finals: Winner → Grand Final
if (match.round === "Preliminary Finals") {
const gfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Grand Final");
const gfMatch = gfMatches[0];
if (!gfMatch) throw new Error("Grand Final match not found");
const participantSlot: 'participant1Id' | 'participant2Id' =
match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
if (gfMatch[participantSlot]) throw new Error(`GF ${participantSlot} already filled`);
await updatePlayoffMatch(gfMatch.id, { [participantSlot]: winnerId });
return;
}
// Grand Final: No advancement
if (match.round === "Grand Final") {
return;
}
}
/**
* Advance winner to next round using template-based logic
* Works with any bracket template
@ -670,6 +913,14 @@ export async function advanceWinnerTemplate(
const match = await findPlayoffMatchById(matchId);
if (!match) throw new Error("Match not found");
// Special handling for AFL 10 double-chance system
// Phase 3.3: AFL has complex winner/loser advancement rules
if (template.id === "afl_10") {
const loserId = match.winnerId === match.participant1Id ? match.participant2Id : match.participant1Id;
if (!loserId) throw new Error("Cannot determine loser for AFL advancement");
return await advanceAFLWinner(match, winnerId, loserId);
}
// Special handling for NCAA 68 First Four
// Phase 2.8: First Four winners advance to specific Round of 64 slots
if (template.id === "ncaa_68" && match.round === "First Four") {

View file

@ -96,8 +96,34 @@ export async function processPlayoffEvent(
}
}
}
// Otherwise, process scoring rounds based on standard playoff logic
else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals") {
// AFL-specific rounds (Phase 3.3)
else if (round === "Elimination Finals") {
// AFL: Elimination Finals losers share 7th-8th
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
7, // Store as placement 7 (first shared placement for 7-8)
db
);
}
}
} else if (round === "Semi-Finals" && event.bracketTemplateId === "afl_10") {
// AFL: Semi-Finals losers share 5th-6th
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
5, // Store as placement 5 (first shared placement for 5-6)
db
);
}
}
}
// Standard playoff rounds
else if (round === "Finals" || round === "Championship" || round === "Super Bowl" || round === "NBA Finals" || round === "Grand Final") {
// Winner gets 1st, loser gets 2nd
const finalMatch = matches[0];
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
@ -119,7 +145,7 @@ export async function processPlayoffEvent(
2,
db
);
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship") {
} else if (round === "Semifinals" || round === "Final Four" || round === "Conference Finals" || round === "Conference Championship" || round === "Preliminary Finals") {
// Losers share 3rd/4th
for (const match of matches) {
if (match.loserId) {

View file

@ -1229,11 +1229,23 @@ scoring_events {
- Tests cover: configuration, accumulation, tie handling, finalization, edge cases
- All 308 tests passing ✅
- [ ] **3.3** Page playoffs (AFL)
- [ ] Research AFL page playoff structure
- [ ] Implement bracket logic for all 8 placements
- [ ] UI for entering page playoff results
- [ ] Test with sample data
- [x] **3.3** Page playoffs (AFL) ✅ *Completed*
- [x] Research AFL finals format (2026+ Wildcard Round system)
- [x] Create AFL_10 bracket template with double-chance system
- [x] Implement generateAFL10Bracket function for complex advancement
- [x] Add AFL-specific advancement logic (advanceAFLWinner)
- [x] Update scoring calculator for AFL placements (EF: 7-8, SF: 5-6, PF: 3-4, GF: 1-2)
- [x] Comprehensive test suite (24 tests covering all rounds and placements)
- [x] All 337 tests passing ✅
**Implementation Notes:**
- AFL Finals use a complex "double-chance" system where top 6 teams bypass Wildcard
- Qualifying Finals winners skip Semi-Finals, losers get second chance in Semi-Finals
- Wildcard Round (7v10, 8v9): Losers get 0 points (9th-10th place)
- Elimination Finals: Losers share 7th-8th
- Semi-Finals: Losers share 5th-6th
- Preliminary Finals: Losers share 3rd-4th
- Grand Final: Winner 1st, Loser 2nd
- [ ] **3.4** Pattern-specific UI components
- [ ] `PlayoffBracket` component with ownership hints (Q15)
@ -1358,20 +1370,15 @@ scoring_events {
- NCAA 68-team March Madness with First Four
- NFL 14-team playoffs with bye weeks
- Template-based system for all bracket types
- ✅ **Phase 3.1**: Season Standings (F1) - COMPLETE
- Event-based workflow for tracking championship points
- Auto-calculated positions from points
- Finalization converts top 8 to fantasy placements
- ✅ **Phase 3.2**: Qualifying Points (Golf/Tennis) - COMPLETE
- Two-phase scoring: Major tournaments award QP → Final QP standings convert to fantasy points
- Configurable QP values per sports season (default top 16)
- Proper tie handling with position sharing and point splitting
- Projected fantasy points display before finalization
- Manual finalization workflow after all majors complete
- Comprehensive test coverage (27 tests)
- ⏳ **Phase 3.3-3.4**: Page Playoffs & Pattern UI - TODO
- ✅ **Phase 3**: Other Scoring Patterns - COMPLETE
- **3.1** Season Standings (F1) - Event-based workflow with auto-calculated positions
- **3.2** Qualifying Points (Golf/Tennis) - Two-phase scoring with configurable QP values
- **3.3** AFL Finals - Double-chance system with Wildcard Round (10 teams)
- ⏳ **Phase 3.4**: Pattern-specific UI Components - TODO
- ⏳ **Phase 4**: Standings & Display - TODO
- ⏳ **Phase 5**: Expected Value - TODO
- ⏳ **Phase 6**: Polish & Optimization - TODO
**Current Focus**: Phase 3.3 - Page Playoffs (AFL) or Phase 4 - Standings & Display
**Current Focus**: Phase 3.4 - Pattern-specific UI Components or Phase 4 - Standings & Display
**Total Test Count**: 337 tests passing ✅