Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions controllers/CronController.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,11 @@ func ShowResultsViaCron() {
func RunPostSeasonMigrationViaCron() {
managers.HandlePostSeasonMigration()
}

func StreamCHLGamesToInterfaceViaCron() {
managers.StartCHLLiveStreamingCron()
}

func StreamPHLGamesToInterfaceViaCron() {
managers.StartPHLLiveStreamingCron()
}
5 changes: 2 additions & 3 deletions controllers/DiscordController.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
127 changes: 127 additions & 0 deletions controllers/LiveScoreboardController.go
Original file line number Diff line number Diff line change
@@ -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()
}
124 changes: 124 additions & 0 deletions engine/live_broadcaster.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
94 changes: 94 additions & 0 deletions firebase/live_service.go
Original file line number Diff line number Diff line change
@@ -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
}

Loading
Loading