package snakegame import ( "fmt" "net/http" "strconv" "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/features/snakegame/pages" "github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/snake" ) func snakeChatColor(slot int) string { if slot >= 0 && slot < len(snake.SnakeColors) { return snake.SnakeColors[slot] } return "#666" } func snakeChatConfig(gameID string) chatcomponents.Config { return chatcomponents.Config{ CSSPrefix: "snake", PostURL: fmt.Sprintf("/snake/%s/chat", gameID), Color: snakeChatColor, StopKeyPropagation: true, } } func HandleSnakePage(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.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() if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } } func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, 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) sse := datastar.NewSSE(w, r, datastar.WithCompression( datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) chatCfg := snakeChatConfig(gameID) // Chat room (multiplayer only) var room *chat.Room sg := si.GetGame() if sg.Mode == snake.ModeMultiplayer { room = chat.NewRoom(nc, snake.ChatSubject(gameID)) } chatMessages := func() []chat.Message { if room == nil { return nil } return room.Messages() } patchAll := func() error { si, ok = snakeStore.Get(gameID) if !ok { return fmt.Errorf("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 } // Subscribe to game updates via NATS gameCh := make(chan *nats.Msg, 64) gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh) if err != nil { return } defer gameSub.Unsubscribe() //nolint:errcheck // 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 <-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, nc *nats.Conn, 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 := chat.NewRoom(nc, snake.ChatSubject(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 } } }