The ConnectionIndicator component caused PatchElementsNoTargetsFound errors due to complex nested IDs. Removing it for now - we can design a better solution later if needed.
313 lines
7.5 KiB
Go
313 lines
7.5 KiB
Go
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 refreshes game state to keep connection alive
|
|
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
|
|
}
|
|
}
|
|
}
|