refactor: replace via framework with chi + templ + datastar #2

Merged
ryan merged 9 commits from refactor/remove-via into main 2026-03-03 00:47:20 +00:00
10 changed files with 475 additions and 448 deletions
Showing only changes of commit 2aa026b1d5 - Show all commits

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
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,

View File

@@ -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))
})

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
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()

10
main.go
View File

@@ -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)
}

View File

@@ -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

View File

@@ -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()

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
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()