Files
games/features/c4game/handlers.go
Ryan Hamamura c6885a069b
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped
refactor: rename Go module from c4 to games
Rename module path github.com/ryanhamamura/c4 to
github.com/ryanhamamura/games across go.mod, all source files,
and golangci config.
2026-03-02 20:41:20 -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/games/chat"
chatcomponents "github.com/ryanhamamura/games/chat/components"
"github.com/ryanhamamura/games/connect4"
"github.com/ryanhamamura/games/db/repository"
"github.com/ryanhamamura/games/features/c4game/components"
"github.com/ryanhamamura/games/features/c4game/pages"
"github.com/ryanhamamura/games/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 *connect4.Store, 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 := &connect4.Player{
ID: playerID,
Nickname: nickname,
}
if userID != "" {
p.UserID = &userID
}
gi.Join(&connect4.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 *connect4.Store, 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, "connect4.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("connect4."+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 *connect4.Store, 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 *connect4.Store, 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, "connect4.chat."+gameID, nil)
room.Send(msg)
sse := datastar.NewSSE(w, r)
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
}
}
func HandleSetNickname(store *connect4.Store, 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 := &connect4.Player{
ID: playerID,
Nickname: signals.Nickname,
}
if userID != "" {
p.UserID = &userID
}
gi.Join(&connect4.PlayerSession{Player: p})
}
sse := datastar.NewSSE(w, r)
sse.Redirect("/games/" + gameID) //nolint:errcheck
}
}
func HandleRematch(store *connect4.Store, 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 *connect4.Instance, 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
}