package snakegame import ( "errors" "net/http" "strconv" "time" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/starfederation/datastar-go/datastar" "github.com/ryanhamamura/games/chat" chatcomponents "github.com/ryanhamamura/games/chat/components" "github.com/ryanhamamura/games/features/snakegame/pages" "github.com/ryanhamamura/games/features/snakegame/services" "github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/snake" ) func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *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 := sessions.GetPlayerID(sm, r) nickname := sessions.GetNickname(sm, r) userID := sessions.GetUserID(sm, r) // Auto-join if nickname exists and not already in game if nickname != "" && si.GetPlayerSlot(playerID) < 0 { p := &snake.Player{ ID: playerID, Nickname: nickname, } if userID != "" { p.UserID = &userID } si.Join(p) } mySlot := si.GetPlayerSlot(playerID) if mySlot < 0 { 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() chatCfg := svc.ChatConfig(gameID) if err := pages.GamePage(sg, mySlot, nil, chatCfg, gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService, sm *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 := sessions.GetPlayerID(sm, r) mySlot := si.GetPlayerSlot(playerID) // Subscribe to game updates BEFORE creating SSE (following portigo pattern) gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } defer gameSub.Unsubscribe() //nolint:errcheck sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) chatCfg := svc.ChatConfig(gameID) // Chat room (multiplayer only) var room *chat.Room sg := si.GetGame() if sg.Mode == snake.ModeMultiplayer { room = svc.ChatRoom(gameID) } chatMessages := func() []chat.Message { if room == nil { return nil } return room.Messages() } patchAll := func() error { si, ok = snakeStore.Get(gameID) if !ok { return errors.New("game not found") } mySlot = si.GetPlayerSlot(playerID) sg = si.GetGame() return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID)) } // Send initial render if err := patchAll(); err != nil { return } heartbeat := time.NewTicker(10 * time.Second) defer heartbeat.Stop() // Chat subscription (multiplayer only) var chatCh <-chan chat.Message var cleanupChat func() if room != nil { chatCh, cleanupChat = room.Subscribe() defer cleanupChat() } ctx := r.Context() for { select { case <-ctx.Done(): return case <-heartbeat.C: // Heartbeat just refreshes game state if err := patchAll(); err != nil { return } case <-gameCh: // Drain backed-up game updates for { select { case <-gameCh: default: goto drained } } drained: if err := patchAll(); err != nil { return } case chatMsg, ok := <-chatCh: if !ok { continue } err := sse.PatchElementTempl( chatcomponents.ChatMessage(chatMsg, chatCfg), datastar.WithSelectorID("snake-chat-history"), datastar.WithModeAppend(), ) if err != nil { return } } } } } func HandleSetDirection(snakeStore *snake.SnakeStore, sm *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 := sessions.GetPlayerID(sm, 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, svc *services.GameService, sm *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 := sessions.GetPlayerID(sm, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) return } sg := si.GetGame() msg := chat.Message{ Nickname: sg.Players[slot].Nickname, Slot: slot, Message: signals.ChatMsg, } room := svc.ChatRoom(gameID) room.Send(msg) 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, sm *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 } sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname) playerID := sessions.GetPlayerID(sm, r) userID := sessions.GetUserID(sm, r) if si.GetPlayerSlot(playerID) < 0 { p := &snake.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { p.UserID = &userID } si.Join(p) } sse := datastar.NewSSE(w, r) sse.Redirect("/snake/" + gameID) //nolint:errcheck } } func HandleRematch(snakeStore *snake.SnakeStore, sm *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 } } }