Add ConnectionIndicator to Connect 4 game page (Snake already had it). Both games now patch the indicator on initial connect and every 10s heartbeat, giving users visual feedback that SSE is connected.
314 lines
7.8 KiB
Go
314 lines
7.8 KiB
Go
package c4game
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/go-chi/chi/v5"
|
|
"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/features/c4game/pages"
|
|
"github.com/ryanhamamura/games/features/c4game/services"
|
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
|
"github.com/ryanhamamura/games/sessions"
|
|
)
|
|
|
|
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")
|
|
|
|
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()
|
|
room := svc.ChatRoom(gameID)
|
|
|
|
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, 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)
|
|
if !exists {
|
|
http.Error(w, "game not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
playerID := sessions.GetPlayerID(sm, r)
|
|
|
|
// 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 BEFORE creating SSE
|
|
chatCfg := svc.ChatConfig(gameID)
|
|
room := svc.ChatRoom(gameID)
|
|
chatCh, cleanupChat := room.Subscribe()
|
|
defer cleanupChat()
|
|
|
|
// Setup heartbeat BEFORE creating SSE
|
|
heartbeat := time.NewTicker(10 * 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 connection indicator and state
|
|
if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
|
|
return
|
|
}
|
|
if err := patchAll(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Event loop
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case <-gameCh:
|
|
// Drain rapid-fire notifications
|
|
drainGame:
|
|
for {
|
|
select {
|
|
case <-gameCh:
|
|
default:
|
|
break drainGame
|
|
}
|
|
}
|
|
if err := patchAll(); err != nil {
|
|
return
|
|
}
|
|
|
|
case chatMsg := <-chatCh:
|
|
if err := sse.PatchElementTempl(
|
|
chatcomponents.ChatMessage(chatMsg, chatCfg),
|
|
datastar.WithSelectorID("c4-chat-history"),
|
|
datastar.WithModeAppend(),
|
|
); err != nil {
|
|
return
|
|
}
|
|
|
|
case <-heartbeat.C:
|
|
// Heartbeat updates connection indicator and refreshes game state
|
|
if err := sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli())); err != nil {
|
|
return
|
|
}
|
|
if err := patchAll(); 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, svc *services.GameService, 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
|
|
}
|
|
|
|
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(),
|
|
}
|
|
room := svc.ChatRoom(gameID)
|
|
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(), sessions.KeyNickname, 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
|
|
}
|
|
}
|
|
}
|