532 lines
14 KiB
Go
532 lines
14 KiB
Go
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
|
|
}
|