feat: Implement AFL Finals bracket template and scoring logic with double-chance system
This commit is contained in:
parent
1089022c09
commit
4d5b4efc23
5 changed files with 598 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
220
app/models/__tests__/afl-finals.test.ts
Normal file
220
app/models/__tests__/afl-finals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ✅
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue