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
This commit is contained in:
326
db/persister.go
326
db/persister.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
157
game/persist.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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,28 +64,28 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
gs.gamesMu.Lock()
|
gs.gamesMu.Lock()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -121,10 +113,10 @@ func GenerateID(size int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameInstance struct {
|
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
10
main.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
186
snake/persist.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,36 +1,28 @@
|
|||||||
package snake
|
package snake
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"sync"
|
"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 {
|
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
|
||||||
}
|
}
|
||||||
@@ -60,18 +52,18 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
|
|||||||
Speed: speed,
|
Speed: speed,
|
||||||
}
|
}
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
ss.gamesMu.Lock()
|
ss.gamesMu.Lock()
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -106,10 +98,10 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
ss.gamesMu.Lock()
|
ss.gamesMu.Lock()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -158,14 +150,14 @@ func (ss *SnakeStore) ActiveGames() []*SnakeGame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SnakeGameInstance struct {
|
type SnakeGameInstance struct {
|
||||||
game *SnakeGame
|
game *SnakeGame
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (si *SnakeGameInstance) ID() string {
|
func (si *SnakeGameInstance) ID() string {
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user