From 8536f8e948e6f4a17cf6f0626117bd1d5547c9c1 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:23:25 -1000 Subject: [PATCH] refactor: extract GameService for Connect 4 NATS/chat handling Move NATS subscription and chat room management into a dedicated GameService, following the portigo service pattern. Handlers now receive the service and call its methods instead of managing NATS connections directly. --- features/c4game/handlers.go | 44 ++++----------- features/c4game/routes.go | 12 ++-- features/c4game/services/game_service.go | 70 ++++++++++++++++++++++++ router/router.go | 16 ++++-- 4 files changed, 95 insertions(+), 47 deletions(-) create mode 100644 features/c4game/services/game_service.go diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 3fa7565..3c94474 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -1,46 +1,23 @@ 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/pages" + "github.com/ryanhamamura/games/features/c4game/services" "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 { +func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -84,15 +61,15 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo } g := gi.GetGame() - room := chat.NewPersistentRoom(nil, "", queries, gameID) + room := svc.ChatRoom(gameID) - if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { + if err := pages.GamePage(g, myColor, room.Messages(), svc.ChatConfig(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 { +func HandleGameEvents(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() gameID := chi.URLParam(r, "id") @@ -106,8 +83,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag playerID := sessions.GetPlayerID(sm, r) // Subscribe to game state updates BEFORE creating SSE - gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh) + gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -115,8 +91,8 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag defer gameSub.Unsubscribe() //nolint:errcheck // Subscribe to chat messages BEFORE creating SSE - chatCfg := c4ChatConfig(gameID) - room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) + chatCfg := svc.ChatConfig(gameID) + room := svc.ChatRoom(gameID) chatCh, cleanupChat := room.Subscribe() defer cleanupChat() @@ -209,7 +185,7 @@ func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.Handler } } -func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleSendChat(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -256,7 +232,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) + room := svc.ChatRoom(gameID) room.Send(msg) sse := datastar.NewSSE(w, r) diff --git a/features/c4game/routes.go b/features/c4game/routes.go index e936fd4..123eb7c 100644 --- a/features/c4game/routes.go +++ b/features/c4game/routes.go @@ -4,24 +4,22 @@ package c4game import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" - "github.com/nats-io/nats.go" "github.com/ryanhamamura/games/connect4" - "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/features/c4game/services" ) func SetupRoutes( router chi.Router, store *connect4.Store, - nc *nats.Conn, + svc *services.GameService, sessions *scs.SessionManager, - queries *repository.Queries, ) { router.Route("/games/{id}", func(r chi.Router) { - r.Get("/", HandleGamePage(store, sessions, queries)) - r.Get("/events", HandleGameEvents(store, nc, sessions, queries)) + r.Get("/", HandleGamePage(store, svc, sessions)) + r.Get("/events", HandleGameEvents(store, svc, sessions)) r.Post("/drop", HandleDropPiece(store, sessions)) - r.Post("/chat", HandleSendChat(store, nc, sessions, queries)) + r.Post("/chat", HandleSendChat(store, svc, sessions)) r.Post("/join", HandleSetNickname(store, sessions)) r.Post("/rematch", HandleRematch(store, sessions)) }) diff --git a/features/c4game/services/game_service.go b/features/c4game/services/game_service.go new file mode 100644 index 0000000..40927ad --- /dev/null +++ b/features/c4game/services/game_service.go @@ -0,0 +1,70 @@ +// Package services provides the game service layer for Connect 4, +// handling NATS subscriptions and chat room management. +package services + +import ( + "fmt" + + "github.com/nats-io/nats.go" + + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" +) + +// c4ChatColors maps player slot (0-indexed) to CSS background colors. +var c4ChatColors = map[int]string{ + 0: "#4a2a3a", // Red player + 1: "#2a4545", // Yellow player +} + +func c4ChatColor(slot int) string { + if c, ok := c4ChatColors[slot]; ok { + return c + } + return "#666" +} + +// GameService manages NATS subscriptions and chat for Connect 4 games. +type GameService struct { + nc *nats.Conn + queries *repository.Queries +} + +// NewGameService creates a new game service. +func NewGameService(nc *nats.Conn, queries *repository.Queries) *GameService { + return &GameService{ + nc: nc, + queries: queries, + } +} + +// SubscribeGameUpdates returns a NATS subscription and channel for game state updates. +func (s *GameService) SubscribeGameUpdates(gameID string) (*nats.Subscription, <-chan *nats.Msg, error) { + ch := make(chan *nats.Msg, 64) + sub, err := s.nc.ChanSubscribe(connect4.GameSubject(gameID), ch) + if err != nil { + return nil, nil, fmt.Errorf("subscribing to game updates: %w", err) + } + return sub, ch, nil +} + +// ChatConfig returns the chat configuration for a game. +func (s *GameService) ChatConfig(gameID string) chatcomponents.Config { + return chatcomponents.Config{ + CSSPrefix: "c4", + PostURL: fmt.Sprintf("/games/%s/chat", gameID), + Color: c4ChatColor, + } +} + +// ChatRoom returns a persistent chat room for a game. +func (s *GameService) ChatRoom(gameID string) *chat.Room { + return chat.NewPersistentRoom(s.nc, connect4.ChatSubject(gameID), s.queries, gameID) +} + +// PublishGameUpdate sends a notification that the game state has changed. +func (s *GameService) PublishGameUpdate(gameID string) error { + return s.nc.Publish(connect4.GameSubject(gameID), nil) +} diff --git a/router/router.go b/router/router.go index 1a2cd8f..a779768 100644 --- a/router/router.go +++ b/router/router.go @@ -7,19 +7,20 @@ import ( "net/http" "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/games/config" "github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/db/repository" "github.com/ryanhamamura/games/features/auth" "github.com/ryanhamamura/games/features/c4game" + "github.com/ryanhamamura/games/features/c4game/services" "github.com/ryanhamamura/games/features/lobby" "github.com/ryanhamamura/games/features/snakegame" "github.com/ryanhamamura/games/snake" - - "github.com/alexedwards/scs/v2" - "github.com/go-chi/chi/v5" - "github.com/nats-io/nats.go" - "github.com/starfederation/datastar-go/datastar" ) func SetupRoutes( @@ -40,9 +41,12 @@ func SetupRoutes( setupReload(router) } + // Services + c4Svc := services.NewGameService(nc, queries) + auth.SetupRoutes(router, queries, sessions) lobby.SetupRoutes(router, queries, sessions, store, snakeStore) - c4game.SetupRoutes(router, store, nc, sessions, queries) + c4game.SetupRoutes(router, store, c4Svc, sessions) snakegame.SetupRoutes(router, snakeStore, nc, sessions) }