Fix SSE architecture for reliable connections #13
@@ -1,46 +1,23 @@
|
|||||||
package c4game
|
package c4game
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/starfederation/datastar-go/datastar"
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
"github.com/ryanhamamura/games/chat"
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
|
||||||
"github.com/ryanhamamura/games/features/c4game/pages"
|
"github.com/ryanhamamura/games/features/c4game/pages"
|
||||||
|
"github.com/ryanhamamura/games/features/c4game/services"
|
||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors.
|
func HandleGamePage(store *connect4.Store, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||||
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -84,15 +61,15 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := gi.GetGame()
|
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)
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
gameID := chi.URLParam(r, "id")
|
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)
|
playerID := sessions.GetPlayerID(sm, r)
|
||||||
|
|
||||||
// Subscribe to game state updates BEFORE creating SSE
|
// Subscribe to game state updates BEFORE creating SSE
|
||||||
gameCh := make(chan *nats.Msg, 64)
|
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
||||||
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -115,8 +91,8 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
|
|||||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||||
|
|
||||||
// Subscribe to chat messages BEFORE creating SSE
|
// Subscribe to chat messages BEFORE creating SSE
|
||||||
chatCfg := c4ChatConfig(gameID)
|
chatCfg := svc.ChatConfig(gameID)
|
||||||
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
room := svc.ChatRoom(gameID)
|
||||||
chatCh, cleanupChat := room.Subscribe()
|
chatCh, cleanupChat := room.Subscribe()
|
||||||
defer cleanupChat()
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -256,7 +232,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
|
|||||||
Message: signals.ChatMsg,
|
Message: signals.ChatMsg,
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
room := svc.ChatRoom(gameID)
|
||||||
room.Send(msg)
|
room.Send(msg)
|
||||||
|
|
||||||
sse := datastar.NewSSE(w, r)
|
sse := datastar.NewSSE(w, r)
|
||||||
|
|||||||
@@ -4,24 +4,22 @@ package c4game
|
|||||||
import (
|
import (
|
||||||
"github.com/alexedwards/scs/v2"
|
"github.com/alexedwards/scs/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
"github.com/ryanhamamura/games/features/c4game/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(
|
func SetupRoutes(
|
||||||
router chi.Router,
|
router chi.Router,
|
||||||
store *connect4.Store,
|
store *connect4.Store,
|
||||||
nc *nats.Conn,
|
svc *services.GameService,
|
||||||
sessions *scs.SessionManager,
|
sessions *scs.SessionManager,
|
||||||
queries *repository.Queries,
|
|
||||||
) {
|
) {
|
||||||
router.Route("/games/{id}", func(r chi.Router) {
|
router.Route("/games/{id}", func(r chi.Router) {
|
||||||
r.Get("/", HandleGamePage(store, sessions, queries))
|
r.Get("/", HandleGamePage(store, svc, sessions))
|
||||||
r.Get("/events", HandleGameEvents(store, nc, sessions, queries))
|
r.Get("/events", HandleGameEvents(store, svc, sessions))
|
||||||
r.Post("/drop", HandleDropPiece(store, 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("/join", HandleSetNickname(store, sessions))
|
||||||
r.Post("/rematch", HandleRematch(store, sessions))
|
r.Post("/rematch", HandleRematch(store, sessions))
|
||||||
})
|
})
|
||||||
|
|||||||
70
features/c4game/services/game_service.go
Normal file
70
features/c4game/services/game_service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -7,19 +7,20 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"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/config"
|
||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/games/features/auth"
|
"github.com/ryanhamamura/games/features/auth"
|
||||||
"github.com/ryanhamamura/games/features/c4game"
|
"github.com/ryanhamamura/games/features/c4game"
|
||||||
|
"github.com/ryanhamamura/games/features/c4game/services"
|
||||||
"github.com/ryanhamamura/games/features/lobby"
|
"github.com/ryanhamamura/games/features/lobby"
|
||||||
"github.com/ryanhamamura/games/features/snakegame"
|
"github.com/ryanhamamura/games/features/snakegame"
|
||||||
"github.com/ryanhamamura/games/snake"
|
"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(
|
func SetupRoutes(
|
||||||
@@ -40,9 +41,12 @@ func SetupRoutes(
|
|||||||
setupReload(router)
|
setupReload(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Services
|
||||||
|
c4Svc := services.NewGameService(nc, queries)
|
||||||
|
|
||||||
auth.SetupRoutes(router, queries, sessions)
|
auth.SetupRoutes(router, queries, sessions)
|
||||||
lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
|
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)
|
snakegame.SetupRoutes(router, snakeStore, nc, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user