Files
games/features/snakegame/handlers.go
Ryan Hamamura 5120eef776
All checks were successful
CI / Deploy / test (pull_request) Successful in 13s
CI / Deploy / lint (pull_request) Successful in 23s
CI / Deploy / deploy (pull_request) Has been skipped
refactor: streamline routes to RESTful naming conventions
Remove /api/ prefix and consolidate route groups:
- /api/lobby/* -> /games, /snake, /logout (top-level)
- /game/{game_id} + /api/game/{game_id}/* -> /games/{id}/*
- /snake/{game_id} + /api/snake/{game_id}/* -> /snake/{id}/*
- /api/auth/* -> /auth/*
- Standardize snake join page to use return_url= (was return=)
2026-03-02 13:19:03 -10:00

325 lines
8.4 KiB
Go

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
}
}
}