package snakegame import ( "encoding/json" "net/http" "strconv" "sync" "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/features/snakegame/components" "github.com/ryanhamamura/c4/features/snakegame/pages" "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/snake" ) func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { pid := sessions.GetString(r.Context(), "player_id") if pid == "" { pid = game.GenerateID(8) sessions.Put(r.Context(), "player_id", pid) } userID := sessions.GetString(r.Context(), "user_id") if userID != "" { return snake.PlayerID(userID) } return snake.PlayerID(pid) } func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } playerID := getPlayerID(sessions, r) nickname := sessions.GetString(r.Context(), "nickname") userID := sessions.GetString(r.Context(), "user_id") // Auto-join if nickname exists and not already in game if nickname != "" && si.GetPlayerSlot(playerID) < 0 { player := &snake.Player{ ID: playerID, Nickname: nickname, } if userID != "" { player.UserID = &userID } si.Join(player) } mySlot := si.GetPlayerSlot(playerID) if mySlot < 0 { // Not in game yet 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 } sg := si.GetGame() if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Error(w, "game not found", http.StatusNotFound) return } playerID := getPlayerID(sessions, r) mySlot := si.GetPlayerSlot(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) // Send initial render sg := si.GetGame() sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck if sg.Mode == snake.ModeMultiplayer { sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown { sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck } } // Subscribe to game updates via NATS gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh) if err != nil { return } defer gameSub.Unsubscribe() //nolint:errcheck // Chat subscription (multiplayer only) var chatCh chan *nats.Msg var chatSub *nats.Subscription var chatMessages []components.ChatMessage var chatMu sync.Mutex if sg.Mode == snake.ModeMultiplayer { chatCh = make(chan *nats.Msg, 64) chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh) if err != nil { return } defer chatSub.Unsubscribe() //nolint:errcheck } ctx := r.Context() for { select { case <-ctx.Done(): return case <-gameCh: // Drain backed-up game updates for { select { case <-gameCh: default: goto drained } } drained: si, ok = snakeStore.Get(gameID) if !ok { return } mySlot = si.GetPlayerSlot(playerID) sg = si.GetGame() if err := sse.PatchElementTempl(components.Board(sg)); err != nil { return } if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil { return } if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil { return } case msg := <-chatCh: if msg == nil { continue } var cm components.ChatMessage if err := json.Unmarshal(msg.Data, &cm); err != nil { continue } chatMu.Lock() chatMessages = append(chatMessages, cm) if len(chatMessages) > 50 { chatMessages = chatMessages[len(chatMessages)-50:] } msgs := make([]components.ChatMessage, len(chatMessages)) copy(msgs, chatMessages) chatMu.Unlock() if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil { return } } } } } func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Error(w, "game not found", http.StatusNotFound) return } playerID := getPlayerID(sessions, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) return } dStr := r.URL.Query().Get("d") d, err := strconv.Atoi(dStr) if err != nil || d < 0 || d > 3 { http.Error(w, "invalid direction", http.StatusBadRequest) return } si.SetDirection(slot, snake.Direction(d)) w.WriteHeader(http.StatusOK) } } type chatSignals struct { ChatMsg string `json:"chatMsg"` } func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Error(w, "game not found", http.StatusNotFound) return } var signals chatSignals if err := datastar.ReadSignals(r, &signals); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if signals.ChatMsg == "" { return } playerID := getPlayerID(sessions, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) return } sg := si.GetGame() cm := components.ChatMessage{ Nickname: sg.Players[slot].Nickname, Slot: slot, Message: signals.ChatMsg, } data, err := json.Marshal(cm) if err != nil { return } nc.Publish("snake.chat."+gameID, data) //nolint:errcheck sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck } } type nicknameSignals struct { Nickname string `json:"nickname"` } func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Error(w, "game not found", http.StatusNotFound) return } var signals nicknameSignals if err := datastar.ReadSignals(r, &signals); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if signals.Nickname == "" { return } sessions.Put(r.Context(), "nickname", signals.Nickname) playerID := getPlayerID(sessions, r) userID := sessions.GetString(r.Context(), "user_id") if si.GetPlayerSlot(playerID) < 0 { player := &snake.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { player.UserID = &userID } si.Join(player) } sse := datastar.NewSSE(w, r) sse.Redirect("/snake/" + gameID) //nolint:errcheck } } func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) if !ok { http.Error(w, "game not found", http.StatusNotFound) return } newSI := si.CreateRematch() sse := datastar.NewSSE(w, r) if newSI != nil { sse.Redirect("/snake/" + newSI.ID()) //nolint:errcheck } } }