Files
oslstats/internal/db/playoff.go
2026-03-15 17:43:39 +11:00

327 lines
11 KiB
Go

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
}