Files
games/features/c4game/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

402 lines
11 KiB
Go

package c4game
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"sync"
"time"
"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/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/player"
)
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
http.Redirect(w, r, "/", http.StatusFound)
return
}
playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
if playerID == "" {
playerID = player.ID(player.GenerateID(8))
sessions.Put(r.Context(), "player_id", string(playerID))
}
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = player.ID(userID)
}
nickname := sessions.GetString(r.Context(), "nickname")
// Auto-join if player has a nickname but isn't in the game yet
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
player.UserID = &userID
}
gi.Join(&game.PlayerSession{Player: player})
}
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
// Player not in game
isGuest := r.URL.Query().Get("guest") == "1"
if userID == "" && !isGuest {
// Show join prompt (login vs guest)
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Show nickname prompt
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
// Player is in the game — render full game page
g := gi.GetGame()
chatMsgs := loadChatMessages(queries, gameID)
msgs := chatToComponents(chatMsgs)
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
}
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
http.Error(w, "game not found", http.StatusNotFound)
return
}
playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
sse := datastar.NewSSE(w, r, datastar.WithCompression(
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
// Load initial chat messages
chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex
chatMessages := chatToComponents(chatMsgs)
// Send initial render of all components
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
// Subscribe to game state updates
gameCh := make(chan *nats.Msg, 64)
gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh)
if err != nil {
return
}
defer gameSub.Unsubscribe() //nolint:errcheck
// Subscribe to chat messages
chatCh := make(chan *nats.Msg, 64)
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
if err != nil {
return
}
defer chatSub.Unsubscribe() //nolint:errcheck
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-gameCh:
// Re-read player color in case we just joined
myColor = gi.GetPlayerColor(playerID)
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
case msg := <-chatCh:
var uiMsg game.ChatMessage
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
continue
}
cm := components.ChatMessage{
Nickname: uiMsg.Nickname,
Color: uiMsg.Color,
Message: uiMsg.Message,
Time: uiMsg.Time,
}
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), datastar.WithSelectorID("c4-chat")); err != nil {
return
}
}
}
}
}
func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
http.Error(w, "game not found", http.StatusNotFound)
return
}
colStr := r.URL.Query().Get("col")
col, err := strconv.Atoi(colStr)
if err != nil {
http.Error(w, "invalid column", http.StatusBadRequest)
return
}
playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
http.Error(w, "not in game", http.StatusForbidden)
return
}
gi.DropPiece(col, myColor)
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
// Return empty SSE response.
datastar.NewSSE(w, r)
}
}
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
http.Error(w, "game not found", http.StatusNotFound)
return
}
type ChatSignals struct {
ChatMsg string `json:"chatMsg"`
}
var signals ChatSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if signals.ChatMsg == "" {
datastar.NewSSE(w, r)
return
}
playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = player.ID(userID)
}
myColor := gi.GetPlayerColor(playerID)
if myColor == 0 {
datastar.NewSSE(w, r)
return
}
g := gi.GetGame()
nick := ""
for _, p := range g.Players {
if p != nil && p.ID == playerID {
nick = p.Nickname
break
}
}
cm := game.ChatMessage{
Nickname: nick,
Color: myColor,
Message: signals.ChatMsg,
Time: time.Now().UnixMilli(),
}
saveChatMessage(queries, gameID, cm)
data, err := json.Marshal(cm)
if err != nil {
datastar.NewSSE(w, r)
return
}
nc.Publish("game.chat."+gameID, data) //nolint:errcheck
// Clear the chat input
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
}
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
sse := datastar.NewSSE(w, r)
sse.Redirect("/") //nolint:errcheck
return
}
type NicknameSignals struct {
Nickname string `json:"nickname"`
}
var signals NicknameSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if signals.Nickname == "" {
datastar.NewSSE(w, r)
return
}
sessions.Put(r.Context(), "nickname", signals.Nickname)
playerID := player.ID(sessions.GetString(r.Context(), "player_id"))
userID := sessions.GetString(r.Context(), "user_id")
if userID != "" {
playerID = player.ID(userID)
}
if gi.GetPlayerColor(playerID) == 0 {
player := &game.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
player.UserID = &userID
}
gi.Join(&game.PlayerSession{Player: player})
}
sse := datastar.NewSSE(w, r)
sse.Redirect("/games/" + gameID) //nolint:errcheck
}
}
func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
gi, exists := store.Get(gameID)
if !exists {
sse := datastar.NewSSE(w, r)
sse.Redirect("/") //nolint:errcheck
return
}
newGI := gi.CreateRematch(store)
sse := datastar.NewSSE(w, r)
if newGI != nil {
sse.Redirectf("/games/%s", newGI.ID()) //nolint:errcheck
}
}
}
// sendGameComponents patches all game-related SSE components.
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
g := gi.GetGame()
sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck
sse.PatchElementTempl(components.StatusBanner(g, myColor), datastar.WithSelectorID("c4-status")) //nolint:errcheck
sse.PatchElementTempl(components.PlayerInfo(g, myColor), datastar.WithSelectorID("c4-players")) //nolint:errcheck
chatMu.Lock()
msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages)
chatMu.Unlock()
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
}
// Chat persistence helpers — inlined from the former ChatPersister.
func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),
Message: msg.Message,
CreatedAt: msg.Time,
})
}
func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage {
rows, err := queries.GetChatMessages(context.Background(), gameID)
if err != nil {
return nil
}
msgs := make([]game.ChatMessage, len(rows))
for i, r := range rows {
msgs[i] = game.ChatMessage{
Nickname: r.Nickname,
Color: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for display
slices.Reverse(msgs)
return msgs
}
func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage {
msgs := make([]components.ChatMessage, len(chatMsgs))
for i, m := range chatMsgs {
msgs[i] = components.ChatMessage{
Nickname: m.Nickname,
Color: m.Color,
Message: m.Message,
Time: m.Time,
}
}
return msgs
}