added log file uploading and match results
This commit is contained in:
@@ -18,7 +18,8 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// FixtureDetailPage renders the fixture detail page with scheduling UI and history
|
||||
// FixtureDetailPage renders the fixture detail page with scheduling UI, history,
|
||||
// result display, and team rosters
|
||||
func FixtureDetailPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
@@ -30,11 +31,18 @@ func FixtureDetailPage(
|
||||
return
|
||||
}
|
||||
|
||||
activeTab := r.URL.Query().Get("tab")
|
||||
if activeTab == "" {
|
||||
activeTab = "overview"
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
var currentSchedule *db.FixtureSchedule
|
||||
var history []*db.FixtureSchedule
|
||||
var canSchedule bool
|
||||
var userTeamID int
|
||||
var result *db.FixtureResult
|
||||
var rosters map[string][]*db.PlayerWithPlayStatus
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
@@ -59,6 +67,16 @@ func FixtureDetailPage(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "fixture.CanSchedule")
|
||||
}
|
||||
// Fetch fixture result if it exists
|
||||
result, err = db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
// Fetch team rosters with play status
|
||||
rosters, err = db.GetFixtureTeamRosters(ctx, tx, fixture, result)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureTeamRosters")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
@@ -66,6 +84,7 @@ func FixtureDetailPage(
|
||||
|
||||
renderSafely(seasonsview.FixtureDetailPage(
|
||||
fixture, currentSchedule, history, canSchedule, userTeamID,
|
||||
result, rosters, activeTab,
|
||||
), s, r, w)
|
||||
})
|
||||
}
|
||||
@@ -88,7 +107,8 @@ func ProposeSchedule(
|
||||
}
|
||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||
scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value
|
||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
return
|
||||
@@ -319,7 +339,8 @@ func RescheduleFixture(
|
||||
}
|
||||
format := timefmt.NewBuilder().Year4().Dash().MonthNumeric2().Dash().
|
||||
DayNumeric2().T().Hour24().Colon().Minute().Build()
|
||||
scheduledTime := getter.Time("scheduled_time", format).After(time.Now()).Value
|
||||
aest, _ := time.LoadLocation("Australia/Sydney")
|
||||
scheduledTime := getter.TimeInLocation("scheduled_time", format, aest).After(time.Now()).Value
|
||||
reason := getter.String("reschedule_reason").TrimSpace().Required().Value
|
||||
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
|
||||
415
internal/handlers/fixture_result.go
Normal file
415
internal/handlers/fixture_result.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 << 20 // 10 MB
|
||||
|
||||
// UploadMatchLogsPage renders the upload form for match log files
|
||||
func UploadMatchLogsPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
// Check if result already exists
|
||||
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
if existing != nil {
|
||||
throw.BadRequest(s, w, r, "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.FixtureUploadResultPage(fixture), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// UploadMatchLogs handles POST /fixtures/{fixture_id}/results/upload
|
||||
// Parses 3 multipart files, validates, detects tampering, and stores results.
|
||||
func UploadMatchLogs(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
err = r.ParseMultipartForm(maxUploadSize)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files. Ensure files are under 10MB.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the 3 period files
|
||||
periodNames := []string{"period_1", "period_2", "period_3"}
|
||||
logs := make([]*slapshotapi.MatchLog, 3)
|
||||
for i, name := range periodNames {
|
||||
file, _, err := r.FormFile(name)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Missing File", "All 3 period files are required. Missing: "+name, nil)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Read Error", "Could not read file: "+name, nil)
|
||||
return
|
||||
}
|
||||
|
||||
log, err := slapshotapi.ParseMatchLog(data)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Parse Error", "Could not parse "+name+": "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
logs[i] = log
|
||||
}
|
||||
|
||||
// Detect tampering
|
||||
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Validation Error", "Tampering check failed: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result *db.FixtureResult
|
||||
var unmappedPlayers []string
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
fixture, err := db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
respond.NotFound(w, errors.Wrap(err, "db.GetFixture"))
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
|
||||
// Check if result already exists
|
||||
existing, err := db.GetFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFixtureResult")
|
||||
}
|
||||
if existing != nil {
|
||||
notify.Warn(s, w, r, "Result Exists", "A result already exists for this fixture. Discard it first to re-upload.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Collect all unique game_user_ids across all periods
|
||||
gameUserIDSet := map[string]bool{}
|
||||
for _, log := range logs {
|
||||
for _, p := range log.Players {
|
||||
gameUserIDSet[p.GameUserID] = true
|
||||
}
|
||||
}
|
||||
gameUserIDs := make([]string, 0, len(gameUserIDSet))
|
||||
for id := range gameUserIDSet {
|
||||
gameUserIDs = append(gameUserIDs, id)
|
||||
}
|
||||
|
||||
// Map game_user_ids to players
|
||||
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
||||
}
|
||||
|
||||
// Determine team orientation using all players from all periods
|
||||
allPlayers := []slapshotapi.Player{}
|
||||
// Use period 3 players for orientation (most complete)
|
||||
allPlayers = append(allPlayers, logs[2].Players...)
|
||||
|
||||
fixtureHomeIsLogsHome, unmapped, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Orientation Error",
|
||||
"Could not determine team orientation: "+err.Error()+". Please ensure players have registered Slapshot IDs.", nil)
|
||||
return false, nil
|
||||
}
|
||||
unmappedPlayers = unmapped
|
||||
|
||||
// Use period 3 (final) data for the result
|
||||
finalLog := logs[2]
|
||||
|
||||
// Determine winner in fixture terms
|
||||
winner := finalLog.Winner
|
||||
homeScore := finalLog.Score.Home
|
||||
awayScore := finalLog.Score.Away
|
||||
if !fixtureHomeIsLogsHome {
|
||||
// Logs are reversed - swap
|
||||
switch winner {
|
||||
case "home":
|
||||
winner = "away"
|
||||
case "away":
|
||||
winner = "home"
|
||||
}
|
||||
homeScore, awayScore = awayScore, homeScore
|
||||
}
|
||||
|
||||
// Parse metadata
|
||||
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
||||
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
||||
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
||||
|
||||
user := db.CurrentUser(ctx)
|
||||
|
||||
// Build result
|
||||
var tamperingReasonPtr *string
|
||||
if tamperingDetected {
|
||||
tamperingReasonPtr = &tamperingReason
|
||||
}
|
||||
|
||||
result = &db.FixtureResult{
|
||||
FixtureID: fixtureID,
|
||||
Winner: winner,
|
||||
HomeScore: homeScore,
|
||||
AwayScore: awayScore,
|
||||
MatchType: finalLog.Type,
|
||||
Arena: finalLog.Arena,
|
||||
EndReason: finalLog.EndReason,
|
||||
PeriodsEnabled: periodsEnabled,
|
||||
CustomMercyRule: customMercyRule,
|
||||
MatchLength: matchLength,
|
||||
UploadedByUserID: user.ID,
|
||||
Finalized: false,
|
||||
TamperingDetected: tamperingDetected,
|
||||
TamperingReason: tamperingReasonPtr,
|
||||
}
|
||||
|
||||
// Build player stats for all 3 periods
|
||||
playerStats := []*db.FixtureResultPlayerStats{}
|
||||
for periodIdx, log := range logs {
|
||||
periodNum := periodIdx + 1
|
||||
for _, p := range log.Players {
|
||||
// Determine team in fixture terms
|
||||
team := p.Team
|
||||
if !fixtureHomeIsLogsHome {
|
||||
if team == "home" {
|
||||
team = "away"
|
||||
} else {
|
||||
team = "home"
|
||||
}
|
||||
}
|
||||
|
||||
// Look up player
|
||||
var playerID *int
|
||||
var teamID *int
|
||||
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
|
||||
playerID = &lookup.Player.ID
|
||||
if !lookup.Unmapped {
|
||||
teamID = &lookup.TeamID
|
||||
}
|
||||
}
|
||||
|
||||
stat := &db.FixtureResultPlayerStats{
|
||||
PeriodNum: periodNum,
|
||||
PlayerID: playerID,
|
||||
PlayerGameUserID: p.GameUserID,
|
||||
PlayerUsername: p.Username,
|
||||
TeamID: teamID,
|
||||
Team: team,
|
||||
// Convert float stats to int
|
||||
Goals: FloatToIntPtr(p.Stats.Goals),
|
||||
Assists: FloatToIntPtr(p.Stats.Assists),
|
||||
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
|
||||
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
|
||||
Saves: FloatToIntPtr(p.Stats.Saves),
|
||||
Blocks: FloatToIntPtr(p.Stats.Blocks),
|
||||
Shots: FloatToIntPtr(p.Stats.Shots),
|
||||
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
|
||||
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
|
||||
Passes: FloatToIntPtr(p.Stats.Passes),
|
||||
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
|
||||
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
|
||||
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
|
||||
PostHits: FloatToIntPtr(p.Stats.PostHits),
|
||||
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
|
||||
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
|
||||
Score: FloatToIntPtr(p.Stats.Score),
|
||||
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
|
||||
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
|
||||
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
|
||||
Wins: FloatToIntPtr(p.Stats.Wins),
|
||||
Losses: FloatToIntPtr(p.Stats.Losses),
|
||||
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
|
||||
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
|
||||
Ties: FloatToIntPtr(p.Stats.Ties),
|
||||
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
|
||||
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
|
||||
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
|
||||
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
|
||||
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
|
||||
}
|
||||
playerStats = append(playerStats, stat)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert result and stats
|
||||
result, err = db.InsertFixtureResult(ctx, tx, result, playerStats, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
_ = unmappedPlayers // stored for review page redirect
|
||||
notify.Success(s, w, r, "Logs Uploaded", "Match logs have been processed. Please review the result.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d/results/review", fixtureID)
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewMatchResult handles GET /fixtures/{fixture_id}/results/review
|
||||
func ReviewMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var fixture *db.Fixture
|
||||
var result *db.FixtureResult
|
||||
var unmappedPlayers []string
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
fixture, err = db.GetFixture(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetFixture")
|
||||
}
|
||||
|
||||
result, err = db.GetPendingFixtureResult(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Build unmapped players list from stats
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.PlayerID == nil && ps.PeriodNum == 3 {
|
||||
unmappedPlayers = append(unmappedPlayers,
|
||||
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.FixtureReviewResultPage(fixture, result, unmappedPlayers), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// FinalizeMatchResult handles POST /fixtures/{fixture_id}/results/finalize
|
||||
func FinalizeMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
err := db.FinalizeFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.FinalizeFixtureResult")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.SuccessWithDelay(s, w, r, "Result Finalized", "The match result has been finalized.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardMatchResult handles POST /fixtures/{fixture_id}/results/discard
|
||||
func DiscardMatchResult(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fixtureID, err := strconv.Atoi(r.PathValue("fixture_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid fixture ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
err := db.DeleteFixtureResult(ctx, tx, fixtureID, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.DeleteFixtureResult")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.Success(s, w, r, "Result Discarded", "The match result has been discarded. You can upload new logs.", nil)
|
||||
respond.HXRedirect(w, "/fixtures/%d", fixtureID)
|
||||
})
|
||||
}
|
||||
187
internal/handlers/fixture_result_validation.go
Normal file
187
internal/handlers/fixture_result_validation.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// PlayerLookupResult stores the resolved player info from a game_user_id lookup
|
||||
type PlayerLookupResult struct {
|
||||
Player *db.Player
|
||||
TeamID int
|
||||
Found bool
|
||||
Unmapped bool // true if player not in system (potential free agent)
|
||||
}
|
||||
|
||||
// MapGameUserIDsToPlayers creates a lookup map from game_user_id to resolved player info.
|
||||
// It looks up players by their SlapID (which corresponds to game_user_id in match logs)
|
||||
// and checks their team assignment in the given season/league.
|
||||
func MapGameUserIDsToPlayers(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
gameUserIDs []string,
|
||||
seasonID, leagueID int,
|
||||
) (map[string]*PlayerLookupResult, error) {
|
||||
result := make(map[string]*PlayerLookupResult, len(gameUserIDs))
|
||||
|
||||
// Initialize all as unmapped
|
||||
for _, id := range gameUserIDs {
|
||||
result[id] = &PlayerLookupResult{Unmapped: true}
|
||||
}
|
||||
|
||||
if len(gameUserIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get all players that have a slap_id matching any of the game_user_ids
|
||||
// game_user_id in logs is a string representation of the slapshot player ID (uint32)
|
||||
players := []*db.Player{}
|
||||
err := tx.NewSelect().
|
||||
Model(&players).
|
||||
Where("p.slap_id::text IN (?)", bun.In(gameUserIDs)).
|
||||
Relation("User").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect players")
|
||||
}
|
||||
|
||||
// Build a map of slapID -> player
|
||||
slapIDToPlayer := make(map[string]*db.Player, len(players))
|
||||
playerIDs := make([]int, 0, len(players))
|
||||
for _, p := range players {
|
||||
if p.SlapID != nil {
|
||||
key := slapIDStr(*p.SlapID)
|
||||
slapIDToPlayer[key] = p
|
||||
playerIDs = append(playerIDs, p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Get team roster entries for these players in the given season/league
|
||||
rosters := []*db.TeamRoster{}
|
||||
if len(playerIDs) > 0 {
|
||||
err = tx.NewSelect().
|
||||
Model(&rosters).
|
||||
Where("tr.season_id = ?", seasonID).
|
||||
Where("tr.league_id = ?", leagueID).
|
||||
Where("tr.player_id IN (?)", bun.In(playerIDs)).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect rosters")
|
||||
}
|
||||
}
|
||||
|
||||
// Build playerID -> teamID map
|
||||
playerTeam := make(map[int]int, len(rosters))
|
||||
for _, r := range rosters {
|
||||
playerTeam[r.PlayerID] = r.TeamID
|
||||
}
|
||||
|
||||
// Populate results
|
||||
for _, id := range gameUserIDs {
|
||||
player, found := slapIDToPlayer[id]
|
||||
if !found {
|
||||
continue // stays unmapped
|
||||
}
|
||||
teamID, onTeam := playerTeam[player.ID]
|
||||
result[id] = &PlayerLookupResult{
|
||||
Player: player,
|
||||
TeamID: teamID,
|
||||
Found: true,
|
||||
Unmapped: !onTeam,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DetermineTeamOrientation validates that logs match fixture's team assignment
|
||||
// by cross-checking player game_user_ids against registered rosters.
|
||||
//
|
||||
// Returns:
|
||||
// - fixtureHomeIsLogsHome: true if fixture's home team maps to "home" in logs
|
||||
// - unmappedPlayers: list of game_user_ids that couldn't be resolved
|
||||
// - error: if orientation cannot be determined
|
||||
func DetermineTeamOrientation(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixture *db.Fixture,
|
||||
allPlayers []slapshotapi.Player,
|
||||
playerLookup map[string]*PlayerLookupResult,
|
||||
) (bool, []string, error) {
|
||||
if fixture == nil {
|
||||
return false, nil, errors.New("fixture cannot be nil")
|
||||
}
|
||||
|
||||
unmapped := []string{}
|
||||
|
||||
// Count how many fixture-home-team players are on "home" vs "away" in logs
|
||||
homeTeamOnHome := 0 // fixture home team players that are "home" in logs
|
||||
homeTeamOnAway := 0 // fixture home team players that are "away" in logs
|
||||
awayTeamOnHome := 0 // fixture away team players that are "home" in logs
|
||||
awayTeamOnAway := 0 // fixture away team players that are "away" in logs
|
||||
|
||||
for _, p := range allPlayers {
|
||||
lookup, exists := playerLookup[p.GameUserID]
|
||||
if !exists || lookup.Unmapped {
|
||||
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||
continue
|
||||
}
|
||||
|
||||
logTeam := p.Team // "home" or "away" in the log
|
||||
|
||||
switch lookup.TeamID {
|
||||
case fixture.HomeTeamID:
|
||||
if logTeam == "home" {
|
||||
homeTeamOnHome++
|
||||
} else {
|
||||
homeTeamOnAway++
|
||||
}
|
||||
case fixture.AwayTeamID:
|
||||
if logTeam == "home" {
|
||||
awayTeamOnHome++
|
||||
} else {
|
||||
awayTeamOnAway++
|
||||
}
|
||||
default:
|
||||
// Player is on a team but not one of the fixture teams
|
||||
unmapped = append(unmapped, p.GameUserID+" ("+p.Username+")")
|
||||
}
|
||||
}
|
||||
|
||||
totalMapped := homeTeamOnHome + homeTeamOnAway + awayTeamOnHome + awayTeamOnAway
|
||||
if totalMapped == 0 {
|
||||
return false, unmapped, errors.New("no mapped players found, cannot determine team orientation")
|
||||
}
|
||||
|
||||
// Calculate orientation: how many agree with "home=home" vs "home=away"
|
||||
matchOrientation := homeTeamOnHome + awayTeamOnAway // logs match fixture orientation
|
||||
reverseOrientation := homeTeamOnAway + awayTeamOnHome // logs are reversed
|
||||
|
||||
if matchOrientation == reverseOrientation {
|
||||
return false, unmapped, errors.New("cannot determine team orientation: equal evidence for both orientations")
|
||||
}
|
||||
|
||||
fixtureHomeIsLogsHome := matchOrientation > reverseOrientation
|
||||
return fixtureHomeIsLogsHome, unmapped, nil
|
||||
}
|
||||
|
||||
// FloatToIntPtr converts a *float64 to *int by truncating the decimal.
|
||||
// Returns nil if input is nil.
|
||||
func FloatToIntPtr(f *float64) *int {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
v := int(math.Round(*f))
|
||||
return &v
|
||||
}
|
||||
|
||||
// slapIDStr converts a uint32 SlapID to a string for map lookups
|
||||
func slapIDStr(id uint32) string {
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func SeasonLeagueFixturesPage(
|
||||
var sl *db.SeasonLeague
|
||||
var fixtures []*db.Fixture
|
||||
var scheduleMap map[int]*db.FixtureSchedule
|
||||
var resultMap map[int]*db.FixtureResult
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
@@ -44,15 +45,19 @@ func SeasonLeagueFixturesPage(
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
resultMap, err = db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetFinalizedResultsForFixtures")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueFixturesPage(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||
} else {
|
||||
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap), s, r, w)
|
||||
renderSafely(seasonsview.SeasonLeagueFixtures(sl.Season, sl.League, fixtures, scheduleMap, resultMap), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user