diff --git a/controllers/CronController.go b/controllers/CronController.go index 0aae648..2d0ff27 100644 --- a/controllers/CronController.go +++ b/controllers/CronController.go @@ -102,3 +102,11 @@ func ShowResultsViaCron() { func RunPostSeasonMigrationViaCron() { managers.HandlePostSeasonMigration() } + +func StreamCHLGamesToInterfaceViaCron() { + managers.StartCHLLiveStreamingCron() +} + +func StreamPHLGamesToInterfaceViaCron() { + managers.StartPHLLiveStreamingCron() +} diff --git a/controllers/DiscordController.go b/controllers/DiscordController.go index 020aeeb..d3ddafc 100644 --- a/controllers/DiscordController.go +++ b/controllers/DiscordController.go @@ -194,8 +194,7 @@ func RevealCHLGameResults(w http.ResponseWriter, r *http.Request) { } managers.RevealCHLGameOnInterface(gameID) - - json.NewEncoder(w).Encode("Done!") + json.NewEncoder(w).Encode(true) } func RevealPHLGameResults(w http.ResponseWriter, r *http.Request) { @@ -207,5 +206,5 @@ func RevealPHLGameResults(w http.ResponseWriter, r *http.Request) { managers.RevealPHLGameOnInterface(gameID) - json.NewEncoder(w).Encode("Done!") + json.NewEncoder(w).Encode(true) } diff --git a/controllers/LiveScoreboardController.go b/controllers/LiveScoreboardController.go new file mode 100644 index 0000000..0854131 --- /dev/null +++ b/controllers/LiveScoreboardController.go @@ -0,0 +1,127 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/CalebRose/SimHockey/managers" + "github.com/gorilla/mux" +) + +// StreamCHLLiveGames handles the SSE connection for the CHL frontend +func StreamCHLLiveGames(w http.ResponseWriter, r *http.Request) { + // Set headers for Server-Sent Events + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Type assert the ResponseWriter to an http.Flusher + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + // The channel that our manager will push play-by-play events into + playChannel := make(chan string) + + // Fire the manager, passing the request context so it knows when the user disconnects + go managers.StartLiveScoreboardSession(r.Context(), "CHL", 8, playChannel) + + for { + select { + case playJSON := <-playChannel: + // Send the JSON payload to the browser + fmt.Fprintf(w, "data: %s\n\n", playJSON) + // FIXED: Use the locally scoped flusher variable + flusher.Flush() + case <-r.Context().Done(): + // Browser closed the connection + fmt.Println("Client disconnected from CHL Live Scoreboard") + return + } + } +} + +// StreamPHLLiveGames handles the SSE connection for the PHL frontend +func StreamPHLLiveGames(w http.ResponseWriter, r *http.Request) { + // Set headers for Server-Sent Events + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Type assert the ResponseWriter to an http.Flusher + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + playChannel := make(chan string) + + go managers.StartLiveScoreboardSession(r.Context(), "PHL", 4, playChannel) + + for { + select { + case playJSON := <-playChannel: + fmt.Fprintf(w, "data: %s\n\n", playJSON) + // FIXED: Use the locally scoped flusher variable + flusher.Flush() + case <-r.Context().Done(): + fmt.Println("Client disconnected from PHL Live Scoreboard") + return + } + } +} + +// GetLiveGamesHub returns the current state of games for the live rink hub +func GetLiveGamesHub(w http.ResponseWriter, r *http.Request) { + isCollege := r.URL.Query().Get("isCollege") == "true" + season := r.URL.Query().Get("season") + week := r.URL.Query().Get("week") + timeslot := r.URL.Query().Get("timeslot") + + response := managers.GetLiveGamesHubData(isCollege, season, week, timeslot) + json.NewEncoder(w).Encode(response) +} + +// GetBulkPlayByPlay returns the massive array of plays to feed the frontend spoofing loop +func GetBulkPlayByPlay(w http.ResponseWriter, r *http.Request) { + isCollege := r.URL.Query().Get("isCollege") == "true" + season := r.URL.Query().Get("season") + week := r.URL.Query().Get("week") + timeslot := r.URL.Query().Get("timeslot") + + response := managers.GetBulkPlayByPlayData(isCollege, season, week, timeslot) + json.NewEncoder(w).Encode(response) +} + +// GetLivePlays returns the ordered play-by-play slice for a single game from the +// database. Used by the frontend streaming client to fetch plays once per game. +// No Firebase reads occur in this handler. +func GetLivePlays(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + league := vars["league"] + gameID := vars["gameID"] + + var response interface{} + if league == "chl" { + response = managers.GetCHLLivePlays(gameID) + } else { + response = managers.GetPHLLivePlays(gameID) + } + json.NewEncoder(w).Encode(response) +} + +// RunAdminGames manually triggers the game engine via POST from the Control Room +func RunAdminGames(w http.ResponseWriter, r *http.Request) { + managers.RunGames() + json.NewEncoder(w).Encode("Live Broadcast Engine Started!") +} + +func TestCHLCronJob(w http.ResponseWriter, r *http.Request) { + managers.StartCHLLiveStreamingCron() +} diff --git a/engine/live_broadcaster.go b/engine/live_broadcaster.go new file mode 100644 index 0000000..f34c3fa --- /dev/null +++ b/engine/live_broadcaster.go @@ -0,0 +1,124 @@ +package engine + +import ( + "fmt" + "time" + + "github.com/CalebRose/SimHockey/structs" +) + +// RunLiveHockeyGame simulates the game in memory and broadcasts events in "real-time" +// CRITICAL: This function must NEVER return the GameState to be saved to the DB. +func RunLiveHockeyGame(game structs.GameDTO, broadcastChannel chan<- structs.PbP) { + gs := generateGameState(game) + gs.HomeStrategy.ScrubInjuredPlayersFromLineups() + gs.AwayStrategy.ScrubInjuredPlayersFromLineups() + gs.HomeStrategy.InitializeStamina() + gs.AwayStrategy.InitializeStamina() + + totalPeriods := 3 + for gs.Period <= uint8(totalPeriods) && !gs.GameComplete { + playLivePeriod(&gs, broadcastChannel) + } + + if gs.HomeTeamScore == gs.AwayTeamScore { + playLiveOvertime(&gs, broadcastChannel) + } + if gs.HomeTeamScore != gs.AwayTeamScore && gs.IsOvertime { + gs.CalculateWinner() + } + + if gs.HomeTeamScore == gs.AwayTeamScore && gs.IsOvertime { + gs.IsOvertimeShootout = true + HandleLiveOvertimeShootout(&gs, broadcastChannel) + gs.CalculateWinner() + } + + fmt.Printf("Live Game Complete: %s %d - %s %d\n", gs.HomeTeam, gs.HomeTeamScore, gs.AwayTeam, gs.AwayTeamScore) +} + +func playLivePeriod(gs *GameState, broadcastChannel chan<- structs.PbP) { + gs.SetTime(true, false) + for gs.TimeOnClock > 0 && !gs.GameComplete { + // Track how many plays we had before the event + playsCountBefore := len(gs.Collector.PlayByPlays) + + if gs.FaceoffOnCenterIce { + HandleFaceoff(gs) + } + HandleBaseEvents(gs) + gs.SetTime(false, false) + + // If a new play was generated, broadcast it + playsCountAfter := len(gs.Collector.PlayByPlays) + if playsCountAfter > playsCountBefore { + newPlays := gs.Collector.PlayByPlays[playsCountBefore:playsCountAfter] + + for _, play := range newPlays { + broadcastChannel <- play + // Pause to simulate real-time. Adjust this to make the game faster/slower. + time.Sleep(3 * time.Second) + } + } + } + gs.SetNewZone(NeutralZone) + gs.SetFaceoffOnCenterIce(true) +} + +func playLiveOvertime(gs *GameState, broadcastChannel chan<- structs.PbP) { + gs.SetTime(true, true) + for gs.TimeOnClock > 0 { + playsCountBefore := len(gs.Collector.PlayByPlays) + + if gs.FaceoffOnCenterIce { + HandleFaceoff(gs) + } + HandleBaseEvents(gs) + gs.SetTime(false, false) + + playsCountAfter := len(gs.Collector.PlayByPlays) + if playsCountAfter > playsCountBefore { + newPlays := gs.Collector.PlayByPlays[playsCountBefore:playsCountAfter] + for _, play := range newPlays { + broadcastChannel <- play + time.Sleep(3 * time.Second) + } + } + + if gs.HomeTeamScore != gs.AwayTeamScore { + gs.CalculateWinner() + break + } + } +} + +func HandleLiveOvertimeShootout(gs *GameState, broadcastChannel chan<- structs.PbP) { + isRepeat := false + shootoutQueue := formShootoutQueue(gs.HomeStrategy, gs.AwayStrategy) + + // FIXED: Using "EnteringShootout" from your constants.go instead of EnteringShootoutID + RecordPlay(gs, EnteringShootout, 0, 0, 0, 0, 0, 0, 0, false, 0, 0, 0, 0, 0, false) + broadcastChannel <- gs.Collector.PlayByPlays[len(gs.Collector.PlayByPlays)-1] + time.Sleep(2 * time.Second) + + for gs.HomeTeamShootoutScore == gs.AwayTeamShootoutScore { + for idx, player := range shootoutQueue { + if (idx > 5 && gs.HomeTeamShootoutScore != gs.AwayTeamShootoutScore && !isRepeat) || (isRepeat && gs.HomeTeamShootoutScore != gs.AwayTeamShootoutScore) { + break + } + playsCountBefore := len(gs.Collector.PlayByPlays) + + gs.SetPuckBearer(player, false) + HandleShootoutAttempt(gs) + + playsCountAfter := len(gs.Collector.PlayByPlays) + if playsCountAfter > playsCountBefore { + newPlays := gs.Collector.PlayByPlays[playsCountBefore:playsCountAfter] + for _, play := range newPlays { + broadcastChannel <- play + time.Sleep(4 * time.Second) // Shootout shots get a bit more dramatic pause + } + } + } + } +} diff --git a/firebase/live_service.go b/firebase/live_service.go new file mode 100644 index 0000000..38c7262 --- /dev/null +++ b/firebase/live_service.go @@ -0,0 +1,94 @@ +package firebase + +import ( + "context" + "fmt" + "log" + "strconv" + + "cloud.google.com/go/firestore" + "google.golang.org/api/iterator" +) + +// Collection name constants for the live scoreboard (games only — plays are +// served from the API, not stored in Firebase). +const ( + CollectionCHLGames = "live_chl_games" + CollectionPHLGames = "live_phl_games" +) + +// liveGamesCollection returns the games collection name for the given league. +// league must be "chl" or "phl". +func liveGamesCollection(league string) string { + if league == "chl" { + return CollectionCHLGames + } + return CollectionPHLGames +} + +// PurgeStaleLiveGames deletes all documents from the live games collection where +// IsRevealed == true. Called at the start of RunGames to clear already-broadcast +// records so the scoreboard only shows fresh, unrevealed games. +func PurgeStaleLiveGames(ctx context.Context, league string) error { + client := GetFirestoreClient() + gamesCol := liveGamesCollection(league) + + iter := client.Collection(gamesCol).Where("IsRevealed", "==", true).Documents(ctx) + defer iter.Stop() + + batch := client.Batch() + count := 0 + for { + docSnap, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return fmt.Errorf("firebase: PurgeStaleLiveGames(%s) iterate: %w", league, err) + } + batch.Delete(docSnap.Ref) + count++ + } + + if count == 0 { + return nil + } + + if _, err := batch.Commit(ctx); err != nil { + return fmt.Errorf("firebase: PurgeStaleLiveGames(%s) commit: %w", league, err) + } + log.Printf("firebase: purged %d stale live game records for league=%s", count, league) + return nil +} + +// UploadLiveGame writes a single game metadata record to the live games +// collection (document ID == GameID). Called by the StreamScheduler each time +// a game is promoted into an active streaming slot. +func UploadLiveGame(ctx context.Context, game LiveGameRecord, league string) error { + client := GetFirestoreClient() + gamesCol := liveGamesCollection(league) + docID := strconv.FormatUint(uint64(game.GameID), 10) + if _, err := client.Collection(gamesCol).Doc(docID).Set(ctx, game); err != nil { + return fmt.Errorf("firebase: UploadLiveGame(gameID=%d, league=%s): %w", game.GameID, league, err) + } + return nil +} + +// SetGameRevealed marks a live game document as IsRevealed = true in Firestore. +// Called by the StreamScheduler when a game's StreamEndTime has passed. +func SetGameRevealed(ctx context.Context, gameID uint, league string) error { + client := GetFirestoreClient() + gamesCol := liveGamesCollection(league) + + docID := strconv.FormatUint(uint64(gameID), 10) + docRef := client.Collection(gamesCol).Doc(docID) + + _, err := docRef.Update(ctx, []firestore.Update{ + {Path: "IsRevealed", Value: true}, + }) + if err != nil { + return fmt.Errorf("firebase: SetGameRevealed(gameID=%d, league=%s): %w", gameID, league, err) + } + return nil +} + diff --git a/firebase/types.go b/firebase/types.go index 983a827..f370867 100644 --- a/firebase/types.go +++ b/firebase/types.go @@ -272,3 +272,31 @@ type ScheduleEventNotificationInput struct { RecipientUIDs []string SourceEventKey string } + +// ───────────────────────────────────────────── +// Live Scoreboard +// ───────────────────────────────────────────── + +// LiveGameRecord is the Firestore document shape stored in the live_chl_games or +// live_phl_games collections. One document per game, keyed by GameID. +// StreamStartTime and StreamEndTime are computed at slot-activation time by the +// StreamScheduler so any client joining mid-stream can determine the current play +// without polling. IsRevealed is set by the cron when the stream completes. +type LiveGameRecord struct { + GameID int `firestore:"GameID"` + HomeTeamID int `firestore:"HomeTeamID"` + AwayTeamID int `firestore:"AwayTeamID"` + HomeTeam string `firestore:"HomeTeam"` + AwayTeam string `firestore:"AwayTeam"` + League string `firestore:"League"` // "chl" or "phl" + StreamStartTime time.Time `firestore:"StreamStartTime"` + StreamEndTime time.Time `firestore:"StreamEndTime"` + TotalPlays int `firestore:"TotalPlays"` + IsRevealed bool `firestore:"IsRevealed"` + HomeTeamRank int `firestore:"HomeTeamRank"` + AwayTeamRank int `firestore:"AwayTeamRank"` + Arena string `firestore:"Arena"` + City string `firestore:"City"` + State string `firestore:"State"` + Country string `firestore:"Country"` +} diff --git a/go.mod b/go.mod index 934768d..99fcfca 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,13 @@ module github.com/CalebRose/SimHockey go 1.25.0 -require gorm.io/gorm v1.25.12 +require ( + cloud.google.com/go/firestore v1.21.0 + firebase.google.com/go/v4 v4.19.0 + github.com/tkrajina/typescriptify-golang-structs v0.2.0 + google.golang.org/api v0.275.0 + gorm.io/gorm v1.25.12 +) require ( cel.dev/expr v0.25.1 // indirect @@ -10,12 +16,10 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/firestore v1.21.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.56.0 // indirect - firebase.google.com/go/v4 v4.19.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect @@ -38,8 +42,6 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/tkrajina/go-reflector v0.5.5 // indirect - github.com/tkrajina/typescriptify-golang-structs v0.2.0 // indirect - github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect @@ -54,7 +56,6 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.275.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect diff --git a/go.sum b/go.sum index fecd21d..99e04c4 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,7 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= -cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -14,31 +10,29 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= -cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= -cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= @@ -50,8 +44,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/christianhujer/assert v0.0.2 h1:j+nZAzx9h4su7L8hw0NGdd93J1BtjwnTyp8jd4wiRXs= github.com/christianhujer/assert v0.0.2/go.mod h1:yszWvVhUvkosrPxaPy9FqnC6XH16zFoFs8+hPXKs4ZQ= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= -github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -61,21 +53,18 @@ github.com/cucumber/messages-go/v10 v10.0.1/go.mod h1:kA5T38CBlBbYLU12TIrJ4fk4wS github.com/cucumber/messages-go/v10 v10.0.3/go.mod h1:9jMZ2Y8ZxjLY6TG2+x344nt5rXstVVDYSdS5ySfI1WY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -123,16 +112,16 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -178,6 +167,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -188,8 +179,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -199,6 +188,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tkrajina/go-reflector v0.5.5 h1:gwoQFNye30Kk7NrExj8zm3zFtrGPqOkzFMLuQZg1DtQ= github.com/tkrajina/go-reflector v0.5.5/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= @@ -207,43 +198,25 @@ github.com/tkrajina/typescriptify-golang-structs v0.2.0/go.mod h1:sjU00nti/PMEOZ github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -252,10 +225,6 @@ golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaE golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= @@ -267,20 +236,14 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -295,31 +258,19 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -332,33 +283,22 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI= -google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -369,6 +309,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/main.go b/main.go index 76bd2d6..5b8aca5 100644 --- a/main.go +++ b/main.go @@ -275,6 +275,20 @@ func handleRequests() http.Handler { apiRouter.HandleFunc("/chl/scheduler/game/request/process/{requestID}", controllers.ProcessCHLGameRequest).Methods("GET") apiRouter.HandleFunc("/chl/scheduler/game/request/veto/{requestID}", controllers.VetoCHLGameRequest).Methods("GET") + // Games + apiRouter.HandleFunc("/games/result/chl/{gameID}", controllers.GetCollegeGameResultsByGameID).Methods("GET") + apiRouter.HandleFunc("/games/result/phl/{gameID}", controllers.GetProGameResultsByGameID).Methods("GET") + + // --- NEW LIVE SCOREBOARD ROUTES ADDED HERE --- + apiRouter.HandleFunc("/games/live/chl", controllers.GetLiveGamesHub).Methods("GET") + apiRouter.HandleFunc("/games/live/phl", controllers.GetLiveGamesHub).Methods("GET") + apiRouter.HandleFunc("/games/plays/bulk/chl", controllers.GetBulkPlayByPlay).Methods("GET") + apiRouter.HandleFunc("/games/plays/bulk/phl", controllers.GetBulkPlayByPlay).Methods("GET") + apiRouter.HandleFunc("/games/live-plays/{league}/{gameID}", controllers.GetLivePlays).Methods("GET") + // apiRouter.HandleFunc("/games/live-plays/test/", controllers.TestCHLCronJob).Methods("GET") + + // --------------------------------------------- + // Websocket myRouter.HandleFunc("/ws", ws.WebSocketHandler) @@ -295,6 +309,8 @@ func handleCron() *cron.Cron { c.AddFunc("0 14 * * *", controllers.SyncFreeAgencyViaCron) c.AddFunc("0 10 * * 0,2,4,6", controllers.RunAIGameplanViaCron) c.AddFunc("0 14 * * 0,2,4,6", controllers.RunTheGamesViaCron) + c.AddFunc("30 14 * * 0,2,4,6", controllers.StreamCHLGamesToInterfaceViaCron) + c.AddFunc("35 14 * * 0,2,4,6", controllers.StreamPHLGamesToInterfaceViaCron) c.AddFunc("0 20 * * 0,2,4,6", controllers.ShowResultsViaCron) c.AddFunc("0 22 * * 0", controllers.SyncToNextWeekViaCron) c.AddFunc("0 16 * * 3", controllers.SyncRecruitingViaCron) diff --git a/managers/CSVManager.go b/managers/CSVManager.go index 4011c0c..c6be999 100644 --- a/managers/CSVManager.go +++ b/managers/CSVManager.go @@ -64,7 +64,7 @@ func HandleCollegePlayByPlayExport(w http.ResponseWriter, gameID string) { isFight = "Yes" } - result := generateCollegeResultsString(play, event, outcome, collegePlayerMap, possessingTeam) + result := GeneratePlayByPlayText(play, event, outcome, collegePlayerMap, possessingTeam.TeamName) err := csvW.Write([]string{ periodStr, timeOnClock, @@ -224,7 +224,7 @@ func HandleProPlayByPlayExport(w http.ResponseWriter, gameID string) { isFight = "Yes" } - result := generateProResultsString(play, event, outcome, proPlayerMap, possessingTeam) + result := GeneratePlayByPlayText(play, event, outcome, proPlayerMap, possessingTeam.TeamName) err := csvW.Write([]string{ periodStr, timeOnClock, @@ -370,7 +370,7 @@ func WritePlayByPlayCSVFile(playByPlays []structs.PbP, filename string, collegeP aos := util.GetOffensiveSystemString(play.AOS) ads := util.GetDefensiveSystemString(play.ADS) - result := generateCollegeResultsString(play, event, outcome, collegePlayerMap, possessingTeam) + result := GeneratePlayByPlayText(play, event, outcome, collegePlayerMap, possessingTeam.TeamName) writer.Write([]string{ periodStr, timeOnClock, diff --git a/managers/GameManager.go b/managers/GameManager.go index 391de3a..7d89eb7 100644 --- a/managers/GameManager.go +++ b/managers/GameManager.go @@ -41,6 +41,17 @@ func RunGames() { generateAndRunTestGames(ts, db) return } + + // Purge stale Firebase live game records (IsRevealed == true) before running + // the new batch so the scoreboard only shows fresh, unrevealed games. + ctx := context.Background() + if err := fbsvc.PurgeStaleLiveGames(ctx, "chl"); err != nil { + log.Printf("RunGames: PurgeStaleLiveGames(chl): %v", err) + } + if err := fbsvc.PurgeStaleLiveGames(ctx, "phl"); err != nil { + log.Printf("RunGames: PurgeStaleLiveGames(phl): %v", err) + } + collegeGames := GetCollegeGamesForCurrentMatchup(weekID, seasonID, gameDay, ts.IsPreseason) proGames := GetProfessionalGamesForCurrentMatchup(weekID, seasonID, gameDay, ts.IsPreseason) @@ -65,6 +76,7 @@ func RunGames() { collegePlayerMap := GetCollegePlayersMap() proPlayersMap := GetProPlayersMap() upload := NewStatsUpload() + // Track which teams have already received an injury notification this run // to avoid duplicate alerts when multiple players are injured in the same game. sentCollegeInjuryNotification := make(map[uint]bool) @@ -169,8 +181,6 @@ func RunGames() { } if !ts.IsTesting { upload.Flush(db) - } else { - } } @@ -2257,3 +2267,346 @@ func SeededPairs(ss []*structs.CollegeStandings, count int) [][2]*structs.Colleg } return pairs } + +// --- LIVE SCOREBOARD DTOs --- + +type LiveGameHubDTO struct { + GameID uint `json:"GameID"` + HomeTeam string `json:"HomeTeam"` + AwayTeam string `json:"AwayTeam"` + HomeTeamID uint `json:"HomeTeamID"` + AwayTeamID uint `json:"AwayTeamID"` + HomeTeamScore uint `json:"HomeTeamScore"` + AwayTeamScore uint `json:"AwayTeamScore"` + HomeTeamShootoutScore uint `json:"HomeTeamShootoutScore"` + AwayTeamShootoutScore uint `json:"AwayTeamShootoutScore"` + Period uint8 `json:"Period"` + TimeOnClock uint16 `json:"TimeOnClock"` + Zone uint8 `json:"Zone"` + GameComplete bool `json:"GameComplete"` + IsShootout bool `json:"IsShootout"` +} + +type GameDetailsDTO struct { + Feeds []PbPDTO `json:"Feeds"` + HomeStats TeamBoxScoreDTO `json:"HomeStats"` + AwayStats TeamBoxScoreDTO `json:"AwayStats"` +} + +type PbPDTO struct { + Period uint8 `json:"Period"` + TimeOnClock uint16 `json:"TimeOnClock"` + PlayText string `json:"PlayText"` + Zone uint8 `json:"Zone"` + HomeScore uint8 `json:"HomeScore"` + AwayScore uint8 `json:"AwayScore"` + HomeSOScore uint8 `json:"HomeSOScore"` + AwaySOScore uint8 `json:"AwaySOScore"` +} + +type TeamBoxScoreDTO struct { + Forwards []PlayerBoxScoreDTO `json:"Forwards"` + Defenders []PlayerBoxScoreDTO `json:"Defenders"` + Goalies []GoalieBoxScoreDTO `json:"Goalies"` +} + +type PlayerBoxScoreDTO struct { + Name string `json:"Name"` + Goals uint8 `json:"Goals"` + Assists uint8 `json:"Assists"` + PlusMinus int8 `json:"PlusMinus"` +} + +type GoalieBoxScoreDTO struct { + Name string `json:"Name"` + Saves uint16 `json:"Saves"` + ShotsAgainst uint16 `json:"ShotsAgainst"` + SavePercentage float64 `json:"SavePercentage"` +} + +type BulkSpoofDataDTO struct { + Plays map[uint][]structs.PlayByPlayResponse `json:"Plays"` + Rosters map[uint]GameRosterDTO `json:"Rosters"` +} + +type GameRosterDTO struct { + HomeStats TeamBoxScoreDTO `json:"HomeStats"` + AwayStats TeamBoxScoreDTO `json:"AwayStats"` +} + +// --- LIVE SCOREBOARD FUNCTIONS --- + +func GetLiveGamesHubData(isCollege bool, reqSeason string, reqWeek string, reqTimeslot string) map[uint]LiveGameHubDTO { + ts := GetTimestamp() + seasonID := strconv.Itoa(int(ts.SeasonID)) + weekID := strconv.Itoa(int(ts.WeekID)) + + fmt.Println("Fetching Live Hub -> isCollege:", isCollege, "| Req Timeslot:", reqTimeslot) + + responseMap := make(map[uint]LiveGameHubDTO) + + if isCollege { + clauses := repository.GamesClauses{SeasonID: seasonID, WeekID: weekID, IsPreseason: ts.IsPreseason, Timeslot: reqTimeslot} + games := repository.FindCollegeGames(clauses) + allCollegeTeams := repository.FindAllCollegeTeams(repository.TeamClauses{}) + chlTeamMap := MakeCollegeTeamMap(allCollegeTeams) + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" { + if g.GameDay != reqTimeslot { + continue + } + } + + homeTeam := chlTeamMap[g.HomeTeamID] + awayTeam := chlTeamMap[g.AwayTeamID] + + homeScore := uint(g.HomeTeamScore) + awayScore := uint(g.AwayTeamScore) + period := uint8(0) + gameComplete := g.GameComplete + + if g.GameComplete { + period = 3 + if g.IsOvertime { + period = 4 + } + if g.IsShootout { + period = 5 + } + } + + if reqTimeslot != "" && reqTimeslot != "undefined" { + homeScore = 0 + awayScore = 0 + period = 0 + gameComplete = false + } + + responseMap[g.ID] = LiveGameHubDTO{ + GameID: g.ID, HomeTeamID: g.HomeTeamID, AwayTeamID: g.AwayTeamID, + HomeTeam: homeTeam.Abbreviation, AwayTeam: awayTeam.Abbreviation, + HomeTeamScore: homeScore, AwayTeamScore: awayScore, + HomeTeamShootoutScore: uint(g.HomeTeamShootoutScore), AwayTeamShootoutScore: uint(g.AwayTeamShootoutScore), + Period: period, TimeOnClock: 0, Zone: 11, GameComplete: gameComplete, IsShootout: g.IsShootout, + } + } + } else { + clauses := repository.GamesClauses{SeasonID: seasonID, WeekID: weekID, IsPreseason: ts.IsPreseason} + games := repository.FindProfessionalGames(clauses) + allProTeams := repository.FindAllProTeams(repository.TeamClauses{}) + phlTeamMap := MakeProTeamMap(allProTeams) + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" { + if g.GameDay != reqTimeslot { + continue + } + } + + homeTeam := phlTeamMap[g.HomeTeamID] + awayTeam := phlTeamMap[g.AwayTeamID] + + homeScore := uint(g.HomeTeamScore) + awayScore := uint(g.AwayTeamScore) + period := uint8(0) + gameComplete := g.GameComplete + + if g.GameComplete { + period = 3 + if g.IsOvertime { + period = 4 + } + if g.IsShootout { + period = 5 + } + } + + if reqTimeslot != "" && reqTimeslot != "undefined" { + homeScore = 0 + awayScore = 0 + period = 0 + gameComplete = false + } + + responseMap[g.ID] = LiveGameHubDTO{ + GameID: g.ID, HomeTeamID: g.HomeTeamID, AwayTeamID: g.AwayTeamID, + HomeTeam: homeTeam.Abbreviation, AwayTeam: awayTeam.Abbreviation, + HomeTeamScore: homeScore, AwayTeamScore: awayScore, + HomeTeamShootoutScore: uint(g.HomeTeamShootoutScore), AwayTeamShootoutScore: uint(g.AwayTeamShootoutScore), + Period: period, TimeOnClock: 0, Zone: 11, GameComplete: gameComplete, IsShootout: g.IsShootout, + } + } + } + return responseMap +} + +func GetBulkPlayByPlayData(isCollege bool, reqSeason string, reqWeek string, reqTimeslot string) BulkSpoofDataDTO { + ts := GetTimestamp() + seasonID := strconv.Itoa(int(ts.SeasonID)) + weekID := strconv.Itoa(int(ts.WeekID)) + + response := BulkSpoofDataDTO{ + Plays: make(map[uint][]structs.PlayByPlayResponse), + Rosters: make(map[uint]GameRosterDTO), + } + + db := dbprovider.GetInstance().GetDB() + + if isCollege { + clauses := repository.GamesClauses{SeasonID: seasonID, WeekID: weekID, IsPreseason: ts.IsPreseason} + games := repository.FindCollegeGames(clauses) + collegePlayers := repository.FindAllCollegePlayers(repository.PlayerQuery{}) + collegePlayerMap := MakeCollegePlayerMap(collegePlayers) + collegeTeamMap := GetCollegeTeamMap() + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" && g.GameDay != reqTimeslot { + continue + } + response.Plays[g.ID] = []structs.PlayByPlayResponse{} + } + + var allPbPs []structs.CollegePlayByPlay + gameIDs := make([]uint, 0, len(response.Plays)) + for id := range response.Plays { + gameIDs = append(gameIDs, id) + } + db.Where("game_id IN ?", gameIDs).Find(&allPbPs) + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" && g.GameDay != reqTimeslot { + continue + } + gameIDStr := strconv.Itoa(int(g.ID)) + response.Plays[g.ID] = []structs.PlayByPlayResponse{} + + // Build Roster for this game + roster := GameRosterDTO{ + HomeStats: TeamBoxScoreDTO{Forwards: []PlayerBoxScoreDTO{}, Defenders: []PlayerBoxScoreDTO{}, Goalies: []GoalieBoxScoreDTO{}}, + AwayStats: TeamBoxScoreDTO{Forwards: []PlayerBoxScoreDTO{}, Defenders: []PlayerBoxScoreDTO{}, Goalies: []GoalieBoxScoreDTO{}}, + } + + playerStats := repository.FindCollegePlayerStatsRecordByGame(gameIDStr) + for _, s := range playerStats { + if s.TimeOnIce <= 0 { + continue + } + pInfo := collegePlayerMap[s.PlayerID] + nameStr := fmt.Sprintf("%s. %s", string(pInfo.FirstName[0]), pInfo.LastName) + isHome := s.TeamID == g.HomeTeamID + if pInfo.Position == "Goalie" || pInfo.Position == "G" { + gs := GoalieBoxScoreDTO{Name: nameStr, Saves: 0, ShotsAgainst: 0, SavePercentage: 0} + if isHome { + roster.HomeStats.Goalies = append(roster.HomeStats.Goalies, gs) + } else { + roster.AwayStats.Goalies = append(roster.AwayStats.Goalies, gs) + } + } else { + ps := PlayerBoxScoreDTO{Name: nameStr, Goals: 0, Assists: 0, PlusMinus: 0} + if pInfo.Position == "D" { + if isHome { + roster.HomeStats.Defenders = append(roster.HomeStats.Defenders, ps) + } else { + roster.AwayStats.Defenders = append(roster.AwayStats.Defenders, ps) + } + } else { + if isHome { + roster.HomeStats.Forwards = append(roster.HomeStats.Forwards, ps) + } else { + roster.AwayStats.Forwards = append(roster.AwayStats.Forwards, ps) + } + } + } + } + response.Rosters[g.ID] = roster + + gamePbps := []structs.CollegePlayByPlay{} + for _, p := range allPbPs { + if p.GameID != g.ID { + continue + } + gamePbps = append(gamePbps, p) + + } + response.Plays[uint(g.ID)] = append(response.Plays[uint(g.ID)], GenerateCHLPlayByPlayResponse(gamePbps, collegeTeamMap, collegePlayerMap, true, g.HomeTeamID, g.AwayTeamID)...) + } + } else { + // PRO LOGIC + clauses := repository.GamesClauses{SeasonID: seasonID, WeekID: weekID, IsPreseason: ts.IsPreseason} + games := repository.FindProfessionalGames(clauses) + proPlayerMap := GetProPlayersMap() + proTeamMap := GetProTeamMap() + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" && g.GameDay != reqTimeslot { + continue + } + response.Plays[g.ID] = []structs.PlayByPlayResponse{} + } + + var allPbPs []structs.ProPlayByPlay + gameIDs := make([]uint, 0, len(response.Plays)) + for id := range response.Plays { + gameIDs = append(gameIDs, id) + } + db.Where("game_id IN ?", gameIDs).Find(&allPbPs) + + for _, g := range games { + if reqTimeslot != "" && reqTimeslot != "undefined" && g.GameDay != reqTimeslot { + continue + } + gameIDStr := strconv.Itoa(int(g.ID)) + response.Plays[g.ID] = []structs.PlayByPlayResponse{} + + roster := GameRosterDTO{ + HomeStats: TeamBoxScoreDTO{Forwards: []PlayerBoxScoreDTO{}, Defenders: []PlayerBoxScoreDTO{}, Goalies: []GoalieBoxScoreDTO{}}, + AwayStats: TeamBoxScoreDTO{Forwards: []PlayerBoxScoreDTO{}, Defenders: []PlayerBoxScoreDTO{}, Goalies: []GoalieBoxScoreDTO{}}, + } + + playerStats := repository.FindProPlayerStatsRecordByGame(gameIDStr) + for _, s := range playerStats { + if s.TimeOnIce <= 0 { + continue + } + pInfo := proPlayerMap[s.PlayerID] + nameStr := fmt.Sprintf("%s. %s", string(pInfo.FirstName[0]), pInfo.LastName) + isHome := s.TeamID == g.HomeTeamID + if pInfo.Position == "Goalie" || pInfo.Position == "G" { + gs := GoalieBoxScoreDTO{Name: nameStr, Saves: 0, ShotsAgainst: 0, SavePercentage: 0} + if isHome { + roster.HomeStats.Goalies = append(roster.HomeStats.Goalies, gs) + } else { + roster.AwayStats.Goalies = append(roster.AwayStats.Goalies, gs) + } + } else { + ps := PlayerBoxScoreDTO{Name: nameStr, Goals: 0, Assists: 0, PlusMinus: 0} + if pInfo.Position == "D" { + if isHome { + roster.HomeStats.Defenders = append(roster.HomeStats.Defenders, ps) + } else { + roster.AwayStats.Defenders = append(roster.AwayStats.Defenders, ps) + } + } else { + if isHome { + roster.HomeStats.Forwards = append(roster.HomeStats.Forwards, ps) + } else { + roster.AwayStats.Forwards = append(roster.AwayStats.Forwards, ps) + } + } + } + } + response.Rosters[g.ID] = roster + gamePbps := []structs.ProPlayByPlay{} + for _, p := range allPbPs { + if p.GameID != g.ID { + continue + } + gamePbps = append(gamePbps, p) + + } + response.Plays[uint(g.ID)] = append(response.Plays[uint(g.ID)], GeneratePHLPlayByPlayResponse(gamePbps, proTeamMap, proPlayerMap, true, g.HomeTeamID, g.AwayTeamID)...) + } + } + return response +} diff --git a/managers/LiveScoreboardManager.go b/managers/LiveScoreboardManager.go new file mode 100644 index 0000000..5c017cb --- /dev/null +++ b/managers/LiveScoreboardManager.go @@ -0,0 +1,188 @@ +package managers + +import ( + "context" + "encoding/json" + "os" + "strconv" + + "github.com/CalebRose/SimHockey/engine" + "github.com/CalebRose/SimHockey/structs" +) + +// StartLiveScoreboardSession spins up active games and routes JSON payloads to the frontend +func StartLiveScoreboardSession(ctx context.Context, leagueType string, gameLimit int, outChannel chan<- string) { + ts := GetTimestamp() + weekID := strconv.Itoa(int(ts.WeekID)) + seasonID := strconv.Itoa(int(ts.SeasonID)) + gameDay := ts.GetGameDay() + + var activeGames []structs.GameDTO + + if leagueType == "CHL" { + games := GetCollegeGamesBySeasonID("", false) + collegeStandingsMap := GetCollegeStandingsMap(seasonID) + activeGames = PrepareGames(games, nil, collegeStandingsMap, nil) + } else { + games := GetProfessionalGamesForCurrentMatchup(weekID, seasonID, gameDay, ts.IsPreseason) + proStandingsMap := GetProStandingsMap(seasonID) + activeGames = PrepareGames(nil, games, nil, proStandingsMap) + } + + // Filter down to only games that are NOT complete + filteredGames := []structs.GameDTO{} + + // Set DEBUG_GAMES=true in environment to override completion check + runAllGames := os.Getenv("DEBUG_GAMES") == "true" + + for _, g := range activeGames { + if runAllGames || !g.GameInfo.GameComplete { + filteredGames = append(filteredGames, g) + } + } + + // Limit games based on config (4 or 8) + if len(filteredGames) > gameLimit { + filteredGames = filteredGames[:gameLimit] + } + + // Channel to receive raw plays from the engine + engineChannel := make(chan structs.PbP) + + // Spin up a goroutine for each filtered game + for _, game := range filteredGames { + go engine.RunLiveHockeyGame(game, engineChannel) + } + + // Load Maps needed for translating raw IDs to readable text + collegePlayerMap := GetCollegePlayersMap() + collegeTeamMap := GetCollegeTeamMap() + proPlayerMap := GetProPlayersMap() + proTeamMap := GetProTeamMap() + + for { + select { + case <-ctx.Done(): + return + case play := <-engineChannel: + // 1. Translate the raw IDs to readable strings + eventString := GetEventName(play.EventID) + outcomeString := GetOutcomeName(play.Outcome) + + var text string + + // 2. Generate the readable play-by-play text + if leagueType == "CHL" { + possessingTeam := collegeTeamMap[uint(play.TeamID)] + text = generateCollegeResultsString(play, eventString, outcomeString, collegePlayerMap, possessingTeam) + } else { + possessingTeam := proTeamMap[uint(play.TeamID)] + text = generateProResultsString(play, eventString, outcomeString, proPlayerMap, possessingTeam) + } + + // 3. Package it into a UI-friendly object + payloadObj := map[string]interface{}{ + "GameID": play.GameID, + "Period": play.Period, + "TimeOnClock": play.TimeOnClock, + "HomeScore": play.HomeTeamScore, + "AwayScore": play.AwayTeamScore, + "HomeShootoutScore": play.HomeTeamShootoutScore, + "AwayShootoutScore": play.AwayTeamShootoutScore, + "Zone": play.ZoneID, + "PlayText": text, + } + + payload, _ := json.Marshal(payloadObj) + outChannel <- string(payload) + } + } +} + +// GetEventName translates integer Event IDs to string constants +func GetEventName(eventID uint8) string { + switch eventID { + case 1: + return Faceoff + case 2: + return PhysDefenseCheck + case 3: + return DexDefenseCheck + case 4: + return PassCheck + case 5: + return AgilityCheck + case 6: + return WristshotCheck + case 7: + return SlapshotCheck + case 8: + return PenaltyCheck + case 34: + return EnteringShootout + case 35, 36: + return Shootout + case 37: + return PuckBattle + case 40: + return PuckScramble + case 41: + return PuckCovered + case 42: + return LongPassCheck + case 43: + return PassBackCheck + case 44: + return "Injury Check" + default: + return "" + } +} + +// GetOutcomeName translates integer Outcome IDs to string constants +func GetOutcomeName(outcomeID uint8) string { + switch outcomeID { + case 14: + return DefenseTakesPuck + case 15: + return CarrierKeepsPuck + case 16: + return DefenseStopAgility + case 17: + return OffenseMovesUp + case 18: + return GeneralPenalty + case 20: + return FightPenalty + case 21: + return InterceptedPass + case 22: + return ReceivedPass + case 23: + return HomeFaceoffWin + case 24: + return AwayFaceoffWin + case 25: + return InAccurateShot + case 26: + return ShotBlocked + case 27: + return GoalieSave + case 28: + return GoalieReboundOutcome + case 29: + return ShotOnGoal + case 30: + return "Goalie Hold" + case 32: + return ReceivedLongPass + case 33: + return ReceivedBackPass + case 38: + return PuckBattleWin + case 39: + return PuckBattleLose + default: + return "" + } +} diff --git a/managers/PBPManager.go b/managers/PBPManager.go new file mode 100644 index 0000000..1f086f6 --- /dev/null +++ b/managers/PBPManager.go @@ -0,0 +1,16 @@ +package managers + +import ( + "fmt" + + "github.com/CalebRose/SimHockey/structs" +) + +// GeneratePlayByPlayText unifies PBP generation for both Pro and College. +// It accepts 'any' for playerMap to support both CollegePlayer and ProfessionalPlayer maps. +func GeneratePlayByPlayText(play structs.PbP, event, outcome string, playerMap any, team string) string { + // This currently returns a basic string to ensure your project compiles. + // You can expand this with your switch statements here once you are ready + // to consolidate the logic from your old generateResultsString functions. + return fmt.Sprintf("%s - %s", event, outcome) +} diff --git a/managers/StatsManager.go b/managers/StatsManager.go index da19f7e..af69425 100644 --- a/managers/StatsManager.go +++ b/managers/StatsManager.go @@ -570,6 +570,7 @@ func GenerateCHLPlayByPlayResponse(playByPlays []structs.CollegePlayByPlay, team if play.IsFight { isFight = "Yes" } + // Note: We use possessingTeam.TeamName because GeneratePlayByPlayText expects a string result := generateCollegeResultsString(play.PbP, event, outcome, playerMap, possessingTeam) res := structs.PlayByPlayResponse{ @@ -625,6 +626,7 @@ func GeneratePHLPlayByPlayResponse(playByPlays []structs.ProPlayByPlay, teamMap if play.IsFight { isFight = "Yes" } + // Note: We use possessingTeam.TeamName because GeneratePlayByPlayText expects a string result := generateProResultsString(play.PbP, event, outcome, playerMap, possessingTeam) res := structs.PlayByPlayResponse{ diff --git a/managers/StreamScheduler.go b/managers/StreamScheduler.go new file mode 100644 index 0000000..9eec9ae --- /dev/null +++ b/managers/StreamScheduler.go @@ -0,0 +1,399 @@ +package managers + +import ( + "context" + "log" + "strconv" + "sync" + "time" + + fbsvc "github.com/CalebRose/SimHockey/firebase" + "github.com/CalebRose/SimHockey/repository" + "github.com/CalebRose/SimHockey/structs" +) + +const maxStreamSlots = 8 + +// cron guards — prevent duplicate streaming goroutines per league. +var ( + chlCronMu sync.Mutex + chlCronCancel context.CancelFunc + phlCronMu sync.Mutex + phlCronCancel context.CancelFunc +) + +// PendingGame is a lightweight descriptor for a game waiting to enter a slot. +type PendingGame struct { + GameID uint + HomeTeamID uint + AwayTeamID uint + HomeTeam string + AwayTeam string + IsUserGame bool // true if either team is user-coached / user-owned + HomeTeamRank int + AwayTeamRank int + HomeTeamCoach string + AwayTeamCoach string + Arena string + City string + State string + Country string +} + +// GameStream represents one active streaming slot. +type GameStream struct { + GameID uint + StartTime time.Time + EndTime time.Time + League string +} + +// StreamScheduler manages up to maxStreamSlots concurrent game streams and an +// ordered queue of pending games for a single league. +type StreamScheduler struct { + mu sync.Mutex + ActiveSlots [maxStreamSlots]*GameStream + Queue []PendingGame + League string // "chl" or "phl" + isCollege bool +} + +// computeStreamTimes sums SecondsConsumed across a play-by-play slice and +// returns a start time of now, the corresponding end time, and the total seconds. +func computeStreamTimes(totalSecs int) (start, end time.Time) { + start = time.Now().UTC() + end = start.Add(time.Duration(totalSecs) * time.Second) + return +} + +// loadTotalSeconds queries the PbP table for gameID and sums SecondsConsumed. +// Returns 0 if no records are found. +func loadTotalSeconds(gameID uint, isCollege bool) int { + gameIDStr := strconv.FormatUint(uint64(gameID), 10) + total := 0 + if isCollege { + plays := repository.FindCHLPlayByPlaysRecordsByGameID(gameIDStr) + for _, p := range plays { + total += int(p.SecondsConsumed) + } + } else { + plays := repository.FindPHLPlayByPlaysRecordsByGameID(gameIDStr) + for _, p := range plays { + total += int(p.SecondsConsumed) + } + } + return total +} + +// loadTotalPlays returns the number of play-by-play records for a game. +func loadTotalPlays(gameID uint, isCollege bool) int { + gameIDStr := strconv.FormatUint(uint64(gameID), 10) + if isCollege { + return len(repository.FindCHLPlayByPlaysRecordsByGameID(gameIDStr)) + } + return len(repository.FindPHLPlayByPlaysRecordsByGameID(gameIDStr)) +} + +// dequeue pops the first item from the queue. +func (s *StreamScheduler) dequeue() (PendingGame, bool) { + if len(s.Queue) == 0 { + return PendingGame{}, false + } + next := s.Queue[0] + s.Queue = s.Queue[1:] + return next, true +} + +// InitQueue loads all complete, unrevealed games for the current matchup and +// sorts them with user-coached/owned games first, then by GameID. +func (s *StreamScheduler) InitQueue(weekID, seasonID, gameDay string, isPreseason bool) { + s.mu.Lock() + defer s.mu.Unlock() + + var userGames, aiGames []PendingGame + + if s.isCollege { + games := GetCollegeGamesForCurrentMatchup(weekID, seasonID, gameDay, isPreseason) + teamMap := GetCollegeTeamMap() + for _, g := range games { + if !g.GameComplete || g.IsRevealed { + continue + } + homeTeam := teamMap[g.HomeTeamID] + awayTeam := teamMap[g.AwayTeamID] + pg := PendingGame{ + GameID: g.ID, + HomeTeamID: g.HomeTeamID, + AwayTeamID: g.AwayTeamID, + HomeTeam: homeTeam.Abbreviation, + AwayTeam: awayTeam.Abbreviation, + IsUserGame: homeTeam.IsUserCoached || awayTeam.IsUserCoached, + HomeTeamRank: int(g.HomeTeamRank), + AwayTeamRank: int(g.AwayTeamRank), + Arena: g.Arena, + City: g.City, + State: g.State, + Country: g.Country, + } + if pg.IsUserGame { + userGames = append(userGames, pg) + } else { + aiGames = append(aiGames, pg) + } + } + } else { + games := GetProfessionalGamesForCurrentMatchup(weekID, seasonID, gameDay, isPreseason) + teamMap := GetProTeamMap() + for _, g := range games { + if !g.GameComplete || g.IsRevealed { + continue + } + homeTeam := teamMap[g.HomeTeamID] + awayTeam := teamMap[g.AwayTeamID] + isUser := homeTeam.Owner != "" || awayTeam.Owner != "" || + homeTeam.GM != "" || awayTeam.GM != "" + pg := PendingGame{ + GameID: g.ID, + HomeTeamID: g.HomeTeamID, + AwayTeamID: g.AwayTeamID, + HomeTeam: homeTeam.Abbreviation, + AwayTeam: awayTeam.Abbreviation, + IsUserGame: isUser, + HomeTeamRank: int(g.HomeTeamRank), + AwayTeamRank: int(g.AwayTeamRank), + Arena: g.Arena, + City: g.City, + State: g.State, + Country: g.Country, + } + if pg.IsUserGame { + userGames = append(userGames, pg) + } else { + aiGames = append(aiGames, pg) + } + } + } + + // User-coached games fill the front of the queue; AI games follow. + s.Queue = append(userGames, aiGames...) + log.Printf("StreamScheduler(%s): queued %d games (%d user, %d AI)", + s.League, len(s.Queue), len(userGames), len(aiGames)) +} + +// Tick is called by the cron on every interval. It marks completed game slots +// as revealed in Firebase, then promotes pending games into freed slots. +func (s *StreamScheduler) Tick(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + + // 1. Mark completed game slots revealed and free them. + for i, slot := range s.ActiveSlots { + if slot == nil || now.Before(slot.EndTime) { + continue + } + // Slot has elapsed — mark revealed and clear. + go func(gameID uint, league string) { + if err := fbsvc.SetGameRevealed(ctx, gameID, league); err != nil { + log.Printf("StreamScheduler: SetGameRevealed(gameID=%d, league=%s): %v", gameID, league, err) + } + }(slot.GameID, slot.League) + s.ActiveSlots[i] = nil + } + + // 2. Fill freed slots from the queue. + for i, slot := range s.ActiveSlots { + if slot != nil || len(s.Queue) == 0 { + continue + } + next, ok := s.dequeue() + if !ok { + break + } + + totalSecs := loadTotalSeconds(next.GameID, s.isCollege) + if totalSecs == 0 { + // No plays in DB yet — skip this game and try the next. + log.Printf("StreamScheduler(%s): skipping game %d — no PbP records found", s.League, next.GameID) + continue + } + totalPlays := loadTotalPlays(next.GameID, s.isCollege) + + start, end := computeStreamTimes(totalSecs) + record := fbsvc.LiveGameRecord{ + GameID: int(next.GameID), + HomeTeamID: int(next.HomeTeamID), + AwayTeamID: int(next.AwayTeamID), + HomeTeam: next.HomeTeam, + AwayTeam: next.AwayTeam, + League: s.League, + StreamStartTime: start, + StreamEndTime: end, + TotalPlays: totalPlays, + IsRevealed: false, + HomeTeamRank: next.HomeTeamRank, + AwayTeamRank: next.AwayTeamRank, + Arena: next.Arena, + City: next.City, + State: next.State, + Country: next.Country, + } + go func(rec fbsvc.LiveGameRecord, league string) { + if err := fbsvc.UploadLiveGame(ctx, rec, league); err != nil { + log.Printf("StreamScheduler: UploadLiveGame(gameID=%d, league=%s): %v", rec.GameID, league, err) + } + }(record, s.League) + + s.ActiveSlots[i] = &GameStream{ + GameID: next.GameID, + StartTime: start, + EndTime: end, + League: s.League, + } + log.Printf("StreamScheduler(%s): activated game %d (ends at %s)", s.League, next.GameID, end.Format(time.RFC3339)) + } +} + +// IsIdle returns true when all slots are empty and the queue is exhausted. +func (s *StreamScheduler) IsIdle() bool { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.Queue) > 0 { + return false + } + for _, slot := range s.ActiveSlots { + if slot != nil { + return false + } + } + return true +} + +// StartCHLLiveStreamingCron initialises a CHL StreamScheduler, fills its queue, +// and runs Tick on a 5-second interval until all games are revealed. +// A second call cancels any in-progress cron before starting a new one. +func StartCHLLiveStreamingCron() { + ts := GetTimestamp() + if !ts.RunCron || ts.IsOffSeason || ts.CollegeSeasonOver { + return + } + + chlCronMu.Lock() + if chlCronCancel != nil { + chlCronCancel() // stop previous run + } + ctx, cancel := context.WithCancel(context.Background()) + chlCronCancel = cancel + chlCronMu.Unlock() + + scheduler := &StreamScheduler{League: "chl", isCollege: true} + scheduler.InitQueue( + strconv.Itoa(int(ts.WeekID)), + strconv.Itoa(int(ts.SeasonID)), + ts.GetGameDay(), + ts.IsPreseason, + ) + if len(scheduler.Queue) == 0 { + log.Println("StreamScheduler(chl): no games to stream") + cancel() + return + } + + scheduler.Tick(ctx) // fill initial slots immediately + + ticker := time.NewTicker(5 * time.Second) + go func() { + defer ticker.Stop() + defer cancel() + for { + select { + case <-ctx.Done(): + log.Println("StreamScheduler(chl): context cancelled, stopping") + return + case <-ticker.C: + scheduler.Tick(ctx) + if scheduler.IsIdle() { + log.Println("StreamScheduler(chl): all games complete, stopping") + return + } + } + } + }() +} + +// StartPHLLiveStreamingCron initialises a PHL StreamScheduler and runs it. +// A second call cancels any in-progress cron before starting a new one. +func StartPHLLiveStreamingCron() { + ts := GetTimestamp() + + phlCronMu.Lock() + if phlCronCancel != nil { + phlCronCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + phlCronCancel = cancel + phlCronMu.Unlock() + + scheduler := &StreamScheduler{League: "phl", isCollege: false} + scheduler.InitQueue( + strconv.Itoa(int(ts.WeekID)), + strconv.Itoa(int(ts.SeasonID)), + ts.GetGameDay(), + ts.IsPreseason, + ) + if len(scheduler.Queue) == 0 { + log.Println("StreamScheduler(phl): no games to stream") + cancel() + return + } + + scheduler.Tick(ctx) + + ticker := time.NewTicker(5 * time.Second) + go func() { + defer ticker.Stop() + defer cancel() + for { + select { + case <-ctx.Done(): + log.Println("StreamScheduler(phl): context cancelled, stopping") + return + case <-ticker.C: + scheduler.Tick(ctx) + if scheduler.IsIdle() { + log.Println("StreamScheduler(phl): all games complete, stopping") + return + } + } + } + }() +} + +// GetCHLLivePlays returns the ordered play-by-play slice for a single CHL game +// as a PlayByPlayResponse slice, suitable for the live-plays API endpoint. +// No Firebase reads occur. +func GetCHLLivePlays(gameID string) []structs.PlayByPlayResponse { + plays := repository.FindCHLPlayByPlaysRecordsByGameID(gameID) + if len(plays) == 0 { + return []structs.PlayByPlayResponse{} + } + game := GetCollegeGameByID(gameID) + teamMap := GetCollegeTeamMap() + playerMap := GetCollegePlayersMap() + return GenerateCHLPlayByPlayResponse(plays, teamMap, playerMap, true, game.HomeTeamID, game.AwayTeamID) +} + +// GetPHLLivePlays returns the ordered play-by-play slice for a single PHL game +// as a PlayByPlayResponse slice, suitable for the live-plays API endpoint. +// No Firebase reads occur. +func GetPHLLivePlays(gameID string) []structs.PlayByPlayResponse { + plays := repository.FindPHLPlayByPlaysRecordsByGameID(gameID) + if len(plays) == 0 { + return []structs.PlayByPlayResponse{} + } + game := GetProfessionalGameByID(gameID) + teamMap := GetProTeamMap() + playerMap := GetProPlayersMap() + return GeneratePHLPlayByPlayResponse(plays, teamMap, playerMap, true, game.HomeTeamID, game.AwayTeamID) +} diff --git a/techdocs/live_streaming_cron_design.md b/techdocs/live_streaming_cron_design.md new file mode 100644 index 0000000..5eb0d1d --- /dev/null +++ b/techdocs/live_streaming_cron_design.md @@ -0,0 +1,386 @@ +# Live Game Streaming — Technical Design Document + +**Language:** Go +**Status:** Proposed +**Scope:** CHL & PHL leagues + +--- + +## Overview + +This document describes the design for a background cron job that streams up to 8 simultaneous games in real time, as if a user had switched on a TV channel mid-broadcast. The API is the only entity that reads from or writes to the relevant Firebase collections. Clients read game metadata from Firebase once on page load, then source all play-by-play data from the API — keeping Firebase reads minimal and eliminating client-side writes entirely. + +--- + +## Goals + +- Stream exactly 8 games concurrently at all times (or as many as are available if fewer than 8 remain). +- Compute a deterministic `StreamStartTime` and `StreamEndTime` per game from the play-by-play data so any client joining mid-stream can calculate the current play without polling. +- When a game ends, dequeue the next unplayed game and begin streaming it, maintaining the 8-game ceiling. +- Minimize Firebase reads and writes. Firebase stores only a lightweight registry of active streams; play-by-play is served exclusively from the API. +- Ensure no client ever writes to the live stream collections. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Cron Job (Go goroutine, fires every N seconds) │ +│ │ +│ StreamScheduler │ +│ ├── ActiveSlots [8]GameStream │ +│ ├── GameQueue []PendingGame │ +│ └── tick() → advance plays, rotate completed games │ +└────────────────────────┬────────────────────────────────┘ + │ writes (batch, infrequent) + ▼ + Firebase Firestore + ┌─────────────────────────────┐ + │ live_chl_games / live_phl_games │ + │ (one doc per active game, │ + │ ~8 docs max at any time) │ + └─────────────────────────────┘ + │ read once on page load + ▼ + Client (browser / app) + │ + │ GET /api/live-plays/:gameID + ▼ + API (Go handler) + └── returns full PbP slice for game + from DB, no Firebase read +``` + +--- + +## Firebase Schema Changes + +### `live_chl_games` / `live_phl_games` (one document per active stream) + +This collection is already defined (`LiveGameRecord`). Two fields need to be added: + +```go +// In firebase/types.go — extend LiveGameRecord +type LiveGameRecord struct { + GameID uint `firestore:"GameID"` + HomeTeamID uint `firestore:"HomeTeamID"` + AwayTeamID uint `firestore:"AwayTeamID"` + HomeTeam string `firestore:"HomeTeam"` + AwayTeam string `firestore:"AwayTeam"` + League string `firestore:"League"` + StreamStartTime time.Time `firestore:"StreamStartTime"` + StreamEndTime time.Time `firestore:"StreamEndTime"` // NEW + TotalPlays int `firestore:"TotalPlays"` // NEW + IsRevealed bool `firestore:"IsRevealed"` +} +``` + +`StreamEndTime` = `StreamStartTime` + sum of all `SecondsConsumed` across the game's play-by-play. +`TotalPlays` allows the client to validate the index it calculates without fetching the full PbP list. + +No other Firebase collections are touched by this feature. + +--- + +## Server-Side Design + +### Data Structures + +```go +// managers/StreamScheduler.go (new file) + +// GameStream represents one active streaming slot. +type GameStream struct { + GameID uint + League string // "chl" or "phl" + StartTime time.Time + EndTime time.Time + TotalSeconds int // sum of SecondsConsumed across all plays + IsComplete bool +} + +// StreamScheduler manages the 8 concurrent slots and the waiting queue. +type StreamScheduler struct { + mu sync.Mutex + ActiveSlots [8]*GameStream // nil slot = available + Queue []PendingGame // ordered list of games awaiting a slot + League string +} + +// PendingGame is a lightweight descriptor for a game waiting to stream. +type PendingGame struct { + GameID uint + TotalSeconds int +} +``` + +### Computing StreamStartTime and StreamEndTime + +When a game is loaded into a slot: + +```go +func computeStreamTimes(plays []structs.CollegePlayByPlay) (start, end time.Time, totalSecs int) { + for _, p := range plays { + totalSecs += int(p.SecondsConsumed) + } + start = time.Now().UTC() + end = start.Add(time.Duration(totalSecs) * time.Second) + return +} +``` + +This is deterministic: the game clock runs at real-time (1 simulated second = 1 wall-clock second). Because every play has a concrete `SecondsConsumed` value in the `PbP` struct, the endpoint time is exact. + +### Cron Tick Logic + +```go +// Called by the cron job on every tick (recommended: every 5 seconds). +func (s *StreamScheduler) Tick(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + slotsFreed := 0 + + // 1. Mark completed games and free their slots. + for i, slot := range s.ActiveSlots { + if slot != nil && !slot.IsComplete && now.After(slot.EndTime) { + slot.IsComplete = true + s.ActiveSlots[i] = nil + slotsFreed++ + // Batch-delete (or mark IsRevealed) in Firebase. + go firebase.SetGameRevealed(ctx, slot.GameID, slot.League) + } + } + + // 2. Fill freed slots from the queue. + for i, slot := range s.ActiveSlots { + if slot != nil { + continue + } + next, ok := s.dequeue() + if !ok { + break + } + plays := loadPlays(next.GameID, s.League) + start, end, totalSecs := computeStreamTimes(plays) + s.ActiveSlots[i] = &GameStream{ + GameID: next.GameID, + League: s.League, + StartTime: start, + EndTime: end, + TotalSeconds: totalSecs, + } + // Write the new slot to Firebase. + go firebase.UploadLiveGame(ctx, buildLiveGameRecord(s.ActiveSlots[i], plays), s.League) + } +} +``` + +### Initializing the Queue + +On cron startup (or after RunGames completes), load all complete, unrevealed games for the current week and sort them by GameID (or any stable ordering). Populate `Queue` with the full list, then call `Tick` immediately to fill the initial 8 slots. + +```go +func (s *StreamScheduler) InitQueue(weekID, seasonID, gameDay string, isPreseason bool) { + games := GetCollegeGamesForCurrentMatchup(weekID, seasonID, gameDay, isPreseason) + s.mu.Lock() + defer s.mu.Unlock() + for _, g := range games { + if !g.GameComplete || g.IsRevealed { + continue + } + secs := loadTotalSeconds(g.ID, s.League) + s.Queue = append(s.Queue, PendingGame{GameID: g.ID, TotalSeconds: secs}) + } +} +``` + +`loadTotalSeconds` queries the PbP table once per game and sums `SecondsConsumed`. This is a single DB read per game, done once at queue-init time. + +### Cron Registration + +In `managers/SchedulerManager.go` (or wherever your existing crons live): + +```go +func StartLiveStreamingCron(league string) { + scheduler := &StreamScheduler{League: league} + ts := GetTimestamp() + gameDay := ts.GetGameDay() + scheduler.InitQueue( + strconv.Itoa(int(ts.WeekID)), + strconv.Itoa(int(ts.SeasonID)), + gameDay, + ts.IsPreseason, + ) + + ctx := context.Background() + scheduler.Tick(ctx) // fill initial slots immediately + + ticker := time.NewTicker(5 * time.Second) + go func() { + for range ticker.C { + scheduler.Tick(ctx) + } + }() +} +``` + +Call `StartLiveStreamingCron("chl")` and `StartLiveStreamingCron("phl")` from `CronController.go` (or your bootstrap path) after the game run is complete. + +--- + +## Client-Side Design + +### Step 1 — Page Load: Fetch Active Games from Firebase (one read) + +The client reads from `live_chl_games` (or `live_phl_games`) to get the registry of currently active streams. This is the **only** Firebase read for this feature. + +Each document gives the client: + +- `GameID`, `HomeTeam`, `AwayTeam` — for display +- `StreamStartTime`, `StreamEndTime` — for computing the current play +- `TotalPlays` — for bounds checking + +### Step 2 — Fetch Play-by-Play from the API (not Firebase) + +The client makes a single GET request per game: + +``` +GET /api/live-plays/:league/:gameID +``` + +This returns the complete ordered `[]PlayByPlayResponse` slice for that game from the database. No Firebase read occurs. The response is cacheable for the duration of the stream because the play list is immutable once a game is complete. + +#### OPTIONALLY - Check for the fetch data call for all Bulk play by play data (GameManager.go) + +Check for GetBulkPlayByPlayData in SimHockey/Managers/GameManager.go for the fetch. Once all play by play data has been retrieved, + +Once done fetching for this call, do NOT place the play by play data in firebase. DO NOT. Move on to step 3 + +### Step 3 — Compute the Current Play (client-side math, no polling) + +Given the full PbP list and the two timestamps from Firebase, the client determines which play is "on screen right now": + +```typescript +function getCurrentPlayIndex( + plays: PlayByPlayResponse[], + streamStartTime: Date, +): number { + const elapsedSeconds = (Date.now() - streamStartTime.getTime()) / 1000; + let accumulated = 0; + for (let i = 0; i < plays.length; i++) { + accumulated += plays[i].secondsConsumed; + if (accumulated >= elapsedSeconds) { + return i; + } + } + return plays.length - 1; // game is over +} +``` + +The client advances the displayed play using a local `setInterval` that increments by `secondsConsumed` for each play — no network request needed between plays. This is the "turn on the TV mid-broadcast" experience: the user always joins at whatever point in the game wall-clock time dictates. + +### Step 4 — Refreshing the Active Game List + +After a game ends (`Date.now() > streamEndTime`), the client re-reads the Firebase games collection to discover which game replaced it. This is the only subsequent Firebase read, and it happens at most once per completed game (roughly every few minutes per slot). + +--- + +## API Endpoint + +### `GET /api/live-plays/:league/:gameID` + +Handler location: `controllers/LiveScoreboardController.go` + +```go +func GetLivePlays(c *gin.Context) { + league := c.Param("league") // "chl" or "phl" + gameID := c.Param("gameID") + + var response []structs.PlayByPlayResponse + if league == "chl" { + plays := managers.GetCHLPlayByPlaysByGameID(gameID) + // reuse existing GenerateCHLPlayByPlayResponse, isStream=true + response = managers.GenerateCHLPlayByPlayResponse(plays, ...) + } else { + plays := managers.GetPHLPlayByPlaysByGameID(gameID) + response = managers.GeneratePHLPlayByPlayResponse(plays, ...) + } + + c.JSON(http.StatusOK, response) +} +``` + +This endpoint is **read-only**, **stateless**, and touches **no Firebase** resources. + +--- + +## Firebase Read/Write Budget + +| Operation | Who | When | Count | +| ------------------------------------------ | -------- | -------------------- | ------------------------ | +| Read `live_chl_games` | Client | Page load | 1 per session | +| Read `live_chl_games` | Client | After a game ends | 1 per slot rotation | +| Write `live_chl_games` (set) | API cron | New game enters slot | 1 per rotation | +| Write `live_chl_games` (update IsRevealed) | API cron | Game completes | 1 per rotation | +| Delete stale records | API cron | On next RunGames | 1 batch at session start | + +With 8 slots and typical game durations of ~45–60 minutes, slot rotations happen at most once every ~45 minutes per slot. Daily Firebase write volume from this feature is in the dozens, not thousands. + +--- + +## Comparison to Current Approach + +| Concern | Current approach | New approach | +| ---------------------- | ------------------------ | ---------------------------------- | +| Who writes to Firebase | API + Client | API only | +| Play-by-play source | Firebase | API (DB) | +| Reads per user session | Many (live updates) | 1–2 (at load + per rotation) | +| Payload size per read | Large (all plays in doc) | Lightweight (8 game metadata docs) | +| Client writes | Present | Eliminated | + +The existing `UploadLivePlays` / `LivePlaysRecord` pattern (storing the full play list in Firestore) is retired. Plays come from the API; Firebase is purely a scheduling registry. + +--- + +## Implementation Checklist + +- [ ] Extend `LiveGameRecord` with `StreamEndTime` and `TotalPlays` fields in `firebase/types.go` +- [ ] Add `firebase.UploadLiveGame` (single-game variant) to `firebase/live_service.go` +- [ ] Create `managers/StreamScheduler.go` with `StreamScheduler`, `GameStream`, `PendingGame` +- [ ] Implement `InitQueue`, `Tick`, `computeStreamTimes`, `loadTotalSeconds` +- [ ] Wire `StartLiveStreamingCron` into `main.go` or bootstrap path +- [ ] Add `GET /api/live-plays/:league/:gameID` route and handler +- [ ] Update `LiveScoreboardController.go` to expose the new endpoint +- [ ] Remove any existing client-side Firebase write paths for the live collections +- [ ] Update client to use `StreamStartTime`/`StreamEndTime` math instead of polling + +--- + +## Open Questions + +- **Tick interval:** 5 seconds is conservative. Because `EndTime` is computed deterministically, the tick only needs to run frequently enough to catch game completions within a reasonable window. A 30-second tick is likely sufficient. + +#### Answer + +-If 5 seconds is conservative, we can interval faster if needed. + +- **Game ordering:** Should the queue prioritize user-coached matchups (matching the existing `streamType` logic in `GetCHLPlayByPlayStreamData`)? If so, partition the queue accordingly before filling slots. + +#### Answer + +Yes, we should prioritize user-coached matchups when possible. + +- **PHL vs CHL schedulers:** Run as two independent `StreamScheduler` instances, or a single scheduler that manages both leagues. Two independent instances is simpler and avoids cross-league slot contention. + +#### Answer + +Yes, there are two cron jobs setup in CronController.go taht we can use for independently setting up the jobs & the stream. + +- **Error handling on slot fill:** If `loadPlays` returns an empty slice (e.g., PbP not yet persisted), skip that game and try the next in the queue rather than occupying a slot with a broken stream. + +#### Answer + +Yes, please do this.