- Replace Create+Get+Update with UpsertGame/UpsertSnakeGame queries - Extract free functions (saveGame, loadGame, etc.) from duplicated receiver methods on Store and Instance types - Remove duplicate generateID from snake package, reuse game.GenerateID - Throttle snake game DB writes to every 2s instead of every tick - Fix double-lock in c4game chat handler - Update all code for sqlc pointer types (*string instead of sql.NullString)
401 lines
11 KiB
Go
401 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"
|
|
)
|
|
|
|
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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
|
if playerID == "" {
|
|
playerID = game.PlayerID(game.GenerateID(8))
|
|
sessions.Put(r.Context(), "player_id", string(playerID))
|
|
}
|
|
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
playerID = game.PlayerID(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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
playerID = game.PlayerID(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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
playerID = game.PlayerID(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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
playerID = game.PlayerID(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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
|
userID := sessions.GetString(r.Context(), "user_id")
|
|
if userID != "" {
|
|
playerID = game.PlayerID(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
|
|
}
|