From 2aa026b1d5ea9f2653abd5753cec9eaa4d076651 Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:30:33 -1000 Subject: [PATCH] refactor: remove persister abstraction layer 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 --- db/persister.go | 326 ------------------------------------ features/c4game/handlers.go | 58 +++++-- features/c4game/routes.go | 10 +- game/persist.go | 157 +++++++++++++++++ game/store.go | 76 ++++----- main.go | 10 +- router/router.go | 4 +- snake/loop.go | 16 +- snake/persist.go | 186 ++++++++++++++++++++ snake/store.go | 80 ++++----- 10 files changed, 475 insertions(+), 448 deletions(-) delete mode 100644 db/persister.go create mode 100644 game/persist.go create mode 100644 snake/persist.go diff --git a/db/persister.go b/db/persister.go deleted file mode 100644 index f87760b..0000000 --- a/db/persister.go +++ /dev/null @@ -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 -} diff --git a/features/c4game/handlers.go b/features/c4game/handlers.go index 4df8425..e0b107b 100644 --- a/features/c4game/handlers.go +++ b/features/c4game/handlers.go @@ -1,8 +1,10 @@ package c4game import ( + "context" "encoding/json" "net/http" + "slices" "strconv" "sync" "time" @@ -10,14 +12,14 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/db" + "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/c4game/components" "github.com/ryanhamamura/c4/features/c4game/pages" "github.com/ryanhamamura/c4/game" "github.com/starfederation/datastar-go/datastar" ) -func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, chatPersister *db.ChatPersister) http.HandlerFunc { +func HandleGamePage(store *game.GameStore, sessions *scs.SessionManager, queries *repository.Queries) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { 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 g := gi.GetGame() - uiMsgs, _ := chatPersister.LoadChatMessages(gameID) - msgs := uiChatToComponents(uiMsgs) + chatMsgs := loadChatMessages(queries, gameID) + msgs := chatToComponents(chatMsgs) if err := pages.GamePage(g, myColor, msgs).Render(r.Context(), w); err != nil { 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) { 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) // Load initial chat messages - uiMsgs, _ := chatPersister.LoadChatMessages(gameID) + chatMsgs := loadChatMessages(queries, gameID) var chatMu sync.Mutex - chatMessages := uiChatToComponents(uiMsgs) + chatMessages := chatToComponents(chatMsgs) // Send initial render of all components 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) { gameID := chi.URLParam(r, "game_id") @@ -254,7 +256,7 @@ func HandleSendChat(store *game.GameStore, nc *nats.Conn, sessions *scs.SessionM Message: signals.ChatMsg, Time: time.Now().UnixMilli(), } - chatPersister.SaveChatMessage(gameID, cm) + saveChatMessage(queries, gameID, cm) data, err := json.Marshal(cm) 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 } -// uiChatToComponents converts ui.C4ChatMessage slice to components.ChatMessage slice. -func uiChatToComponents(uiMsgs []game.ChatMessage) []components.ChatMessage { - msgs := make([]components.ChatMessage, len(uiMsgs)) - for i, m := range uiMsgs { +// 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, diff --git a/features/c4game/routes.go b/features/c4game/routes.go index 99783f6..917e5a0 100644 --- a/features/c4game/routes.go +++ b/features/c4game/routes.go @@ -4,7 +4,7 @@ import ( "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/nats-io/nats.go" - "github.com/ryanhamamura/c4/db" + "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/game" ) @@ -13,14 +13,14 @@ func SetupRoutes( store *game.GameStore, nc *nats.Conn, sessions *scs.SessionManager, - chatPersister *db.ChatPersister, + queries *repository.Queries, ) error { - router.Get("/game/{game_id}", HandleGamePage(store, sessions, chatPersister)) - router.Get("/game/{game_id}/events", HandleGameEvents(store, nc, sessions, chatPersister)) + router.Get("/game/{game_id}", HandleGamePage(store, sessions, queries)) + router.Get("/game/{game_id}/events", HandleGameEvents(store, nc, sessions, queries)) router.Route("/api/game/{game_id}", func(r chi.Router) { 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("/rematch", HandleRematch(store, sessions)) }) diff --git a/game/persist.go b/game/persist.go new file mode 100644 index 0000000..2322adc --- /dev/null +++ b/game/persist.go @@ -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 +} diff --git a/game/store.go b/game/store.go index 9e7aae8..e1c9c40 100644 --- a/game/store.go +++ b/game/store.go @@ -1,40 +1,32 @@ package game import ( + "context" "crypto/rand" "encoding/hex" "sync" + + "github.com/ryanhamamura/c4/db/repository" ) type PlayerSession struct { 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 { - games map[string]*GameInstance - gamesMu sync.RWMutex - persister Persister + games map[string]*GameInstance + gamesMu sync.RWMutex + queries *repository.Queries notifyFunc func(gameID string) } -func NewGameStore() *GameStore { +func NewGameStore(queries *repository.Queries) *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)) { gs.notifyFunc = f } @@ -50,14 +42,14 @@ func (gs *GameStore) makeNotify(gameID string) func() { func (gs *GameStore) Create() *GameInstance { id := GenerateID(4) gi := NewGameInstance(id) - gi.persister = gs.persister + gi.queries = gs.queries gi.notify = gs.makeNotify(id) gs.gamesMu.Lock() gs.games[id] = gi gs.gamesMu.Unlock() - if gs.persister != nil { - gs.persister.SaveGame(gi.game) + if gs.queries != nil { + gs.saveGame(gi.game) } return gi @@ -72,28 +64,28 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) { return gi, true } - if gs.persister == nil { + if gs.queries == nil { return nil, false } - game, err := gs.persister.LoadGame(id) - if err != nil || game == nil { + g, err := gs.loadGame(id) + if err != nil || g == nil { return nil, false } - players, _ := gs.persister.LoadGamePlayers(id) + players, _ := gs.loadGamePlayers(id) for _, p := range players { if p.Color == 1 { - game.Players[0] = p + g.Players[0] = p } else if p.Color == 2 { - game.Players[1] = p + g.Players[1] = p } } gi = &GameInstance{ - game: game, - persister: gs.persister, - notify: gs.makeNotify(id), + game: g, + queries: gs.queries, + notify: gs.makeNotify(id), } gs.gamesMu.Lock() @@ -108,8 +100,8 @@ func (gs *GameStore) Delete(id string) error { delete(gs.games, id) gs.gamesMu.Unlock() - if gs.persister != nil { - return gs.persister.DeleteGame(id) + if gs.queries != nil { + return gs.queries.DeleteGame(context.Background(), id) } return nil } @@ -121,10 +113,10 @@ func GenerateID(size int) string { } type GameInstance struct { - game *Game - gameMu sync.RWMutex - notify func() - persister Persister + game *Game + gameMu sync.RWMutex + notify func() + queries *repository.Queries } func NewGameInstance(id string) *GameInstance { @@ -158,9 +150,9 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { return false } - if gi.persister != nil { - gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot) - gi.persister.SaveGame(gi.game) + if gi.queries != nil { + gi.saveGamePlayer(gi.game.ID, ps.Player, slot) + gi.saveGame(gi.game) } gi.notify() @@ -196,8 +188,8 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { newID := newGI.ID() gi.game.RematchGameID = &newID - if gi.persister != nil { - if err := gi.persister.SaveGame(gi.game); err != nil { + if gi.queries != nil { + if err := gi.saveGame(gi.game); err != nil { gs.Delete(newID) gi.game.RematchGameID = nil return nil @@ -230,8 +222,8 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool { gi.game.SwitchTurn() } - if gi.persister != nil { - gi.persister.SaveGame(gi.game) + if gi.queries != nil { + gi.saveGame(gi.game) } gi.notify() diff --git a/main.go b/main.go index bb437a1..5cae7e6 100644 --- a/main.go +++ b/main.go @@ -71,20 +71,16 @@ func run(ctx context.Context) error { defer cleanupNATS() // Game stores - store := game.NewGameStore() - store.SetPersister(db.NewGamePersister(queries)) + store := game.NewGameStore(queries) store.SetNotifyFunc(func(gameID string) { nc.Publish("game."+gameID, nil) //nolint:errcheck // best-effort notification }) - snakeStore := snake.NewSnakeStore() - snakeStore.SetPersister(db.NewSnakePersister(queries)) + snakeStore := snake.NewSnakeStore(queries) snakeStore.SetNotifyFunc(func(gameID string) { nc.Publish("snake."+gameID, nil) //nolint:errcheck // best-effort notification }) - chatPersister := db.NewChatPersister(queries) - // Router logger := log.Logger r := chi.NewMux() @@ -94,7 +90,7 @@ func run(ctx context.Context) error { 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) } diff --git a/router/router.go b/router/router.go index a42a09d..c6198fc 100644 --- a/router/router.go +++ b/router/router.go @@ -8,7 +8,6 @@ import ( "sync" "github.com/ryanhamamura/c4/config" - "github.com/ryanhamamura/c4/db" "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/features/auth" "github.com/ryanhamamura/c4/features/c4game" @@ -30,7 +29,6 @@ func SetupRoutes( nc *nats.Conn, store *game.GameStore, snakeStore *snake.SnakeStore, - chatPersister *db.ChatPersister, assets embed.FS, ) error { // Static assets @@ -44,7 +42,7 @@ func SetupRoutes( auth.SetupRoutes(router, queries, sessions) 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) return nil diff --git a/snake/loop.go b/snake/loop.go index 9d46c09..fb1a839 100644 --- a/snake/loop.go +++ b/snake/loop.go @@ -61,16 +61,16 @@ func (si *SnakeGameInstance) countdownPhase() { si.initGame() si.game.Status = StatusInProgress - if si.persister != nil { - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakeGame(si.game) } si.gameMu.Unlock() si.notify() return } - if si.persister != nil { - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakeGame(si.game) } si.gameMu.Unlock() si.notify() @@ -123,8 +123,8 @@ func (si *SnakeGameInstance) gamePhase() { // Inactivity timeout if time.Since(lastInput) > inactivityLimit { si.game.Status = StatusFinished - if si.persister != nil { - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakeGame(si.game) } si.gameMu.Unlock() si.notify() @@ -195,8 +195,8 @@ func (si *SnakeGameInstance) gamePhase() { si.game.Status = StatusFinished } - if si.persister != nil { - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakeGame(si.game) } si.gameMu.Unlock() diff --git a/snake/persist.go b/snake/persist.go new file mode 100644 index 0000000..6ef51d0 --- /dev/null +++ b/snake/persist.go @@ -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 +} diff --git a/snake/store.go b/snake/store.go index fac2574..ef1749e 100644 --- a/snake/store.go +++ b/snake/store.go @@ -1,36 +1,28 @@ package snake import ( + "context" "crypto/rand" "encoding/hex" "sync" + + "github.com/ryanhamamura/c4/db/repository" ) -type Persister interface { - 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 { - games map[string]*SnakeGameInstance - gamesMu sync.RWMutex - persister Persister + games map[string]*SnakeGameInstance + gamesMu sync.RWMutex + queries *repository.Queries notifyFunc func(gameID string) } -func NewSnakeStore() *SnakeStore { +func NewSnakeStore(queries *repository.Queries) *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)) { ss.notifyFunc = f } @@ -60,18 +52,18 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake Speed: speed, } si := &SnakeGameInstance{ - game: sg, - notify: ss.makeNotify(id), - persister: ss.persister, - store: ss, + game: sg, + notify: ss.makeNotify(id), + queries: ss.queries, + store: ss, } ss.gamesMu.Lock() ss.games[id] = si ss.gamesMu.Unlock() - if ss.persister != nil { - ss.persister.SaveSnakeGame(sg) + if ss.queries != nil { + ss.saveSnakeGame(sg) } return si @@ -86,16 +78,16 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) { return si, true } - if ss.persister == nil { + if ss.queries == nil { return nil, false } - sg, err := ss.persister.LoadSnakeGame(id) + sg, err := ss.loadSnakeGame(id) if err != nil || sg == nil { return nil, false } - players, _ := ss.persister.LoadSnakePlayers(id) + players, _ := ss.loadSnakePlayers(id) if sg.Players == nil { sg.Players = make([]*Player, 8) } @@ -106,10 +98,10 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) { } si = &SnakeGameInstance{ - game: sg, - notify: ss.makeNotify(id), - persister: ss.persister, - store: ss, + game: sg, + notify: ss.makeNotify(id), + queries: ss.queries, + store: ss, } ss.gamesMu.Lock() @@ -129,8 +121,8 @@ func (ss *SnakeStore) Delete(id string) error { si.Stop() } - if ss.persister != nil { - return ss.persister.DeleteSnakeGame(id) + if ss.queries != nil { + return ss.queries.DeleteSnakeGame(context.Background(), id) } return nil } @@ -158,14 +150,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame { } type SnakeGameInstance struct { - game *SnakeGame - gameMu sync.RWMutex + game *SnakeGame + gameMu sync.RWMutex pendingDirQueue [8][]Direction // queued directions per slot (max 3) - notify func() - persister Persister - store *SnakeStore - stopCh chan struct{} - loopOnce sync.Once + notify func() + queries *repository.Queries + store *SnakeStore + stopCh chan struct{} + loopOnce sync.Once } func (si *SnakeGameInstance) ID() string { @@ -214,9 +206,9 @@ func (si *SnakeGameInstance) Join(player *Player) bool { player.Slot = slot si.game.Players[slot] = player - if si.persister != nil { - si.persister.SaveSnakePlayer(si.game.ID, player) - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakePlayer(si.game.ID, player) + si.saveSnakeGame(si.game) } si.notify() @@ -301,8 +293,8 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance { } si.game.RematchGameID = &newID - if si.persister != nil { - si.persister.SaveSnakeGame(si.game) + if si.queries != nil { + si.saveSnakeGame(si.game) } si.gameMu.Unlock()