package db import ( "context" "fmt" "github.com/pkg/errors" "github.com/uptrace/bun" ) // GeneratePlayoffBracket creates a complete bracket structure from the leaderboard. // It creates the bracket, all series with advancement links, but no individual // matches (those are created when results are recorded). // roundFormats maps round names (e.g. "grand_final") to matches_to_win values // (1 = BO1, 2 = BO3, 3 = BO5). Rounds not in the map default to BO1. func GeneratePlayoffBracket( ctx context.Context, tx bun.Tx, seasonID, leagueID int, format PlayoffFormat, leaderboard []*LeaderboardEntry, roundFormats map[string]int, audit *AuditMeta, ) (*PlayoffBracket, error) { // Validate format and team count if err := validateFormatTeamCount(format, len(leaderboard)); err != nil { return nil, err } // Check no bracket already exists existing, err := GetPlayoffBracket(ctx, tx, seasonID, leagueID) if err != nil { return nil, errors.Wrap(err, "GetPlayoffBracket") } if existing != nil { return nil, BadRequest("playoff bracket already exists for this season and league") } // Create the bracket bracket, err := NewPlayoffBracket(ctx, tx, seasonID, leagueID, format, audit) if err != nil { return nil, errors.Wrap(err, "NewPlayoffBracket") } // Generate series based on format switch format { case PlayoffFormat5to6: err = generate5to6Bracket(ctx, tx, bracket, leaderboard, roundFormats) case PlayoffFormat7to9: err = generate7to9Bracket(ctx, tx, bracket, leaderboard, roundFormats) case PlayoffFormat10to15: err = generate10to15Bracket(ctx, tx, bracket, leaderboard, roundFormats) default: return nil, BadRequest(fmt.Sprintf("unknown playoff format: %s", format)) } if err != nil { return nil, errors.Wrap(err, "generateBracket") } return bracket, nil } func validateFormatTeamCount(format PlayoffFormat, teamCount int) error { switch format { case PlayoffFormat5to6: if teamCount < 5 { return BadRequest( fmt.Sprintf("5-6 team format requires at least 5 teams, got %d", teamCount)) } case PlayoffFormat7to9: if teamCount < 7 { return BadRequest( fmt.Sprintf("7-9 team format requires at least 7 teams, got %d", teamCount)) } case PlayoffFormat10to15: if teamCount < 10 { return BadRequest( fmt.Sprintf("10-15 team format requires at least 10 teams, got %d", teamCount)) } default: return BadRequest(fmt.Sprintf("unknown playoff format: %s", format)) } return nil } // intPtr is a helper to create a pointer to an int func intPtr(i int) *int { return &i } // strPtr is a helper to create a pointer to a string func strPtr(s string) *string { return &s } // getMatchesToWin looks up the matches_to_win value for a round from the config map. // Returns 1 (BO1) if the round is not in the map or the value is invalid. func getMatchesToWin(roundFormats map[string]int, round string) int { if roundFormats == nil { return 1 } if v, ok := roundFormats[round]; ok && v >= 1 && v <= 3 { return v } return 1 } // generate5to6Bracket creates: // // Round 1: // S1 (Upper Bracket): 2nd vs 3rd // S2 (Lower Bracket): 4th vs 5th // Round 2: // S3 (Upper Final): 1st vs Winner(S1) — second chance for both // S4 (Lower Final): Loser(S3) vs Winner(S2) // Round 3: // S5 (Grand Final): Winner(S3) vs Winner(S4) func generate5to6Bracket( ctx context.Context, tx bun.Tx, bracket *PlayoffBracket, leaderboard []*LeaderboardEntry, roundFormats map[string]int, ) error { seed1 := leaderboard[0] seed2 := leaderboard[1] seed3 := leaderboard[2] seed4 := leaderboard[3] seed5 := leaderboard[4] // S1: Upper Bracket - 2nd vs 3rd s1, err := NewPlayoffSeries(ctx, tx, bracket, 1, "upper_bracket", "Upper Bracket", &seed2.Team.ID, &seed3.Team.ID, intPtr(2), intPtr(3), getMatchesToWin(roundFormats, "upper_bracket"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S1") } // S2: Lower Bracket - 4th vs 5th (elimination) s2, err := NewPlayoffSeries(ctx, tx, bracket, 2, "lower_bracket", "Lower Bracket", &seed4.Team.ID, &seed5.Team.ID, intPtr(4), intPtr(5), getMatchesToWin(roundFormats, "lower_bracket"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S2") } // S3: Upper Final - 1st vs Winner(S1) s3, err := NewPlayoffSeries(ctx, tx, bracket, 3, "upper_final", "Upper Final", &seed1.Team.ID, nil, // team2 filled by S1 winner intPtr(1), nil, getMatchesToWin(roundFormats, "upper_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S3") } // S4: Lower Final - Loser(S3) vs Winner(S2) s4, err := NewPlayoffSeries(ctx, tx, bracket, 4, "lower_final", "Lower Final", nil, nil, // team1 = Loser(S3), team2 = Winner(S2) nil, nil, getMatchesToWin(roundFormats, "lower_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S4") } // S5: Grand Final - Winner(S3) vs Winner(S4) s5, err := NewPlayoffSeries(ctx, tx, bracket, 5, "grand_final", "Grand Final", nil, nil, nil, nil, getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S5") } // Wire up advancement // S1: Winner -> S3 (team2), no loser advancement (eliminated) err = SetSeriesAdvancement(ctx, tx, s1.ID, &s3.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire S1") } // S2: Winner -> S4 (team2), no loser advancement (eliminated) err = SetSeriesAdvancement(ctx, tx, s2.ID, &s4.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire S2") } // S3: Winner -> S5 (team1), Loser -> S4 (team1) — second chance err = SetSeriesAdvancement(ctx, tx, s3.ID, &s5.ID, strPtr("team1"), &s4.ID, strPtr("team1")) if err != nil { return errors.Wrap(err, "wire S3") } // S4: Winner -> S5 (team2), no loser advancement (eliminated) err = SetSeriesAdvancement(ctx, tx, s4.ID, &s5.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire S4") } // S5: Grand Final - no advancement _ = s5 return nil } // generate7to9Bracket creates: // // Quarter Finals: // S1 (QF1): 3rd vs 6th // S2 (QF2): 4th vs 5th // Semi Finals: // S3 (SF1): 1st vs Winner(S1) // S4 (SF2): 2nd vs Winner(S2) // Third Place Playoff: // S5: Loser(S3) vs Loser(S4) // Grand Final: // S6: Winner(S3) vs Winner(S4) func generate7to9Bracket( ctx context.Context, tx bun.Tx, bracket *PlayoffBracket, leaderboard []*LeaderboardEntry, roundFormats map[string]int, ) error { seed1 := leaderboard[0] seed2 := leaderboard[1] seed3 := leaderboard[2] seed4 := leaderboard[3] seed5 := leaderboard[4] seed6 := leaderboard[5] // S1: QF1 - 3rd vs 6th s1, err := NewPlayoffSeries(ctx, tx, bracket, 1, "quarter_final", "QF1", &seed3.Team.ID, &seed6.Team.ID, intPtr(3), intPtr(6), getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S1") } // S2: QF2 - 4th vs 5th s2, err := NewPlayoffSeries(ctx, tx, bracket, 2, "quarter_final", "QF2", &seed4.Team.ID, &seed5.Team.ID, intPtr(4), intPtr(5), getMatchesToWin(roundFormats, "quarter_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S2") } // S3: SF1 - 1st vs Winner(QF1) s3, err := NewPlayoffSeries(ctx, tx, bracket, 3, "semi_final", "SF1", &seed1.Team.ID, nil, intPtr(1), nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S3") } // S4: SF2 - 2nd vs Winner(QF2) s4, err := NewPlayoffSeries(ctx, tx, bracket, 4, "semi_final", "SF2", &seed2.Team.ID, nil, intPtr(2), nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S4") } // S5: Third Place Playoff - Loser(SF1) vs Loser(SF2) s5, err := NewPlayoffSeries(ctx, tx, bracket, 5, "third_place", "Third Place Playoff", nil, nil, nil, nil, getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S5") } // S6: Grand Final - Winner(SF1) vs Winner(SF2) s6, err := NewPlayoffSeries(ctx, tx, bracket, 6, "grand_final", "Grand Final", nil, nil, nil, nil, getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create S6") } // Wire up advancement // S1 QF1: Winner -> S3 SF1 (team2) err = SetSeriesAdvancement(ctx, tx, s1.ID, &s3.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire S1") } // S2 QF2: Winner -> S4 SF2 (team2) err = SetSeriesAdvancement(ctx, tx, s2.ID, &s4.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire S2") } // S3 SF1: Winner -> S6 GF (team1), Loser -> S5 3rd Place (team1) err = SetSeriesAdvancement(ctx, tx, s3.ID, &s6.ID, strPtr("team1"), &s5.ID, strPtr("team1")) if err != nil { return errors.Wrap(err, "wire S3") } // S4 SF2: Winner -> S6 GF (team2), Loser -> S5 3rd Place (team2) err = SetSeriesAdvancement(ctx, tx, s4.ID, &s6.ID, strPtr("team2"), &s5.ID, strPtr("team2")) if err != nil { return errors.Wrap(err, "wire S4") } // S5 and S6 are terminal — no advancement _ = s5 _ = s6 return nil } // generate10to15Bracket creates a finals bracket for 10-15 teams: // // Qualifying Finals (QF1-QF4): Top 4 get second chance // QF1: 1st vs 4th // QF2: 2nd vs 3rd // QF3: 5th vs 8th // QF4: 6th vs 7th // // Semi Finals: // SF1: Loser(QF1) vs Winner(QF4) — loser eliminated // SF2: Loser(QF2) vs Winner(QF3) — loser eliminated // // Preliminary Finals: // PF1: Winner(QF1) vs Winner(SF2) // PF2: Winner(QF2) vs Winner(SF1) // // Third Place Playoff: // 3rd: Loser(PF1) vs Loser(PF2) // // Grand Final: // GF: Winner(PF1) vs Winner(PF2) func generate10to15Bracket( ctx context.Context, tx bun.Tx, bracket *PlayoffBracket, leaderboard []*LeaderboardEntry, roundFormats map[string]int, ) error { seed1 := leaderboard[0] seed2 := leaderboard[1] seed3 := leaderboard[2] seed4 := leaderboard[3] seed5 := leaderboard[4] seed6 := leaderboard[5] seed7 := leaderboard[6] seed8 := leaderboard[7] // Qualifying Finals qf1, err := NewPlayoffSeries(ctx, tx, bracket, 1, "qualifying_final", "QF1", &seed1.Team.ID, &seed4.Team.ID, intPtr(1), intPtr(4), getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create QF1") } qf2, err := NewPlayoffSeries(ctx, tx, bracket, 2, "qualifying_final", "QF2", &seed2.Team.ID, &seed3.Team.ID, intPtr(2), intPtr(3), getMatchesToWin(roundFormats, "qualifying_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create QF2") } // Elimination Finals qf3, err := NewPlayoffSeries(ctx, tx, bracket, 3, "elimination_final", "EF1", &seed5.Team.ID, &seed8.Team.ID, intPtr(5), intPtr(8), getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create EF1") } qf4, err := NewPlayoffSeries(ctx, tx, bracket, 4, "elimination_final", "EF2", &seed6.Team.ID, &seed7.Team.ID, intPtr(6), intPtr(7), getMatchesToWin(roundFormats, "elimination_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create EF2") } // Semi Finals sf1, err := NewPlayoffSeries(ctx, tx, bracket, 5, "semi_final", "SF1", nil, nil, // Loser(QF1) vs Winner(EF2) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create SF1") } sf2, err := NewPlayoffSeries(ctx, tx, bracket, 6, "semi_final", "SF2", nil, nil, // Loser(QF2) vs Winner(EF1) nil, nil, getMatchesToWin(roundFormats, "semi_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create SF2") } // Preliminary Finals pf1, err := NewPlayoffSeries(ctx, tx, bracket, 7, "preliminary_final", "PF1", nil, nil, // Winner(QF1) vs Winner(SF2) nil, nil, getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create PF1") } pf2, err := NewPlayoffSeries(ctx, tx, bracket, 8, "preliminary_final", "PF2", nil, nil, // Winner(QF2) vs Winner(SF1) nil, nil, getMatchesToWin(roundFormats, "preliminary_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create PF2") } // Third Place Playoff - Loser(PF1) vs Loser(PF2) tp, err := NewPlayoffSeries(ctx, tx, bracket, 9, "third_place", "Third Place Playoff", nil, nil, nil, nil, getMatchesToWin(roundFormats, "third_place"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create 3rd Place") } // Grand Final gf, err := NewPlayoffSeries(ctx, tx, bracket, 10, "grand_final", "Grand Final", nil, nil, nil, nil, getMatchesToWin(roundFormats, "grand_final"), SeriesStatusPending) if err != nil { return errors.Wrap(err, "create GF") } // Wire up advancement // QF1: Winner -> PF1 (team1), Loser -> SF1 (team1) err = SetSeriesAdvancement(ctx, tx, qf1.ID, &pf1.ID, strPtr("team1"), &sf1.ID, strPtr("team1")) if err != nil { return errors.Wrap(err, "wire QF1") } // QF2: Winner -> PF2 (team1), Loser -> SF2 (team1) err = SetSeriesAdvancement(ctx, tx, qf2.ID, &pf2.ID, strPtr("team1"), &sf2.ID, strPtr("team1")) if err != nil { return errors.Wrap(err, "wire QF2") } // EF1 (QF3): Winner -> SF2 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf3.ID, &sf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF1") } // EF2 (QF4): Winner -> SF1 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, qf4.ID, &sf1.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire EF2") } // SF1: Winner -> PF2 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, sf1.ID, &pf2.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire SF1") } // SF2: Winner -> PF1 (team2), Loser eliminated err = SetSeriesAdvancement(ctx, tx, sf2.ID, &pf1.ID, strPtr("team2"), nil, nil) if err != nil { return errors.Wrap(err, "wire SF2") } // PF1: Winner -> GF (team1), Loser -> 3rd Place (team1) err = SetSeriesAdvancement(ctx, tx, pf1.ID, &gf.ID, strPtr("team1"), &tp.ID, strPtr("team1")) if err != nil { return errors.Wrap(err, "wire PF1") } // PF2: Winner -> GF (team2), Loser -> 3rd Place (team2) err = SetSeriesAdvancement(ctx, tx, pf2.ID, &gf.ID, strPtr("team2"), &tp.ID, strPtr("team2")) if err != nil { return errors.Wrap(err, "wire PF2") } // 3rd Place and Grand Final are terminal — no advancement _ = tp _ = gf return nil }