refactor: remove persister abstraction layer
Some checks failed
CI / Deploy / test (pull_request) Successful in 8s
CI / Deploy / lint (pull_request) Failing after 46s
CI / Deploy / deploy (pull_request) Has been skipped

Inline persistence logic directly into game stores and handlers:
- game/persist.go: DB mapping methods on GameStore and GameInstance
- snake/persist.go: DB mapping methods on SnakeStore and SnakeGameInstance
- Chat persistence inlined into c4game handlers
- Delete db/persister.go (GamePersister, SnakePersister, ChatPersister)
- Stores now take *repository.Queries directly instead of Persister interface
This commit is contained in:
Ryan Hamamura
2026-03-02 12:30:33 -10:00
parent 8c3b3fc6ea
commit 2aa026b1d5
10 changed files with 475 additions and 448 deletions

View File

@@ -1,326 +0,0 @@
package db
import (
"context"
"database/sql"
"slices"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
)
type GamePersister struct {
queries *repository.Queries
}
func NewGamePersister(q *repository.Queries) *GamePersister {
return &GamePersister{queries: q}
}
func (p *GamePersister) SaveGame(g *game.Game) error {
ctx := context.Background()
_, err := p.queries.GetGame(ctx, g.ID)
if err == sql.ErrNoRows {
_, err = p.queries.CreateGame(ctx, repository.CreateGameParams{
ID: g.ID,
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
})
return err
}
if err != nil {
return err
}
var winnerUserID sql.NullString
if g.Winner != nil && g.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true}
}
winningCells := sql.NullString{}
if wc := g.WinningCellsToJSON(); wc != "" {
winningCells = sql.NullString{String: wc, Valid: true}
}
rematchGameID := sql.NullString{}
if g.RematchGameID != nil {
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
}
return p.queries.UpdateGame(ctx, repository.UpdateGameParams{
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
WinnerUserID: winnerUserID,
WinningCells: winningCells,
RematchGameID: rematchGameID,
ID: g.ID,
})
}
func (p *GamePersister) LoadGame(id string) (*game.Game, error) {
ctx := context.Background()
row, err := p.queries.GetGame(ctx, id)
if err != nil {
return nil, err
}
g := &game.Game{
ID: row.ID,
CurrentTurn: int(row.CurrentTurn),
Status: game.GameStatus(row.Status),
}
if err := g.BoardFromJSON(row.Board); err != nil {
return nil, err
}
if row.WinningCells.Valid {
g.WinningCellsFromJSON(row.WinningCells.String)
}
if row.RematchGameID.Valid {
g.RematchGameID = &row.RematchGameID.String
}
return g, nil
}
func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot int) error {
ctx := context.Background()
var userID, guestPlayerID sql.NullString
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return p.queries.CreateGamePlayer(ctx, repository.CreateGamePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: player.Nickname,
Color: int64(player.Color),
Slot: int64(slot),
})
}
func (p *GamePersister) LoadGamePlayers(gameID string) ([]*game.Player, error) {
ctx := context.Background()
rows, err := p.queries.GetGamePlayers(ctx, gameID)
if err != nil {
return nil, err
}
players := make([]*game.Player, 0, len(rows))
for _, row := range rows {
player := &game.Player{
Nickname: row.Nickname,
Color: int(row.Color),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = game.PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = game.PlayerID(row.GuestPlayerID.String)
}
players = append(players, player)
}
return players, nil
}
func (p *GamePersister) DeleteGame(id string) error {
ctx := context.Background()
return p.queries.DeleteGame(ctx, id)
}
// SnakePersister implements snake.Persister
type SnakePersister struct {
queries *repository.Queries
}
func NewSnakePersister(q *repository.Queries) *SnakePersister {
return &SnakePersister{queries: q}
}
func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
ctx := context.Background()
boardJSON := "{}"
if sg.State != nil {
boardJSON = sg.State.ToJSON()
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
}
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = p.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
})
return err
}
if err != nil {
return err
}
var winnerUserID sql.NullString
if sg.Winner != nil && sg.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
}
rematchGameID := sql.NullString{}
if sg.RematchGameID != nil {
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
}
return p.queries.UpdateSnakeGame(ctx, repository.UpdateSnakeGameParams{
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
RematchGameID: rematchGameID,
Score: int64(sg.Score),
ID: sg.ID,
})
}
func (p *SnakePersister) LoadSnakeGame(id string) (*snake.SnakeGame, error) {
ctx := context.Background()
row, err := p.queries.GetSnakeGame(ctx, id)
if err != nil {
return nil, err
}
state, err := snake.GameStateFromJSON(row.Board)
if err != nil {
state = &snake.GameState{}
}
if row.GridWidth.Valid {
state.Width = int(row.GridWidth.Int64)
}
if row.GridHeight.Valid {
state.Height = int(row.GridHeight.Int64)
}
sg := &snake.SnakeGame{
ID: row.ID,
State: state,
Players: make([]*snake.Player, 8),
Status: snake.Status(row.Status),
Mode: snake.GameMode(row.GameMode),
Score: int(row.Score),
Speed: int(row.SnakeSpeed),
}
if row.RematchGameID.Valid {
sg.RematchGameID = &row.RematchGameID.String
}
return sg, nil
}
func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) error {
ctx := context.Background()
var userID, guestPlayerID sql.NullString
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return p.queries.CreateSnakePlayer(ctx, repository.CreateSnakePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: player.Nickname,
Color: int64(player.Slot + 1),
Slot: int64(player.Slot),
})
}
func (p *SnakePersister) LoadSnakePlayers(gameID string) ([]*snake.Player, error) {
ctx := context.Background()
rows, err := p.queries.GetSnakePlayers(ctx, gameID)
if err != nil {
return nil, err
}
players := make([]*snake.Player, 0, len(rows))
for _, row := range rows {
player := &snake.Player{
Nickname: row.Nickname,
Slot: int(row.Slot),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = snake.PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = snake.PlayerID(row.GuestPlayerID.String)
}
players = append(players, player)
}
return players, nil
}
func (p *SnakePersister) DeleteSnakeGame(id string) error {
ctx := context.Background()
return p.queries.DeleteSnakeGame(ctx, id)
}
type ChatPersister struct {
queries *repository.Queries
}
func NewChatPersister(q *repository.Queries) *ChatPersister {
return &ChatPersister{queries: q}
}
func (p *ChatPersister) SaveChatMessage(gameID string, msg game.ChatMessage) error {
return p.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),
Message: msg.Message,
CreatedAt: msg.Time,
})
}
func (p *ChatPersister) LoadChatMessages(gameID string) ([]game.ChatMessage, error) {
rows, err := p.queries.GetChatMessages(context.Background(), gameID)
if err != nil {
return nil, err
}
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,
}
}
// Query returns newest-first; reverse to oldest-first for display
slices.Reverse(msgs)
return msgs, nil
}

View File

@@ -1,8 +1,10 @@
package c4game package c4game
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"sync" "sync"
"time" "time"
@@ -10,14 +12,14 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/components"
"github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/features/c4game/pages"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
"github.com/starfederation/datastar-go/datastar" "github.com/starfederation/datastar-go/datastar"
) )
func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc { func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "game_id") gameID := chi.URLParam(r, "game_id")
@@ -73,8 +75,8 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPer
// Player is in the game — render full game page // Player is in the game — render full game page
g := gi.GetGame() g := gi.GetGame()
uiMsgs, _ := chatPersister.LoadChatMessages(gameID) chatMsgs := loadChatMessages(queries, gameID)
msgs := uiChatToComponents(uiMsgs) msgs := chatToComponents(chatMsgs)
if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -82,7 +84,7 @@ func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPer
} }
} }
func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc { func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "game_id") gameID := chi.URLParam(r, "game_id")
@@ -103,9 +105,9 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)
// Load initial chat messages // Load initial chat messages
uiMsgs, _ := chatPersister.LoadChatMessages(gameID) chatMsgs := loadChatMessages(queries, gameID)
var chatMu sync.Mutex var chatMu sync.Mutex
chatMessages := uiChatToComponents(uiMsgs) chatMessages := chatToComponents(chatMsgs)
// Send initial render of all components // Send initial render of all components
sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID) sendGameComponents(sse, gi, myColor, chatMessages, &chatMu, gameID)
@@ -203,7 +205,7 @@ func HandleDropPiece(store *game.GameStore, sessions *scs.SessionManager) http.H
} }
} }
func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc { func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "game_id") gameID := chi.URLParam(r, "game_id")
@@ -254,7 +256,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
chatPersister.SaveChatMessage(gameID, cm) saveChatMessage(queries, gameID, cm)
data, err := json.Marshal(cm) data, err := json.Marshal(cm)
if err != nil { if err != nil {
@@ -353,10 +355,40 @@ func sendGameComponents(sse *datastar.ServerSentEventGenerator, gi *game.GameIns
sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck sse.PatchElementTempl(components.Chat(msgs, gameID), datastar.WithSelectorID("c4-chat")) //nolint:errcheck
} }
// uiChatToComponents converts ui.C4ChatMessage slice to components.ChatMessage slice. // Chat persistence helpers — inlined from the former ChatPersister.
func uiChatToComponents(uiMsgs []game.ChatMessage) []components.ChatMessage {
msgs := make([]components.ChatMessage, len(uiMsgs)) func saveChatMessage(queries *repository.Queries, gameID string, msg game.ChatMessage) {
for i, m := range uiMsgs { 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{ msgs[i] = components.ChatMessage{
Nickname: m.Nickname, Nickname: m.Nickname,
Color: m.Color, Color: m.Color,

View File

@@ -4,7 +4,7 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/game"
) )
@@ -13,14 +13,14 @@ func SetupRoutes(
store *game.GameStore, store *game.GameStore,
nc *nats.Conn, nc *nats.Conn,
sessions *scs.SessionManager, sessions *scs.SessionManager,
chatPersister *db.ChatPersister, queries *repository.Queries,
) error { ) error {
router.Get("/game/{game_id}", HandleGamePage(store, sessions, chatPersister)) router.Get("/game/{game_id}", HandleGamePage(store, sessions, queries))
router.Get("/game/{game_id}/events", HandleGameEvents(store, nc, sessions, chatPersister)) router.Get("/game/{game_id}/events", HandleGameEvents(store, nc, sessions, queries))
router.Route("/api/game/{game_id}", func(r chi.Router) { router.Route("/api/game/{game_id}", func(r chi.Router) {
r.Post("/drop", HandleDropPiece(store, sessions)) r.Post("/drop", HandleDropPiece(store, sessions))
r.Post("/chat", HandleSendChat(store, nc, sessions, chatPersister)) r.Post("/chat", HandleSendChat(store, nc, sessions, queries))
r.Post("/join", HandleSetNickname(store, sessions)) r.Post("/join", HandleSetNickname(store, sessions))
r.Post("/rematch", HandleRematch(store, sessions)) r.Post("/rematch", HandleRematch(store, sessions))
}) })

157
game/persist.go Normal file
View File

@@ -0,0 +1,157 @@
package game
import (
"context"
"database/sql"
"github.com/ryanhamamura/c4/db/repository"
)
// Persistence methods on GameStore (used during Get to hydrate from DB).
func (gs *GameStore) saveGame(g *Game) error {
ctx := context.Background()
_, err := gs.queries.GetGame(ctx, g.ID)
if err == sql.ErrNoRows {
_, err = gs.queries.CreateGame(ctx, repository.CreateGameParams{
ID: g.ID,
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
})
return err
}
if err != nil {
return err
}
return gs.queries.UpdateGame(ctx, updateGameParams(g))
}
func (gs *GameStore) loadGame(id string) (*Game, error) {
row, err := gs.queries.GetGame(context.Background(), id)
if err != nil {
return nil, err
}
return gameFromRow(row)
}
func (gs *GameStore) loadGamePlayers(id string) ([]*Player, error) {
rows, err := gs.queries.GetGamePlayers(context.Background(), id)
if err != nil {
return nil, err
}
return playersFromRows(rows), nil
}
// Persistence methods on GameInstance (used during gameplay mutations).
func (gi *GameInstance) saveGame(g *Game) error {
ctx := context.Background()
_, err := gi.queries.GetGame(ctx, g.ID)
if err == sql.ErrNoRows {
_, err = gi.queries.CreateGame(ctx, repository.CreateGameParams{
ID: g.ID,
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
})
return err
}
if err != nil {
return err
}
return gi.queries.UpdateGame(ctx, updateGameParams(g))
}
func (gi *GameInstance) saveGamePlayer(gameID string, player *Player, slot int) error {
var userID, guestPlayerID sql.NullString
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return gi.queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: player.Nickname,
Color: int64(player.Color),
Slot: int64(slot),
})
}
// Shared helpers for domain ↔ DB mapping.
func updateGameParams(g *Game) repository.UpdateGameParams {
var winnerUserID sql.NullString
if g.Winner != nil && g.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true}
}
var winningCells sql.NullString
if wc := g.WinningCellsToJSON(); wc != "" {
winningCells = sql.NullString{String: wc, Valid: true}
}
var rematchGameID sql.NullString
if g.RematchGameID != nil {
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
}
return repository.UpdateGameParams{
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
WinnerUserID: winnerUserID,
WinningCells: winningCells,
RematchGameID: rematchGameID,
ID: g.ID,
}
}
func gameFromRow(row repository.Game) (*Game, error) {
g := &Game{
ID: row.ID,
CurrentTurn: int(row.CurrentTurn),
Status: GameStatus(row.Status),
}
if err := g.BoardFromJSON(row.Board); err != nil {
return nil, err
}
if row.WinningCells.Valid {
g.WinningCellsFromJSON(row.WinningCells.String)
}
if row.RematchGameID.Valid {
g.RematchGameID = &row.RematchGameID.String
}
return g, nil
}
func playersFromRows(rows []repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
player := &Player{
Nickname: row.Nickname,
Color: int(row.Color),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = PlayerID(row.GuestPlayerID.String)
}
players = append(players, player)
}
return players
}

View File

@@ -1,40 +1,32 @@
package game package game
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository"
) )
type PlayerSession struct { type PlayerSession struct {
Player *Player Player *Player
} }
type Persister interface {
SaveGame(g *Game) error
LoadGame(id string) (*Game, error)
SaveGamePlayer(gameID string, player *Player, slot int) error
LoadGamePlayers(gameID string) ([]*Player, error)
DeleteGame(id string) error
}
type GameStore struct { type GameStore struct {
games map[string]*GameInstance games map[string]*GameInstance
gamesMu sync.RWMutex gamesMu sync.RWMutex
persister Persister queries *repository.Queries
notifyFunc func(gameID string) notifyFunc func(gameID string)
} }
func NewGameStore() *GameStore { func NewGameStore(queries *repository.Queries) *GameStore {
return &GameStore{ return &GameStore{
games: make(map[string]*GameInstance), games: make(map[string]*GameInstance),
queries: queries,
} }
} }
func (gs *GameStore) SetPersister(p Persister) {
gs.persister = p
}
func (gs *GameStore) SetNotifyFunc(f func(gameID string)) { func (gs *GameStore) SetNotifyFunc(f func(gameID string)) {
gs.notifyFunc = f gs.notifyFunc = f
} }
@@ -50,14 +42,14 @@ func (gs *GameStore) makeNotify(gameID string) func() {
func (gs *GameStore) Create() *GameInstance { func (gs *GameStore) Create() *GameInstance {
id := GenerateID(4) id := GenerateID(4)
gi := NewGameInstance(id) gi := NewGameInstance(id)
gi.persister = gs.persister gi.queries = gs.queries
gi.notify = gs.makeNotify(id) gi.notify = gs.makeNotify(id)
gs.gamesMu.Lock() gs.gamesMu.Lock()
gs.games[id] = gi gs.games[id] = gi
gs.gamesMu.Unlock() gs.gamesMu.Unlock()
if gs.persister != nil { if gs.queries != nil {
gs.persister.SaveGame(gi.game) gs.saveGame(gi.game)
} }
return gi return gi
@@ -72,27 +64,27 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
return gi, true return gi, true
} }
if gs.persister == nil { if gs.queries == nil {
return nil, false return nil, false
} }
game, err := gs.persister.LoadGame(id) g, err := gs.loadGame(id)
if err != nil || game == nil { if err != nil || g == nil {
return nil, false return nil, false
} }
players, _ := gs.persister.LoadGamePlayers(id) players, _ := gs.loadGamePlayers(id)
for _, p := range players { for _, p := range players {
if p.Color == 1 { if p.Color == 1 {
game.Players[0] = p g.Players[0] = p
} else if p.Color == 2 { } else if p.Color == 2 {
game.Players[1] = p g.Players[1] = p
} }
} }
gi = &GameInstance{ gi = &GameInstance{
game: game, game: g,
persister: gs.persister, queries: gs.queries,
notify: gs.makeNotify(id), notify: gs.makeNotify(id),
} }
@@ -108,8 +100,8 @@ func (gs *GameStore) Delete(id string) error {
delete(gs.games, id) delete(gs.games, id)
gs.gamesMu.Unlock() gs.gamesMu.Unlock()
if gs.persister != nil { if gs.queries != nil {
return gs.persister.DeleteGame(id) return gs.queries.DeleteGame(context.Background(), id)
} }
return nil return nil
} }
@@ -124,7 +116,7 @@ type GameInstance struct {
game *Game game *Game
gameMu sync.RWMutex gameMu sync.RWMutex
notify func() notify func()
persister Persister queries *repository.Queries
} }
func NewGameInstance(id string) *GameInstance { func NewGameInstance(id string) *GameInstance {
@@ -158,9 +150,9 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
return false return false
} }
if gi.persister != nil { if gi.queries != nil {
gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot) gi.saveGamePlayer(gi.game.ID, ps.Player, slot)
gi.persister.SaveGame(gi.game) gi.saveGame(gi.game)
} }
gi.notify() gi.notify()
@@ -196,8 +188,8 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
newID := newGI.ID() newID := newGI.ID()
gi.game.RematchGameID = &newID gi.game.RematchGameID = &newID
if gi.persister != nil { if gi.queries != nil {
if err := gi.persister.SaveGame(gi.game); err != nil { if err := gi.saveGame(gi.game); err != nil {
gs.Delete(newID) gs.Delete(newID)
gi.game.RematchGameID = nil gi.game.RematchGameID = nil
return nil return nil
@@ -230,8 +222,8 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
gi.game.SwitchTurn() gi.game.SwitchTurn()
} }
if gi.persister != nil { if gi.queries != nil {
gi.persister.SaveGame(gi.game) gi.saveGame(gi.game)
} }
gi.notify() gi.notify()

10
main.go
View File

@@ -71,20 +71,16 @@ func run(ctx context.Context) error {
defer cleanupNATS() defer cleanupNATS()
// Game stores // Game stores
store := game.NewGameStore() store := game.NewGameStore(queries)
store.SetPersister(db.NewGamePersister(queries))
store.SetNotifyFunc(func(gameID string) { store.SetNotifyFunc(func(gameID string) {
nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification
}) })
snakeStore := snake.NewSnakeStore() snakeStore := snake.NewSnakeStore(queries)
snakeStore.SetPersister(db.NewSnakePersister(queries))
snakeStore.SetNotifyFunc(func(gameID string) { snakeStore.SetNotifyFunc(func(gameID string) {
nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification
}) })
chatPersister := db.NewChatPersister(queries)
// Router // Router
logger := log.Logger logger := log.Logger
r := chi.NewMux() r := chi.NewMux()
@@ -94,7 +90,7 @@ func run(ctx context.Context) error {
sessionManager.LoadAndSave, sessionManager.LoadAndSave,
) )
if err := router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, chatPersister, assets); err != nil { if err := router.SetupRoutes(r, queries, sessionManager, nc, store, snakeStore, assets); err != nil {
return fmt.Errorf("setting up routes: %w", err) return fmt.Errorf("setting up routes: %w", err)
} }

View File

@@ -8,7 +8,6 @@ import (
"sync" "sync"
"github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/c4/config"
"github.com/ryanhamamura/c4/db"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/features/auth" "github.com/ryanhamamura/c4/features/auth"
"github.com/ryanhamamura/c4/features/c4game" "github.com/ryanhamamura/c4/features/c4game"
@@ -30,7 +29,6 @@ func SetupRoutes(
nc *nats.Conn, nc *nats.Conn,
store *game.GameStore, store *game.GameStore,
snakeStore *snake.SnakeStore, snakeStore *snake.SnakeStore,
chatPersister *db.ChatPersister,
assets embed.FS, assets embed.FS,
) error { ) error {
// Static assets // Static assets
@@ -44,7 +42,7 @@ func SetupRoutes(
auth.SetupRoutes(router, queries, sessions) auth.SetupRoutes(router, queries, sessions)
lobby.SetupRoutes(router, queries, sessions, store, snakeStore) lobby.SetupRoutes(router, queries, sessions, store, snakeStore)
c4game.SetupRoutes(router, store, nc, sessions, chatPersister) c4game.SetupRoutes(router, store, nc, sessions, queries)
snakegame.SetupRoutes(router, snakeStore, nc, sessions) snakegame.SetupRoutes(router, snakeStore, nc, sessions)
return nil return nil

View File

@@ -61,16 +61,16 @@ func (si *SnakeGameInstance) countdownPhase() {
si.initGame() si.initGame()
si.game.Status = StatusInProgress si.game.Status = StatusInProgress
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
return return
} }
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
@@ -123,8 +123,8 @@ func (si *SnakeGameInstance) gamePhase() {
// Inactivity timeout // Inactivity timeout
if time.Since(lastInput) > inactivityLimit { if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished si.game.Status = StatusFinished
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
@@ -195,8 +195,8 @@ func (si *SnakeGameInstance) gamePhase() {
si.game.Status = StatusFinished si.game.Status = StatusFinished
} }
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.gameMu.Unlock() si.gameMu.Unlock()

186
snake/persist.go Normal file
View File

@@ -0,0 +1,186 @@
package snake
import (
"context"
"database/sql"
"github.com/ryanhamamura/c4/db/repository"
)
// Persistence methods on SnakeStore (used during Get to hydrate from DB).
func (ss *SnakeStore) saveSnakeGame(sg *SnakeGame) error {
ctx := context.Background()
boardJSON := "{}"
if sg.State != nil {
boardJSON = sg.State.ToJSON()
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
}
_, err := ss.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = ss.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
})
return err
}
if err != nil {
return err
}
return ss.queries.UpdateSnakeGame(ctx, updateSnakeGameParams(sg, boardJSON))
}
func (ss *SnakeStore) loadSnakeGame(id string) (*SnakeGame, error) {
row, err := ss.queries.GetSnakeGame(context.Background(), id)
if err != nil {
return nil, err
}
return snakeGameFromRow(row)
}
func (ss *SnakeStore) loadSnakePlayers(id string) ([]*Player, error) {
rows, err := ss.queries.GetSnakePlayers(context.Background(), id)
if err != nil {
return nil, err
}
return snakePlayersFromRows(rows), nil
}
// Persistence methods on SnakeGameInstance (used during gameplay mutations).
func (si *SnakeGameInstance) saveSnakeGame(sg *SnakeGame) error {
ctx := context.Background()
boardJSON := "{}"
if sg.State != nil {
boardJSON = sg.State.ToJSON()
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
}
_, err := si.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = si.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
})
return err
}
if err != nil {
return err
}
return si.queries.UpdateSnakeGame(ctx, updateSnakeGameParams(sg, boardJSON))
}
func (si *SnakeGameInstance) saveSnakePlayer(gameID string, player *Player) error {
var userID, guestPlayerID sql.NullString
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return si.queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
Nickname: player.Nickname,
Color: int64(player.Slot + 1),
Slot: int64(player.Slot),
})
}
// Shared helpers for domain ↔ DB mapping.
func updateSnakeGameParams(sg *SnakeGame, boardJSON string) repository.UpdateSnakeGameParams {
var winnerUserID sql.NullString
if sg.Winner != nil && sg.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
}
var rematchGameID sql.NullString
if sg.RematchGameID != nil {
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
}
return repository.UpdateSnakeGameParams{
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
RematchGameID: rematchGameID,
Score: int64(sg.Score),
ID: sg.ID,
}
}
func snakeGameFromRow(row repository.Game) (*SnakeGame, error) {
state, err := GameStateFromJSON(row.Board)
if err != nil {
state = &GameState{}
}
if row.GridWidth.Valid {
state.Width = int(row.GridWidth.Int64)
}
if row.GridHeight.Valid {
state.Height = int(row.GridHeight.Int64)
}
sg := &SnakeGame{
ID: row.ID,
State: state,
Players: make([]*Player, 8),
Status: Status(row.Status),
Mode: GameMode(row.GameMode),
Score: int(row.Score),
Speed: int(row.SnakeSpeed),
}
if row.RematchGameID.Valid {
sg.RematchGameID = &row.RematchGameID.String
}
return sg, nil
}
func snakePlayersFromRows(rows []repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
player := &Player{
Nickname: row.Nickname,
Slot: int(row.Slot),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = PlayerID(row.GuestPlayerID.String)
}
players = append(players, player)
}
return players
}

View File

@@ -1,36 +1,28 @@
package snake package snake
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"sync" "sync"
)
type Persister interface { "github.com/ryanhamamura/c4/db/repository"
SaveSnakeGame(sg *SnakeGame) error )
LoadSnakeGame(id string) (*SnakeGame, error)
SaveSnakePlayer(gameID string, player *Player) error
LoadSnakePlayers(gameID string) ([]*Player, error)
DeleteSnakeGame(id string) error
}
type SnakeStore struct { type SnakeStore struct {
games map[string]*SnakeGameInstance games map[string]*SnakeGameInstance
gamesMu sync.RWMutex gamesMu sync.RWMutex
persister Persister queries *repository.Queries
notifyFunc func(gameID string) notifyFunc func(gameID string)
} }
func NewSnakeStore() *SnakeStore { func NewSnakeStore(queries *repository.Queries) *SnakeStore {
return &SnakeStore{ return &SnakeStore{
games: make(map[string]*SnakeGameInstance), games: make(map[string]*SnakeGameInstance),
queries: queries,
} }
} }
func (ss *SnakeStore) SetPersister(p Persister) {
ss.persister = p
}
func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) { func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
ss.notifyFunc = f ss.notifyFunc = f
} }
@@ -62,7 +54,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
si := &SnakeGameInstance{ si := &SnakeGameInstance{
game: sg, game: sg,
notify: ss.makeNotify(id), notify: ss.makeNotify(id),
persister: ss.persister, queries: ss.queries,
store: ss, store: ss,
} }
@@ -70,8 +62,8 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
ss.games[id] = si ss.games[id] = si
ss.gamesMu.Unlock() ss.gamesMu.Unlock()
if ss.persister != nil { if ss.queries != nil {
ss.persister.SaveSnakeGame(sg) ss.saveSnakeGame(sg)
} }
return si return si
@@ -86,16 +78,16 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
return si, true return si, true
} }
if ss.persister == nil { if ss.queries == nil {
return nil, false return nil, false
} }
sg, err := ss.persister.LoadSnakeGame(id) sg, err := ss.loadSnakeGame(id)
if err != nil || sg == nil { if err != nil || sg == nil {
return nil, false return nil, false
} }
players, _ := ss.persister.LoadSnakePlayers(id) players, _ := ss.loadSnakePlayers(id)
if sg.Players == nil { if sg.Players == nil {
sg.Players = make([]*Player, 8) sg.Players = make([]*Player, 8)
} }
@@ -108,7 +100,7 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
si = &SnakeGameInstance{ si = &SnakeGameInstance{
game: sg, game: sg,
notify: ss.makeNotify(id), notify: ss.makeNotify(id),
persister: ss.persister, queries: ss.queries,
store: ss, store: ss,
} }
@@ -129,8 +121,8 @@ func (ss *SnakeStore) Delete(id string) error {
si.Stop() si.Stop()
} }
if ss.persister != nil { if ss.queries != nil {
return ss.persister.DeleteSnakeGame(id) return ss.queries.DeleteSnakeGame(context.Background(), id)
} }
return nil return nil
} }
@@ -162,7 +154,7 @@ type SnakeGameInstance struct {
gameMu sync.RWMutex gameMu sync.RWMutex
pendingDirQueue [8][]Direction // queued directions per slot (max 3) pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func() notify func()
persister Persister queries *repository.Queries
store *SnakeStore store *SnakeStore
stopCh chan struct{} stopCh chan struct{}
loopOnce sync.Once loopOnce sync.Once
@@ -214,9 +206,9 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
player.Slot = slot player.Slot = slot
si.game.Players[slot] = player si.game.Players[slot] = player
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakePlayer(si.game.ID, player) si.saveSnakePlayer(si.game.ID, player)
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.notify() si.notify()
@@ -301,8 +293,8 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
} }
si.game.RematchGameID = &newID si.game.RematchGameID = &newID
if si.persister != nil { if si.queries != nil {
si.persister.SaveSnakeGame(si.game) si.saveSnakeGame(si.game)
} }
si.gameMu.Unlock() si.gameMu.Unlock()