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.
402 lines
11 KiB
Go
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
|
|
}
|