Files
oslstats/internal/db/fixture_result.go
2026-03-06 21:37:02 +11:00

1034 lines
31 KiB
Go

package db
import (
"context"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
type FixtureResult struct {
bun.BaseModel `bun:"table:fixture_results,alias:fr"`
ID int `bun:"id,pk,autoincrement"`
FixtureID int `bun:",notnull,unique"`
Winner string `bun:",notnull"`
HomeScore int `bun:",notnull"`
AwayScore int `bun:",notnull"`
MatchType string
Arena string
EndReason string
PeriodsEnabled bool
CustomMercyRule int
MatchLength int
CreatedAt int64 `bun:",notnull"`
UpdatedAt *int64
UploadedByUserID int `bun:",notnull"`
Finalized bool `bun:",default:false"`
TamperingDetected bool `bun:",default:false"`
TamperingReason *string
// Forfeit-related fields
IsForfeit bool `bun:"is_forfeit,default:false"`
ForfeitType *string `bun:"forfeit_type"` // "mutual" or "outright"
ForfeitTeam *string `bun:"forfeit_team"` // "home" or "away" (nil for mutual)
ForfeitReason *string `bun:"forfeit_reason"` // User-provided reason
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_id=id"`
UploadedBy *User `bun:"rel:belongs-to,join:uploaded_by_user_id=id"`
PlayerStats []*FixtureResultPlayerStats `bun:"rel:has-many,join:id=fixture_result_id"`
}
type FixtureResultPlayerStats struct {
bun.BaseModel `bun:"table:fixture_result_player_stats,alias:frps"`
ID int `bun:"id,pk,autoincrement"`
FixtureResultID int `bun:",notnull"`
PeriodNum int `bun:",notnull"`
PlayerID *int // NULL for unmapped/free agents
PlayerGameUserID string `bun:",notnull"`
PlayerUsername string `bun:",notnull"`
TeamID *int // NULL for unmapped
Team string `bun:",notnull"` // 'home' or 'away'
// All stats as INT (nullable)
Goals *int
Assists *int
PrimaryAssists *int
SecondaryAssists *int
Saves *int
Blocks *int
Shots *int
Turnovers *int
Takeaways *int
Passes *int
PossessionTimeSec *int
FaceoffsWon *int
FaceoffsLost *int
PostHits *int
OvertimeGoals *int
GameWinningGoals *int
Score *int
ContributedGoals *int
ConcededGoals *int
GamesPlayed *int
Wins *int
Losses *int
OvertimeWins *int
OvertimeLosses *int
Ties *int
Shutouts *int
ShutoutsAgainst *int
HasMercyRuled *int
WasMercyRuled *int
PeriodsPlayed *int
IsFreeAgent bool `bun:"is_free_agent,default:false"`
FixtureResult *FixtureResult `bun:"rel:belongs-to,join:fixture_result_id=id"`
Player *Player `bun:"rel:belongs-to,join:player_id=id"`
TeamRel *Team `bun:"rel:belongs-to,join:team_id=id"`
}
// PlayerWithPlayStatus is a helper struct for overview display
type PlayerWithPlayStatus struct {
Player *Player
Played bool
IsManager bool
IsFreeAgent bool
Stats *FixtureResultPlayerStats // Period 3 (final cumulative) stats, nil if no result
}
// Forfeit type constants
const (
ForfeitTypeMutual = "mutual"
ForfeitTypeOutright = "outright"
)
// CreateForfeitResult creates a finalized forfeit result for a fixture.
// For outright forfeits, forfeitTeam specifies which team ("home" or "away") forfeited.
// For mutual forfeits, forfeitTeam should be empty.
func CreateForfeitResult(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
forfeitType string,
forfeitTeam string,
reason string,
userID int,
audit *AuditMeta,
) (*FixtureResult, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
// Validate forfeit type
if forfeitType != ForfeitTypeMutual && forfeitType != ForfeitTypeOutright {
return nil, BadRequest("invalid forfeit type: must be 'mutual' or 'outright'")
}
// Validate forfeit team for outright forfeits
if forfeitType == ForfeitTypeOutright {
if forfeitTeam != "home" && forfeitTeam != "away" {
return nil, BadRequest("outright forfeit requires a team: must be 'home' or 'away'")
}
}
// Determine winner and scores based on forfeit type
var winner string
var homeScore, awayScore int
var endReason string
var forfeitTeamPtr *string
switch forfeitType {
case ForfeitTypeMutual:
// Mutual forfeit: both teams get an OT loss, no winner
// Use "draw" as winner to signal mutual loss
winner = "draw"
homeScore = 0
awayScore = 0
endReason = "Forfeit"
case ForfeitTypeOutright:
// Outright forfeit: forfeiting team loses, opponent wins
forfeitTeamPtr = &forfeitTeam
if forfeitTeam == "home" {
winner = "away"
} else {
winner = "home"
}
homeScore = 0
awayScore = 0
endReason = "Forfeit"
}
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
result := &FixtureResult{
FixtureID: fixture.ID,
Winner: winner,
HomeScore: homeScore,
AwayScore: awayScore,
EndReason: endReason,
UploadedByUserID: userID,
Finalized: true, // Forfeits are immediately finalized
IsForfeit: true,
ForfeitType: &forfeitType,
ForfeitTeam: forfeitTeamPtr,
ForfeitReason: reasonPtr,
CreatedAt: time.Now().Unix(),
}
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
Action: "fixture_results.forfeit",
ResourceType: "fixture_result",
ResourceID: nil,
Details: map[string]any{
"fixture_id": fixture.ID,
"forfeit_type": forfeitType,
"forfeit_team": forfeitTeam,
"reason": reason,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert forfeit result")
}
return result, nil
}
// InsertFixtureResult stores a new match result with all player stats in a single transaction.
func InsertFixtureResult(
ctx context.Context,
tx bun.Tx,
result *FixtureResult,
playerStats []*FixtureResultPlayerStats,
audit *AuditMeta,
) (*FixtureResult, error) {
if result == nil {
return nil, errors.New("result cannot be nil")
}
result.CreatedAt = time.Now().Unix()
err := Insert(tx, result).WithAudit(audit, &AuditInfo{
Action: "fixture_results.create",
ResourceType: "fixture_result",
ResourceID: nil,
Details: map[string]any{
"fixture_id": result.FixtureID,
"winner": result.Winner,
"home_score": result.HomeScore,
"away_score": result.AwayScore,
"tampering_detected": result.TamperingDetected,
},
}).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "Insert result")
}
// Set the fixture_result_id on all player stats
for _, ps := range playerStats {
ps.FixtureResultID = result.ID
}
// Insert player stats in bulk
if len(playerStats) > 0 {
err = InsertMultiple(tx, playerStats).Exec(ctx)
if err != nil {
return nil, errors.Wrap(err, "InsertMultiple player stats")
}
}
return result, nil
}
// GetFixtureResult retrieves a result with all player stats for a fixture.
// Returns nil, nil if no result exists.
func GetFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
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 result, nil
}
// GetPendingFixtureResult retrieves a non-finalized result for review/edit.
// Returns nil, nil if no pending result exists.
func GetPendingFixtureResult(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureResult, error) {
result := new(FixtureResult)
err := tx.NewSelect().
Model(result).
Where("fr.fixture_id = ?", fixtureID).
Where("fr.finalized = false").
Relation("Fixture").
Relation("UploadedBy").
Relation("PlayerStats", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("frps.team ASC", "frps.period_num ASC", "frps.player_username ASC")
}).
Relation("PlayerStats.Player").
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 result, nil
}
// FinalizeFixtureResult marks a pending result as finalized.
func FinalizeFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to finalize")
}
now := time.Now().Unix()
result.Finalized = true
result.UpdatedAt = &now
err = UpdateByID(tx, result.ID, result).
Column("finalized", "updated_at").
WithAudit(audit, &AuditInfo{
Action: "fixture_results.finalize",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Exec(ctx)
if err != nil {
return errors.Wrap(err, "UpdateByID")
}
return nil
}
// DeleteFixtureResult removes a pending result and all associated player stats (CASCADE).
func DeleteFixtureResult(
ctx context.Context,
tx bun.Tx,
fixtureID int,
audit *AuditMeta,
) error {
result, err := GetPendingFixtureResult(ctx, tx, fixtureID)
if err != nil {
return errors.Wrap(err, "GetPendingFixtureResult")
}
if result == nil {
return BadRequest("no pending result to discard")
}
err = DeleteByID[FixtureResult](tx, result.ID).
WithAudit(audit, &AuditInfo{
Action: "fixture_results.discard",
ResourceType: "fixture_result",
ResourceID: result.ID,
Details: map[string]any{
"fixture_id": fixtureID,
},
}).Delete(ctx)
if err != nil {
return errors.Wrap(err, "DeleteByID")
}
return nil
}
// GetFinalizedResultsForFixtures returns finalized results for a list of fixture IDs.
// Returns a map of fixtureID -> *FixtureResult (without player stats for efficiency).
func GetFinalizedResultsForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureResult, error) {
if len(fixtureIDs) == 0 {
return map[int]*FixtureResult{}, nil
}
results, err := GetList[FixtureResult](tx).
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
Where("finalized = true").
GetAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "GetList")
}
resultMap := make(map[int]*FixtureResult, len(results))
for _, r := range results {
resultMap[r.FixtureID] = r
}
return resultMap, nil
}
// AggregatedPlayerStats holds summed stats for a player across multiple fixtures.
type AggregatedPlayerStats struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
GamesPlayed int `bun:"games_played"`
PeriodsPlayed int `bun:"total_periods_played"`
Score int `bun:"total_score"`
Goals int `bun:"total_goals"`
Assists int `bun:"total_assists"`
Saves int `bun:"total_saves"`
Shots int `bun:"total_shots"`
Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
}
// GetAggregatedPlayerStatsForTeam returns aggregated period-3 stats for all mapped
// players on a given team across all finalized fixture results.
func GetAggregatedPlayerStatsForTeam(
ctx context.Context,
tx bun.Tx,
teamID int,
fixtureIDs []int,
) ([]*AggregatedPlayerStats, error) {
if len(fixtureIDs) == 0 {
return nil, nil
}
var stats []*AggregatedPlayerStats
err := tx.NewRaw(`
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.score), 0) AS total_score,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.shots), 0) AS total_shots,
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
COALESCE(SUM(frps.passes), 0) AS total_passes
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND fr.fixture_id IN (?)
AND frps.team_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_score DESC
`, bun.In(fixtureIDs), teamID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeaguePlayerStats holds all aggregated stats for a player in a season-league.
type LeaguePlayerStats struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
GamesPlayed int `bun:"games_played"`
PeriodsPlayed int `bun:"total_periods_played"`
Goals int `bun:"total_goals"`
Assists int `bun:"total_assists"`
PrimaryAssists int `bun:"total_primary_assists"`
SecondaryAssists int `bun:"total_secondary_assists"`
Saves int `bun:"total_saves"`
Shots int `bun:"total_shots"`
Blocks int `bun:"total_blocks"`
Passes int `bun:"total_passes"`
Score int `bun:"total_score"`
}
// GetAllLeaguePlayerStats returns aggregated stats for all players in a season-league.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetAllLeaguePlayerStats(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeaguePlayerStats, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeaguePlayerStats
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.games_played,
agg.total_periods_played,
agg.total_goals,
agg.total_assists,
agg.total_primary_assists,
agg.total_secondary_assists,
agg.total_saves,
agg.total_shots,
agg.total_blocks,
agg.total_passes,
agg.total_score
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists,
COALESCE(SUM(frps.secondary_assists), 0) AS total_secondary_assists,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.shots), 0) AS total_shots,
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
COALESCE(SUM(frps.passes), 0) AS total_passes,
COALESCE(SUM(frps.score), 0) AS total_score
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_score DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopGoalScorer holds aggregated goal scoring stats for a player in a season-league.
type LeagueTopGoalScorer struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Goals int `bun:"total_goals"`
PeriodsPlayed int `bun:"total_periods_played"`
Shots int `bun:"total_shots"`
}
// GetTopGoalScorers returns the top goal scorers for a season-league,
// sorted by goals DESC, periods ASC, shots ASC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopGoalScorers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopGoalScorer, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopGoalScorer
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_goals,
agg.total_periods_played,
agg.total_shots
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.goals), 0) AS total_goals,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.shots), 0) AS total_shots
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_goals DESC, total_periods_played ASC, total_shots ASC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_goals DESC, agg.total_periods_played ASC, agg.total_shots ASC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopAssister holds aggregated assist stats for a player in a season-league.
type LeagueTopAssister struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Assists int `bun:"total_assists"`
PeriodsPlayed int `bun:"total_periods_played"`
PrimaryAssists int `bun:"total_primary_assists"`
}
// GetTopAssisters returns the top assisters for a season-league,
// sorted by assists DESC, periods ASC, primary assists DESC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopAssisters(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopAssister, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopAssister
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_assists,
agg.total_periods_played,
agg.total_primary_assists
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.assists), 0) AS total_assists,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.primary_assists), 0) AS total_primary_assists
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_assists DESC, total_periods_played ASC, total_primary_assists DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_assists DESC, agg.total_periods_played ASC, agg.total_primary_assists DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// LeagueTopSaver holds aggregated save stats for a player in a season-league.
type LeagueTopSaver struct {
PlayerID int `bun:"player_id"`
PlayerName string `bun:"player_name"`
TeamID int `bun:"team_id"`
TeamName string `bun:"team_name"`
TeamColor string `bun:"team_color"`
Saves int `bun:"total_saves"`
PeriodsPlayed int `bun:"total_periods_played"`
Blocks int `bun:"total_blocks"`
}
// GetTopSavers returns the top savers for a season-league,
// sorted by saves DESC, periods ASC, blocks DESC.
// Stats are combined across all teams a player may have played on,
// and the player's current roster team is shown.
func GetTopSavers(
ctx context.Context,
tx bun.Tx,
seasonID, leagueID int,
) ([]*LeagueTopSaver, error) {
if seasonID == 0 {
return nil, errors.New("seasonID not provided")
}
if leagueID == 0 {
return nil, errors.New("leagueID not provided")
}
var stats []*LeagueTopSaver
err := tx.NewRaw(`
SELECT
agg.player_id,
agg.player_name,
COALESCE(tr.team_id, 0) AS team_id,
COALESCE(t.name, '') AS team_name,
COALESCE(t.color, '') AS team_color,
agg.total_saves,
agg.total_periods_played,
agg.total_blocks
FROM (
SELECT
frps.player_id AS player_id,
COALESCE(p.name, frps.player_username) AS player_name,
COALESCE(SUM(frps.saves), 0) AS total_saves,
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
COALESCE(SUM(frps.blocks), 0) AS total_blocks
FROM fixture_result_player_stats frps
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
JOIN fixtures f ON f.id = fr.fixture_id
LEFT JOIN players p ON p.id = frps.player_id
WHERE fr.finalized = true
AND f.season_id = ?
AND f.league_id = ?
AND frps.period_num = 3
AND frps.player_id IS NOT NULL
GROUP BY frps.player_id, COALESCE(p.name, frps.player_username)
ORDER BY total_saves DESC, total_periods_played ASC, total_blocks DESC
LIMIT 10
) agg
LEFT JOIN team_rosters tr
ON tr.player_id = agg.player_id
AND tr.season_id = ?
AND tr.league_id = ?
LEFT JOIN teams t ON t.id = tr.team_id
ORDER BY agg.total_saves DESC, agg.total_periods_played ASC, agg.total_blocks DESC
`, seasonID, leagueID, seasonID, leagueID).Scan(ctx, &stats)
if err != nil {
return nil, errors.Wrap(err, "tx.NewRaw")
}
return stats, nil
}
// TeamRecord holds win/loss/draw record and goal totals for a team.
type TeamRecord struct {
Played int
Wins int
OvertimeWins int
OvertimeLosses int
Losses int
Draws int
GoalsFor int
GoalsAgainst int
Points int
}
// Point values for the leaderboard scoring system.
const (
PointsWin = 3
PointsOvertimeWin = 2
PointsOvertimeLoss = 1
PointsLoss = 0
)
// ComputeTeamRecord calculates W-OTW-OTL-L, GF/GA, and points from fixtures and results.
// Points: Win=3, OT Win=2, OT Loss=1, Loss=0.
// Forfeits: Outright = Win(3)/Loss(0), Mutual = OT Loss(1) for both teams.
func ComputeTeamRecord(teamID int, fixtures []*Fixture, resultMap map[int]*FixtureResult) *TeamRecord {
rec := &TeamRecord{}
for _, f := range fixtures {
res, ok := resultMap[f.ID]
if !ok {
continue
}
rec.Played++
isHome := f.HomeTeamID == teamID
// Handle forfeits separately
if res.IsForfeit {
// Forfeits have 0-0 score, no goal impact
if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeMutual {
// Mutual forfeit: both teams get OT loss (1 point)
rec.OvertimeLosses++
rec.Points += PointsOvertimeLoss
} else if res.ForfeitType != nil && *res.ForfeitType == ForfeitTypeOutright {
// Outright forfeit: check if this team forfeited
thisSide := "away"
if isHome {
thisSide = "home"
}
if res.ForfeitTeam != nil && *res.ForfeitTeam == thisSide {
// This team forfeited - loss
rec.Losses++
rec.Points += PointsLoss
} else {
// Opponent forfeited - win
rec.Wins++
rec.Points += PointsWin
}
}
continue
}
// Normal match handling
if isHome {
rec.GoalsFor += res.HomeScore
rec.GoalsAgainst += res.AwayScore
} else {
rec.GoalsFor += res.AwayScore
rec.GoalsAgainst += res.HomeScore
}
won := (isHome && res.Winner == "home") || (!isHome && res.Winner == "away")
lost := (isHome && res.Winner == "away") || (!isHome && res.Winner == "home")
isOT := strings.EqualFold(res.EndReason, "Overtime")
switch {
case won && isOT:
rec.OvertimeWins++
rec.Points += PointsOvertimeWin
case won:
rec.Wins++
rec.Points += PointsWin
case lost && isOT:
rec.OvertimeLosses++
rec.Points += PointsOvertimeLoss
case lost:
rec.Losses++
rec.Points += PointsLoss
default:
rec.Draws++
}
}
return rec
}
// LeaderboardEntry represents a single team's standing in the league table.
type LeaderboardEntry struct {
Position int
Team *Team
Record *TeamRecord
}
// ComputeLeaderboard builds a sorted leaderboard from teams, fixtures, and results.
// Teams are sorted by: Points DESC, Goal Differential DESC, Goals For DESC, Name ASC.
func ComputeLeaderboard(teams []*Team, fixtures []*Fixture, resultMap map[int]*FixtureResult) []*LeaderboardEntry {
entries := make([]*LeaderboardEntry, 0, len(teams))
// Build a map of team ID -> fixtures involving that team
teamFixtures := make(map[int][]*Fixture)
for _, f := range fixtures {
teamFixtures[f.HomeTeamID] = append(teamFixtures[f.HomeTeamID], f)
teamFixtures[f.AwayTeamID] = append(teamFixtures[f.AwayTeamID], f)
}
for _, team := range teams {
record := ComputeTeamRecord(team.ID, teamFixtures[team.ID], resultMap)
entries = append(entries, &LeaderboardEntry{
Team: team,
Record: record,
})
}
// Sort: Points DESC, then goal diff DESC, then GF DESC, then name ASC
sort.Slice(entries, func(i, j int) bool {
ri, rj := entries[i].Record, entries[j].Record
if ri.Points != rj.Points {
return ri.Points > rj.Points
}
diffI := ri.GoalsFor - ri.GoalsAgainst
diffJ := rj.GoalsFor - rj.GoalsAgainst
if diffI != diffJ {
return diffI > diffJ
}
if ri.GoalsFor != rj.GoalsFor {
return ri.GoalsFor > rj.GoalsFor
}
return entries[i].Team.Name < entries[j].Team.Name
})
// Assign positions
for i := range entries {
entries[i].Position = i + 1
}
return entries
}
// GetFixtureTeamRosters returns all team players with participation status for a fixture.
// Returns: map["home"|"away"] -> []*PlayerWithPlayStatus
func GetFixtureTeamRosters(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
result *FixtureResult,
) (map[string][]*PlayerWithPlayStatus, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
rosters := map[string][]*PlayerWithPlayStatus{}
// Get home team roster
homeRosters := []*TeamRoster{}
err := tx.NewSelect().
Model(&homeRosters).
Where("tr.team_id = ?", fixture.HomeTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.LeagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect home roster")
}
// Get away team roster
awayRosters := []*TeamRoster{}
err = tx.NewSelect().
Model(&awayRosters).
Where("tr.team_id = ?", fixture.AwayTeamID).
Where("tr.season_id = ?", fixture.SeasonID).
Where("tr.league_id = ?", fixture.LeagueID).
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Relation("User")
}).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect away roster")
}
// Build maps of player IDs that played and their period 3 stats
playedPlayerIDs := map[int]bool{}
playerStatsByID := map[int]*FixtureResultPlayerStats{}
freeAgentPlayerIDs := map[int]bool{}
// Track free agents by team side for roster inclusion
freeAgentsByTeam := map[string]map[int]*FixtureResultPlayerStats{} // "home"/"away" -> playerID -> stats
freeAgentsByTeam["home"] = map[int]*FixtureResultPlayerStats{}
freeAgentsByTeam["away"] = map[int]*FixtureResultPlayerStats{}
if result != nil {
for _, ps := range result.PlayerStats {
if ps.PlayerID != nil {
playedPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
playerStatsByID[*ps.PlayerID] = ps
}
if ps.IsFreeAgent {
freeAgentPlayerIDs[*ps.PlayerID] = true
if ps.PeriodNum == 3 {
freeAgentsByTeam[ps.Team][*ps.PlayerID] = ps
}
}
}
}
}
// Build a set of roster player IDs so we can skip them when adding free agents
rosterPlayerIDs := map[int]bool{}
for _, r := range homeRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
for _, r := range awayRosters {
if r.Player != nil {
rosterPlayerIDs[r.Player.ID] = true
}
}
// Build home roster with play status and stats
for _, r := range homeRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["home"] = append(rosters["home"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Build away roster with play status and stats
for _, r := range awayRosters {
played := false
var stats *FixtureResultPlayerStats
if result != nil && r.Player != nil {
played = playedPlayerIDs[r.Player.ID]
stats = playerStatsByID[r.Player.ID]
}
rosters["away"] = append(rosters["away"], &PlayerWithPlayStatus{
Player: r.Player,
Played: played,
IsManager: r.IsManager,
Stats: stats,
})
}
// Add free agents who played but are not on the team roster
for team, faStats := range freeAgentsByTeam {
for playerID, stats := range faStats {
if rosterPlayerIDs[playerID] {
continue // Already on the roster, skip
}
if stats.Player == nil {
// Try to load the player
player, err := GetPlayer(ctx, tx, playerID)
if err != nil {
continue // Skip if we can't load
}
stats.Player = player
}
rosters[team] = append(rosters[team], &PlayerWithPlayStatus{
Player: stats.Player,
Played: true,
IsManager: false,
IsFreeAgent: true,
Stats: stats,
})
}
}
return rosters, nil
}