package c4game import ( "fmt" "net/http" "strconv" "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/games/chat" chatcomponents "github.com/ryanhamamura/games/chat/components" "github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/db/repository" "github.com/ryanhamamura/games/features/c4game/components" "github.com/ryanhamamura/games/features/c4game/pages" "github.com/ryanhamamura/games/sessions" ) // c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors. var c4ChatColors = map[int]string{ 0: "#4a2a3a", // color 1 stored as slot 0 1: "#2a4545", // color 2 stored as slot 1 } func c4ChatColor(slot int) string { if c, ok := c4ChatColors[slot]; ok { return c } return "#666" } func c4ChatConfig(gameID string) chatcomponents.Config { return chatcomponents.Config{ CSSPrefix: "c4", PostURL: fmt.Sprintf("/games/%s/chat", gameID), Color: c4ChatColor, } } func HandleGamePage(store *connect4.Store, sm *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 := sessions.GetPlayerID(sm, r) userID := sessions.GetUserID(sm, r) nickname := sessions.GetNickname(sm, r) // Auto-join if player has a nickname but isn't in the game yet if nickname != "" && gi.GetPlayerColor(playerID) == 0 { p := &connect4.Player{ ID: playerID, Nickname: nickname, } if userID != "" { p.UserID = &userID } gi.Join(&connect4.PlayerSession{Player: p}) } myColor := gi.GetPlayerColor(playerID) if myColor == 0 { // Player not in game isGuest := r.URL.Query().Get("guest") == "1" if userID == "" && !isGuest { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } return } g := gi.GetGame() msgs := chat.LoadMessages(queries, gameID) if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *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 := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) chatCfg := c4ChatConfig(gameID) room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID)) // Send initial render sendGameComponents(sse, gi, myColor, room, chatCfg) // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe("connect4."+gameID, gameCh) if err != nil { return } defer gameSub.Unsubscribe() //nolint:errcheck // Subscribe to chat messages chatCh, chatSub, err := room.Subscribe() if err != nil { return } defer chatSub.Unsubscribe() //nolint:errcheck ctx := r.Context() for { select { case <-ctx.Done(): return case <-gameCh: myColor = gi.GetPlayerColor(playerID) sendGameComponents(sse, gi, myColor, room, chatCfg) case msg := <-chatCh: _, snapshot := room.Receive(msg.Data) if snapshot == nil { continue } if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil { return } } } } } func HandleDropPiece(store *connect4.Store, sm *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 := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) if myColor == 0 { http.Error(w, "not in game", http.StatusForbidden) return } gi.DropPiece(col, myColor) datastar.NewSSE(w, r) } } func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *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 := sessions.GetPlayerID(sm, r) 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 } } // Map color (1-based) to slot (0-based) for the unified chat message msg := chat.Message{ Nickname: nick, Slot: myColor - 1, Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } chat.SaveMessage(queries, gameID, msg) room := chat.NewRoom(nc, "connect4.chat."+gameID, nil) room.Send(msg) sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck } } func HandleSetNickname(store *connect4.Store, sm *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 } sm.Put(r.Context(), "nickname", signals.Nickname) playerID := sessions.GetPlayerID(sm, r) userID := sessions.GetUserID(sm, r) if gi.GetPlayerColor(playerID) == 0 { p := &connect4.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { p.UserID = &userID } gi.Join(&connect4.PlayerSession{Player: p}) } sse := datastar.NewSSE(w, r) sse.Redirect("/games/" + gameID) //nolint:errcheck } } func HandleRematch(store *connect4.Store, sm *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 *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { 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 sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck }