Create chat/ package with Message type, Room (NATS pub/sub + buffer), DB persistence helpers, and a unified templ component parameterized by Config (CSS prefix, post URL, color function, key propagation). Both c4game and snakegame now use chat.Room for message management and chatcomponents.Chat for rendering, eliminating the duplicated ChatMessage types, chat templ components, chatAutoScroll scripts, color functions, and inline buffer management.
315 lines
8.3 KiB
Go
315 lines
8.3 KiB
Go
package c4game
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"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/chat"
|
|
chatcomponents "github.com/ryanhamamura/c4/chat/components"
|
|
"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/sessions"
|
|
)
|
|
|
|
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
|
|
var c4ChatColors = map[int]string{
|
|
0: "#4a2a3a", // color 1 stored as slot 0
|
|
1: "#2a4545", // color 2 stored as slot 1
|
|
}
|
|
|
|
func c4ChatColor(slot int) string {
|
|
if c, ok := c4ChatColors[slot]; ok {
|
|
return c
|
|
}
|
|
return "#666"
|
|
}
|
|
|
|
func c4ChatConfig(gameID string) chatcomponents.Config {
|
|
return chatcomponents.Config{
|
|
CSSPrefix: "c4",
|
|
PostURL: fmt.Sprintf("/games/%s/chat", gameID),
|
|
Color: c4ChatColor,
|
|
}
|
|
}
|
|
|
|
func HandleGamePage(store *game.GameStore, sm *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 := sessions.GetPlayerID(sm, r)
|
|
userID := sessions.GetUserID(sm, r)
|
|
nickname := sessions.GetNickname(sm, r)
|
|
|
|
// Auto-join if player has a nickname but isn't in the game yet
|
|
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
|
p := &game.Player{
|
|
ID: playerID,
|
|
Nickname: nickname,
|
|
}
|
|
if userID != "" {
|
|
p.UserID = &userID
|
|
}
|
|
gi.Join(&game.PlayerSession{Player: p})
|
|
}
|
|
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
|
|
if myColor == 0 {
|
|
// Player not in game
|
|
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
|
|
}
|
|
|
|
g := gi.GetGame()
|
|
msgs := chat.LoadMessages(queries, gameID)
|
|
|
|
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *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 := sessions.GetPlayerID(sm, r)
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
|
|
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
|
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
|
))
|
|
|
|
chatCfg := c4ChatConfig(gameID)
|
|
room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID))
|
|
|
|
// Send initial render
|
|
sendGameComponents(sse, gi, myColor, room, chatCfg)
|
|
|
|
// 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, chatSub, err := room.Subscribe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer chatSub.Unsubscribe() //nolint:errcheck
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-gameCh:
|
|
myColor = gi.GetPlayerColor(playerID)
|
|
sendGameComponents(sse, gi, myColor, room, chatCfg)
|
|
case msg := <-chatCh:
|
|
_, snapshot := room.Receive(msg.Data)
|
|
if snapshot == nil {
|
|
continue
|
|
}
|
|
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func HandleDropPiece(store *game.GameStore, sm *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 := sessions.GetPlayerID(sm, r)
|
|
myColor := gi.GetPlayerColor(playerID)
|
|
if myColor == 0 {
|
|
http.Error(w, "not in game", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
gi.DropPiece(col, myColor)
|
|
datastar.NewSSE(w, r)
|
|
}
|
|
}
|
|
|
|
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *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 := sessions.GetPlayerID(sm, r)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Map color (1-based) to slot (0-based) for the unified chat message
|
|
msg := chat.Message{
|
|
Nickname: nick,
|
|
Slot: myColor - 1,
|
|
Message: signals.ChatMsg,
|
|
Time: time.Now().UnixMilli(),
|
|
}
|
|
chat.SaveMessage(queries, gameID, msg)
|
|
|
|
room := chat.NewRoom(nc, "game.chat."+gameID, nil)
|
|
room.Send(msg)
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func HandleSetNickname(store *game.GameStore, sm *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
|
|
}
|
|
|
|
sm.Put(r.Context(), "nickname", signals.Nickname)
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
userID := sessions.GetUserID(sm, r)
|
|
|
|
if gi.GetPlayerColor(playerID) == 0 {
|
|
p := &game.Player{
|
|
ID: playerID,
|
|
Nickname: signals.Nickname,
|
|
}
|
|
if userID != "" {
|
|
p.UserID = &userID
|
|
}
|
|
gi.Join(&game.PlayerSession{Player: p})
|
|
}
|
|
|
|
sse := datastar.NewSSE(w, r)
|
|
sse.Redirect("/games/" + gameID) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
func HandleRematch(store *game.GameStore, sm *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, room *chat.Room, chatCfg chatcomponents.Config) {
|
|
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
|
|
sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
|
|
}
|