Files
games/features/snakegame/handlers.go
Ryan Hamamura 063b03ce25 refactor: extract shared player.ID type and GenerateID to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
2026-03-02 19:09:01 -10:00

325 lines
8.3 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/player"
"github.com/ryanhamamura/c4/snake"
)
func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID {
pid := sessions.GetString(r.Context(), "player_id")
if pid == "" {
pid = player.GenerateID(8)
sessions.Put(r.Context(), "player_id", pid)
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
return player.ID(userID)
}
return player.ID(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
}
}
}