refactor: extract standalone chat package from game-specific handlers
Some checks failed
CI / Deploy / test (pull_request) Failing after 11s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped

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.
This commit is contained in:
Ryan Hamamura
2026-03-02 19:20:21 -10:00
parent 7eadfbbb0c
commit 10de5d21ad
10 changed files with 305 additions and 287 deletions

View File

@@ -1,22 +1,39 @@
package snakegame
import (
"encoding/json"
"fmt"
"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/chat"
chatcomponents "github.com/ryanhamamura/c4/chat/components"
"github.com/ryanhamamura/c4/features/snakegame/components"
"github.com/ryanhamamura/c4/features/snakegame/pages"
"github.com/ryanhamamura/c4/sessions"
"github.com/ryanhamamura/c4/snake"
)
func snakeChatColor(slot int) string {
if slot >= 0 && slot < len(snake.SnakeColors) {
return snake.SnakeColors[slot]
}
return "#666"
}
func snakeChatConfig(gameID string) chatcomponents.Config {
return chatcomponents.Config{
CSSPrefix: "snake",
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
Color: snakeChatColor,
StopKeyPropagation: true,
}
}
func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
@@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
// Auto-join if nickname exists and not already in game
if nickname != "" && si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
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 {
@@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
}
sg := si.GetGame()
if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil {
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
))
chatCfg := snakeChatConfig(gameID)
// 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
sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck
if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown {
sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck
}
@@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
// Chat subscription (multiplayer only)
var chatCh chan *nats.Msg
var chatSub *nats.Subscription
var chatMessages []components.ChatMessage
var chatMu sync.Mutex
var room *chat.Room
if sg.Mode == snake.ModeMultiplayer {
chatCh = make(chan *nats.Msg, 64)
chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh)
room = chat.NewRoom(nc, "snake.chat."+gameID, nil)
chatCh, chatSub, err = room.Subscribe()
if err != nil {
return
}
@@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
if msg == nil {
continue
}
var cm components.ChatMessage
if err := json.Unmarshal(msg.Data, &cm); err != nil {
_, snapshot := room.Receive(msg.Data)
if snapshot == 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 {
if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil {
return
}
}
@@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
}
sg := si.GetGame()
cm := components.ChatMessage{
msg := chat.Message{
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
room := chat.NewRoom(nc, "snake.chat."+gameID, nil)
room.Send(msg)
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
@@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt
userID := sessions.GetUserID(sm, r)
if si.GetPlayerSlot(playerID) < 0 {
player := &snake.Player{
p := &snake.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
player.UserID = &userID
p.UserID = &userID
}
si.Join(player)
si.Join(p)
}
sse := datastar.NewSSE(w, r)