Files
games/features/c4game/handlers.go
Ryan Hamamura 10de5d21ad
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
refactor: extract standalone chat package from game-specific handlers
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.
2026-03-02 19:20:21 -10:00

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
}