Fix SSE architecture for reliable connections (#13)
This commit was merged in pull request #13.
This commit is contained in:
@@ -1,47 +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"
|
||||
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||
"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")
|
||||
|
||||
@@ -85,16 +61,17 @@ 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")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
@@ -104,68 +81,74 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
|
||||
}
|
||||
|
||||
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.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID)
|
||||
|
||||
patchAll := func() error {
|
||||
myColor = gi.GetPlayerColor(playerID)
|
||||
g := gi.GetGame()
|
||||
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
|
||||
}
|
||||
|
||||
sendPing := func() error {
|
||||
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||
}
|
||||
|
||||
// Send initial render and ping
|
||||
if err := sendPing(); err != nil {
|
||||
return
|
||||
}
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
// Subscribe to game state updates
|
||||
gameCh := make(chan *nats.Msg, 64)
|
||||
gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh)
|
||||
// Subscribe to game state updates BEFORE creating SSE
|
||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
// Subscribe to chat messages
|
||||
// Subscribe to chat messages BEFORE creating SSE
|
||||
chatCfg := svc.ChatConfig(gameID)
|
||||
room := svc.ChatRoom(gameID)
|
||||
chatCh, cleanupChat := room.Subscribe()
|
||||
defer cleanupChat()
|
||||
|
||||
ctx := r.Context()
|
||||
// Setup heartbeat BEFORE creating SSE
|
||||
heartbeat := time.NewTicker(1 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
// NOW create SSE
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||
))
|
||||
|
||||
// Define patch function
|
||||
patchAll := func() error {
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
g := gi.GetGame()
|
||||
return sse.PatchElementTempl(pages.GameContent(g, myColor, room.Messages(), chatCfg))
|
||||
}
|
||||
|
||||
// Send initial state
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Event loop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
if err := sendPing(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-gameCh:
|
||||
// Drain rapid-fire notifications
|
||||
drainGame:
|
||||
for {
|
||||
select {
|
||||
case <-gameCh:
|
||||
default:
|
||||
break drainGame
|
||||
}
|
||||
}
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case chatMsg := <-chatCh:
|
||||
err := sse.PatchElementTempl(
|
||||
if err := sse.PatchElementTempl(
|
||||
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
||||
datastar.WithSelectorID("c4-chat-history"),
|
||||
datastar.WithModeAppend(),
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
case <-heartbeat.C:
|
||||
// Heartbeat refreshes game state to keep connection alive
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -202,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")
|
||||
|
||||
@@ -249,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)
|
||||
|
||||
@@ -18,7 +18,6 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
|
||||
data-signals="{chatMsg: ''}"
|
||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||
>
|
||||
@sharedcomponents.ConnectionIndicator(0)
|
||||
@GameContent(g, myColor, messages, chatCfg)
|
||||
</main>
|
||||
}
|
||||
@@ -26,6 +25,7 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
|
||||
|
||||
templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) {
|
||||
<div id="game-content">
|
||||
@sharedcomponents.LiveClock()
|
||||
@sharedcomponents.BackToLobby()
|
||||
@sharedcomponents.StealthTitle("text-3xl font-bold")
|
||||
@components.PlayerInfo(g, myColor)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
@@ -48,60 +48,13 @@ templ NicknamePrompt(returnPath string) {
|
||||
</main>
|
||||
}
|
||||
|
||||
func isStale(lastPing int64) bool {
|
||||
return lastPing == 0
|
||||
}
|
||||
|
||||
var connectionWatcherHandle = templ.NewOnceHandle()
|
||||
|
||||
// ConnectionIndicator shows a small dot indicating SSE connection status.
|
||||
// Server patches this with a timestamp; client JS detects staleness.
|
||||
templ ConnectionIndicator(lastPing int64) {
|
||||
<div
|
||||
id="connection-indicator"
|
||||
class="fixed top-2 right-2"
|
||||
data-last-ping={ fmt.Sprintf("%d", lastPing) }
|
||||
>
|
||||
<div class="inline-grid *:[grid-area:1/1]">
|
||||
<div
|
||||
id="connection-ping"
|
||||
class={
|
||||
"status status-sm",
|
||||
templ.KV("status-success animate-ping", !isStale(lastPing)),
|
||||
templ.KV("status-error", isStale(lastPing)),
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
id="connection-dot"
|
||||
class={
|
||||
"status status-sm",
|
||||
templ.KV("status-success", !isStale(lastPing)),
|
||||
templ.KV("status-error", isStale(lastPing)),
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
// LiveClock shows the current server time, updated every second via SSE.
|
||||
// If the clock stops updating, users know the connection is stale.
|
||||
templ LiveClock() {
|
||||
<div class="fixed top-2 right-2 flex items-center gap-1.5 text-xs opacity-60 font-mono">
|
||||
<div style="width: 6px; height: 6px; border-radius: 50%; background-color: #22c55e;"></div>
|
||||
{ time.Now().Format("15:04:05") }
|
||||
</div>
|
||||
@connectionWatcherHandle.Once() {
|
||||
@connectionWatcher()
|
||||
}
|
||||
}
|
||||
|
||||
script connectionWatcher() {
|
||||
setInterval(function() {
|
||||
var el = document.getElementById('connection-indicator');
|
||||
var dot = document.getElementById('connection-dot');
|
||||
var ping = document.getElementById('connection-ping');
|
||||
if (!el || !dot || !ping) return;
|
||||
|
||||
var lastPing = parseInt(el.dataset.lastPing, 10) || 0;
|
||||
var stale = Date.now() - lastPing > 20000;
|
||||
|
||||
dot.classList.toggle('status-success', !stale);
|
||||
dot.classList.toggle('status-error', stale);
|
||||
ping.classList.toggle('status-success', !stale);
|
||||
ping.classList.toggle('status-error', stale);
|
||||
ping.classList.toggle('animate-ping', !stale);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package layouts
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/games/assets"
|
||||
"github.com/ryanhamamura/games/config"
|
||||
"github.com/ryanhamamura/games/version"
|
||||
)
|
||||
@@ -11,8 +12,8 @@ templ Base(title string) {
|
||||
<head>
|
||||
<title>{ title }</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
|
||||
<script defer type="module" src="/assets/js/datastar.js"></script>
|
||||
<link href="/assets/css/output.css" rel="stylesheet" type="text/css"/>
|
||||
<script defer type="module" src={ assets.StaticPath("js/datastar.js") }></script>
|
||||
<link href={ assets.StaticPath("css/output.css") } rel="stylesheet" type="text/css"/>
|
||||
</head>
|
||||
<body class="flex flex-col h-screen">
|
||||
if config.Global.Environment == config.Dev {
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
package snakegame
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"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"
|
||||
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||
"github.com/ryanhamamura/games/features/snakegame/pages"
|
||||
"github.com/ryanhamamura/games/features/snakegame/services"
|
||||
"github.com/ryanhamamura/games/sessions"
|
||||
"github.com/ryanhamamura/games/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 {
|
||||
func HandleSnakePage(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
@@ -77,13 +60,14 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.
|
||||
}
|
||||
|
||||
sg := si.GetGame()
|
||||
if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil {
|
||||
chatCfg := svc.ChatConfig(gameID)
|
||||
if err := pages.GamePage(sg, mySlot, nil, chatCfg, gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
||||
func HandleSnakeEvents(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
@@ -95,17 +79,25 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
||||
playerID := sessions.GetPlayerID(sm, r)
|
||||
mySlot := si.GetPlayerSlot(playerID)
|
||||
|
||||
// Subscribe to game updates BEFORE creating SSE (following portigo pattern)
|
||||
gameSub, gameCh, err := svc.SubscribeGameUpdates(gameID)
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
sse := datastar.NewSSE(w, r, datastar.WithCompression(
|
||||
datastar.WithBrotli(datastar.WithBrotliLevel(5)),
|
||||
))
|
||||
|
||||
chatCfg := snakeChatConfig(gameID)
|
||||
chatCfg := svc.ChatConfig(gameID)
|
||||
|
||||
// Chat room (multiplayer only)
|
||||
var room *chat.Room
|
||||
sg := si.GetGame()
|
||||
if sg.Mode == snake.ModeMultiplayer {
|
||||
room = chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||
room = svc.ChatRoom(gameID)
|
||||
}
|
||||
|
||||
chatMessages := func() []chat.Message {
|
||||
@@ -118,36 +110,21 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
||||
patchAll := func() error {
|
||||
si, ok = snakeStore.Get(gameID)
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found")
|
||||
return errors.New("game not found")
|
||||
}
|
||||
mySlot = si.GetPlayerSlot(playerID)
|
||||
sg = si.GetGame()
|
||||
return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID))
|
||||
}
|
||||
|
||||
sendPing := func() error {
|
||||
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||
}
|
||||
|
||||
// Send initial render and ping
|
||||
if err := sendPing(); err != nil {
|
||||
return
|
||||
}
|
||||
// Send initial render
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
heartbeat := time.NewTicker(1 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
// Subscribe to game updates via NATS
|
||||
gameCh := make(chan *nats.Msg, 64)
|
||||
gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe() //nolint:errcheck
|
||||
|
||||
// Chat subscription (multiplayer only)
|
||||
var chatCh <-chan chat.Message
|
||||
var cleanupChat func()
|
||||
@@ -164,7 +141,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
||||
return
|
||||
|
||||
case <-heartbeat.C:
|
||||
if err := sendPing(); err != nil {
|
||||
// Heartbeat refreshes game state to keep connection alive
|
||||
if err := patchAll(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,7 +209,7 @@ type chatSignals struct {
|
||||
ChatMsg string `json:"chatMsg"`
|
||||
}
|
||||
|
||||
func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc {
|
||||
func HandleSendChat(snakeStore *snake.SnakeStore, svc *services.GameService, sm *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "id")
|
||||
si, ok := snakeStore.Get(gameID)
|
||||
@@ -264,7 +242,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
|
||||
Message: signals.ChatMsg,
|
||||
}
|
||||
|
||||
room := chat.NewRoom(nc, snake.ChatSubject(gameID))
|
||||
room := svc.ChatRoom(gameID)
|
||||
room.Send(msg)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
|
||||
@@ -37,7 +37,6 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
|
||||
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
||||
tabindex="0"
|
||||
>
|
||||
@components.ConnectionIndicator(0)
|
||||
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
||||
</main>
|
||||
}
|
||||
@@ -45,6 +44,7 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
|
||||
|
||||
templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) {
|
||||
<div id="game-content">
|
||||
@components.LiveClock()
|
||||
@components.BackToLobby()
|
||||
<h1 class="text-3xl font-bold">~~~~</h1>
|
||||
@snakecomponents.PlayerList(sg, mySlot)
|
||||
|
||||
@@ -4,17 +4,17 @@ package snakegame
|
||||
import (
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/ryanhamamura/games/features/snakegame/services"
|
||||
"github.com/ryanhamamura/games/snake"
|
||||
)
|
||||
|
||||
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) {
|
||||
func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, svc *services.GameService, sessions *scs.SessionManager) {
|
||||
router.Route("/snake/{id}", func(r chi.Router) {
|
||||
r.Get("/", HandleSnakePage(snakeStore, sessions))
|
||||
r.Get("/events", HandleSnakeEvents(snakeStore, nc, sessions))
|
||||
r.Get("/", HandleSnakePage(snakeStore, svc, sessions))
|
||||
r.Get("/events", HandleSnakeEvents(snakeStore, svc, sessions))
|
||||
r.Post("/dir", HandleSetDirection(snakeStore, sessions))
|
||||
r.Post("/chat", HandleSendChat(snakeStore, nc, sessions))
|
||||
r.Post("/chat", HandleSendChat(snakeStore, svc, sessions))
|
||||
r.Post("/join", HandleSetNickname(snakeStore, sessions))
|
||||
r.Post("/rematch", HandleRematch(snakeStore, sessions))
|
||||
})
|
||||
|
||||
62
features/snakegame/services/game_service.go
Normal file
62
features/snakegame/services/game_service.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package services provides the game service layer for Snake,
|
||||
// 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/snake"
|
||||
)
|
||||
|
||||
func snakeChatColor(slot int) string {
|
||||
if slot >= 0 && slot < len(snake.SnakeColors) {
|
||||
return snake.SnakeColors[slot]
|
||||
}
|
||||
return "#666"
|
||||
}
|
||||
|
||||
// GameService manages NATS subscriptions and chat for Snake games.
|
||||
type GameService struct {
|
||||
nc *nats.Conn
|
||||
}
|
||||
|
||||
// NewGameService creates a new game service.
|
||||
func NewGameService(nc *nats.Conn) *GameService {
|
||||
return &GameService{
|
||||
nc: nc,
|
||||
}
|
||||
}
|
||||
|
||||
// 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(snake.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: "snake",
|
||||
PostURL: fmt.Sprintf("/snake/%s/chat", gameID),
|
||||
Color: snakeChatColor,
|
||||
StopKeyPropagation: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ChatRoom returns a chat room for a game (ephemeral, not persisted).
|
||||
func (s *GameService) ChatRoom(gameID string) *chat.Room {
|
||||
return chat.NewRoom(s.nc, snake.ChatSubject(gameID))
|
||||
}
|
||||
|
||||
// PublishGameUpdate sends a notification that the game state has changed.
|
||||
func (s *GameService) PublishGameUpdate(gameID string) error {
|
||||
return s.nc.Publish(snake.GameSubject(gameID), nil)
|
||||
}
|
||||
Reference in New Issue
Block a user