package c4game import ( "context" "encoding/json" "net/http" "slices" "strconv" "sync" "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/game" ) func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { http.Redirect(w, r, "/", http.StatusFound) return } playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) if playerID == "" { playerID = game.PlayerID(game.GenerateID(8)) sessions.Put(r.Context(), "player_id", string(playerID)) } userID := sessions.GetString(r.Context(), "user_id") if userID != "" { playerID = game.PlayerID(userID) } nickname := sessions.GetString(r.Context(), "nickname") // Auto-join if player has a nickname but isn't in the game yet if nickname != "" && gi.GetPlayerColor(playerID) == 0 { player := &game.Player{ ID: playerID, Nickname: nickname, } if userID != "" { player.UserID = &userID } gi.Join(&game.PlayerSession{Player: player}) } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { // Player not in game isGuest := r.URL.Query().Get("guest") == "1" if userID == "" && !isGuest { // Show join prompt (login vs guest) if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } // Show nickname prompt if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } // Player is in the game — render full game page g := gi.GetGame() chatMsgs := loadChatMessages(queries, gameID) msgs := chatToComponents(chatMsgs) if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { http.Error(w, "game not found", http.StatusNotFound) return } playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { playerID = game.PlayerID(userID) } myColor := gi.GetPlayerColor(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) // Load initial chat messages chatMsgs := loadChatMessages(queries, gameID) var chatMu sync.Mutex chatMessages := chatToComponents(chatMsgs) // Send initial render of all components sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh) if err != nil { return } defer gameSub.Unsubscribe() //nolint:errcheck // Subscribe to chat messages chatCh := make(chan *nats.Msg, 64) chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh) if err != nil { return } defer chatSub.Unsubscribe() //nolint:errcheck ctx := r.Context() for { select { case <-ctx.Done(): return case <-gameCh: // Re-read player color in case we just joined myColor = gi.GetPlayerColor(playerID) sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) case msg := <-chatCh: var uiMsg game.ChatMessage if err := json.Unmarshal(msg.Data, &uiMsg); err != nil { continue } cm := components.ChatMessage{ Nickname: uiMsg.Nickname, Color: uiMsg.Color, Message: uiMsg.Message, Time: uiMsg.Time, } chatMu.Lock() chatMessages = append(chatMessages, cm) if len(chatMessages) > 50 { chatMessages = chatMessages[len(chatMessages)-50:] } chatMu.Unlock() chatMu.Lock() msgs := make([]components.ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil { return } } } } } func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { http.Error(w, "game not found", http.StatusNotFound) return } colStr := r.URL.Query().Get("col") col, err := strconv.Atoi(colStr) if err != nil { http.Error(w, "invalid column", http.StatusBadRequest) return } playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { playerID = game.PlayerID(userID) } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { http.Error(w, "not in game", http.StatusForbidden) return } gi.DropPiece(col, myColor) // The store's notifyFunc publishes to NATS, which triggers SSE updates. // Return empty SSE response. datastar.NewSSE(w, r) } } func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { http.Error(w, "game not found", http.StatusNotFound) return } type ChatSignals struct { ChatMsg string `json:"chatMsg"` } var signals ChatSignals if err := datastar.ReadSignals(r, &signals); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if signals.ChatMsg == "" { datastar.NewSSE(w, r) return } playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { playerID = game.PlayerID(userID) } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { datastar.NewSSE(w, r) return } g := gi.GetGame() nick := "" for _, p := range g.Players { if p != nil && p.ID == playerID { nick = p.Nickname break } } cm := game.ChatMessage{ Nickname: nick, Color: myColor, Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } saveChatMessage(queries, gameID, cm) data, err := json.Marshal(cm) if err != nil { datastar.NewSSE(w, r) return } nc.Publish("game.chat."+gameID, data) //nolint:errcheck // Clear the chat input sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck } } func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { sse := datastar.NewSSE(w, r) sse.Redirect("/") //nolint:errcheck return } type NicknameSignals struct { Nickname string `json:"nickname"` } var signals NicknameSignals if err := datastar.ReadSignals(r, &signals); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if signals.Nickname == "" { datastar.NewSSE(w, r) return } sessions.Put(r.Context(), "nickname", signals.Nickname) playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { playerID = game.PlayerID(userID) } if gi.GetPlayerColor(playerID) == 0 { player := &game.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { player.UserID = &userID } gi.Join(&game.PlayerSession{Player: player}) } sse := datastar.NewSSE(w, r) sse.Redirect("/games/" + gameID) //nolint:errcheck } } func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") gi, exists := store.Get(gameID) if !exists { sse := datastar.NewSSE(w, r) sse.Redirect("/") //nolint:errcheck return } newGI := gi.CreateRematch(store) sse := datastar.NewSSE(w, r) if newGI != nil { sse.Redirectf("/games/%s", newGI.ID()) //nolint:errcheck } } } // sendGameComponents patches all game-related SSE components. func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) { g := gi.GetGame() sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck chatMu.Lock() msgs := make([]components.ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck } // Chat persistence helpers — inlined from the former ChatPersister. func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) { queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck GameID: gameID, Nickname: msg.Nickname, Color: int64(msg.Color), Message: msg.Message, CreatedAt: msg.Time, }) } func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage { rows, err := queries.GetChatMessages(context.Background(), gameID) if err != nil { return nil } msgs := make([]game.ChatMessage, len(rows)) for i, r := range rows { msgs[i] = game.ChatMessage{ Nickname: r.Nickname, Color: int(r.Color), Message: r.Message, Time: r.CreatedAt, } } // DB returns newest-first; reverse for display slices.Reverse(msgs) return msgs } func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage { msgs := make([]components.ChatMessage, len(chatMsgs)) for i, m := range chatMsgs { msgs[i] = components.ChatMessage{ Nickname: m.Nickname, Color: m.Color, Message: m.Message, Time: m.Time, } } return msgs }