finals generation added
This commit is contained in:
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
98
internal/db/migrations/20260308140000_add_playoffs.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
// UP migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
// Create playoff_brackets table
|
||||
_, err := conn.NewCreateTable().
|
||||
Model((*db.PlayoffBracket)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create playoff_series table
|
||||
_, err = conn.NewCreateTable().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create playoff_matches table
|
||||
_, err = conn.NewCreateTable().
|
||||
Model((*db.PlayoffMatch)(nil)).
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key for winner_next_series_id
|
||||
_, err = conn.NewRaw(`
|
||||
ALTER TABLE playoff_series
|
||||
ADD CONSTRAINT fk_winner_next_series
|
||||
FOREIGN KEY (winner_next_series_id) REFERENCES playoff_series(id)
|
||||
ON DELETE SET NULL
|
||||
`).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key for loser_next_series_id
|
||||
_, err = conn.NewRaw(`
|
||||
ALTER TABLE playoff_series
|
||||
ADD CONSTRAINT fk_loser_next_series
|
||||
FOREIGN KEY (loser_next_series_id) REFERENCES playoff_series(id)
|
||||
ON DELETE SET NULL
|
||||
`).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
// Drop tables in reverse order (respecting foreign keys)
|
||||
_, err := conn.NewDropTable().
|
||||
Model((*db.PlayoffMatch)(nil)).
|
||||
IfExists().
|
||||
Cascade().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropTable().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
IfExists().
|
||||
Cascade().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropTable().
|
||||
Model((*db.PlayoffBracket)(nil)).
|
||||
IfExists().
|
||||
Cascade().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
326
internal/db/playoff.go
Normal file
326
internal/db/playoff.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// PlayoffFormat represents the bracket format based on team count
|
||||
type PlayoffFormat string
|
||||
|
||||
const (
|
||||
// PlayoffFormat5to6 is for 5-6 teams: top 5 qualify, double-elimination style
|
||||
PlayoffFormat5to6 PlayoffFormat = "5-6-teams"
|
||||
// PlayoffFormat7to9 is for 7-9 teams: top 6 qualify, seeded bracket
|
||||
PlayoffFormat7to9 PlayoffFormat = "7-9-teams"
|
||||
// PlayoffFormat10to15 is for 10-15 teams: top 8 qualify
|
||||
PlayoffFormat10to15 PlayoffFormat = "10-15-teams"
|
||||
)
|
||||
|
||||
// PlayoffStatus represents the current state of a playoff bracket
|
||||
type PlayoffStatus string
|
||||
|
||||
const (
|
||||
PlayoffStatusUpcoming PlayoffStatus = "upcoming"
|
||||
PlayoffStatusInProgress PlayoffStatus = "in_progress"
|
||||
PlayoffStatusCompleted PlayoffStatus = "completed"
|
||||
)
|
||||
|
||||
// SeriesStatus represents the current state of a playoff series
|
||||
type SeriesStatus string
|
||||
|
||||
const (
|
||||
SeriesStatusPending SeriesStatus = "pending"
|
||||
SeriesStatusInProgress SeriesStatus = "in_progress"
|
||||
SeriesStatusCompleted SeriesStatus = "completed"
|
||||
SeriesStatusBye SeriesStatus = "bye"
|
||||
)
|
||||
|
||||
// PlayoffBracket is the top-level container for a league's playoff bracket
|
||||
type PlayoffBracket struct {
|
||||
bun.BaseModel `bun:"table:playoff_brackets,alias:pb"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
SeasonID int `bun:",notnull,unique:season_league"`
|
||||
LeagueID int `bun:",notnull,unique:season_league"`
|
||||
Format PlayoffFormat `bun:",notnull"`
|
||||
Status PlayoffStatus `bun:",notnull,default:'upcoming'"`
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
UpdatedAt *int64 `bun:"updated_at"`
|
||||
|
||||
Season *Season `bun:"rel:belongs-to,join:season_id=id"`
|
||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||
Series []*PlayoffSeries `bun:"rel:has-many,join:id=bracket_id"`
|
||||
}
|
||||
|
||||
// PlayoffSeries represents a single matchup (potentially best-of-N) in the bracket
|
||||
type PlayoffSeries struct {
|
||||
bun.BaseModel `bun:"table:playoff_series,alias:ps"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
BracketID int `bun:",notnull"`
|
||||
SeriesNumber int `bun:",notnull"` // Display order within bracket
|
||||
Round string `bun:",notnull"` // e.g. "qualifying_final", "semi_final", "grand_final"
|
||||
Label string `bun:",notnull"` // Human-readable label e.g. "QF1", "SF2", "Grand Final"
|
||||
Team1ID *int `bun:"team1_id"`
|
||||
Team2ID *int `bun:"team2_id"`
|
||||
Team1Seed *int `bun:"team1_seed"` // Original seeding position (1st, 2nd, etc.)
|
||||
Team2Seed *int `bun:"team2_seed"` // Original seeding position
|
||||
WinnerTeamID *int `bun:"winner_team_id"` // Set when series is decided
|
||||
LoserTeamID *int `bun:"loser_team_id"` // Set when series is decided
|
||||
MatchesToWin int `bun:",notnull,default:1"` // 1 = single match, 2 = Bo3, 3 = Bo5, etc.
|
||||
Team1Wins int `bun:",notnull,default:0"` // Matches won by team1
|
||||
Team2Wins int `bun:",notnull,default:0"` // Matches won by team2
|
||||
Status SeriesStatus `bun:",notnull,default:'pending'"` // pending, in_progress, completed, bye
|
||||
WinnerNextID *int `bun:"winner_next_series_id"` // Series the winner advances to
|
||||
WinnerNextSlot *string `bun:"winner_next_slot"` // "team1" or "team2" in next series
|
||||
LoserNextID *int `bun:"loser_next_series_id"` // Series the loser drops to (double-elim)
|
||||
LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
|
||||
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
|
||||
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
|
||||
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
|
||||
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
|
||||
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
|
||||
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
|
||||
}
|
||||
|
||||
// PlayoffMatch represents a single game within a series
|
||||
type PlayoffMatch struct {
|
||||
bun.BaseModel `bun:"table:playoff_matches,alias:pm"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
SeriesID int `bun:",notnull"`
|
||||
MatchNumber int `bun:",notnull"` // 1-indexed: game 1, game 2, etc.
|
||||
HomeTeamID *int `bun:"home_team_id"`
|
||||
AwayTeamID *int `bun:"away_team_id"`
|
||||
FixtureID *int `bun:"fixture_id"` // Links to existing fixture system
|
||||
Status string `bun:",notnull,default:'pending'"`
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
|
||||
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
||||
Home *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
||||
Away *Team `bun:"rel:belongs-to,join:away_team_id=id"`
|
||||
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
|
||||
}
|
||||
|
||||
// NewPlayoffBracket creates a new playoff bracket for a season+league
|
||||
func NewPlayoffBracket(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seasonID, leagueID int,
|
||||
format PlayoffFormat,
|
||||
audit *AuditMeta,
|
||||
) (*PlayoffBracket, error) {
|
||||
bracket := &PlayoffBracket{
|
||||
SeasonID: seasonID,
|
||||
LeagueID: leagueID,
|
||||
Format: format,
|
||||
Status: PlayoffStatusUpcoming,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err := Insert(tx, bracket).WithAudit(audit, &AuditInfo{
|
||||
Action: "playoffs.create_bracket",
|
||||
ResourceType: "playoff_bracket",
|
||||
ResourceID: nil,
|
||||
Details: map[string]any{
|
||||
"season_id": seasonID,
|
||||
"league_id": leagueID,
|
||||
"format": string(format),
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert")
|
||||
}
|
||||
return bracket, nil
|
||||
}
|
||||
|
||||
// GetPlayoffBracket retrieves a playoff bracket for a season+league with all series and teams
|
||||
func GetPlayoffBracket(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seasonID, leagueID int,
|
||||
) (*PlayoffBracket, error) {
|
||||
bracket := new(PlayoffBracket)
|
||||
err := tx.NewSelect().
|
||||
Model(bracket).
|
||||
Where("pb.season_id = ?", seasonID).
|
||||
Where("pb.league_id = ?", leagueID).
|
||||
Relation("Season").
|
||||
Relation("League").
|
||||
Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("ps.series_number ASC")
|
||||
}).
|
||||
Relation("Series.Team1").
|
||||
Relation("Series.Team2").
|
||||
Relation("Series.Winner").
|
||||
Relation("Series.Loser").
|
||||
Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("pm.match_number ASC")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return bracket, nil
|
||||
}
|
||||
|
||||
// GetPlayoffBracketByID retrieves a playoff bracket by ID with all series
|
||||
func GetPlayoffBracketByID(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
bracketID int,
|
||||
) (*PlayoffBracket, error) {
|
||||
return GetByID[PlayoffBracket](tx, bracketID).
|
||||
Relation("Season").
|
||||
Relation("League").
|
||||
Relation("Series", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("ps.series_number ASC")
|
||||
}).
|
||||
Relation("Series.Team1").
|
||||
Relation("Series.Team2").
|
||||
Relation("Series.Winner").
|
||||
Relation("Series.Loser").
|
||||
Relation("Series.Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("pm.match_number ASC")
|
||||
}).
|
||||
Get(ctx)
|
||||
}
|
||||
|
||||
// NewPlayoffSeries creates a new series within a bracket
|
||||
func NewPlayoffSeries(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
bracket *PlayoffBracket,
|
||||
seriesNumber int,
|
||||
round, label string,
|
||||
team1ID, team2ID *int,
|
||||
team1Seed, team2Seed *int,
|
||||
matchesToWin int,
|
||||
status SeriesStatus,
|
||||
) (*PlayoffSeries, error) {
|
||||
series := &PlayoffSeries{
|
||||
BracketID: bracket.ID,
|
||||
SeriesNumber: seriesNumber,
|
||||
Round: round,
|
||||
Label: label,
|
||||
Team1ID: team1ID,
|
||||
Team2ID: team2ID,
|
||||
Team1Seed: team1Seed,
|
||||
Team2Seed: team2Seed,
|
||||
MatchesToWin: matchesToWin,
|
||||
Status: status,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err := Insert(tx, series).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert")
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// SetSeriesAdvancement sets the advancement links for a series
|
||||
func SetSeriesAdvancement(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID int,
|
||||
winnerNextID *int,
|
||||
winnerNextSlot *string,
|
||||
loserNextID *int,
|
||||
loserNextSlot *string,
|
||||
) error {
|
||||
_, err := tx.NewUpdate().
|
||||
Model((*PlayoffSeries)(nil)).
|
||||
Set("winner_next_series_id = ?", winnerNextID).
|
||||
Set("winner_next_slot = ?", winnerNextSlot).
|
||||
Set("loser_next_series_id = ?", loserNextID).
|
||||
Set("loser_next_slot = ?", loserNextSlot).
|
||||
Where("id = ?", seriesID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "tx.NewUpdate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountUnplayedFixtures counts fixtures without finalized results for a season+league
|
||||
func CountUnplayedFixtures(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seasonID, leagueID int,
|
||||
) (int, error) {
|
||||
count, err := tx.NewSelect().
|
||||
Model((*Fixture)(nil)).
|
||||
Where("f.season_id = ?", seasonID).
|
||||
Where("f.league_id = ?", leagueID).
|
||||
Where("f.game_week IS NOT NULL").
|
||||
Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)").
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "tx.NewSelect.Count")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetUnplayedFixtures returns all fixtures without finalized results for a season+league
|
||||
func GetUnplayedFixtures(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seasonID, leagueID int,
|
||||
) ([]*Fixture, error) {
|
||||
fixtures, err := GetList[Fixture](tx).
|
||||
Where("f.season_id = ?", seasonID).
|
||||
Where("f.league_id = ?", leagueID).
|
||||
Where("f.game_week IS NOT NULL").
|
||||
Where("NOT EXISTS (SELECT 1 FROM fixture_results fr WHERE fr.fixture_id = f.id AND fr.finalized = true)").
|
||||
Order("f.game_week ASC", "f.round ASC", "f.id ASC").
|
||||
Relation("HomeTeam").
|
||||
Relation("AwayTeam").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
return fixtures, nil
|
||||
}
|
||||
|
||||
// AutoForfeitUnplayedFixtures creates mutual forfeit results for all unplayed fixtures
|
||||
func AutoForfeitUnplayedFixtures(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seasonID, leagueID int,
|
||||
userID int,
|
||||
audit *AuditMeta,
|
||||
) (int, error) {
|
||||
unplayed, err := GetUnplayedFixtures(ctx, tx, seasonID, leagueID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "GetUnplayedFixtures")
|
||||
}
|
||||
|
||||
reason := "Auto-forfeited: regular season ended for finals"
|
||||
for _, fixture := range unplayed {
|
||||
// Check if a result already exists (non-finalized)
|
||||
existing, err := GetFixtureResult(ctx, tx, fixture.ID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "GetFixtureResult")
|
||||
}
|
||||
if existing != nil {
|
||||
// Skip fixtures that already have any result
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = CreateForfeitResult(ctx, tx, fixture,
|
||||
ForfeitTypeMutual, "", reason, userID, audit)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "CreateForfeitResult")
|
||||
}
|
||||
}
|
||||
|
||||
return len(unplayed), nil
|
||||
}
|
||||
531
internal/db/playoff_generation.go
Normal file
531
internal/db/playoff_generation.go
Normal file
@@ -0,0 +1,531 @@
|
||||
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
|
||||
}
|
||||
@@ -38,6 +38,9 @@ func (db *DB) RegisterModels() []any {
|
||||
(*Player)(nil),
|
||||
(*FixtureResult)(nil),
|
||||
(*FixtureResultPlayerStats)(nil),
|
||||
(*PlayoffBracket)(nil),
|
||||
(*PlayoffSeries)(nil),
|
||||
(*PlayoffMatch)(nil),
|
||||
}
|
||||
db.RegisterModel(models...)
|
||||
return models
|
||||
|
||||
Reference in New Issue
Block a user