From 063b03ce25a25c37cdec73121f034be310e2d858 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:09:01 -1000 Subject: [PATCH 01/14] refactor: extract shared player.ID type and GenerateID to player package Both game and snake packages had identical PlayerID types and the snake package imported game.GenerateID. Now both use player.ID and player.GenerateID from the shared player package. --- features/c4game/handlers.go | 23 ++++++++++++----------- features/snakegame/handlers.go | 10 +++++----- game/persist.go | 11 ++++++----- game/store.go | 13 +++---------- game/types.go | 8 +++++--- player/player.go | 18 ++++++++++++++++++ snake/persist.go | 11 ++++++----- snake/store.go | 6 +++--- snake/types.go | 6 +++--- 9 files changed, 61 insertions(+), 45 deletions(-) create mode 100644 player/player.go diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 8b9d0d7..8b8b251 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -18,6 +18,7 @@ import ( "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/player" ) func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { @@ -30,15 +31,15 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) + playerID := player.ID(sessions.GetString(r.Context(), "player_id")) if playerID == "" { - playerID = game.PlayerID(game.GenerateID(8)) + playerID = player.ID(player.GenerateID(8)) sessions.Put(r.Context(), "player_id", string(playerID)) } userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - playerID = game.PlayerID(userID) + playerID = player.ID(userID) } nickname := sessions.GetString(r.Context(), "nickname") @@ -95,10 +96,10 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) + playerID := player.ID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - playerID = game.PlayerID(userID) + playerID = player.ID(userID) } myColor := gi.GetPlayerColor(playerID) @@ -185,10 +186,10 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) + playerID := player.ID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - playerID = game.PlayerID(userID) + playerID = player.ID(userID) } myColor := gi.GetPlayerColor(playerID) @@ -229,10 +230,10 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM return } - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) + playerID := player.ID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - playerID = game.PlayerID(userID) + playerID = player.ID(userID) } myColor := gi.GetPlayerColor(playerID) @@ -298,10 +299,10 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http sessions.Put(r.Context(), "nickname", signals.Nickname) - playerID := game.PlayerID(sessions.GetString(r.Context(), "player_id")) + playerID := player.ID(sessions.GetString(r.Context(), "player_id")) userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - playerID = game.PlayerID(userID) + playerID = player.ID(userID) } if gi.GetPlayerColor(playerID) == 0 { diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 03292a6..3fa0143 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -13,21 +13,21 @@ import ( "github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/c4/features/snakegame/pages" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/player" "github.com/ryanhamamura/c4/snake" ) -func getPlayerID(sessions *scs.SessionManager, r *http.Request) snake.PlayerID { +func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID { pid := sessions.GetString(r.Context(), "player_id") if pid == "" { - pid = game.GenerateID(8) + pid = player.GenerateID(8) sessions.Put(r.Context(), "player_id", pid) } userID := sessions.GetString(r.Context(), "user_id") if userID != "" { - return snake.PlayerID(userID) + return player.ID(userID) } - return snake.PlayerID(pid) + return player.ID(pid) } func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { diff --git a/game/persist.go b/game/persist.go index 0b615b7..5efe140 100644 --- a/game/persist.go +++ b/game/persist.go @@ -4,6 +4,7 @@ import ( "context" "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/c4/player" "github.com/rs/zerolog/log" ) @@ -109,19 +110,19 @@ func gameFromRow(row *repository.Game) (*Game, error) { func playersFromRows(rows []*repository.GamePlayer) []*Player { players := make([]*Player, 0, len(rows)) for _, row := range rows { - player := &Player{ + p := &Player{ Nickname: row.Nickname, Color: int(row.Color), } if row.UserID != nil { - player.UserID = row.UserID - player.ID = PlayerID(*row.UserID) + p.UserID = row.UserID + p.ID = player.ID(*row.UserID) } else if row.GuestPlayerID != nil { - player.ID = PlayerID(*row.GuestPlayerID) + p.ID = player.ID(*row.GuestPlayerID) } - players = append(players, player) + players = append(players, p) } return players } diff --git a/game/store.go b/game/store.go index d919441..818942c 100644 --- a/game/store.go +++ b/game/store.go @@ -2,11 +2,10 @@ package game import ( "context" - "crypto/rand" - "encoding/hex" "sync" "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/c4/player" ) type PlayerSession struct { @@ -40,7 +39,7 @@ func (gs *GameStore) makeNotify(gameID string) func() { } func (gs *GameStore) Create() *GameInstance { - id := GenerateID(4) + id := player.GenerateID(4) gi := NewGameInstance(id) gi.queries = gs.queries gi.notify = gs.makeNotify(id) @@ -107,12 +106,6 @@ func (gs *GameStore) Delete(id string) error { return nil } -func GenerateID(size int) string { - b := make([]byte, size) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - type GameInstance struct { game *Game gameMu sync.RWMutex @@ -166,7 +159,7 @@ func (gi *GameInstance) GetGame() *Game { return gi.game } -func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { +func (gi *GameInstance) GetPlayerColor(pid player.ID) int { gi.gameMu.RLock() defer gi.gameMu.RUnlock() for _, p := range gi.game.Players { diff --git a/game/types.go b/game/types.go index 71f0ae8..2eec2b1 100644 --- a/game/types.go +++ b/game/types.go @@ -1,11 +1,13 @@ package game -import "encoding/json" +import ( + "encoding/json" -type PlayerID string + "github.com/ryanhamamura/c4/player" +) type Player struct { - ID PlayerID + ID player.ID UserID *string // UUID for authenticated users, nil for guests Nickname string Color int // 1 = Red, 2 = Yellow diff --git a/player/player.go b/player/player.go new file mode 100644 index 0000000..ca65a3f --- /dev/null +++ b/player/player.go @@ -0,0 +1,18 @@ +// Package player provides shared identity types used across game packages. +package player + +import ( + "crypto/rand" + "encoding/hex" +) + +// ID uniquely identifies a player within a session. For authenticated users +// this is their user UUID; for guests it's a random hex string. +type ID string + +// GenerateID returns a random hex string of 2*size characters. +func GenerateID(size int) string { + b := make([]byte, size) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/snake/persist.go b/snake/persist.go index dba9da9..741cb40 100644 --- a/snake/persist.go +++ b/snake/persist.go @@ -4,6 +4,7 @@ import ( "context" "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/c4/player" "github.com/rs/zerolog/log" ) @@ -122,19 +123,19 @@ func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) { func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player { players := make([]*Player, 0, len(rows)) for _, row := range rows { - player := &Player{ + p := &Player{ Nickname: row.Nickname, Slot: int(row.Slot), } if row.UserID != nil { - player.UserID = row.UserID - player.ID = PlayerID(*row.UserID) + p.UserID = row.UserID + p.ID = player.ID(*row.UserID) } else if row.GuestPlayerID != nil { - player.ID = PlayerID(*row.GuestPlayerID) + p.ID = player.ID(*row.GuestPlayerID) } - players = append(players, player) + players = append(players, p) } return players } diff --git a/snake/store.go b/snake/store.go index 4543a92..2590a4a 100644 --- a/snake/store.go +++ b/snake/store.go @@ -5,7 +5,7 @@ import ( "sync" "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/player" ) type SnakeStore struct { @@ -38,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake if speed <= 0 { speed = DefaultSpeed } - id := game.GenerateID(4) + id := player.GenerateID(4) sg := &SnakeGame{ ID: id, State: &GameState{ @@ -172,7 +172,7 @@ func (si *SnakeGameInstance) GetGame() *SnakeGame { return si.game.snapshot() } -func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int { +func (si *SnakeGameInstance) GetPlayerSlot(pid player.ID) int { si.gameMu.RLock() defer si.gameMu.RUnlock() for i, p := range si.game.Players { diff --git a/snake/types.go b/snake/types.go index 8272765..bbcb836 100644 --- a/snake/types.go +++ b/snake/types.go @@ -3,6 +3,8 @@ package snake import ( "encoding/json" "time" + + "github.com/ryanhamamura/c4/player" ) type Direction int @@ -78,10 +80,8 @@ const ( StatusFinished ) -type PlayerID string - type Player struct { - ID PlayerID + ID player.ID UserID *string Nickname string Slot int // 0-7 From 7eadfbbb0c075e31540605d5c5e19bf46b4bb746 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:16:09 -1000 Subject: [PATCH 02/14] refactor: extract session helpers for player identity resolution Add GetPlayerID, GetUserID, GetNickname to the sessions package. Remove the inline player-ID-from-session pattern duplicated across every handler in c4game and snakegame, and the local getPlayerID helper in snakegame. --- features/c4game/handlers.go | 71 +++++++++++----------------------- features/snakegame/handlers.go | 45 ++++++++------------- sessions/sessions.go | 30 +++++++++++++- 3 files changed, 67 insertions(+), 79 deletions(-) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 8b8b251..c47551b 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -18,10 +18,10 @@ import ( "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/game" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/c4/sessions" ) -func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -31,29 +31,20 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries return } - playerID := player.ID(sessions.GetString(r.Context(), "player_id")) - if playerID == "" { - playerID = player.ID(player.GenerateID(8)) - sessions.Put(r.Context(), "player_id", string(playerID)) - } - - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = player.ID(userID) - } - - nickname := sessions.GetString(r.Context(), "nickname") + 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 { - player := &game.Player{ + p := &game.Player{ ID: playerID, Nickname: nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: player}) + gi.Join(&game.PlayerSession{Player: p}) } myColor := gi.GetPlayerColor(playerID) @@ -86,7 +77,7 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries } } -func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -96,12 +87,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio return } - playerID := player.ID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = player.ID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( @@ -169,7 +155,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio } } -func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -186,12 +172,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H return } - playerID := player.ID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = player.ID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) if myColor == 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -206,7 +187,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H } } -func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -230,12 +211,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM return } - playerID := player.ID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = player.ID(userID) - } - + playerID := sessions.GetPlayerID(sm, r) myColor := gi.GetPlayerColor(playerID) if myColor == 0 { datastar.NewSSE(w, r) @@ -272,7 +248,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM } } -func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -297,23 +273,20 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), "nickname", signals.Nickname) - playerID := player.ID(sessions.GetString(r.Context(), "player_id")) - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - playerID = player.ID(userID) - } + playerID := sessions.GetPlayerID(sm, r) + userID := sessions.GetUserID(sm, r) if gi.GetPlayerColor(playerID) == 0 { - player := &game.Player{ + p := &game.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: player}) + gi.Join(&game.PlayerSession{Player: p}) } sse := datastar.NewSSE(w, r) @@ -321,7 +294,7 @@ func HandleSetNickname(store *game.GameStore, sessions *scs.SessionManager) http } } -func HandleRematch(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 3fa0143..b72fcc1 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -13,24 +13,11 @@ import ( "github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/c4/features/snakegame/pages" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/c4/sessions" "github.com/ryanhamamura/c4/snake" ) -func getPlayerID(sessions *scs.SessionManager, r *http.Request) player.ID { - pid := sessions.GetString(r.Context(), "player_id") - if pid == "" { - pid = player.GenerateID(8) - sessions.Put(r.Context(), "player_id", pid) - } - userID := sessions.GetString(r.Context(), "user_id") - if userID != "" { - return player.ID(userID) - } - return player.ID(pid) -} - -func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -39,9 +26,9 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) return } - playerID := getPlayerID(sessions, r) - nickname := sessions.GetString(r.Context(), "nickname") - userID := sessions.GetString(r.Context(), "user_id") + playerID := sessions.GetPlayerID(sm, r) + nickname := sessions.GetNickname(sm, r) + userID := sessions.GetUserID(sm, r) // Auto-join if nickname exists and not already in game if nickname != "" && si.GetPlayerSlot(playerID) < 0 { @@ -79,7 +66,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) } } -func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -88,7 +75,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) mySlot := si.GetPlayerSlot(playerID) sse := datastar.NewSSE(w, r, datastar.WithCompression( @@ -187,7 +174,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *sc } } -func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSetDirection(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -196,7 +183,7 @@ func HandleSetDirection(snakeStore *snake.SnakeStore, sessions *scs.SessionManag return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -219,7 +206,7 @@ type chatSignals struct { ChatMsg string `json:"chatMsg"` } -func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -238,7 +225,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.S return } - playerID := getPlayerID(sessions, r) + playerID := sessions.GetPlayerID(sm, r) slot := si.GetPlayerSlot(playerID) if slot < 0 { http.Error(w, "not in game", http.StatusForbidden) @@ -266,7 +253,7 @@ type nicknameSignals struct { Nickname string `json:"nickname"` } -func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) @@ -285,10 +272,10 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), "nickname", signals.Nickname) - playerID := getPlayerID(sessions, r) - userID := sessions.GetString(r.Context(), "user_id") + playerID := sessions.GetPlayerID(sm, r) + userID := sessions.GetUserID(sm, r) if si.GetPlayerSlot(playerID) < 0 { player := &snake.Player{ @@ -306,7 +293,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sessions *scs.SessionManage } } -func HandleRematch(snakeStore *snake.SnakeStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleRematch(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") si, ok := snakeStore.Get(gameID) diff --git a/sessions/sessions.go b/sessions/sessions.go index 5b52bb4..c4e50d6 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -1,4 +1,5 @@ -// Package sessions configures the SCS session manager backed by SQLite. +// Package sessions configures the SCS session manager and provides +// helpers for resolving player identity from the session. package sessions import ( @@ -7,6 +8,8 @@ import ( "net/http" "time" + "github.com/ryanhamamura/c4/player" + "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" ) @@ -30,3 +33,28 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { return sessionManager, cleanup } + +// GetPlayerID returns the current player's identity from the session. +// Authenticated users get their user UUID; guests get a random ID that +// is generated and persisted on first access. +func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID { + pid := sm.GetString(r.Context(), "player_id") + if pid == "" { + pid = player.GenerateID(8) + sm.Put(r.Context(), "player_id", pid) + } + if userID := sm.GetString(r.Context(), "user_id"); userID != "" { + return player.ID(userID) + } + return player.ID(pid) +} + +// GetUserID returns the authenticated user's UUID, or empty string for guests. +func GetUserID(sm *scs.SessionManager, r *http.Request) string { + return sm.GetString(r.Context(), "user_id") +} + +// GetNickname returns the player's display name from the session. +func GetNickname(sm *scs.SessionManager, r *http.Request) string { + return sm.GetString(r.Context(), "nickname") +} From 10de5d21ad6609f86a309dda3460fbd657d5b1dc Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:20:21 -1000 Subject: [PATCH 03/14] refactor: extract standalone chat package from game-specific handlers Create chat/ package with Message type, Room (NATS pub/sub + buffer), DB persistence helpers, and a unified templ component parameterized by Config (CSS prefix, post URL, color function, key propagation). Both c4game and snakegame now use chat.Room for message management and chatcomponents.Chat for rendering, eliminating the duplicated ChatMessage types, chat templ components, chatAutoScroll scripts, color functions, and inline buffer management. --- chat/chat.go | 92 ++++++++++++++ chat/components/chat.templ | 74 +++++++++++ chat/persist.go | 45 +++++++ features/c4game/components/chat.templ | 69 ---------- features/c4game/handlers.go | 152 +++++++---------------- features/c4game/pages/game.templ | 6 +- features/snakegame/components/chat.templ | 66 ---------- features/snakegame/handlers.go | 72 ++++++----- features/snakegame/pages/game.templ | 8 +- game/types.go | 8 -- 10 files changed, 305 insertions(+), 287 deletions(-) create mode 100644 chat/chat.go create mode 100644 chat/components/chat.templ create mode 100644 chat/persist.go delete mode 100644 features/c4game/components/chat.templ delete mode 100644 features/snakegame/components/chat.templ diff --git a/chat/chat.go b/chat/chat.go new file mode 100644 index 0000000..632f919 --- /dev/null +++ b/chat/chat.go @@ -0,0 +1,92 @@ +// Package chat provides a reusable chat room backed by NATS pub/sub +// with optional database persistence. +package chat + +import ( + "encoding/json" + "sync" + + "github.com/nats-io/nats.go" + "github.com/rs/zerolog/log" +) + +// Message is the wire format for chat messages over NATS. +type Message struct { + Nickname string `json:"nickname"` + Slot int `json:"slot"` // player slot/color index + Message string `json:"message"` + Time int64 `json:"time"` // unix millis, zero for ephemeral messages +} + +const maxMessages = 50 + +// Room manages an in-memory message buffer and NATS pub/sub for a single +// chat room (typically one per game). +type Room struct { + subject string + nc *nats.Conn + messages []Message + mu sync.Mutex +} + +// NewRoom creates a chat room that publishes and subscribes on the given +// NATS subject (e.g. "chat.abc123"). +func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room { + return &Room{ + subject: subject, + nc: nc, + messages: initial, + } +} + +// Send publishes a message to the room's NATS subject. +func (r *Room) Send(msg Message) { + data, err := json.Marshal(msg) + if err != nil { + log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") + return + } + if err := r.nc.Publish(r.subject, data); err != nil { + log.Error().Err(err).Str("subject", r.subject).Msg("failed to publish chat message") + } +} + +// Receive processes an incoming NATS message, appending it to the buffer. +// Returns the new message and a snapshot of all messages. +func (r *Room) Receive(data []byte) (Message, []Message) { + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return msg, nil + } + + r.mu.Lock() + r.messages = append(r.messages, msg) + if len(r.messages) > maxMessages { + r.messages = r.messages[len(r.messages)-maxMessages:] + } + snapshot := make([]Message, len(r.messages)) + copy(snapshot, r.messages) + r.mu.Unlock() + + return msg, snapshot +} + +// Messages returns a snapshot of the current message buffer. +func (r *Room) Messages() []Message { + r.mu.Lock() + defer r.mu.Unlock() + snapshot := make([]Message, len(r.messages)) + copy(snapshot, r.messages) + return snapshot +} + +// Subscribe creates a NATS channel subscription for the room's subject. +// Caller is responsible for unsubscribing. +func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) { + ch := make(chan *nats.Msg, 64) + sub, err := r.nc.ChanSubscribe(r.subject, ch) + if err != nil { + return nil, nil, err + } + return ch, sub, nil +} diff --git a/chat/components/chat.templ b/chat/components/chat.templ new file mode 100644 index 0000000..2a2199e --- /dev/null +++ b/chat/components/chat.templ @@ -0,0 +1,74 @@ +package components + +import ( + "fmt" + + "github.com/ryanhamamura/c4/chat" + "github.com/starfederation/datastar-go/datastar" +) + +// ColorFunc resolves a player slot to a CSS color string. +type ColorFunc func(slot int) string + +// Config holds the game-specific settings for rendering a chat component. +type Config struct { + // CSSPrefix is used for element IDs and CSS classes (e.g. "c4" or "snake"). + CSSPrefix string + // PostURL is the URL to POST chat messages to (e.g. "/games/{id}/chat"). + PostURL string + // Color resolves a player slot to a CSS color string. + Color ColorFunc + // StopKeyPropagation adds data-on:keydown.stop="" to the input to prevent + // key events from propagating (needed for snake to avoid steering while typing). + StopKeyPropagation bool +} + +templ Chat(messages []chat.Message, cfg Config) { +
+
+ for _, m := range messages { +
+ + { m.Nickname + ": " } + + { m.Message } +
+ } +
+
+ if cfg.StopKeyPropagation { + + } else { + + } + +
+ @chatAutoScroll(cfg.CSSPrefix) +
+} + +script chatAutoScroll(cssPrefix string) { + var el = document.querySelector('.' + cssPrefix + '-chat-history'); + if (!el) return; + el.scrollTop = el.scrollHeight; + new MutationObserver(function(){ el.scrollTop = el.scrollHeight; }) + .observe(el, {childList:true, subtree:true}); +} diff --git a/chat/persist.go b/chat/persist.go new file mode 100644 index 0000000..0297b42 --- /dev/null +++ b/chat/persist.go @@ -0,0 +1,45 @@ +package chat + +import ( + "context" + "slices" + + "github.com/ryanhamamura/c4/db/repository" + + "github.com/rs/zerolog/log" +) + +// SaveMessage persists a chat message to the database. +func SaveMessage(queries *repository.Queries, roomID string, msg Message) { + err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ + GameID: roomID, + Nickname: msg.Nickname, + Color: int64(msg.Slot), + Message: msg.Message, + CreatedAt: msg.Time, + }) + if err != nil { + log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message") + } +} + +// LoadMessages loads persisted chat messages for a room, returning them +// in chronological order (oldest first). +func LoadMessages(queries *repository.Queries, roomID string) []Message { + rows, err := queries.GetChatMessages(context.Background(), roomID) + if err != nil { + return nil + } + msgs := make([]Message, len(rows)) + for i, r := range rows { + msgs[i] = Message{ + Nickname: r.Nickname, + Slot: int(r.Color), + Message: r.Message, + Time: r.CreatedAt, + } + } + // DB returns newest-first; reverse for chronological display + slices.Reverse(msgs) + return msgs +} diff --git a/features/c4game/components/chat.templ b/features/c4game/components/chat.templ deleted file mode 100644 index c1e6c07..0000000 --- a/features/c4game/components/chat.templ +++ /dev/null @@ -1,69 +0,0 @@ -package components - -import ( - "fmt" - - "github.com/starfederation/datastar-go/datastar" -) - -type ChatMessage struct { - Nickname string `json:"nickname"` - Color int `json:"color"` - Message string `json:"message"` - Time int64 `json:"time"` -} - -var chatColors = map[int]string{ - 1: "#4a2a3a", - 2: "#2a4545", -} - -templ Chat(messages []ChatMessage, gameID string) { -
-
- for _, m := range messages { -
- - { m.Nickname }:  - - { m.Message } -
- } - @chatAutoScroll() -
-
- - -
-
-} - -templ chatAutoScroll() { - -} - -func chatColor(color int) string { - if c, ok := chatColors[color]; ok { - return c - } - return "#666" -} diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index c47551b..906541a 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -1,12 +1,9 @@ package c4game import ( - "context" - "encoding/json" + "fmt" "net/http" - "slices" "strconv" - "sync" "time" "github.com/alexedwards/scs/v2" @@ -14,6 +11,8 @@ import ( "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" + "github.com/ryanhamamura/c4/chat" + chatcomponents "github.com/ryanhamamura/c4/chat/components" "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" @@ -21,6 +20,27 @@ import ( "github.com/ryanhamamura/c4/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 *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -53,25 +73,21 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo // 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() - chatMsgs := loadChatMessages(queries, gameID) - msgs := chatToComponents(chatMsgs) + msgs := chat.LoadMessages(queries, gameID) - if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { + if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } @@ -94,13 +110,11 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) - // Load initial chat messages - chatMsgs := loadChatMessages(queries, gameID) - var chatMu sync.Mutex - chatMessages := chatToComponents(chatMsgs) + chatCfg := c4ChatConfig(gameID) + room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID)) - // Send initial render of all components - sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) + // Send initial render + sendGameComponents(sse, gi, myColor, room, chatCfg) // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) @@ -111,8 +125,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag defer gameSub.Unsubscribe() //nolint:errcheck // Subscribe to chat messages - chatCh := make(chan *nats.Msg, 64) - chatSub, err := nc.ChanSubscribe("game.chat."+gameID, chatCh) + chatCh, chatSub, err := room.Subscribe() if err != nil { return } @@ -124,30 +137,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag 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) + sendGameComponents(sse, gi, myColor, room, chatCfg) case msg := <-chatCh: - var uiMsg game.ChatMessage - if err := json.Unmarshal(msg.Data, &uiMsg); err != nil { + _, snapshot := room.Receive(msg.Data) + if snapshot == 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:] - } - 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 { + if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil { return } } @@ -180,9 +177,6 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler } gi.DropPiece(col, myColor) - - // The store's notifyFunc publishes to NATS, which triggers SSE updates. - // Return empty SSE response. datastar.NewSSE(w, r) } } @@ -227,22 +221,18 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager } } - cm := game.ChatMessage{ + // Map color (1-based) to slot (0-based) for the unified chat message + msg := chat.Message{ Nickname: nick, - Color: myColor, + Slot: myColor - 1, Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - saveChatMessage(queries, gameID, cm) + chat.SaveMessage(queries, gameID, msg) - data, err := json.Marshal(cm) - if err != nil { - datastar.NewSSE(w, r) - return - } - nc.Publish("game.chat."+gameID, data) //nolint:errcheck + room := chat.NewRoom(nc, "game.chat."+gameID, nil) + room.Send(msg) - // Clear the chat input sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck } @@ -314,61 +304,11 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu } // 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) { +func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { 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 -} - -// Chat persistence helpers — inlined from the former ChatPersister. - -func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) { - queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ //nolint:errcheck - GameID: gameID, - Nickname: msg.Nickname, - Color: int64(msg.Color), - Message: msg.Message, - CreatedAt: msg.Time, - }) -} - -func loadChatMessages(queries *repository.Queries, gameID string) []game.ChatMessage { - rows, err := queries.GetChatMessages(context.Background(), gameID) - if err != nil { - return nil - } - msgs := make([]game.ChatMessage, len(rows)) - for i, r := range rows { - msgs[i] = game.ChatMessage{ - Nickname: r.Nickname, - Color: int(r.Color), - Message: r.Message, - Time: r.CreatedAt, - } - } - // DB returns newest-first; reverse for display - slices.Reverse(msgs) - return msgs -} - -func chatToComponents(chatMsgs []game.ChatMessage) []components.ChatMessage { - msgs := make([]components.ChatMessage, len(chatMsgs)) - for i, m := range chatMsgs { - msgs[i] = components.ChatMessage{ - Nickname: m.Nickname, - Color: m.Color, - Message: m.Message, - Time: m.Time, - } - } - return msgs + 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 + sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck } diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index eb328ad..eee6222 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -1,6 +1,8 @@ package pages import ( + "github.com/ryanhamamura/c4/chat" + chatcomponents "github.com/ryanhamamura/c4/chat/components" "github.com/ryanhamamura/c4/features/c4game/components" sharedcomponents "github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/c4/features/common/layouts" @@ -8,7 +10,7 @@ import ( "github.com/starfederation/datastar-go/datastar" ) -templ GamePage(g *game.Game, myColor int, messages []components.ChatMessage) { +templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { @layouts.Base("Connect 4") {
@components.Board(g, myColor) - @components.Chat(messages, g.ID) + @chatcomponents.Chat(messages, chatCfg) if g.Status == game.StatusWaitingForPlayer { @components.InviteLink(g.ID) diff --git a/features/snakegame/components/chat.templ b/features/snakegame/components/chat.templ deleted file mode 100644 index 58c137b..0000000 --- a/features/snakegame/components/chat.templ +++ /dev/null @@ -1,66 +0,0 @@ -package components - -import ( - "fmt" - - "github.com/ryanhamamura/c4/snake" - "github.com/starfederation/datastar-go/datastar" -) - -type ChatMessage struct { - Nickname string `json:"nickname"` - Slot int `json:"slot"` - Message string `json:"message"` - Time int64 `json:"time"` -} - -templ Chat(messages []ChatMessage, gameID string) { -
-
- for _, m := range messages { -
- - { m.Nickname + ": " } - - { m.Message } -
- } -
-
- - -
- @chatAutoScroll() -
-} - -templ chatAutoScroll() { - -} - -func chatColor(slot int) string { - if slot >= 0 && slot < len(snake.SnakeColors) { - return snake.SnakeColors[slot] - } - return "#666" -} diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index b72fcc1..13dde02 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -1,22 +1,39 @@ package snakegame import ( - "encoding/json" + "fmt" "net/http" "strconv" - "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/c4/chat" + chatcomponents "github.com/ryanhamamura/c4/chat/components" "github.com/ryanhamamura/c4/features/snakegame/components" "github.com/ryanhamamura/c4/features/snakegame/pages" "github.com/ryanhamamura/c4/sessions" "github.com/ryanhamamura/c4/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 { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -32,20 +49,19 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http. // Auto-join if nickname exists and not already in game if nickname != "" && si.GetPlayerSlot(playerID) < 0 { - player := &snake.Player{ + p := &snake.Player{ ID: playerID, Nickname: nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - si.Join(player) + si.Join(p) } mySlot := si.GetPlayerSlot(playerID) if mySlot < 0 { - // Not in game yet isGuest := r.URL.Query().Get("guest") == "1" if userID == "" && !isGuest { if err := pages.JoinPage(gameID).Render(r.Context(), w); err != nil { @@ -60,7 +76,7 @@ func HandleSnakePage(snakeStore *snake.SnakeStore, sm *scs.SessionManager) http. } sg := si.GetGame() - if err := pages.GamePage(sg, mySlot, nil, gameID).Render(r.Context(), w); err != nil { + if err := pages.GamePage(sg, mySlot, nil, snakeChatConfig(gameID), gameID).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } @@ -82,13 +98,15 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess datastar.WithBrotli(datastar.WithBrotliLevel(5)), )) + chatCfg := snakeChatConfig(gameID) + // Send initial render sg := si.GetGame() sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck if sg.Mode == snake.ModeMultiplayer { - sse.PatchElementTempl(components.Chat(nil, gameID)) //nolint:errcheck + sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown { sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck } @@ -105,12 +123,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess // Chat subscription (multiplayer only) var chatCh chan *nats.Msg var chatSub *nats.Subscription - var chatMessages []components.ChatMessage - var chatMu sync.Mutex + var room *chat.Room if sg.Mode == snake.ModeMultiplayer { - chatCh = make(chan *nats.Msg, 64) - chatSub, err = nc.ChanSubscribe("snake.chat."+gameID, chatCh) + room = chat.NewRoom(nc, "snake.chat."+gameID, nil) + chatCh, chatSub, err = room.Subscribe() if err != nil { return } @@ -153,20 +170,11 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess if msg == nil { continue } - var cm components.ChatMessage - if err := json.Unmarshal(msg.Data, &cm); err != nil { + _, snapshot := room.Receive(msg.Data) + if snapshot == nil { continue } - chatMu.Lock() - chatMessages = append(chatMessages, cm) - if len(chatMessages) > 50 { - chatMessages = chatMessages[len(chatMessages)-50:] - } - msgs := make([]components.ChatMessage, len(chatMessages)) - copy(msgs, chatMessages) - chatMu.Unlock() - - if err := sse.PatchElementTempl(components.Chat(msgs, gameID)); err != nil { + if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil { return } } @@ -233,16 +241,14 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session } sg := si.GetGame() - cm := components.ChatMessage{ + msg := chat.Message{ Nickname: sg.Players[slot].Nickname, Slot: slot, Message: signals.ChatMsg, } - data, err := json.Marshal(cm) - if err != nil { - return - } - nc.Publish("snake.chat."+gameID, data) //nolint:errcheck + + room := chat.NewRoom(nc, "snake.chat."+gameID, nil) + room.Send(msg) sse := datastar.NewSSE(w, r) sse.MarshalAndPatchSignals(map[string]any{"chatMsg": ""}) //nolint:errcheck @@ -278,14 +284,14 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt userID := sessions.GetUserID(sm, r) if si.GetPlayerSlot(playerID) < 0 { - player := &snake.Player{ + p := &snake.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { - player.UserID = &userID + p.UserID = &userID } - si.Join(player) + si.Join(p) } sse := datastar.NewSSE(w, r) diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 49b90de..46fe32d 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -3,6 +3,8 @@ package pages import ( "fmt" + "github.com/ryanhamamura/c4/chat" + chatcomponents "github.com/ryanhamamura/c4/chat/components" "github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/c4/features/common/layouts" snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" @@ -26,7 +28,7 @@ func keydownScript(gameID string) string { ) } -templ GamePage(sg *snake.SnakeGame, mySlot int, messages []snakecomponents.ChatMessage, gameID string) { +templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) { @layouts.Base("Snake") {
@snakecomponents.Board(sg) - @snakecomponents.Chat(messages, gameID) + @chatcomponents.Chat(messages, chatCfg) } else { @snakecomponents.Board(sg) } } else if sg.Mode == snake.ModeMultiplayer { - @snakecomponents.Chat(messages, gameID) + @chatcomponents.Chat(messages, chatCfg) } if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { @snakecomponents.InviteLink(gameID) diff --git a/game/types.go b/game/types.go index 2eec2b1..2ceef46 100644 --- a/game/types.go +++ b/game/types.go @@ -69,11 +69,3 @@ func (g *Game) WinningCellsFromJSON(data string) error { } return json.Unmarshal([]byte(data), &g.WinningCells) } - -// ChatMessage is the domain type for persisted C4 chat messages. -type ChatMessage struct { - Nickname string `json:"nickname"` - Color int `json:"color"` // 1=Red, 2=Yellow - Message string `json:"message"` - Time int64 `json:"time"` -} From f71acfc73ef57ff08a14def3467c39fcb4c188fa Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:47:05 -1000 Subject: [PATCH 04/14] fix: use format string for datastar.PostSSE in chat component PostSSE requires a constant format string; pass "%s" with the URL as an argument instead of passing the URL directly. --- chat/components/chat.templ | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/chat/components/chat.templ b/chat/components/chat.templ index 2a2199e..da7e329 100644 --- a/chat/components/chat.templ +++ b/chat/components/chat.templ @@ -43,20 +43,20 @@ templ Chat(messages []chat.Message, cfg Config) { autocomplete="off" data-bind="chatMsg" data-on:keydown.stop="" - data-on:keydown.key_enter={ datastar.PostSSE(cfg.PostURL) } - /> - } else { - - } - From 38eb9ee39878882a64a8d32fb6f789c9ae7589ba Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:31:00 -1000 Subject: [PATCH 05/14] refactor: rename game package to connect4, drop Game prefix from types Rename game/ -> connect4/ to avoid c4/game stutter. Drop redundant Game prefix from exported types (GameStore -> Store, GameInstance -> Instance, GameStatus -> Status). Rename NATS subjects from game.{id} to connect4.{id}. URL routes unchanged. --- {game => connect4}/logic.go | 4 +- {game => connect4}/persist.go | 24 +++--- {game => connect4}/store.go | 98 ++++++++++++------------ {game => connect4}/types.go | 8 +- features/c4game/components/board.templ | 14 ++-- features/c4game/components/status.templ | 30 ++++---- features/c4game/handlers.go | 30 ++++---- features/c4game/pages/game.templ | 6 +- features/c4game/routes.go | 4 +- features/lobby/components/gamelist.templ | 14 ++-- features/lobby/handlers.go | 6 +- features/lobby/routes.go | 4 +- main.go | 6 +- router/router.go | 4 +- 14 files changed, 125 insertions(+), 127 deletions(-) rename {game => connect4}/logic.go (95%) rename {game => connect4}/persist.go (85%) rename {game => connect4}/store.go (59%) rename {game => connect4}/types.go (93%) diff --git a/game/logic.go b/connect4/logic.go similarity index 95% rename from game/logic.go rename to connect4/logic.go index 7a4d167..8abbb74 100644 --- a/game/logic.go +++ b/connect4/logic.go @@ -1,5 +1,5 @@ -// Package game implements Connect 4 game logic, state management, and persistence. -package game +// Package connect4 implements Connect 4 game logic, state management, and persistence. +package connect4 // DropPiece attempts to drop a piece in the given column. // Returns (row placed, success). diff --git a/game/persist.go b/connect4/persist.go similarity index 85% rename from game/persist.go rename to connect4/persist.go index 5efe140..9772b93 100644 --- a/game/persist.go +++ b/connect4/persist.go @@ -1,4 +1,4 @@ -package game +package connect4 import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/rs/zerolog/log" ) -func (gi *GameInstance) save() error { +func (gi *Instance) save() error { err := saveGame(gi.queries, gi.game) if err != nil { log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game") @@ -17,8 +17,8 @@ func (gi *GameInstance) save() error { return err } -func (gi *GameInstance) savePlayer(player *Player, slot int) error { - err := saveGamePlayer(gi.queries, gi.game.ID, player, slot) +func (gi *Instance) savePlayer(p *Player, slot int) error { + err := saveGamePlayer(gi.queries, gi.game.ID, p, slot) if err != nil { log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player") } @@ -48,12 +48,12 @@ func saveGame(queries *repository.Queries, g *Game) error { }) } -func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, slot int) error { +func saveGamePlayer(queries *repository.Queries, gameID string, p *Player, slot int) error { var userID, guestPlayerID *string - if player.UserID != nil { - userID = player.UserID + if p.UserID != nil { + userID = p.UserID } else { - id := string(player.ID) + id := string(p.ID) guestPlayerID = &id } @@ -61,8 +61,8 @@ func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, GameID: gameID, UserID: userID, GuestPlayerID: guestPlayerID, - Nickname: player.Nickname, - Color: int64(player.Color), + Nickname: p.Nickname, + Color: int64(p.Color), Slot: int64(slot), }) } @@ -83,13 +83,11 @@ func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) return playersFromRows(rows), nil } -// Domain ↔ DB mapping helpers. - func gameFromRow(row *repository.Game) (*Game, error) { g := &Game{ ID: row.ID, CurrentTurn: int(row.CurrentTurn), - Status: GameStatus(row.Status), + Status: Status(row.Status), } if err := g.BoardFromJSON(row.Board); err != nil { diff --git a/game/store.go b/connect4/store.go similarity index 59% rename from game/store.go rename to connect4/store.go index 818942c..fe68b1e 100644 --- a/game/store.go +++ b/connect4/store.go @@ -1,4 +1,4 @@ -package game +package connect4 import ( "context" @@ -12,67 +12,67 @@ type PlayerSession struct { Player *Player } -type GameStore struct { - games map[string]*GameInstance +type Store struct { + games map[string]*Instance gamesMu sync.RWMutex queries *repository.Queries notifyFunc func(gameID string) } -func NewGameStore(queries *repository.Queries) *GameStore { - return &GameStore{ - games: make(map[string]*GameInstance), +func NewStore(queries *repository.Queries) *Store { + return &Store{ + games: make(map[string]*Instance), queries: queries, } } -func (gs *GameStore) SetNotifyFunc(f func(gameID string)) { - gs.notifyFunc = f +func (s *Store) SetNotifyFunc(f func(gameID string)) { + s.notifyFunc = f } -func (gs *GameStore) makeNotify(gameID string) func() { +func (s *Store) makeNotify(gameID string) func() { return func() { - if gs.notifyFunc != nil { - gs.notifyFunc(gameID) + if s.notifyFunc != nil { + s.notifyFunc(gameID) } } } -func (gs *GameStore) Create() *GameInstance { +func (s *Store) Create() *Instance { id := player.GenerateID(4) - gi := NewGameInstance(id) - gi.queries = gs.queries - gi.notify = gs.makeNotify(id) - gs.gamesMu.Lock() - gs.games[id] = gi - gs.gamesMu.Unlock() + gi := NewInstance(id) + gi.queries = s.queries + gi.notify = s.makeNotify(id) + s.gamesMu.Lock() + s.games[id] = gi + s.gamesMu.Unlock() - if gs.queries != nil { + if s.queries != nil { gi.save() //nolint:errcheck } return gi } -func (gs *GameStore) Get(id string) (*GameInstance, bool) { - gs.gamesMu.RLock() - gi, ok := gs.games[id] - gs.gamesMu.RUnlock() +func (s *Store) Get(id string) (*Instance, bool) { + s.gamesMu.RLock() + gi, ok := s.games[id] + s.gamesMu.RUnlock() if ok { return gi, true } - if gs.queries == nil { + if s.queries == nil { return nil, false } - g, err := loadGame(gs.queries, id) + g, err := loadGame(s.queries, id) if err != nil || g == nil { return nil, false } - players, _ := loadGamePlayers(gs.queries, id) + players, _ := loadGamePlayers(s.queries, id) for _, p := range players { switch p.Color { case 1: @@ -82,51 +82,51 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) { } } - gi = &GameInstance{ + gi = &Instance{ game: g, - queries: gs.queries, - notify: gs.makeNotify(id), + queries: s.queries, + notify: s.makeNotify(id), } - gs.gamesMu.Lock() - gs.games[id] = gi - gs.gamesMu.Unlock() + s.gamesMu.Lock() + s.games[id] = gi + s.gamesMu.Unlock() return gi, true } -func (gs *GameStore) Delete(id string) error { - gs.gamesMu.Lock() - delete(gs.games, id) - gs.gamesMu.Unlock() +func (s *Store) Delete(id string) error { + s.gamesMu.Lock() + delete(s.games, id) + s.gamesMu.Unlock() - if gs.queries != nil { - return gs.queries.DeleteGame(context.Background(), id) + if s.queries != nil { + return s.queries.DeleteGame(context.Background(), id) } return nil } -type GameInstance struct { +type Instance struct { game *Game gameMu sync.RWMutex notify func() queries *repository.Queries } -func NewGameInstance(id string) *GameInstance { - return &GameInstance{ +func NewInstance(id string) *Instance { + return &Instance{ game: NewGame(id), notify: func() {}, } } -func (gi *GameInstance) ID() string { +func (gi *Instance) ID() string { gi.gameMu.RLock() defer gi.gameMu.RUnlock() return gi.game.ID } -func (gi *GameInstance) Join(ps *PlayerSession) bool { +func (gi *Instance) Join(ps *PlayerSession) bool { gi.gameMu.Lock() defer gi.gameMu.Unlock() @@ -153,13 +153,13 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { return true } -func (gi *GameInstance) GetGame() *Game { +func (gi *Instance) GetGame() *Game { gi.gameMu.RLock() defer gi.gameMu.RUnlock() return gi.game } -func (gi *GameInstance) GetPlayerColor(pid player.ID) int { +func (gi *Instance) GetPlayerColor(pid player.ID) int { gi.gameMu.RLock() defer gi.gameMu.RUnlock() for _, p := range gi.game.Players { @@ -170,7 +170,7 @@ func (gi *GameInstance) GetPlayerColor(pid player.ID) int { return 0 } -func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { +func (gi *Instance) CreateRematch(s *Store) *Instance { gi.gameMu.Lock() defer gi.gameMu.Unlock() @@ -178,13 +178,13 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { return nil } - newGI := gs.Create() + newGI := s.Create() newID := newGI.ID() gi.game.RematchGameID = &newID if gi.queries != nil { if err := gi.save(); err != nil { - gs.Delete(newID) //nolint:errcheck + s.Delete(newID) //nolint:errcheck gi.game.RematchGameID = nil return nil } @@ -194,7 +194,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { return newGI } -func (gi *GameInstance) DropPiece(col int, playerColor int) bool { +func (gi *Instance) DropPiece(col int, playerColor int) bool { gi.gameMu.Lock() defer gi.gameMu.Unlock() diff --git a/game/types.go b/connect4/types.go similarity index 93% rename from game/types.go rename to connect4/types.go index 2ceef46..0dac3d8 100644 --- a/game/types.go +++ b/connect4/types.go @@ -1,4 +1,4 @@ -package game +package connect4 import ( "encoding/json" @@ -13,10 +13,10 @@ type Player struct { Color int // 1 = Red, 2 = Yellow } -type GameStatus int +type Status int const ( - StatusWaitingForPlayer GameStatus = iota + StatusWaitingForPlayer Status = iota StatusInProgress StatusWon StatusDraw @@ -27,7 +27,7 @@ type Game struct { Board [6][7]int // 6 rows, 7 columns; 0=empty, 1=red, 2=yellow Players [2]*Player // Index 0 = creator (Red), Index 1 = joiner (Yellow) CurrentTurn int // 1 or 2 (matches player color) - Status GameStatus + Status Status Winner *Player WinningCells [][2]int // Coordinates of winning 4 cells for highlighting RematchGameID *string // ID of the rematch game, if one was created diff --git a/features/c4game/components/board.templ b/features/c4game/components/board.templ index ee4cb72..6ac381c 100644 --- a/features/c4game/components/board.templ +++ b/features/c4game/components/board.templ @@ -3,11 +3,11 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/connect4" "github.com/starfederation/datastar-go/datastar" ) -templ Board(g *game.Game, myColor int) { +templ Board(g *connect4.Game, myColor int) {
for col := 0; col < 7; col++ { @column(g, col, myColor) @@ -15,8 +15,8 @@ templ Board(g *game.Game, myColor int) {
} -templ column(g *game.Game, colIdx int, myColor int) { - if g.Status == game.StatusInProgress && myColor == g.CurrentTurn { +templ column(g *connect4.Game, colIdx int, myColor int) { + if g.Status == connect4.StatusInProgress && myColor == g.CurrentTurn {
} -func cellClass(g *game.Game, row, col int) string { +func cellClass(g *connect4.Game, row, col int) string { color := g.Board[row][col] activeTurn := 0 - if g.Status == game.StatusInProgress { + if g.Status == connect4.StatusInProgress { activeTurn = g.CurrentTurn } diff --git a/features/c4game/components/status.templ b/features/c4game/components/status.templ index d4f4e71..c2e6483 100644 --- a/features/c4game/components/status.templ +++ b/features/c4game/components/status.templ @@ -2,11 +2,11 @@ package components import ( "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/connect4" "github.com/starfederation/datastar-go/datastar" ) -templ StatusBanner(g *game.Game, myColor int) { +templ StatusBanner(g *connect4.Game, myColor int) {
{ statusMessage(g, myColor) } if g.IsFinished() { @@ -30,7 +30,7 @@ templ StatusBanner(g *game.Game, myColor int) {
} -templ PlayerInfo(g *game.Game, myColor int) { +templ PlayerInfo(g *connect4.Game, myColor int) {
for _, info := range playerInfoPairs(g, myColor) {
@@ -61,36 +61,36 @@ script copyToClipboard(url string) { navigator.clipboard.writeText(url) } -func statusClass(g *game.Game, myColor int) string { +func statusClass(g *connect4.Game, myColor int) string { switch g.Status { - case game.StatusWaitingForPlayer: + case connect4.StatusWaitingForPlayer: return "alert bg-base-200 text-xl font-bold" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.CurrentTurn == myColor { return "alert alert-success text-xl font-bold" } return "alert bg-base-200 text-xl font-bold" - case game.StatusWon: + case connect4.StatusWon: if g.Winner != nil && g.Winner.Color == myColor { return "alert alert-success text-xl font-bold" } return "alert alert-error text-xl font-bold" - case game.StatusDraw: + case connect4.StatusDraw: return "alert alert-warning text-xl font-bold" } return "alert bg-base-200 text-xl font-bold" } -func statusMessage(g *game.Game, myColor int) string { +func statusMessage(g *connect4.Game, myColor int) string { switch g.Status { - case game.StatusWaitingForPlayer: + case connect4.StatusWaitingForPlayer: return "Waiting for opponent..." - case game.StatusInProgress: + case connect4.StatusInProgress: if g.CurrentTurn == myColor { return "Your turn!" } return opponentName(g, myColor) + "'s turn" - case game.StatusWon: + case connect4.StatusWon: if g.Winner != nil && g.Winner.Color == myColor { return "You win!" } @@ -98,13 +98,13 @@ func statusMessage(g *game.Game, myColor int) string { return g.Winner.Nickname + " wins!" } return "Game over" - case game.StatusDraw: + case connect4.StatusDraw: return "It's a draw!" } return "" } -func opponentName(g *game.Game, myColor int) string { +func opponentName(g *connect4.Game, myColor int) string { for _, p := range g.Players { if p != nil && p.Color != myColor { return p.Nickname @@ -118,7 +118,7 @@ type playerInfoData struct { Label string } -func playerInfoPairs(g *game.Game, myColor int) []playerInfoData { +func playerInfoPairs(g *connect4.Game, myColor int) []playerInfoData { var result []playerInfoData var myName, oppName string diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 906541a..c82573d 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -13,10 +13,10 @@ import ( "github.com/ryanhamamura/c4/chat" chatcomponents "github.com/ryanhamamura/c4/chat/components" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" - "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/sessions" ) @@ -41,7 +41,7 @@ func c4ChatConfig(gameID string) chatcomponents.Config { } } -func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -57,14 +57,14 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo // Auto-join if player has a nickname but isn't in the game yet if nickname != "" && gi.GetPlayerColor(playerID) == 0 { - p := &game.Player{ + p := &connect4.Player{ ID: playerID, Nickname: nickname, } if userID != "" { p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: p}) + gi.Join(&connect4.PlayerSession{Player: p}) } myColor := gi.GetPlayerColor(playerID) @@ -93,7 +93,7 @@ func HandleGamePage(store *game.GameStore, sm *scs.SessionManager, queries *repo } } -func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -111,14 +111,14 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag )) chatCfg := c4ChatConfig(gameID) - room := chat.NewRoom(nc, "game.chat."+gameID, chat.LoadMessages(queries, gameID)) + room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID)) // Send initial render sendGameComponents(sse, gi, myColor, room, chatCfg) // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe("game."+gameID, gameCh) + gameSub, err := nc.ChanSubscribe("connect4."+gameID, gameCh) if err != nil { return } @@ -152,7 +152,7 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManag } } -func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { +func HandleDropPiece(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -181,7 +181,7 @@ func HandleDropPiece(store *game.GameStore, sm *scs.SessionManager) http.Handler } } -func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { +func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -230,7 +230,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager } chat.SaveMessage(queries, gameID, msg) - room := chat.NewRoom(nc, "game.chat."+gameID, nil) + room := chat.NewRoom(nc, "connect4.chat."+gameID, nil) room.Send(msg) sse := datastar.NewSSE(w, r) @@ -238,7 +238,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sm *scs.SessionManager } } -func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { +func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -269,14 +269,14 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl userID := sessions.GetUserID(sm, r) if gi.GetPlayerColor(playerID) == 0 { - p := &game.Player{ + p := &connect4.Player{ ID: playerID, Nickname: signals.Nickname, } if userID != "" { p.UserID = &userID } - gi.Join(&game.PlayerSession{Player: p}) + gi.Join(&connect4.PlayerSession{Player: p}) } sse := datastar.NewSSE(w, r) @@ -284,7 +284,7 @@ func HandleSetNickname(store *game.GameStore, sm *scs.SessionManager) http.Handl } } -func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFunc { +func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") @@ -304,7 +304,7 @@ func HandleRematch(store *game.GameStore, sm *scs.SessionManager) http.HandlerFu } // sendGameComponents patches all game-related SSE components. -func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameInstance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { +func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { g := gi.GetGame() sse.PatchElementTempl(components.Board(g, myColor), datastar.WithSelectorID("c4-board")) //nolint:errcheck diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index eee6222..19d8418 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -3,14 +3,14 @@ package pages import ( "github.com/ryanhamamura/c4/chat" chatcomponents "github.com/ryanhamamura/c4/chat/components" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/features/c4game/components" sharedcomponents "github.com/ryanhamamura/c4/features/common/components" "github.com/ryanhamamura/c4/features/common/layouts" - "github.com/ryanhamamura/c4/game" "github.com/starfederation/datastar-go/datastar" ) -templ GamePage(g *game.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { +templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { @layouts.Base("Connect 4") {
- if g.Status == game.StatusWaitingForPlayer { + if g.Status == connect4.StatusWaitingForPlayer { @components.InviteLink(g.ID) }
diff --git a/features/c4game/routes.go b/features/c4game/routes.go index 2ffd2dc..bc35047 100644 --- a/features/c4game/routes.go +++ b/features/c4game/routes.go @@ -6,13 +6,13 @@ import ( "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" ) func SetupRoutes( router chi.Router, - store *game.GameStore, + store *connect4.Store, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries, diff --git a/features/lobby/components/gamelist.templ b/features/lobby/components/gamelist.templ index 24563e5..3fbcb5c 100644 --- a/features/lobby/components/gamelist.templ +++ b/features/lobby/components/gamelist.templ @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/connect4" "github.com/starfederation/datastar-go/datastar" ) @@ -46,10 +46,10 @@ templ gameListEntry(g GameListItem) { } func statusText(g GameListItem) string { - switch game.GameStatus(g.Status) { - case game.StatusWaitingForPlayer: + switch connect4.Status(g.Status) { + case connect4.StatusWaitingForPlayer: return "Waiting for opponent" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.IsMyTurn { return "Your turn!" } @@ -59,10 +59,10 @@ func statusText(g GameListItem) string { } func statusClass(g GameListItem) string { - switch game.GameStatus(g.Status) { - case game.StatusWaitingForPlayer: + switch connect4.Status(g.Status) { + case connect4.StatusWaitingForPlayer: return "text-sm opacity-60" - case game.StatusInProgress: + case connect4.StatusInProgress: if g.IsMyTurn { return "text-sm text-success font-bold" } diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go index 938c02a..609eee3 100644 --- a/features/lobby/handlers.go +++ b/features/lobby/handlers.go @@ -7,10 +7,10 @@ import ( "strconv" "time" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db/repository" lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" "github.com/ryanhamamura/c4/features/lobby/pages" - "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/snake" "github.com/alexedwards/scs/v2" @@ -80,7 +80,7 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, } // HandleCreateGame reads the nickname signal, creates a connect4 game, and redirects via SSE. -func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type Signals struct { Nickname string `json:"nickname"` @@ -104,7 +104,7 @@ func HandleCreateGame(store *game.GameStore, sessions *scs.SessionManager) http. } // HandleDeleteGame deletes a connect4 game and redirects to the lobby. -func HandleDeleteGame(store *game.GameStore, sessions *scs.SessionManager) http.HandlerFunc { +func HandleDeleteGame(store *connect4.Store, sessions *scs.SessionManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gameID := chi.URLParam(r, "id") if gameID == "" { diff --git a/features/lobby/routes.go b/features/lobby/routes.go index ea8a433..103f3de 100644 --- a/features/lobby/routes.go +++ b/features/lobby/routes.go @@ -2,8 +2,8 @@ package lobby import ( + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/snake" "github.com/alexedwards/scs/v2" @@ -14,7 +14,7 @@ func SetupRoutes( router chi.Router, queries *repository.Queries, sessions *scs.SessionManager, - store *game.GameStore, + store *connect4.Store, snakeStore *snake.SnakeStore, ) { router.Get("/", HandleLobbyPage(queries, sessions, snakeStore)) diff --git a/main.go b/main.go index 1b97103..04c3f51 100644 --- a/main.go +++ b/main.go @@ -12,9 +12,9 @@ import ( "time" "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/logging" appnats "github.com/ryanhamamura/c4/nats" "github.com/ryanhamamura/c4/router" @@ -71,9 +71,9 @@ func run(ctx context.Context) error { defer cleanupNATS() // Game stores - store := game.NewGameStore(queries) + store := connect4.NewStore(queries) store.SetNotifyFunc(func(gameID string) { - nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification + nc.Publish("connect4."+gameID, nil) //nolint:errcheck // best-effort notification }) snakeStore := snake.NewSnakeStore(queries) diff --git a/router/router.go b/router/router.go index 6f8b6eb..ce62137 100644 --- a/router/router.go +++ b/router/router.go @@ -8,12 +8,12 @@ import ( "sync" "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/c4/connect4" "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/auth" "github.com/ryanhamamura/c4/features/c4game" "github.com/ryanhamamura/c4/features/lobby" "github.com/ryanhamamura/c4/features/snakegame" - "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/snake" "github.com/alexedwards/scs/v2" @@ -27,7 +27,7 @@ func SetupRoutes( queries *repository.Queries, sessions *scs.SessionManager, nc *nats.Conn, - store *game.GameStore, + store *connect4.Store, snakeStore *snake.SnakeStore, assets embed.FS, ) { From c6885a069bd8032a6540ef46eefd8bc672136f91 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:41:20 -1000 Subject: [PATCH 06/14] refactor: rename Go module from c4 to games Rename module path github.com/ryanhamamura/c4 to github.com/ryanhamamura/games across go.mod, all source files, and golangci config. --- .golangci.yml | 2 +- chat/components/chat.templ | 2 +- chat/persist.go | 2 +- connect4/persist.go | 4 ++-- connect4/store.go | 4 ++-- connect4/types.go | 2 +- features/auth/handlers.go | 6 +++--- features/auth/pages/login.templ | 2 +- features/auth/pages/register.templ | 2 +- features/auth/routes.go | 2 +- features/c4game/components/board.templ | 2 +- features/c4game/components/status.templ | 4 ++-- features/c4game/handlers.go | 14 +++++++------- features/c4game/pages/game.templ | 12 ++++++------ features/c4game/routes.go | 4 ++-- features/common/layouts/base.templ | 2 +- features/lobby/components/gamelist.templ | 2 +- features/lobby/handlers.go | 10 +++++----- features/lobby/pages/lobby.templ | 8 ++++---- features/lobby/pages/types.go | 2 +- features/lobby/routes.go | 6 +++--- features/snakegame/components/board.templ | 2 +- features/snakegame/components/status.templ | 4 ++-- features/snakegame/handlers.go | 12 ++++++------ features/snakegame/pages/game.templ | 12 ++++++------ features/snakegame/routes.go | 2 +- go.mod | 2 +- logging/log.go | 2 +- logging/middleware.go | 2 +- main.go | 18 +++++++++--------- router/router.go | 16 ++++++++-------- sessions/sessions.go | 2 +- snake/persist.go | 4 ++-- snake/store.go | 4 ++-- snake/types.go | 2 +- testutil/db.go | 4 ++-- 36 files changed, 91 insertions(+), 91 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 961c8c8..59bc7bc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -35,7 +35,7 @@ formatters: settings: goimports: local-prefixes: - - github.com/ryanhamamura/c4 + - github.com/ryanhamamura/games issues: exclude-rules: diff --git a/chat/components/chat.templ b/chat/components/chat.templ index da7e329..ec10136 100644 --- a/chat/components/chat.templ +++ b/chat/components/chat.templ @@ -3,7 +3,7 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/chat" + "github.com/ryanhamamura/games/chat" "github.com/starfederation/datastar-go/datastar" ) diff --git a/chat/persist.go b/chat/persist.go index 0297b42..45b6ee7 100644 --- a/chat/persist.go +++ b/chat/persist.go @@ -4,7 +4,7 @@ import ( "context" "slices" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" "github.com/rs/zerolog/log" ) diff --git a/connect4/persist.go b/connect4/persist.go index 9772b93..5e311ad 100644 --- a/connect4/persist.go +++ b/connect4/persist.go @@ -3,8 +3,8 @@ package connect4 import ( "context" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" "github.com/rs/zerolog/log" ) diff --git a/connect4/store.go b/connect4/store.go index fe68b1e..2f1bba6 100644 --- a/connect4/store.go +++ b/connect4/store.go @@ -4,8 +4,8 @@ import ( "context" "sync" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" ) type PlayerSession struct { diff --git a/connect4/types.go b/connect4/types.go index 0dac3d8..a423030 100644 --- a/connect4/types.go +++ b/connect4/types.go @@ -3,7 +3,7 @@ package connect4 import ( "encoding/json" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/player" ) type Player struct { diff --git a/features/auth/handlers.go b/features/auth/handlers.go index dc34675..8c7c8fb 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -8,9 +8,9 @@ import ( "github.com/google/uuid" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/auth" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/auth/pages" + "github.com/ryanhamamura/games/auth" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/features/auth/pages" ) type LoginSignals struct { diff --git a/features/auth/pages/login.templ b/features/auth/pages/login.templ index 3a4bcae..8a93ff9 100644 --- a/features/auth/pages/login.templ +++ b/features/auth/pages/login.templ @@ -1,7 +1,7 @@ package pages import ( - "github.com/ryanhamamura/c4/features/common/layouts" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/auth/pages/register.templ b/features/auth/pages/register.templ index 00ef50a..92fd0c4 100644 --- a/features/auth/pages/register.templ +++ b/features/auth/pages/register.templ @@ -1,7 +1,7 @@ package pages import ( - "github.com/ryanhamamura/c4/features/common/layouts" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/auth/routes.go b/features/auth/routes.go index 16e5e85..bb39e44 100644 --- a/features/auth/routes.go +++ b/features/auth/routes.go @@ -5,7 +5,7 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db/repository" ) func SetupRoutes(router chi.Router, queries *repository.Queries, sessions *scs.SessionManager) { diff --git a/features/c4game/components/board.templ b/features/c4game/components/board.templ index 6ac381c..07bbd2a 100644 --- a/features/c4game/components/board.templ +++ b/features/c4game/components/board.templ @@ -3,7 +3,7 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/connect4" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/c4game/components/status.templ b/features/c4game/components/status.templ index c2e6483..161d1c6 100644 --- a/features/c4game/components/status.templ +++ b/features/c4game/components/status.templ @@ -1,8 +1,8 @@ package components import ( - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/connect4" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index c82573d..79461d9 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -11,13 +11,13 @@ import ( "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/chat" - chatcomponents "github.com/ryanhamamura/c4/chat/components" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/c4game/components" - "github.com/ryanhamamura/c4/features/c4game/pages" - "github.com/ryanhamamura/c4/sessions" + "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/components" + "github.com/ryanhamamura/games/features/c4game/pages" + "github.com/ryanhamamura/games/sessions" ) // c4ChatColors maps player color (1=Red, 2=Yellow) to CSS background colors. diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index 19d8418..16e0155 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -1,12 +1,12 @@ package pages import ( - "github.com/ryanhamamura/c4/chat" - chatcomponents "github.com/ryanhamamura/c4/chat/components" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/features/c4game/components" - sharedcomponents "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/features/c4game/components" + sharedcomponents "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/c4game/routes.go b/features/c4game/routes.go index bc35047..e936fd4 100644 --- a/features/c4game/routes.go +++ b/features/c4game/routes.go @@ -6,8 +6,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" ) func SetupRoutes( diff --git a/features/common/layouts/base.templ b/features/common/layouts/base.templ index ffd9585..86d563a 100644 --- a/features/common/layouts/base.templ +++ b/features/common/layouts/base.templ @@ -1,6 +1,6 @@ package layouts -import "github.com/ryanhamamura/c4/config" +import "github.com/ryanhamamura/games/config" templ Base(title string) { diff --git a/features/lobby/components/gamelist.templ b/features/lobby/components/gamelist.templ index 3fbcb5c..73efab3 100644 --- a/features/lobby/components/gamelist.templ +++ b/features/lobby/components/gamelist.templ @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/ryanhamamura/c4/connect4" + "github.com/ryanhamamura/games/connect4" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go index 609eee3..5698447 100644 --- a/features/lobby/handlers.go +++ b/features/lobby/handlers.go @@ -7,11 +7,11 @@ import ( "strconv" "time" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db/repository" - lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" - "github.com/ryanhamamura/c4/features/lobby/pages" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + lobbycomponents "github.com/ryanhamamura/games/features/lobby/components" + "github.com/ryanhamamura/games/features/lobby/pages" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" diff --git a/features/lobby/pages/lobby.templ b/features/lobby/pages/lobby.templ index cff0080..c2dde2e 100644 --- a/features/lobby/pages/lobby.templ +++ b/features/lobby/pages/lobby.templ @@ -3,10 +3,10 @@ package pages import ( "fmt" - "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" - lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" + lobbycomponents "github.com/ryanhamamura/games/features/lobby/components" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/lobby/pages/types.go b/features/lobby/pages/types.go index a386a6f..89acc28 100644 --- a/features/lobby/pages/types.go +++ b/features/lobby/pages/types.go @@ -1,6 +1,6 @@ package pages -import "github.com/ryanhamamura/c4/features/lobby/components" +import "github.com/ryanhamamura/games/features/lobby/components" // SnakeGameListItem represents a joinable snake game in the lobby. type SnakeGameListItem struct { diff --git a/features/lobby/routes.go b/features/lobby/routes.go index 103f3de..bec75e2 100644 --- a/features/lobby/routes.go +++ b/features/lobby/routes.go @@ -2,9 +2,9 @@ package lobby import ( - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" diff --git a/features/snakegame/components/board.templ b/features/snakegame/components/board.templ index 6083935..9c4c156 100644 --- a/features/snakegame/components/board.templ +++ b/features/snakegame/components/board.templ @@ -3,7 +3,7 @@ package components import ( "fmt" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/snake" ) func cellSizeForGrid(width, height int) int { diff --git a/features/snakegame/components/status.templ b/features/snakegame/components/status.templ index b09613d..d1b045e 100644 --- a/features/snakegame/components/status.templ +++ b/features/snakegame/components/status.templ @@ -5,8 +5,8 @@ import ( "math" "time" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 13dde02..5f518dd 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -10,12 +10,12 @@ import ( "github.com/nats-io/nats.go" "github.com/starfederation/datastar-go/datastar" - "github.com/ryanhamamura/c4/chat" - chatcomponents "github.com/ryanhamamura/c4/chat/components" - "github.com/ryanhamamura/c4/features/snakegame/components" - "github.com/ryanhamamura/c4/features/snakegame/pages" - "github.com/ryanhamamura/c4/sessions" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/features/snakegame/components" + "github.com/ryanhamamura/games/features/snakegame/pages" + "github.com/ryanhamamura/games/sessions" + "github.com/ryanhamamura/games/snake" ) func snakeChatColor(slot int) string { diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 46fe32d..63bda61 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -3,12 +3,12 @@ package pages import ( "fmt" - "github.com/ryanhamamura/c4/chat" - chatcomponents "github.com/ryanhamamura/c4/chat/components" - "github.com/ryanhamamura/c4/features/common/components" - "github.com/ryanhamamura/c4/features/common/layouts" - snakecomponents "github.com/ryanhamamura/c4/features/snakegame/components" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/chat" + chatcomponents "github.com/ryanhamamura/games/chat/components" + "github.com/ryanhamamura/games/features/common/components" + "github.com/ryanhamamura/games/features/common/layouts" + snakecomponents "github.com/ryanhamamura/games/features/snakegame/components" + "github.com/ryanhamamura/games/snake" "github.com/starfederation/datastar-go/datastar" ) diff --git a/features/snakegame/routes.go b/features/snakegame/routes.go index 880c073..0f30757 100644 --- a/features/snakegame/routes.go +++ b/features/snakegame/routes.go @@ -6,7 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/snake" ) func SetupRoutes(router chi.Router, snakeStore *snake.SnakeStore, nc *nats.Conn, sessions *scs.SessionManager) { diff --git a/go.mod b/go.mod index d7a24c5..069eb86 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ryanhamamura/c4 +module github.com/ryanhamamura/games go 1.25.4 diff --git a/logging/log.go b/logging/log.go index 4cdaed3..7a4245d 100644 --- a/logging/log.go +++ b/logging/log.go @@ -6,7 +6,7 @@ import ( stdlog "log" "os" - "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/games/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" diff --git a/logging/middleware.go b/logging/middleware.go index dd6404e..be6c21a 100644 --- a/logging/middleware.go +++ b/logging/middleware.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/ryanhamamura/c4/config" + "github.com/ryanhamamura/games/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" diff --git a/main.go b/main.go index 04c3f51..4bf49a3 100644 --- a/main.go +++ b/main.go @@ -11,15 +11,15 @@ import ( "syscall" "time" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/logging" - appnats "github.com/ryanhamamura/c4/nats" - "github.com/ryanhamamura/c4/router" - "github.com/ryanhamamura/c4/sessions" - "github.com/ryanhamamura/c4/snake" + "github.com/ryanhamamura/games/config" + "github.com/ryanhamamura/games/connect4" + "github.com/ryanhamamura/games/db" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/logging" + appnats "github.com/ryanhamamura/games/nats" + "github.com/ryanhamamura/games/router" + "github.com/ryanhamamura/games/sessions" + "github.com/ryanhamamura/games/snake" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" diff --git a/router/router.go b/router/router.go index ce62137..1a2cd8f 100644 --- a/router/router.go +++ b/router/router.go @@ -7,14 +7,14 @@ import ( "net/http" "sync" - "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/connect4" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/features/auth" - "github.com/ryanhamamura/c4/features/c4game" - "github.com/ryanhamamura/c4/features/lobby" - "github.com/ryanhamamura/c4/features/snakegame" - "github.com/ryanhamamura/c4/snake" + "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/lobby" + "github.com/ryanhamamura/games/features/snakegame" + "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" diff --git a/sessions/sessions.go b/sessions/sessions.go index c4e50d6..fdbbe08 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -8,7 +8,7 @@ import ( "net/http" "time" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/player" "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" diff --git a/snake/persist.go b/snake/persist.go index 741cb40..e044a61 100644 --- a/snake/persist.go +++ b/snake/persist.go @@ -3,8 +3,8 @@ package snake import ( "context" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" "github.com/rs/zerolog/log" ) diff --git a/snake/store.go b/snake/store.go index 2590a4a..888dfa2 100644 --- a/snake/store.go +++ b/snake/store.go @@ -4,8 +4,8 @@ import ( "context" "sync" - "github.com/ryanhamamura/c4/db/repository" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/db/repository" + "github.com/ryanhamamura/games/player" ) type SnakeStore struct { diff --git a/snake/types.go b/snake/types.go index bbcb836..62376f9 100644 --- a/snake/types.go +++ b/snake/types.go @@ -4,7 +4,7 @@ import ( "encoding/json" "time" - "github.com/ryanhamamura/c4/player" + "github.com/ryanhamamura/games/player" ) type Direction int diff --git a/testutil/db.go b/testutil/db.go index d2cb2f9..e642aba 100644 --- a/testutil/db.go +++ b/testutil/db.go @@ -8,8 +8,8 @@ import ( "io/fs" "testing" - "github.com/ryanhamamura/c4/db" - "github.com/ryanhamamura/c4/db/repository" + "github.com/ryanhamamura/games/db" + "github.com/ryanhamamura/games/db/repository" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" From 6d43bdea16bb13ed9d585701fb5e6b78047af7c6 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:16:12 -1000 Subject: [PATCH 07/14] refactor: rename remaining c4 references to games Update binary name, DB path, session cookie, deploy scripts, systemd service, Docker config, CI workflow, and .dockerignore. Remove stale Claude command and settings files. --- .claude/commands/release.md | 45 ---------------------------- .dockerignore | 8 ++--- .env.example | 6 ++-- .gitea/workflows/deploy.yml | 2 +- Dockerfile | 8 ++--- Taskfile.yml | 10 +++---- config/config.go | 2 +- deploy/deploy.sh | 14 ++++----- deploy/{c4.service => games.service} | 8 ++--- deploy/package.sh | 28 ++++++++--------- deploy/reassemble.sh | 26 ++++++++-------- deploy/setup.sh | 10 +++---- docker-compose.yml | 4 +-- sessions/sessions.go | 2 +- 14 files changed, 64 insertions(+), 109 deletions(-) delete mode 100644 .claude/commands/release.md rename deploy/{c4.service => games.service} (70%) diff --git a/.claude/commands/release.md b/.claude/commands/release.md deleted file mode 100644 index df59678..0000000 --- a/.claude/commands/release.md +++ /dev/null @@ -1,45 +0,0 @@ -Create a new Gitea release for this project using semantic versioning. - -## Current state - -Fetch tags and find the latest version: - -``` -!git fetch --tags && git tag --sort=-v:refname | head -5 -``` - -Commits since the last release (if no tags exist, this shows all commits): - -``` -!git log $(git describe --tags --abbrev=0 2>/dev/null && echo "$(git describe --tags --abbrev=0)..HEAD" || echo "") --oneline -``` - -## Instructions - -1. **Determine current version** from the tag output above. If no `vX.Y.Z` tags exist, treat current version as `v0.0.0`. - -2. **Analyze commits** using conventional commit prefixes to pick the semver bump: - - Breaking changes (`!` after type, or `BREAKING CHANGE` in body) → **major** bump - - `feat:` → **minor** bump - - `fix:`, `chore:`, `deps:`, `revert:`, and everything else → **patch** bump - - Use the **highest** applicable bump level across all commits - -3. **Generate release notes** — group commits into sections: - - **Features** — `feat:` commits - - **Fixes** — `fix:` commits - - **Other** — everything else (`chore:`, `deps:`, `revert:`, etc.) - - Omit empty sections. Each commit is a bullet point with its short description (strip the prefix). - -4. **Present for approval** — show the user: - - Current version → proposed new version - - The full release notes - - The exact `tea` command that will run - - Ask the user to confirm before proceeding - -5. **Create the release** — on user approval, run: - ``` - tea releases create --login gitea --repo ryan/c4 --tag --target main -t "" -n "" - ``` - Do NOT create a local git tag — Gitea creates it server-side. - -6. **Verify** — run `tea releases ls --login gitea --repo ryan/c4` to confirm the release was created. diff --git a/.dockerignore b/.dockerignore index 8362dd4..5471e11 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,10 @@ -c4 -c4.db +games +games.db data/ deploy/ .env .git .gitignore assets/css/output.css -c4-deploy-*.tar.gz -c4-deploy-*_b64*.txt +games-deploy-*.tar.gz +games-deploy-*_b64*.txt diff --git a/.env.example b/.env.example index 7ae4ff9..c317103 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO. # LOG_LEVEL=DEBUG -# SQLite database path. Defaults to data/c4.db. -# DB_PATH=data/c4.db +# SQLite database path. Defaults to data/games.db. +# DB_PATH=data/games.db # Application URL for invite links. Defaults to https://games.adriatica.io. # APP_URL=http://localhost:7331 @@ -12,5 +12,5 @@ # Goose CLI migration config (only needed for running goose manually) GOOSE_DRIVER=sqlite3 -GOOSE_DBSTRING=data/c4.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) +GOOSE_DBSTRING=data/games.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) GOOSE_MIGRATION_DIR=db/migrations diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 28baa5a..9aa9673 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: pull_request: env: - DEPLOY_DIR: /home/ryan/c4 + DEPLOY_DIR: /home/ryan/games jobs: test: diff --git a/Dockerfile b/Dockerfile index a22afc9..e0b0d87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ RUN go mod download COPY . . RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify RUN --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 . -RUN upx -9 -k /bin/c4 + CGO_ENABLED=0 go build -ldflags="-s" -o /bin/games . +RUN upx -9 -k /bin/games FROM scratch ENV PORT=8080 -COPY --from=build /bin/c4 / -ENTRYPOINT ["/c4"] +COPY --from=build /bin/games / +ENTRYPOINT ["/games"] diff --git a/Taskfile.yml b/Taskfile.yml index e0df8ef..fcb0a9d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -27,9 +27,9 @@ tasks: - "assets/css/output.css" build: - desc: Production build to bin/c4 + desc: Production build to bin/games cmds: - - go build -o bin/c4 . + - go build -o bin/games . deps: - build:templ - build:styles @@ -49,8 +49,8 @@ tasks: cmds: - | go tool air \ - -build.cmd "go build -tags=dev -o tmp/bin/c4 ." \ - -build.bin "tmp/bin/c4" \ + -build.cmd "go build -tags=dev -o tmp/bin/games ." \ + -build.bin "tmp/bin/games" \ -build.exclude_dir "data,bin,tmp,deploy" \ -build.include_ext "go,templ" \ -misc.clean_on_exit "true" @@ -75,7 +75,7 @@ tasks: run: desc: Build and run the server cmds: - - ./bin/c4 + - ./bin/games deps: - build diff --git a/config/config.go b/config/config.go index 31774f2..330c660 100644 --- a/config/config.go +++ b/config/config.go @@ -71,6 +71,6 @@ func loadBase() *Config { } }(), AppURL: getEnv("APP_URL", "https://games.adriatica.io"), - DBPath: getEnv("DB_PATH", "data/c4.db"), + DBPath: getEnv("DB_PATH", "data/games.db"), } } diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4834fc9..c1f96f5 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Deploy the c4 binary to /opt/c4, then restart the service. +# Deploy the games binary to /opt/games, then restart the service. # Works from the repo (builds first) or from an extracted tarball (pre-built binary). set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -INSTALL_DIR="/opt/c4" -BINARY="$ROOT_DIR/c4" +INSTALL_DIR="/opt/games" +BINARY="$ROOT_DIR/games" # If Go is available and we have source, build fresh if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then @@ -13,7 +13,7 @@ if [[ -f "$ROOT_DIR/go.mod" ]] && command -v go &>/dev/null; then (cd "$ROOT_DIR" && go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify) echo "Building binary..." - (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o c4 .) + (cd "$ROOT_DIR" && CGO_ENABLED=0 go build -o games .) fi if [[ ! -f "$BINARY" ]]; then @@ -22,10 +22,10 @@ if [[ ! -f "$BINARY" ]]; then fi echo "Installing to $INSTALL_DIR..." -install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/c4" +install -o games -g games -m 755 "$BINARY" "$INSTALL_DIR/games" echo "Restarting service..." -systemctl restart c4.service +systemctl restart games.service echo "Done. Status:" -systemctl status c4.service --no-pager +systemctl status games.service --no-pager diff --git a/deploy/c4.service b/deploy/games.service similarity index 70% rename from deploy/c4.service rename to deploy/games.service index f1aa7c9..1836bac 100644 --- a/deploy/c4.service +++ b/deploy/games.service @@ -1,13 +1,13 @@ [Unit] -Description=C4 Game Lobby +Description=Games Lobby After=network.target [Service] Type=simple User=games Group=games -WorkingDirectory=/opt/c4 -ExecStart=/opt/c4/c4 +WorkingDirectory=/opt/games +ExecStart=/opt/games/games Restart=on-failure RestartSec=5 @@ -17,7 +17,7 @@ Environment=PORT=8080 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/opt/c4 +ReadWritePaths=/opt/games PrivateTmp=true [Install] diff --git a/deploy/package.sh b/deploy/package.sh index add6a3e..d200f67 100755 --- a/deploy/package.sh +++ b/deploy/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build the c4 binary, bundle it with deploy files into a tarball, +# Build the games binary, bundle it with deploy files into a tarball, # base64-encode it, and split into 25MB chunks for transfer. set -euo pipefail @@ -7,14 +7,14 @@ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_DIR" TIMESTAMP=$(date +%Y%m%d-%H%M%S) -TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" -BASE64_FILE="c4-deploy-${TIMESTAMP}_b64.txt" +TARBALL="games-deploy-${TIMESTAMP}.tar.gz" +BASE64_FILE="games-deploy-${TIMESTAMP}_b64.txt" #============================================================================== # Clean previous artifacts #============================================================================== echo "--- Cleaning old artifacts ---" -rm -f ./c4 c4-deploy-*.tar.gz c4-deploy-*_b64*.txt +rm -f ./games games-deploy-*.tar.gz games-deploy-*_b64*.txt #============================================================================== # Build @@ -23,18 +23,18 @@ echo "--- Building CSS ---" go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify echo "--- Building binary (linux/amd64) ---" -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o c4 . +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o games . #============================================================================== # Verify required files #============================================================================== echo "--- Verifying files ---" REQUIRED_FILES=( - c4 + games deploy/setup.sh deploy/deploy.sh deploy/reassemble.sh - deploy/c4.service + deploy/games.service ) for f in "${REQUIRED_FILES[@]}"; do if [[ ! -f "$f" ]]; then @@ -48,12 +48,12 @@ done # Create tarball #============================================================================== echo "--- Creating tarball ---" -tar -czf "/tmp/${TARBALL}" --transform 's,^,c4/,' \ - c4 \ +tar -czf "/tmp/${TARBALL}" --transform 's,^,games/,' \ + games \ deploy/setup.sh \ deploy/deploy.sh \ deploy/reassemble.sh \ - deploy/c4.service + deploy/games.service mv "/tmp/${TARBALL}" . echo " -> ${TARBALL} ($(du -h "${TARBALL}" | cut -f1))" @@ -66,10 +66,10 @@ base64 "${TARBALL}" > "${BASE64_FILE}" echo " -> ${BASE64_FILE} ($(du -h "${BASE64_FILE}" | cut -f1))" echo "--- Splitting into 25MB chunks ---" -split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "c4-deploy-${TIMESTAMP}_b64_part" +split -b 25M -d --additional-suffix=.txt "${BASE64_FILE}" "games-deploy-${TIMESTAMP}_b64_part" rm -f "${BASE64_FILE}" -CHUNKS=(c4-deploy-${TIMESTAMP}_b64_part*.txt) +CHUNKS=(games-deploy-${TIMESTAMP}_b64_part*.txt) echo " -> ${#CHUNKS[@]} chunk(s):" for chunk in "${CHUNKS[@]}"; do echo " $chunk ($(du -h "$chunk" | cut -f1))" @@ -83,5 +83,5 @@ echo "=== Package Complete ===" echo "" echo "Transfer the chunk files to the target server, then run:" echo " ./reassemble.sh" -echo " cd ~/c4 && sudo ./deploy/setup.sh # first time only" -echo " cd ~/c4 && sudo ./deploy/deploy.sh" +echo " cd ~/games && sudo ./deploy/setup.sh # first time only" +echo " cd ~/games && sudo ./deploy/deploy.sh" diff --git a/deploy/reassemble.sh b/deploy/reassemble.sh index 73127ee..59278ed 100755 --- a/deploy/reassemble.sh +++ b/deploy/reassemble.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Reassembles base64 chunks and extracts the c4 deployment tarball. +# Reassembles base64 chunks and extracts the games deployment tarball. # Expects chunk files in the current directory. set -euo pipefail cd "$HOME" -echo "=== C4 Deployment Reassembler ===" +echo "=== Games Deployment Reassembler ===" echo "Working directory: $HOME" echo "" @@ -14,10 +14,10 @@ echo "" #============================================================================== echo "--- Finding chunk files ---" -CHUNKS=($(ls -1 c4-deploy-*_b64_part*.txt 2>/dev/null | sort)) +CHUNKS=($(ls -1 games-deploy-*_b64_part*.txt 2>/dev/null | sort)) if [[ ${#CHUNKS[@]} -eq 0 ]]; then - echo "ERROR: No chunk files found matching c4-deploy-*_b64_part*.txt" + echo "ERROR: No chunk files found matching games-deploy-*_b64_part*.txt" exit 1 fi @@ -32,8 +32,8 @@ done echo "" echo "--- Reassembling chunks ---" -TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/c4-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/') -TARBALL="c4-deploy-${TIMESTAMP}.tar.gz" +TIMESTAMP=$(echo "${CHUNKS[0]}" | sed -E 's/games-deploy-([0-9]+-[0-9]+)_b64_part.*/\1/') +TARBALL="games-deploy-${TIMESTAMP}.tar.gz" COMBINED="combined_b64.txt" echo "Concatenating chunks..." @@ -58,12 +58,12 @@ fi echo "" echo "--- Archiving existing source ---" -if [[ -d c4 ]]; then - rm -rf c4.bak - mv c4 c4.bak - echo " -> Moved c4 -> c4.bak" +if [[ -d games ]]; then + rm -rf games.bak + mv games games.bak + echo " -> Moved games -> games.bak" else - echo " -> No existing c4 directory" + echo " -> No existing games directory" fi #============================================================================== @@ -73,7 +73,7 @@ echo "" echo "--- Extracting tarball ---" tar -xzf "$TARBALL" -echo " -> Extracted to ~/c4" +echo " -> Extracted to ~/games" #============================================================================== # Cleanup @@ -91,6 +91,6 @@ echo "" echo "=== Reassembly Complete ===" echo "" echo "Next steps:" -echo " cd ~/c4" +echo " cd ~/games" echo " sudo ./deploy/setup.sh # first time only" echo " sudo ./deploy/deploy.sh" diff --git a/deploy/setup.sh b/deploy/setup.sh index 920dde2..917137f 100755 --- a/deploy/setup.sh +++ b/deploy/setup.sh @@ -10,20 +10,20 @@ fi # Create system user if it doesn't exist if ! id -u games &>/dev/null; then - useradd --system --shell /usr/sbin/nologin --home-dir /opt/c4 --create-home games + useradd --system --shell /usr/sbin/nologin --home-dir /opt/games --create-home games echo "Created system user: games" else echo "User 'games' already exists" fi # Ensure install directory exists with correct ownership -install -d -o games -g games -m 755 /opt/c4 -install -d -o games -g games -m 755 /opt/c4/data +install -d -o games -g games -m 755 /opt/games +install -d -o games -g games -m 755 /opt/games/data # Install systemd unit SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cp "$SCRIPT_DIR/c4.service" /etc/systemd/system/c4.service +cp "$SCRIPT_DIR/games.service" /etc/systemd/system/games.service systemctl daemon-reload -systemctl enable c4.service +systemctl enable games.service echo "Setup complete. Run deploy.sh to build and start the service." diff --git a/docker-compose.yml b/docker-compose.yml index c22954e..79083e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - c4: + games: build: . - container_name: c4 + container_name: games restart: unless-stopped ports: - "8080:8080" diff --git a/sessions/sessions.go b/sessions/sessions.go index fdbbe08..02ebae2 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -23,7 +23,7 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { sessionManager := scs.New() sessionManager.Store = store sessionManager.Lifetime = 30 * 24 * time.Hour - sessionManager.Cookie.Name = "c4_session" + sessionManager.Cookie.Name = "games_session" sessionManager.Cookie.Path = "/" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.Secure = true From 2cfd42b606af4fb540bdf8108ce649cc91aee8ec Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:25:03 -1000 Subject: [PATCH 08/14] refactor: integrate chat persistence into Room Move SaveMessage/LoadMessages logic into Room as private methods. NewPersistentRoom auto-loads history and auto-saves on Send, removing the need for handlers to coordinate persistence separately. --- chat/chat.go | 73 ++++++++++++++++++++++++++++++---- chat/persist.go | 45 --------------------- features/c4game/handlers.go | 10 ++--- features/snakegame/handlers.go | 4 +- 4 files changed, 71 insertions(+), 61 deletions(-) delete mode 100644 chat/persist.go diff --git a/chat/chat.go b/chat/chat.go index 632f919..c5b98be 100644 --- a/chat/chat.go +++ b/chat/chat.go @@ -3,11 +3,15 @@ package chat import ( + "context" "encoding/json" + "slices" "sync" "github.com/nats-io/nats.go" "github.com/rs/zerolog/log" + + "github.com/ryanhamamura/games/db/repository" ) // Message is the wire format for chat messages over NATS. @@ -21,26 +25,47 @@ type Message struct { const maxMessages = 50 // Room manages an in-memory message buffer and NATS pub/sub for a single -// chat room (typically one per game). +// chat room (typically one per game). When created with NewPersistentRoom, +// messages are automatically loaded from and saved to the database. type Room struct { subject string nc *nats.Conn messages []Message mu sync.Mutex + + // Optional persistence; nil for ephemeral rooms (e.g. snake). + queries *repository.Queries + roomID string } -// NewRoom creates a chat room that publishes and subscribes on the given -// NATS subject (e.g. "chat.abc123"). -func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room { +// NewRoom creates an ephemeral chat room with no database persistence. +func NewRoom(nc *nats.Conn, subject string) *Room { return &Room{ - subject: subject, - nc: nc, - messages: initial, + subject: subject, + nc: nc, } } -// Send publishes a message to the room's NATS subject. +// NewPersistentRoom creates a chat room backed by the database. It loads +// existing messages on creation and auto-saves new messages on Send. +func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room { + r := &Room{ + subject: subject, + nc: nc, + queries: queries, + roomID: roomID, + } + r.messages = r.loadMessages() + return r +} + +// Send publishes a message to the room's NATS subject and persists it +// if the room is backed by a database. func (r *Room) Send(msg Message) { + if r.queries != nil { + r.saveMessage(msg) + } + data, err := json.Marshal(msg) if err != nil { log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") @@ -90,3 +115,35 @@ func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) { } return ch, sub, nil } + +func (r *Room) saveMessage(msg Message) { + err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ + GameID: r.roomID, + Nickname: msg.Nickname, + Color: int64(msg.Slot), + Message: msg.Message, + CreatedAt: msg.Time, + }) + if err != nil { + log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message") + } +} + +func (r *Room) loadMessages() []Message { + rows, err := r.queries.GetChatMessages(context.Background(), r.roomID) + if err != nil { + return nil + } + msgs := make([]Message, len(rows)) + for i, row := range rows { + msgs[i] = Message{ + Nickname: row.Nickname, + Slot: int(row.Color), + Message: row.Message, + Time: row.CreatedAt, + } + } + // DB returns newest-first; reverse for chronological display + slices.Reverse(msgs) + return msgs +} diff --git a/chat/persist.go b/chat/persist.go deleted file mode 100644 index 45b6ee7..0000000 --- a/chat/persist.go +++ /dev/null @@ -1,45 +0,0 @@ -package chat - -import ( - "context" - "slices" - - "github.com/ryanhamamura/games/db/repository" - - "github.com/rs/zerolog/log" -) - -// SaveMessage persists a chat message to the database. -func SaveMessage(queries *repository.Queries, roomID string, msg Message) { - err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ - GameID: roomID, - Nickname: msg.Nickname, - Color: int64(msg.Slot), - Message: msg.Message, - CreatedAt: msg.Time, - }) - if err != nil { - log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message") - } -} - -// LoadMessages loads persisted chat messages for a room, returning them -// in chronological order (oldest first). -func LoadMessages(queries *repository.Queries, roomID string) []Message { - rows, err := queries.GetChatMessages(context.Background(), roomID) - if err != nil { - return nil - } - msgs := make([]Message, len(rows)) - for i, r := range rows { - msgs[i] = Message{ - Nickname: r.Nickname, - Slot: int(r.Color), - Message: r.Message, - Time: r.CreatedAt, - } - } - // DB returns newest-first; reverse for chronological display - slices.Reverse(msgs) - return msgs -} diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 79461d9..777b0c6 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -85,9 +85,9 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo } g := gi.GetGame() - msgs := chat.LoadMessages(queries, gameID) + room := chat.NewPersistentRoom(nil, "", queries, gameID) - if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { + if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } } @@ -111,7 +111,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag )) chatCfg := c4ChatConfig(gameID) - room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID)) + room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) // Send initial render sendGameComponents(sse, gi, myColor, room, chatCfg) @@ -228,9 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - chat.SaveMessage(queries, gameID, msg) - - room := chat.NewRoom(nc, "connect4.chat."+gameID, nil) + room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) room.Send(msg) sse := datastar.NewSSE(w, r) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 5f518dd..e42471d 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess var room *chat.Room if sg.Mode == snake.ModeMultiplayer { - room = chat.NewRoom(nc, "snake.chat."+gameID, nil) + room = chat.NewRoom(nc, "snake.chat."+gameID) chatCh, chatSub, err = room.Subscribe() if err != nil { return @@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session Message: signals.ChatMsg, } - room := chat.NewRoom(nc, "snake.chat."+gameID, nil) + room := chat.NewRoom(nc, "snake.chat."+gameID) room.Send(msg) sse := datastar.NewSSE(w, r) From fb6c0e3d90da6ec46dfe542e4c0635b7e6c003fb Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:30:47 -1000 Subject: [PATCH 09/14] refactor: replace hardcoded NATS subjects with typed helpers Add GameSubject/ChatSubject helpers to connect4 and snake packages, eliminating magic string concatenation from handlers and main.go. --- connect4/types.go | 6 ++++++ features/c4game/handlers.go | 6 +++--- features/snakegame/handlers.go | 6 +++--- main.go | 4 ++-- snake/types.go | 6 ++++++ 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/connect4/types.go b/connect4/types.go index a423030..0aa56b7 100644 --- a/connect4/types.go +++ b/connect4/types.go @@ -6,6 +6,12 @@ import ( "github.com/ryanhamamura/games/player" ) +// NATS subject helpers. +const SubjectPrefix = "connect4" + +func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } +func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } + type Player struct { ID player.ID UserID *string // UUID for authenticated users, nil for guests diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 777b0c6..8a8c309 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -111,14 +111,14 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag )) chatCfg := c4ChatConfig(gameID) - room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) + room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) // Send initial render sendGameComponents(sse, gi, myColor, room, chatCfg) // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe("connect4."+gameID, gameCh) + gameSub, err := nc.ChanSubscribe(connect4.GameSubject(gameID), gameCh) if err != nil { return } @@ -228,7 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID) + room := chat.NewPersistentRoom(nc, connect4.ChatSubject(gameID), queries, gameID) room.Send(msg) sse := datastar.NewSSE(w, r) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index e42471d..effa3ff 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -114,7 +114,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess // Subscribe to game updates via NATS gameCh := make(chan *nats.Msg, 64) - gameSub, err := nc.ChanSubscribe("snake."+gameID, gameCh) + gameSub, err := nc.ChanSubscribe(snake.GameSubject(gameID), gameCh) if err != nil { return } @@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess var room *chat.Room if sg.Mode == snake.ModeMultiplayer { - room = chat.NewRoom(nc, "snake.chat."+gameID) + room = chat.NewRoom(nc, snake.ChatSubject(gameID)) chatCh, chatSub, err = room.Subscribe() if err != nil { return @@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session Message: signals.ChatMsg, } - room := chat.NewRoom(nc, "snake.chat."+gameID) + room := chat.NewRoom(nc, snake.ChatSubject(gameID)) room.Send(msg) sse := datastar.NewSSE(w, r) diff --git a/main.go b/main.go index 4bf49a3..041e13c 100644 --- a/main.go +++ b/main.go @@ -73,12 +73,12 @@ func run(ctx context.Context) error { // Game stores store := connect4.NewStore(queries) store.SetNotifyFunc(func(gameID string) { - nc.Publish("connect4."+gameID, nil) //nolint:errcheck // best-effort notification + nc.Publish(connect4.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification }) snakeStore := snake.NewSnakeStore(queries) snakeStore.SetNotifyFunc(func(gameID string) { - nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification + nc.Publish(snake.GameSubject(gameID), nil) //nolint:errcheck // best-effort notification }) // Router diff --git a/snake/types.go b/snake/types.go index 62376f9..d1284c1 100644 --- a/snake/types.go +++ b/snake/types.go @@ -7,6 +7,12 @@ import ( "github.com/ryanhamamura/games/player" ) +// NATS subject helpers. +const SubjectPrefix = "snake" + +func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } +func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } + type Direction int const ( From 42211439c98bf06b27fc7b4d673a1f7a8d9dbc7d Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:34:46 -1000 Subject: [PATCH 10/14] refactor: drop redundant WithSelectorID from SSE patches All templ components already have id attributes on their root elements, which PatchElementTempl uses automatically. --- features/c4game/handlers.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 8a8c309..e385bbb 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -144,7 +144,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag if snapshot == nil { continue } - if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg), datastar.WithSelectorID("c4-chat")); err != nil { + if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil { return } } @@ -305,8 +305,8 @@ func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFu func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { 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 - sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg), datastar.WithSelectorID("c4-chat")) //nolint:errcheck + sse.PatchElementTempl(components.Board(g, myColor)) //nolint:errcheck + sse.PatchElementTempl(components.StatusBanner(g, myColor)) //nolint:errcheck + sse.PatchElementTempl(components.PlayerInfo(g, myColor)) //nolint:errcheck + sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg)) //nolint:errcheck } From 0808c4d972145348aaf1138b5c5010f3aaeb5126 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:43:25 -1000 Subject: [PATCH 11/14] refactor: patch entire game content instead of individual components Extract GameContent from GamePage so the SSE handler can patch a single element and let DOM morphing diff the changes, replacing the per-component sendGameComponents helper. --- features/c4game/handlers.go | 33 ++++++++++++++------------------ features/c4game/pages/game.templ | 28 ++++++++++++++++----------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index e385bbb..68cead1 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -15,7 +15,6 @@ import ( chatcomponents "github.com/ryanhamamura/games/chat/components" "github.com/ryanhamamura/games/connect4" "github.com/ryanhamamura/games/db/repository" - "github.com/ryanhamamura/games/features/c4game/components" "github.com/ryanhamamura/games/features/c4game/pages" "github.com/ryanhamamura/games/sessions" ) @@ -113,8 +112,16 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag 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)) + } + // Send initial render - sendGameComponents(sse, gi, myColor, room, chatCfg) + if err := patchAll(); err != nil { + return + } // Subscribe to game state updates gameCh := make(chan *nats.Msg, 64) @@ -137,14 +144,12 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag case <-ctx.Done(): return case <-gameCh: - myColor = gi.GetPlayerColor(playerID) - sendGameComponents(sse, gi, myColor, room, chatCfg) - case msg := <-chatCh: - _, snapshot := room.Receive(msg.Data) - if snapshot == nil { - continue + if err := patchAll(); err != nil { + return } - if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil { + case msg := <-chatCh: + room.Receive(msg.Data) + if err := patchAll(); err != nil { return } } @@ -300,13 +305,3 @@ func HandleRematch(store *connect4.Store, sm *scs.SessionManager) http.HandlerFu } } } - -// sendGameComponents patches all game-related SSE components. -func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *connect4.Instance, myColor int, room *chat.Room, chatCfg chatcomponents.Config) { - g := gi.GetGame() - - sse.PatchElementTempl(components.Board(g, myColor)) //nolint:errcheck - sse.PatchElementTempl(components.StatusBanner(g, myColor)) //nolint:errcheck - sse.PatchElementTempl(components.PlayerInfo(g, myColor)) //nolint:errcheck - sse.PatchElementTempl(chatcomponents.Chat(room.Messages(), chatCfg)) //nolint:errcheck -} diff --git a/features/c4game/pages/game.templ b/features/c4game/pages/game.templ index 16e0155..c175b42 100644 --- a/features/c4game/pages/game.templ +++ b/features/c4game/pages/game.templ @@ -17,21 +17,27 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c data-signals="{chatMsg: ''}" data-init={ datastar.GetSSE("/games/%s/events", g.ID) } > - @sharedcomponents.BackToLobby() - @sharedcomponents.StealthTitle("text-3xl font-bold") - @components.PlayerInfo(g, myColor) - @components.StatusBanner(g, myColor) -
- @components.Board(g, myColor) - @chatcomponents.Chat(messages, chatCfg) -
- if g.Status == connect4.StatusWaitingForPlayer { - @components.InviteLink(g.ID) - } + @GameContent(g, myColor, messages, chatCfg)
} } +templ GameContent(g *connect4.Game, myColor int, messages []chat.Message, chatCfg chatcomponents.Config) { +
+ @sharedcomponents.BackToLobby() + @sharedcomponents.StealthTitle("text-3xl font-bold") + @components.PlayerInfo(g, myColor) + @components.StatusBanner(g, myColor) +
+ @components.Board(g, myColor) + @chatcomponents.Chat(messages, chatCfg) +
+ if g.Status == connect4.StatusWaitingForPlayer { + @components.InviteLink(g.ID) + } +
+} + templ JoinPage(gameID string) { @layouts.Base("Connect 4 - Join") { @sharedcomponents.GameJoinPrompt( From 4faf4f73b078d0be3c493403ab472dc691c28660 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:34:20 -1000 Subject: [PATCH 12/14] refactor: patch entire game content for snake SSE handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same approach as connect4 — extract GameContent component and patch it as a single element, letting DOM morphing handle the diff. --- features/snakegame/handlers.go | 57 ++++++++++++++--------------- features/snakegame/pages/game.templ | 44 ++++++++++++---------- 2 files changed, 53 insertions(+), 48 deletions(-) diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index effa3ff..9620377 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -12,7 +12,6 @@ import ( "github.com/ryanhamamura/games/chat" chatcomponents "github.com/ryanhamamura/games/chat/components" - "github.com/ryanhamamura/games/features/snakegame/components" "github.com/ryanhamamura/games/features/snakegame/pages" "github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/snake" @@ -100,16 +99,33 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess chatCfg := snakeChatConfig(gameID) - // Send initial render + // Chat room (multiplayer only) + var room *chat.Room sg := si.GetGame() - sse.PatchElementTempl(components.Board(sg)) //nolint:errcheck - sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)) //nolint:errcheck - sse.PatchElementTempl(components.PlayerList(sg, mySlot)) //nolint:errcheck if sg.Mode == snake.ModeMultiplayer { - sse.PatchElementTempl(chatcomponents.Chat(nil, chatCfg)) //nolint:errcheck - if sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown { - sse.PatchElementTempl(components.InviteLink(gameID)) //nolint:errcheck + room = chat.NewRoom(nc, snake.ChatSubject(gameID)) + } + + chatMessages := func() []chat.Message { + if room == nil { + return nil } + return room.Messages() + } + + patchAll := func() error { + si, ok = snakeStore.Get(gameID) + if !ok { + return fmt.Errorf("game not found") + } + mySlot = si.GetPlayerSlot(playerID) + sg = si.GetGame() + return sse.PatchElementTempl(pages.GameContent(sg, mySlot, chatMessages(), chatCfg, gameID)) + } + + // Send initial render + if err := patchAll(); err != nil { + return } // Subscribe to game updates via NATS @@ -123,10 +139,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess // Chat subscription (multiplayer only) var chatCh chan *nats.Msg var chatSub *nats.Subscription - var room *chat.Room - if sg.Mode == snake.ModeMultiplayer { - room = chat.NewRoom(nc, snake.ChatSubject(gameID)) + if room != nil { chatCh, chatSub, err = room.Subscribe() if err != nil { return @@ -150,19 +164,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess } } drained: - si, ok = snakeStore.Get(gameID) - if !ok { - return - } - mySlot = si.GetPlayerSlot(playerID) - sg = si.GetGame() - if err := sse.PatchElementTempl(components.Board(sg)); err != nil { - return - } - if err := sse.PatchElementTempl(components.StatusBanner(sg, mySlot, gameID)); err != nil { - return - } - if err := sse.PatchElementTempl(components.PlayerList(sg, mySlot)); err != nil { + if err := patchAll(); err != nil { return } @@ -170,11 +172,8 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess if msg == nil { continue } - _, snapshot := room.Receive(msg.Data) - if snapshot == nil { - continue - } - if err := sse.PatchElementTempl(chatcomponents.Chat(snapshot, chatCfg)); err != nil { + room.Receive(msg.Data) + if err := patchAll(); err != nil { return } } diff --git a/features/snakegame/pages/game.templ b/features/snakegame/pages/game.templ index 63bda61..98b98fb 100644 --- a/features/snakegame/pages/game.templ +++ b/features/snakegame/pages/game.templ @@ -37,29 +37,35 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg data-on:keydown.throttle_100ms={ keydownScript(gameID) } tabindex="0" > - @components.BackToLobby() -

~~~~

- @snakecomponents.PlayerList(sg, mySlot) - @snakecomponents.StatusBanner(sg, mySlot, gameID) - if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { - if sg.Mode == snake.ModeMultiplayer { -
- @snakecomponents.Board(sg) - @chatcomponents.Chat(messages, chatCfg) -
- } else { - @snakecomponents.Board(sg) - } - } else if sg.Mode == snake.ModeMultiplayer { - @chatcomponents.Chat(messages, chatCfg) - } - if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { - @snakecomponents.InviteLink(gameID) - } + @GameContent(sg, mySlot, messages, chatCfg, gameID)
} } +templ GameContent(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg chatcomponents.Config, gameID string) { +
+ @components.BackToLobby() +

~~~~

+ @snakecomponents.PlayerList(sg, mySlot) + @snakecomponents.StatusBanner(sg, mySlot, gameID) + if sg.Status == snake.StatusInProgress || sg.Status == snake.StatusFinished { + if sg.Mode == snake.ModeMultiplayer { +
+ @snakecomponents.Board(sg) + @chatcomponents.Chat(messages, chatCfg) +
+ } else { + @snakecomponents.Board(sg) + } + } else if sg.Mode == snake.ModeMultiplayer { + @chatcomponents.Chat(messages, chatCfg) + } + if sg.Mode == snake.ModeMultiplayer && (sg.Status == snake.StatusWaitingForPlayers || sg.Status == snake.StatusCountdown) { + @snakecomponents.InviteLink(gameID) + } +
+} + templ JoinPage(gameID string) { @layouts.Base("Snake - Join") { @components.GameJoinPrompt( From dcf76bb7736171be6c166a3459d05751dc2996ae Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:40:10 -1000 Subject: [PATCH 13/14] refactor: replace session key strings with consts Define KeyPlayerID, KeyUserID, and KeyNickname in the sessions package and use them across all handlers to avoid duplicated magic strings. --- features/auth/handlers.go | 9 +++++---- features/c4game/handlers.go | 2 +- features/lobby/handlers.go | 7 ++++--- features/snakegame/handlers.go | 2 +- sessions/sessions.go | 17 ++++++++++++----- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/features/auth/handlers.go b/features/auth/handlers.go index 8c7c8fb..3ca7f6b 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -11,6 +11,7 @@ import ( "github.com/ryanhamamura/games/auth" "github.com/ryanhamamura/games/db/repository" "github.com/ryanhamamura/games/features/auth/pages" + appsessions "github.com/ryanhamamura/games/sessions" ) type LoginSignals struct { @@ -65,9 +66,9 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http } sessions.RenewToken(r.Context()) //nolint:errcheck - sessions.Put(r.Context(), "user_id", user.ID) + sessions.Put(r.Context(), appsessions.KeyUserID, user.ID) sessions.Put(r.Context(), "username", user.Username) - sessions.Put(r.Context(), "nickname", user.Username) + sessions.Put(r.Context(), appsessions.KeyNickname, user.Username) redirectURL := "/" if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { @@ -119,9 +120,9 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h } sessions.RenewToken(r.Context()) //nolint:errcheck - sessions.Put(r.Context(), "user_id", user.ID) + sessions.Put(r.Context(), appsessions.KeyUserID, user.ID) sessions.Put(r.Context(), "username", user.Username) - sessions.Put(r.Context(), "nickname", user.Username) + sessions.Put(r.Context(), appsessions.KeyNickname, user.Username) redirectURL := "/" if returnURL := sessions.GetString(r.Context(), "return_url"); returnURL != "" { diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 68cead1..c5a5bc0 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -266,7 +266,7 @@ func HandleSetNickname(store *connect4.Store, sm *scs.SessionManager) http.Handl return } - sm.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname) playerID := sessions.GetPlayerID(sm, r) userID := sessions.GetUserID(sm, r) diff --git a/features/lobby/handlers.go b/features/lobby/handlers.go index 5698447..074b0aa 100644 --- a/features/lobby/handlers.go +++ b/features/lobby/handlers.go @@ -11,6 +11,7 @@ import ( "github.com/ryanhamamura/games/db/repository" lobbycomponents "github.com/ryanhamamura/games/features/lobby/components" "github.com/ryanhamamura/games/features/lobby/pages" + appsessions "github.com/ryanhamamura/games/sessions" "github.com/ryanhamamura/games/snake" "github.com/alexedwards/scs/v2" @@ -21,7 +22,7 @@ import ( // HandleLobbyPage renders the main lobby page with active games for logged-in users. func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager, snakeStore *snake.SnakeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userID := sessions.GetString(r.Context(), "user_id") + userID := sessions.GetString(r.Context(), appsessions.KeyUserID) username := sessions.GetString(r.Context(), "username") isLoggedIn := userID != "" @@ -95,7 +96,7 @@ func HandleCreateGame(store *connect4.Store, sessions *scs.SessionManager) http. return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname) gi := store.Create() sse := datastar.NewSSE(w, r) @@ -137,7 +138,7 @@ func HandleCreateSnakeGame(snakeStore *snake.SnakeStore, sessions *scs.SessionMa return } - sessions.Put(r.Context(), "nickname", signals.Nickname) + sessions.Put(r.Context(), appsessions.KeyNickname, signals.Nickname) mode := snake.ModeMultiplayer if r.URL.Query().Get("mode") == "solo" { diff --git a/features/snakegame/handlers.go b/features/snakegame/handlers.go index 9620377..dffb6a0 100644 --- a/features/snakegame/handlers.go +++ b/features/snakegame/handlers.go @@ -277,7 +277,7 @@ func HandleSetNickname(snakeStore *snake.SnakeStore, sm *scs.SessionManager) htt return } - sm.Put(r.Context(), "nickname", signals.Nickname) + sm.Put(r.Context(), sessions.KeyNickname, signals.Nickname) playerID := sessions.GetPlayerID(sm, r) userID := sessions.GetUserID(sm, r) diff --git a/sessions/sessions.go b/sessions/sessions.go index 02ebae2..3880d47 100644 --- a/sessions/sessions.go +++ b/sessions/sessions.go @@ -14,6 +14,13 @@ import ( "github.com/alexedwards/scs/v2" ) +// Session key names. +const ( + KeyPlayerID = "player_id" + KeyUserID = "user_id" + KeyNickname = "nickname" +) + // SetupSessionManager creates a configured session manager backed by SQLite. // Returns the manager and a cleanup function the caller should defer. func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { @@ -38,12 +45,12 @@ func SetupSessionManager(db *sql.DB) (*scs.SessionManager, func()) { // Authenticated users get their user UUID; guests get a random ID that // is generated and persisted on first access. func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID { - pid := sm.GetString(r.Context(), "player_id") + pid := sm.GetString(r.Context(), KeyPlayerID) if pid == "" { pid = player.GenerateID(8) - sm.Put(r.Context(), "player_id", pid) + sm.Put(r.Context(), KeyPlayerID, pid) } - if userID := sm.GetString(r.Context(), "user_id"); userID != "" { + if userID := sm.GetString(r.Context(), KeyUserID); userID != "" { return player.ID(userID) } return player.ID(pid) @@ -51,10 +58,10 @@ func GetPlayerID(sm *scs.SessionManager, r *http.Request) player.ID { // GetUserID returns the authenticated user's UUID, or empty string for guests. func GetUserID(sm *scs.SessionManager, r *http.Request) string { - return sm.GetString(r.Context(), "user_id") + return sm.GetString(r.Context(), KeyUserID) } // GetNickname returns the player's display name from the session. func GetNickname(sm *scs.SessionManager, r *http.Request) string { - return sm.GetString(r.Context(), "nickname") + return sm.GetString(r.Context(), KeyNickname) } From 718e0c55c9031b995070f92349481865f2ec86b1 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:48:16 -1000 Subject: [PATCH 14/14] fix: satisfy staticcheck comment style for exported consts --- connect4/types.go | 5 ++++- snake/types.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/connect4/types.go b/connect4/types.go index 0aa56b7..078947c 100644 --- a/connect4/types.go +++ b/connect4/types.go @@ -6,10 +6,13 @@ import ( "github.com/ryanhamamura/games/player" ) -// NATS subject helpers. +// SubjectPrefix is the NATS subject namespace for connect4 games. const SubjectPrefix = "connect4" +// GameSubject returns the NATS subject for game state updates. func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } + +// ChatSubject returns the NATS subject for chat messages. func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } type Player struct { diff --git a/snake/types.go b/snake/types.go index d1284c1..d94b5d2 100644 --- a/snake/types.go +++ b/snake/types.go @@ -7,10 +7,13 @@ import ( "github.com/ryanhamamura/games/player" ) -// NATS subject helpers. +// SubjectPrefix is the NATS subject namespace for snake games. const SubjectPrefix = "snake" +// GameSubject returns the NATS subject for game state updates. func GameSubject(gameID string) string { return SubjectPrefix + "." + gameID } + +// ChatSubject returns the NATS subject for chat messages. func ChatSubject(gameID string) string { return SubjectPrefix + ".chat." + gameID } type Direction int