Files
oslstats/internal/db/match_preview.go

255 lines
7.3 KiB
Go

package db
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
// Game outcome type constants.
const (
OutcomeWin = "W"
OutcomeLoss = "L"
OutcomeOTWin = "OTW"
OutcomeOTLoss = "OTL"
OutcomeDraw = "D"
OutcomeForfeit = "F"
)
// GameOutcome represents the result of a single game from a team's perspective.
type GameOutcome struct {
Type string // One of Outcome* constants: "W", "L", "OTW", "OTL", "D", "F"
Opponent *Team // The opposing team (may be nil if relation not loaded)
Score string // e.g. "3-1" or "" for forfeits
IsForfeit bool // Whether this game was decided by forfeit
Fixture *Fixture // The fixture itself
}
// MatchPreviewData holds all computed data needed for the match preview tab.
type MatchPreviewData struct {
HomeRecord *TeamRecord
AwayRecord *TeamRecord
HomePosition int
AwayPosition int
TotalTeams int
HomeRecentGames []*GameOutcome
AwayRecentGames []*GameOutcome
}
// ComputeRecentGames calculates the last N game outcomes for a given team.
// Fixtures should be all allocated fixtures for the season+league.
// Results should be finalized results mapped by fixture ID.
// Schedules should be accepted schedules mapped by fixture ID (for ordering by scheduled time).
// The returned outcomes are in chronological order (oldest first, newest last).
func ComputeRecentGames(
teamID int,
fixtures []*Fixture,
resultMap map[int]*FixtureResult,
scheduleMap map[int]*FixtureSchedule,
limit int,
) []*GameOutcome {
// Collect fixtures involving this team that have finalized results
type fixtureWithTime struct {
fixture *Fixture
result *FixtureResult
time time.Time
}
var played []fixtureWithTime
for _, f := range fixtures {
if f.HomeTeamID != teamID && f.AwayTeamID != teamID {
continue
}
res, ok := resultMap[f.ID]
if !ok {
continue
}
// Use schedule time for ordering, fall back to result creation time
t := time.Unix(res.CreatedAt, 0)
if scheduleMap != nil {
if sched, ok := scheduleMap[f.ID]; ok && sched.ScheduledTime != nil {
t = *sched.ScheduledTime
}
}
played = append(played, fixtureWithTime{fixture: f, result: res, time: t})
}
// Sort by time descending (most recent first)
sort.Slice(played, func(i, j int) bool {
return played[i].time.After(played[j].time)
})
// Take only the most recent N
if len(played) > limit {
played = played[:limit]
}
// Reverse to chronological order (oldest first)
for i, j := 0, len(played)-1; i < j; i, j = i+1, j-1 {
played[i], played[j] = played[j], played[i]
}
// Build outcome list
outcomes := make([]*GameOutcome, len(played))
for i, p := range played {
outcomes[i] = buildGameOutcome(teamID, p.fixture, p.result)
}
return outcomes
}
// buildGameOutcome determines the outcome type for a single game from a team's perspective.
// Note: fixtures must have their HomeTeam and AwayTeam relations loaded.
func buildGameOutcome(teamID int, fixture *Fixture, result *FixtureResult) *GameOutcome {
isHome := fixture.HomeTeamID == teamID
var opponent *Team
if isHome {
opponent = fixture.AwayTeam // may be nil if relation not loaded
} else {
opponent = fixture.HomeTeam // may be nil if relation not loaded
}
outcome := &GameOutcome{
Opponent: opponent,
Fixture: fixture,
}
// Handle forfeits
if result.IsForfeit {
outcome.IsForfeit = true
outcome.Type = OutcomeForfeit
if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeMutual {
outcome.Type = OutcomeOTLoss // mutual forfeit counts as OT loss for both
} else if result.ForfeitType != nil && *result.ForfeitType == ForfeitTypeOutright {
thisSide := "away"
if isHome {
thisSide = "home"
}
if result.ForfeitTeam != nil && *result.ForfeitTeam == thisSide {
outcome.Type = OutcomeLoss // this team forfeited
} else {
outcome.Type = OutcomeWin // opponent forfeited
}
}
return outcome
}
// Normal match - build score string from this team's perspective
if isHome {
outcome.Score = fmt.Sprintf("%d-%d", result.HomeScore, result.AwayScore)
} else {
outcome.Score = fmt.Sprintf("%d-%d", result.AwayScore, result.HomeScore)
}
won := (isHome && result.Winner == "home") || (!isHome && result.Winner == "away")
lost := (isHome && result.Winner == "away") || (!isHome && result.Winner == "home")
isOT := strings.EqualFold(result.EndReason, "Overtime")
switch {
case won && isOT:
outcome.Type = OutcomeOTWin
case won:
outcome.Type = OutcomeWin
case lost && isOT:
outcome.Type = OutcomeOTLoss
case lost:
outcome.Type = OutcomeLoss
default:
outcome.Type = OutcomeDraw
}
return outcome
}
// GetTeamsForSeasonLeague returns all teams participating in a given season+league.
func GetTeamsForSeasonLeague(ctx context.Context, tx bun.Tx, seasonID, leagueID int) ([]*Team, error) {
var teams []*Team
err := tx.NewSelect().
Model(&teams).
Join("INNER JOIN team_participations AS tp ON tp.team_id = t.id").
Where("tp.season_id = ? AND tp.league_id = ?", seasonID, leagueID).
Scan(ctx)
if err != nil {
return nil, errors.Wrap(err, "tx.NewSelect")
}
return teams, nil
}
// ComputeMatchPreview fetches all data needed for the match preview tab:
// team standings, positions, and recent game outcomes for both teams.
func ComputeMatchPreview(
ctx context.Context,
tx bun.Tx,
fixture *Fixture,
) (*MatchPreviewData, error) {
if fixture == nil {
return nil, errors.New("fixture cannot be nil")
}
// Get all teams in this season+league
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
}
// Get all allocated fixtures for the season+league
allFixtures, err := GetAllocatedFixtures(ctx, tx, fixture.SeasonID, fixture.LeagueID)
if err != nil {
return nil, errors.Wrap(err, "GetAllocatedFixtures")
}
// Get finalized results
allFixtureIDs := make([]int, len(allFixtures))
for i, f := range allFixtures {
allFixtureIDs[i] = f.ID
}
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
if err != nil {
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
}
// Get accepted schedules for ordering recent games
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
if err != nil {
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
}
// Compute leaderboard
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
// Extract positions and records for both teams
preview := &MatchPreviewData{
TotalTeams: len(leaderboard),
}
for _, entry := range leaderboard {
if entry.Team.ID == fixture.HomeTeamID {
preview.HomePosition = entry.Position
preview.HomeRecord = entry.Record
}
if entry.Team.ID == fixture.AwayTeamID {
preview.AwayPosition = entry.Position
preview.AwayRecord = entry.Record
}
}
if preview.HomeRecord == nil {
preview.HomeRecord = &TeamRecord{}
}
if preview.AwayRecord == nil {
preview.AwayRecord = &TeamRecord{}
}
// Compute recent games (last 5) for each team
preview.HomeRecentGames = ComputeRecentGames(
fixture.HomeTeamID, allFixtures, allResultMap, allScheduleMap, 5,
)
preview.AwayRecentGames = ComputeRecentGames(
fixture.AwayTeamID, allFixtures, allResultMap, allScheduleMap, 5,
)
return preview, nil
}