refactor: replace via framework with chi + templ + datastar
Migrate from the via meta-framework to direct dependencies:
- chi for routing, templ for HTML templates, datastar for SSE/reactivity
- Feature-sliced architecture (features/{auth,lobby,c4game,snakegame}/)
- Shared layouts and components (features/common/)
- Handler factory pattern (HandleX(deps) http.HandlerFunc)
- Embedded NATS server (nats/), SCS sessions (sessions/), chi router wiring (router/)
- Move ChatMessage domain type from ui package to game package
- Remove old ui/ package (gomponents-based via/h views)
- Remove via dependency from go.mod entirely
This commit is contained in:
368
features/c4game/handlers.go
Normal file
368
features/c4game/handlers.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package c4game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/features/c4game/components"
|
||||
"github.com/ryanhamamura/c4/features/c4game/pages"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
|
||||
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
||||
if playerID == "" {
|
||||
playerID = game.PlayerID(game.GenerateID(8))
|
||||
sessions.Put(r.Context(), "player_id", string(playerID))
|
||||
}
|
||||
|
||||
userID := sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
playerID = game.PlayerID(userID)
|
||||
}
|
||||
|
||||
nickname := sessions.GetString(r.Context(), "nickname")
|
||||
|
||||
// Auto-join if player has a nickname but isn't in the game yet
|
||||
if nickname != "" && gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
player.UserID = &userID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{Player: player})
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
if myColor == 0 {
|
||||
// Player not in game
|
||||
isGuest := r.URL.Query().Get("guest") == "1"
|
||||
if userID == "" && !isGuest {
|
||||
// Show join prompt (login vs guest)
|
||||
if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Show nickname prompt
|
||||
if err := pages.NicknamePage(gameID).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Player is in the game — render full game page
|
||||
g := gi.GetGame()
|
||||
uiMsgs, _ := chatPersister.LoadChatMessages(gameID)
|
||||
msgs := uiChatToComponents(uiMsgs)
|
||||
|
||||
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_id")
|
||||
|
||||
gi, exists := store.Get(gameID)
|
||||
if !exists {
|
||||
http.Error(w, "game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
||||
userID := sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
playerID = game.PlayerID(userID)
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
|
||||
// Load initial chat messages
|
||||
uiMsgs, _ := chatPersister.LoadChatMessages(gameID)
|
||||
var chatMu sync.Mutex
|
||||
chatMessages := uiChatToComponents(uiMsgs)
|
||||
|
||||
// Send initial render of all components
|
||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
||||
|
||||
// Subscribe to game state updates
|
||||
gameCh := make(chan *nats.Msg, 64)
|
||||
gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer gameSub.Unsubscribe()
|
||||
|
||||
// Subscribe to chat messages
|
||||
chatCh := make(chan *nats.Msg, 64)
|
||||
chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer chatSub.Unsubscribe()
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-gameCh:
|
||||
// Re-read player color in case we just joined
|
||||
myColor = gi.GetPlayerColor(playerID)
|
||||
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
|
||||
case msg := <-chatCh:
|
||||
var uiMsg game.ChatMessage
|
||||
if err := json.Unmarshal(msg.Data, &uiMsg); err != nil {
|
||||
continue
|
||||
}
|
||||
cm := components.ChatMessage{
|
||||
Nickname: uiMsg.Nickname,
|
||||
Color: uiMsg.Color,
|
||||
Message: uiMsg.Message,
|
||||
Time: uiMsg.Time,
|
||||
}
|
||||
chatMu.Lock()
|
||||
chatMessages = append(chatMessages, cm)
|
||||
if len(chatMessages) > 50 {
|
||||
chatMessages = chatMessages[len(chatMessages)-50:]
|
||||
}
|
||||
chatMu.Unlock()
|
||||
|
||||
chatMu.Lock()
|
||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
|
||||
if err := sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
||||
userID := sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
playerID = game.PlayerID(userID)
|
||||
}
|
||||
|
||||
myColor := gi.GetPlayerColor(playerID)
|
||||
if myColor == 0 {
|
||||
http.Error(w, "not in game", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
gi.DropPiece(col, myColor)
|
||||
|
||||
// The store's notifyFunc publishes to NATS, which triggers SSE updates.
|
||||
// Return empty SSE response.
|
||||
datastar.NewSSE(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_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 := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
||||
userID := sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
playerID = game.PlayerID(userID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
cm := game.ChatMessage{
|
||||
Nickname: nick,
|
||||
Color: myColor,
|
||||
Message: signals.ChatMsg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
}
|
||||
chatPersister.SaveChatMessage(gameID, cm)
|
||||
|
||||
data, err := json.Marshal(cm)
|
||||
if err != nil {
|
||||
datastar.NewSSE(w, r)
|
||||
return
|
||||
}
|
||||
nc.Publish("game.chat."+gameID, data)
|
||||
|
||||
// Clear the chat input
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_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
|
||||
}
|
||||
|
||||
sessions.Put(r.Context(), "nickname", signals.Nickname)
|
||||
|
||||
playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id"))
|
||||
userID := sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
playerID = game.PlayerID(userID)
|
||||
}
|
||||
|
||||
if gi.GetPlayerColor(playerID) == 0 {
|
||||
player := &game.Player{
|
||||
ID: playerID,
|
||||
Nickname: signals.Nickname,
|
||||
}
|
||||
if userID != "" {
|
||||
player.UserID = &userID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{Player: player})
|
||||
}
|
||||
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.Redirect("/game/" + gameID) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
gameID := chi.URLParam(r, "game_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("/game/%s", newGI.ID()) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendGameComponents patches all game-related SSE components.
|
||||
func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, chatMessages []components.ChatMessage, chatMu *sync.Mutex, gameID string) {
|
||||
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
|
||||
|
||||
chatMu.Lock()
|
||||
msgs := make([]components.ChatMessage, len(chatMessages))
|
||||
copy(msgs, chatMessages)
|
||||
chatMu.Unlock()
|
||||
|
||||
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
|
||||
}
|
||||
|
||||
// uiChatToComponents converts ui.C4ChatMessage slice to components.ChatMessage slice.
|
||||
func uiChatToComponents(uiMsgs []game.ChatMessage) []components.ChatMessage {
|
||||
msgs := make([]components.ChatMessage, len(uiMsgs))
|
||||
for i, m := range uiMsgs {
|
||||
msgs[i] = components.ChatMessage{
|
||||
Nickname: m.Nickname,
|
||||
Color: m.Color,
|
||||
Message: m.Message,
|
||||
Time: m.Time,
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
Reference in New Issue
Block a user