From 4d5b4efc232dc4bc5f6c7033bd97e95af9c3ae32 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 12 Nov 2025 23:21:22 -0800 Subject: [PATCH] feat: Implement AFL Finals bracket template and scoring logic with double-chance system --- app/lib/bracket-templates.ts | 73 +++++++ app/models/__tests__/afl-finals.test.ts | 220 +++++++++++++++++++++ app/models/playoff-match.ts | 251 ++++++++++++++++++++++++ app/models/scoring-calculator.ts | 32 ++- plans/scoring-system.md | 43 ++-- 5 files changed, 598 insertions(+), 21 deletions(-) create mode 100644 app/models/__tests__/afl-finals.test.ts diff --git a/app/lib/bracket-templates.ts b/app/lib/bracket-templates.ts index a6474af..222c368 100644 --- a/app/lib/bracket-templates.ts +++ b/app/lib/bracket-templates.ts @@ -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 = { 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 diff --git a/app/models/__tests__/afl-finals.test.ts b/app/models/__tests__/afl-finals.test.ts new file mode 100644 index 0000000..33d07bc --- /dev/null +++ b/app/models/__tests__/afl-finals.test.ts @@ -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); + }); + }); +}); diff --git a/app/models/playoff-match.ts b/app/models/playoff-match.ts index d001e18..409266c 100644 --- a/app/models/playoff-match.ts +++ b/app/models/playoff-match.ts @@ -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 { + 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 { + 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") { diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 6169e4b..cc4ffbd 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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) { diff --git a/plans/scoring-system.md b/plans/scoring-system.md index 9f35cf4..be8fd9b 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -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 ✅