Merge pull request 'refactor: deduplicate persistence, add upsert queries, throttle snake saves' (#4) from refactor/game-efficiency into main
Some checks failed
CI / Deploy / test (push) Successful in 13s
CI / Deploy / lint (push) Successful in 25s
CI / Deploy / deploy (push) Failing after 17s

This commit was merged in pull request #4.
This commit is contained in:
2026-03-03 05:02:04 +00:00
15 changed files with 351 additions and 485 deletions

View File

@@ -18,6 +18,9 @@ jobs:
with: with:
go-version: "1.25" go-version: "1.25"
- name: Generate templ
run: go tool templ generate
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
@@ -30,6 +33,9 @@ jobs:
with: with:
go-version: "1.25" go-version: "1.25"
- name: Generate templ
run: go tool templ generate
- name: Install golangci-lint - name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest

View File

@@ -1,16 +1,18 @@
-- name: CreateGame :one -- name: UpsertGame :exec
INSERT INTO games (id, board, current_turn, status) INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
RETURNING *; ON CONFLICT(id) DO UPDATE SET
board = excluded.board,
current_turn = excluded.current_turn,
status = excluded.status,
winner_user_id = excluded.winner_user_id,
winning_cells = excluded.winning_cells,
rematch_game_id = excluded.rematch_game_id,
updated_at = CURRENT_TIMESTAMP;
-- name: GetGame :one -- name: GetGame :one
SELECT * FROM games WHERE id = ?; SELECT * FROM games WHERE id = ?;
-- name: UpdateGame :exec
UPDATE games
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- name: DeleteGame :exec -- name: DeleteGame :exec
DELETE FROM games WHERE id = ?; DELETE FROM games WHERE id = ?;

View File

@@ -1,16 +1,17 @@
-- name: CreateSnakeGame :one -- name: UpsertSnakeGame :exec
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed) INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?) VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
RETURNING *; ON CONFLICT(id) DO UPDATE SET
board = excluded.board,
status = excluded.status,
winner_user_id = excluded.winner_user_id,
rematch_game_id = excluded.rematch_game_id,
score = excluded.score,
updated_at = CURRENT_TIMESTAMP;
-- name: GetSnakeGame :one -- name: GetSnakeGame :one
SELECT * FROM games WHERE id = ? AND game_type = 'snake'; SELECT * FROM games WHERE id = ? AND game_type = 'snake';
-- name: UpdateSnakeGame :exec
UPDATE games
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND game_type = 'snake';
-- name: DeleteSnakeGame :exec -- name: DeleteSnakeGame :exec
DELETE FROM games WHERE id = ? AND game_type = 'snake'; DELETE FROM games WHERE id = ? AND game_type = 'snake';

View File

@@ -15,11 +15,11 @@ VALUES (?, ?, ?, ?, ?)
` `
type CreateChatMessageParams struct { type CreateChatMessageParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Message string Message string `db:"message" json:"message"`
CreatedAt int64 CreatedAt int64 `db:"created_at" json:"created_at"`
} }
func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error { func (q *Queries) CreateChatMessage(ctx context.Context, arg CreateChatMessageParams) error {
@@ -40,13 +40,13 @@ ORDER BY created_at DESC, id DESC
LIMIT 50 LIMIT 50
` `
func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMessage, error) { func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]*ChatMessage, error) {
rows, err := q.db.QueryContext(ctx, getChatMessages, gameID) rows, err := q.db.QueryContext(ctx, getChatMessages, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []ChatMessage var items []*ChatMessage
for rows.Next() { for rows.Next() {
var i ChatMessage var i ChatMessage
if err := rows.Scan( if err := rows.Scan(
@@ -59,7 +59,7 @@ func (q *Queries) GetChatMessages(ctx context.Context, gameID string) ([]ChatMes
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err

View File

@@ -7,63 +7,21 @@ package repository
import ( import (
"context" "context"
"database/sql" "time"
) )
const createGame = `-- name: CreateGame :one
INSERT INTO games (id, board, current_turn, status)
VALUES (?, ?, ?, ?)
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
`
type CreateGameParams struct {
ID string
Board string
CurrentTurn int64
Status int64
}
func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) {
row := q.db.QueryRowContext(ctx, createGame,
arg.ID,
arg.Board,
arg.CurrentTurn,
arg.Status,
)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
&i.GameMode,
&i.Score,
&i.SnakeSpeed,
)
return i, err
}
const createGamePlayer = `-- name: CreateGamePlayer :exec const createGamePlayer = `-- name: CreateGamePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
` `
type CreateGamePlayerParams struct { type CreateGamePlayerParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
UserID sql.NullString UserID *string `db:"user_id" json:"user_id"`
GuestPlayerID sql.NullString GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Slot int64 Slot int64 `db:"slot" json:"slot"`
} }
func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error { func (q *Queries) CreateGamePlayer(ctx context.Context, arg CreateGamePlayerParams) error {
@@ -91,13 +49,13 @@ const getActiveGames = `-- name: GetActiveGames :many
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2 SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'connect4' AND status < 2
` `
func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) { func (q *Queries) GetActiveGames(ctx context.Context) ([]*Game, error) {
rows, err := q.db.QueryContext(ctx, getActiveGames) rows, err := q.db.QueryContext(ctx, getActiveGames)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -120,7 +78,7 @@ func (q *Queries) GetActiveGames(ctx context.Context) ([]Game, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -135,7 +93,7 @@ const getGame = `-- name: GetGame :one
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ?
` `
func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) { func (q *Queries) GetGame(ctx context.Context, id string) (*Game, error) {
row := q.db.QueryRowContext(ctx, getGame, id) row := q.db.QueryRowContext(ctx, getGame, id)
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -156,20 +114,20 @@ func (q *Queries) GetGame(ctx context.Context, id string) (Game, error) {
&i.Score, &i.Score,
&i.SnakeSpeed, &i.SnakeSpeed,
) )
return i, err return &i, err
} }
const getGamePlayers = `-- name: GetGamePlayers :many const getGamePlayers = `-- name: GetGamePlayers :many
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ?
` `
func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) { func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID) rows, err := q.db.QueryContext(ctx, getGamePlayers, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GamePlayer var items []*GamePlayer
for rows.Next() { for rows.Next() {
var i GamePlayer var i GamePlayer
if err := rows.Scan( if err := rows.Scan(
@@ -183,7 +141,7 @@ func (q *Queries) GetGamePlayers(ctx context.Context, gameID string) ([]GamePlay
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -201,13 +159,13 @@ WHERE gp.user_id = ?
ORDER BY g.updated_at DESC ORDER BY g.updated_at DESC
` `
func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) ([]Game, error) { func (q *Queries) GetGamesByUserID(ctx context.Context, userID *string) ([]*Game, error) {
rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID) rows, err := q.db.QueryContext(ctx, getGamesByUserID, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -230,7 +188,7 @@ func (q *Queries) GetGamesByUserID(ctx context.Context, userID sql.NullString) (
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -257,21 +215,21 @@ ORDER BY g.updated_at DESC
` `
type GetUserActiveGamesRow struct { type GetUserActiveGamesRow struct {
ID string ID string `db:"id" json:"id"`
Status int64 Status int64 `db:"status" json:"status"`
CurrentTurn int64 CurrentTurn int64 `db:"current_turn" json:"current_turn"`
UpdatedAt sql.NullTime UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
MyColor int64 MyColor int64 `db:"my_color" json:"my_color"`
OpponentNickname sql.NullString OpponentNickname *string `db:"opponent_nickname" json:"opponent_nickname"`
} }
func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveGamesRow, error) { func (q *Queries) GetUserActiveGames(ctx context.Context, userID *string) ([]*GetUserActiveGamesRow, error) {
rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID) rows, err := q.db.QueryContext(ctx, getUserActiveGames, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetUserActiveGamesRow var items []*GetUserActiveGamesRow
for rows.Next() { for rows.Next() {
var i GetUserActiveGamesRow var i GetUserActiveGamesRow
if err := rows.Scan( if err := rows.Scan(
@@ -284,7 +242,7 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -295,31 +253,38 @@ func (q *Queries) GetUserActiveGames(ctx context.Context, userID sql.NullString)
return items, nil return items, nil
} }
const updateGame = `-- name: UpdateGame :exec const upsertGame = `-- name: UpsertGame :exec
UPDATE games INSERT INTO games (id, board, current_turn, status, game_type, winner_user_id, winning_cells, rematch_game_id)
SET board = ?, current_turn = ?, status = ?, winner_user_id = ?, winning_cells = ?, rematch_game_id = ?, updated_at = CURRENT_TIMESTAMP VALUES (?, ?, ?, ?, 'connect4', ?, ?, ?)
WHERE id = ? ON CONFLICT(id) DO UPDATE SET
board = excluded.board,
current_turn = excluded.current_turn,
status = excluded.status,
winner_user_id = excluded.winner_user_id,
winning_cells = excluded.winning_cells,
rematch_game_id = excluded.rematch_game_id,
updated_at = CURRENT_TIMESTAMP
` `
type UpdateGameParams struct { type UpsertGameParams struct {
Board string ID string `db:"id" json:"id"`
CurrentTurn int64 Board string `db:"board" json:"board"`
Status int64 CurrentTurn int64 `db:"current_turn" json:"current_turn"`
WinnerUserID sql.NullString Status int64 `db:"status" json:"status"`
WinningCells sql.NullString WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
RematchGameID sql.NullString WinningCells *string `db:"winning_cells" json:"winning_cells"`
ID string RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
} }
func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) error { func (q *Queries) UpsertGame(ctx context.Context, arg UpsertGameParams) error {
_, err := q.db.ExecContext(ctx, updateGame, _, err := q.db.ExecContext(ctx, upsertGame,
arg.ID,
arg.Board, arg.Board,
arg.CurrentTurn, arg.CurrentTurn,
arg.Status, arg.Status,
arg.WinnerUserID, arg.WinnerUserID,
arg.WinningCells, arg.WinningCells,
arg.RematchGameID, arg.RematchGameID,
arg.ID,
) )
return err return err
} }

View File

@@ -5,50 +5,56 @@
package repository package repository
import ( import (
"database/sql" "time"
) )
type ChatMessage struct { type ChatMessage struct {
ID int64 ID int64 `db:"id" json:"id"`
GameID string GameID string `db:"game_id" json:"game_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Message string Message string `db:"message" json:"message"`
CreatedAt int64 CreatedAt int64 `db:"created_at" json:"created_at"`
} }
type Game struct { type Game struct {
ID string ID string `db:"id" json:"id"`
Board string Board string `db:"board" json:"board"`
CurrentTurn int64 CurrentTurn int64 `db:"current_turn" json:"current_turn"`
Status int64 Status int64 `db:"status" json:"status"`
WinnerUserID sql.NullString WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
WinningCells sql.NullString WinningCells *string `db:"winning_cells" json:"winning_cells"`
CreatedAt sql.NullTime CreatedAt *time.Time `db:"created_at" json:"created_at"`
UpdatedAt sql.NullTime UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
RematchGameID sql.NullString RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
GameType string GameType string `db:"game_type" json:"game_type"`
GridWidth sql.NullInt64 GridWidth *int64 `db:"grid_width" json:"grid_width"`
GridHeight sql.NullInt64 GridHeight *int64 `db:"grid_height" json:"grid_height"`
MaxPlayers int64 MaxPlayers int64 `db:"max_players" json:"max_players"`
GameMode int64 GameMode int64 `db:"game_mode" json:"game_mode"`
Score int64 Score int64 `db:"score" json:"score"`
SnakeSpeed int64 SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
} }
type GamePlayer struct { type GamePlayer struct {
GameID string GameID string `db:"game_id" json:"game_id"`
UserID sql.NullString UserID *string `db:"user_id" json:"user_id"`
GuestPlayerID sql.NullString GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Slot int64 Slot int64 `db:"slot" json:"slot"`
CreatedAt sql.NullTime CreatedAt *time.Time `db:"created_at" json:"created_at"`
}
type Session struct {
Token string `db:"token" json:"token"`
Data []byte `db:"data" json:"data"`
Expiry float64 `db:"expiry" json:"expiry"`
} }
type User struct { type User struct {
ID string ID string `db:"id" json:"id"`
Username string Username string `db:"username" json:"username"`
PasswordHash string PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt sql.NullTime CreatedAt *time.Time `db:"created_at" json:"created_at"`
} }

View File

@@ -7,69 +7,21 @@ package repository
import ( import (
"context" "context"
"database/sql" "time"
) )
const createSnakeGame = `-- name: CreateSnakeGame :one
INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed)
VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?)
RETURNING id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed
`
type CreateSnakeGameParams struct {
ID string
Board string
Status int64
GridWidth sql.NullInt64
GridHeight sql.NullInt64
GameMode int64
SnakeSpeed int64
}
func (q *Queries) CreateSnakeGame(ctx context.Context, arg CreateSnakeGameParams) (Game, error) {
row := q.db.QueryRowContext(ctx, createSnakeGame,
arg.ID,
arg.Board,
arg.Status,
arg.GridWidth,
arg.GridHeight,
arg.GameMode,
arg.SnakeSpeed,
)
var i Game
err := row.Scan(
&i.ID,
&i.Board,
&i.CurrentTurn,
&i.Status,
&i.WinnerUserID,
&i.WinningCells,
&i.CreatedAt,
&i.UpdatedAt,
&i.RematchGameID,
&i.GameType,
&i.GridWidth,
&i.GridHeight,
&i.MaxPlayers,
&i.GameMode,
&i.Score,
&i.SnakeSpeed,
)
return i, err
}
const createSnakePlayer = `-- name: CreateSnakePlayer :exec const createSnakePlayer = `-- name: CreateSnakePlayer :exec
INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot) INSERT INTO game_players (game_id, user_id, guest_player_id, nickname, color, slot)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
` `
type CreateSnakePlayerParams struct { type CreateSnakePlayerParams struct {
GameID string GameID string `db:"game_id" json:"game_id"`
UserID sql.NullString UserID *string `db:"user_id" json:"user_id"`
GuestPlayerID sql.NullString GuestPlayerID *string `db:"guest_player_id" json:"guest_player_id"`
Nickname string Nickname string `db:"nickname" json:"nickname"`
Color int64 Color int64 `db:"color" json:"color"`
Slot int64 Slot int64 `db:"slot" json:"slot"`
} }
func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error { func (q *Queries) CreateSnakePlayer(ctx context.Context, arg CreateSnakePlayerParams) error {
@@ -97,13 +49,13 @@ const getActiveSnakeGames = `-- name: GetActiveSnakeGames :many
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0 SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE game_type = 'snake' AND status < 2 AND game_mode = 0
` `
func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) { func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]*Game, error) {
rows, err := q.db.QueryContext(ctx, getActiveSnakeGames) rows, err := q.db.QueryContext(ctx, getActiveSnakeGames)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Game var items []*Game
for rows.Next() { for rows.Next() {
var i Game var i Game
if err := rows.Scan( if err := rows.Scan(
@@ -126,7 +78,7 @@ func (q *Queries) GetActiveSnakeGames(ctx context.Context) ([]Game, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -141,7 +93,7 @@ const getSnakeGame = `-- name: GetSnakeGame :one
SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake' SELECT id, board, current_turn, status, winner_user_id, winning_cells, created_at, updated_at, rematch_game_id, game_type, grid_width, grid_height, max_players, game_mode, score, snake_speed FROM games WHERE id = ? AND game_type = 'snake'
` `
func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) { func (q *Queries) GetSnakeGame(ctx context.Context, id string) (*Game, error) {
row := q.db.QueryRowContext(ctx, getSnakeGame, id) row := q.db.QueryRowContext(ctx, getSnakeGame, id)
var i Game var i Game
err := row.Scan( err := row.Scan(
@@ -162,20 +114,20 @@ func (q *Queries) GetSnakeGame(ctx context.Context, id string) (Game, error) {
&i.Score, &i.Score,
&i.SnakeSpeed, &i.SnakeSpeed,
) )
return i, err return &i, err
} }
const getSnakePlayers = `-- name: GetSnakePlayers :many const getSnakePlayers = `-- name: GetSnakePlayers :many
SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot SELECT game_id, user_id, guest_player_id, nickname, color, slot, created_at FROM game_players WHERE game_id = ? ORDER BY slot
` `
func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePlayer, error) { func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]*GamePlayer, error) {
rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID) rows, err := q.db.QueryContext(ctx, getSnakePlayers, gameID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GamePlayer var items []*GamePlayer
for rows.Next() { for rows.Next() {
var i GamePlayer var i GamePlayer
if err := rows.Scan( if err := rows.Scan(
@@ -189,7 +141,7 @@ func (q *Queries) GetSnakePlayers(ctx context.Context, gameID string) ([]GamePla
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -214,20 +166,20 @@ ORDER BY g.updated_at DESC
` `
type GetUserActiveSnakeGamesRow struct { type GetUserActiveSnakeGamesRow struct {
ID string ID string `db:"id" json:"id"`
Status int64 Status int64 `db:"status" json:"status"`
GridWidth sql.NullInt64 GridWidth *int64 `db:"grid_width" json:"grid_width"`
GridHeight sql.NullInt64 GridHeight *int64 `db:"grid_height" json:"grid_height"`
UpdatedAt sql.NullTime UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
} }
func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullString) ([]GetUserActiveSnakeGamesRow, error) { func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID *string) ([]*GetUserActiveSnakeGamesRow, error) {
rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID) rows, err := q.db.QueryContext(ctx, getUserActiveSnakeGames, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetUserActiveSnakeGamesRow var items []*GetUserActiveSnakeGamesRow
for rows.Next() { for rows.Next() {
var i GetUserActiveSnakeGamesRow var i GetUserActiveSnakeGamesRow
if err := rows.Scan( if err := rows.Scan(
@@ -239,7 +191,7 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
); err != nil { ); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, &i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -250,29 +202,43 @@ func (q *Queries) GetUserActiveSnakeGames(ctx context.Context, userID sql.NullSt
return items, nil return items, nil
} }
const updateSnakeGame = `-- name: UpdateSnakeGame :exec const upsertSnakeGame = `-- name: UpsertSnakeGame :exec
UPDATE games INSERT INTO games (id, board, current_turn, status, game_type, grid_width, grid_height, max_players, game_mode, snake_speed, winner_user_id, rematch_game_id, score)
SET board = ?, status = ?, winner_user_id = ?, rematch_game_id = ?, score = ?, updated_at = CURRENT_TIMESTAMP VALUES (?, ?, 0, ?, 'snake', ?, ?, 8, ?, ?, ?, ?, ?)
WHERE id = ? AND game_type = 'snake' ON CONFLICT(id) DO UPDATE SET
board = excluded.board,
status = excluded.status,
winner_user_id = excluded.winner_user_id,
rematch_game_id = excluded.rematch_game_id,
score = excluded.score,
updated_at = CURRENT_TIMESTAMP
` `
type UpdateSnakeGameParams struct { type UpsertSnakeGameParams struct {
Board string ID string `db:"id" json:"id"`
Status int64 Board string `db:"board" json:"board"`
WinnerUserID sql.NullString Status int64 `db:"status" json:"status"`
RematchGameID sql.NullString GridWidth *int64 `db:"grid_width" json:"grid_width"`
Score int64 GridHeight *int64 `db:"grid_height" json:"grid_height"`
ID string GameMode int64 `db:"game_mode" json:"game_mode"`
SnakeSpeed int64 `db:"snake_speed" json:"snake_speed"`
WinnerUserID *string `db:"winner_user_id" json:"winner_user_id"`
RematchGameID *string `db:"rematch_game_id" json:"rematch_game_id"`
Score int64 `db:"score" json:"score"`
} }
func (q *Queries) UpdateSnakeGame(ctx context.Context, arg UpdateSnakeGameParams) error { func (q *Queries) UpsertSnakeGame(ctx context.Context, arg UpsertSnakeGameParams) error {
_, err := q.db.ExecContext(ctx, updateSnakeGame, _, err := q.db.ExecContext(ctx, upsertSnakeGame,
arg.ID,
arg.Board, arg.Board,
arg.Status, arg.Status,
arg.GridWidth,
arg.GridHeight,
arg.GameMode,
arg.SnakeSpeed,
arg.WinnerUserID, arg.WinnerUserID,
arg.RematchGameID, arg.RematchGameID,
arg.Score, arg.Score,
arg.ID,
) )
return err return err
} }

View File

@@ -16,12 +16,12 @@ RETURNING id, username, password_hash, created_at
` `
type CreateUserParams struct { type CreateUserParams struct {
ID string ID string `db:"id" json:"id"`
Username string Username string `db:"username" json:"username"`
PasswordHash string PasswordHash string `db:"password_hash" json:"password_hash"`
} }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (*User, error) {
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash) row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -30,14 +30,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, username, password_hash, created_at FROM users WHERE id = ? SELECT id, username, password_hash, created_at FROM users WHERE id = ?
` `
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id string) (*User, error) {
row := q.db.QueryRowContext(ctx, getUserByID, id) row := q.db.QueryRowContext(ctx, getUserByID, id)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -46,14 +46,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }
const getUserByUsername = `-- name: GetUserByUsername :one const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, username, password_hash, created_at FROM users WHERE username = ? SELECT id, username, password_hash, created_at FROM users WHERE username = ?
` `
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { func (q *Queries) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := q.db.QueryRowContext(ctx, getUserByUsername, username) row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i User var i User
err := row.Scan( err := row.Scan(
@@ -62,5 +62,5 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
&i.PasswordHash, &i.PasswordHash,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return &i, err
} }

View File

@@ -156,9 +156,6 @@ func HandleGameEvents(store *game.GameStore, nc *nats.Conn, sessions *scs.Sessio
if len(chatMessages) > 50 { if len(chatMessages) > 50 {
chatMessages = chatMessages[len(chatMessages)-50:] chatMessages = chatMessages[len(chatMessages)-50:]
} }
chatMu.Unlock()
chatMu.Lock()
msgs := make([]components.ChatMessage, len(chatMessages)) msgs := make([]components.ChatMessage, len(chatMessages))
copy(msgs, chatMessages) copy(msgs, chatMessages)
chatMu.Unlock() chatMu.Unlock()

View File

@@ -2,10 +2,10 @@ package lobby
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components" lobbycomponents "github.com/ryanhamamura/c4/features/lobby/components"
@@ -28,16 +28,24 @@ func HandleLobbyPage(queries *repository.Queries, sessions *scs.SessionManager,
var userGames []lobbycomponents.GameListItem var userGames []lobbycomponents.GameListItem
if isLoggedIn { if isLoggedIn {
ctx := context.Background() ctx := context.Background()
games, err := queries.GetUserActiveGames(ctx, sql.NullString{String: userID, Valid: true}) games, err := queries.GetUserActiveGames(ctx, &userID)
if err == nil { if err == nil {
for _, g := range games { for _, g := range games {
isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor isMyTurn := g.Status == 1 && g.CurrentTurn == g.MyColor
opponentName := ""
if g.OpponentNickname != nil {
opponentName = *g.OpponentNickname
}
var lastPlayed time.Time
if g.UpdatedAt != nil {
lastPlayed = *g.UpdatedAt
}
userGames = append(userGames, lobbycomponents.GameListItem{ userGames = append(userGames, lobbycomponents.GameListItem{
ID: g.ID, ID: g.ID,
Status: int(g.Status), Status: int(g.Status),
OpponentName: g.OpponentNickname.String, OpponentName: opponentName,
IsMyTurn: isMyTurn, IsMyTurn: isMyTurn,
LastPlayed: g.UpdatedAt.Time, LastPlayed: lastPlayed,
}) })
} }
} }

View File

@@ -2,80 +2,61 @@ package game
import ( import (
"context" "context"
"database/sql"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/rs/zerolog/log"
) )
// Persistence methods on GameStore (used during Get to hydrate from DB). func (gi *GameInstance) save() error {
err := saveGame(gi.queries, gi.game)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Msg("failed to save game")
}
return err
}
func (gs *GameStore) saveGame(g *Game) error { func (gi *GameInstance) savePlayer(player *Player, slot int) error {
ctx := context.Background() err := saveGamePlayer(gi.queries, gi.game.ID, player, slot)
if err != nil {
log.Error().Err(err).Str("game_id", gi.game.ID).Int("slot", slot).Msg("failed to save game player")
}
return err
}
_, err := gs.queries.GetGame(ctx, g.ID) // saveGame persists the game state via upsert.
if err == sql.ErrNoRows { func saveGame(queries *repository.Queries, g *Game) error {
_, err = gs.queries.CreateGame(ctx, repository.CreateGameParams{ var winnerUserID *string
if g.Winner != nil && g.Winner.UserID != nil {
winnerUserID = g.Winner.UserID
}
var winningCells *string
if wc := g.WinningCellsToJSON(); wc != "" {
winningCells = &wc
}
return queries.UpsertGame(context.Background(), repository.UpsertGameParams{
ID: g.ID, ID: g.ID,
Board: g.BoardToJSON(), Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn), CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status), Status: int64(g.Status),
WinnerUserID: winnerUserID,
WinningCells: winningCells,
RematchGameID: g.RematchGameID,
}) })
return err
}
if err != nil {
return err
} }
return gs.queries.UpdateGame(ctx, updateGameParams(g)) func saveGamePlayer(queries *repository.Queries, gameID string, player *Player, slot int) error {
} var userID, guestPlayerID *string
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 { if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true} userID = player.UserID
} else { } else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true} id := string(player.ID)
guestPlayerID = &id
} }
return gi.queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{ return queries.CreateGamePlayer(context.Background(), repository.CreateGamePlayerParams{
GameID: gameID, GameID: gameID,
UserID: userID, UserID: userID,
GuestPlayerID: guestPlayerID, GuestPlayerID: guestPlayerID,
@@ -85,36 +66,25 @@ func (gi *GameInstance) saveGamePlayer(gameID string, player *Player, slot int)
}) })
} }
// Shared helpers for domain ↔ DB mapping. func loadGame(queries *repository.Queries, id string) (*Game, error) {
row, err := queries.GetGame(context.Background(), id)
func updateGameParams(g *Game) repository.UpdateGameParams { if err != nil {
var winnerUserID sql.NullString return nil, err
if g.Winner != nil && g.Winner.UserID != nil { }
winnerUserID = sql.NullString{String: *g.Winner.UserID, Valid: true} return gameFromRow(row)
} }
var winningCells sql.NullString func loadGamePlayers(queries *repository.Queries, id string) ([]*Player, error) {
if wc := g.WinningCellsToJSON(); wc != "" { rows, err := queries.GetGamePlayers(context.Background(), id)
winningCells = sql.NullString{String: wc, Valid: true} if err != nil {
return nil, err
}
return playersFromRows(rows), nil
} }
var rematchGameID sql.NullString // Domain ↔ DB mapping helpers.
if g.RematchGameID != nil {
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
}
return repository.UpdateGameParams{ func gameFromRow(row *repository.Game) (*Game, error) {
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{ g := &Game{
ID: row.ID, ID: row.ID,
CurrentTurn: int(row.CurrentTurn), CurrentTurn: int(row.CurrentTurn),
@@ -125,18 +95,18 @@ func gameFromRow(row repository.Game) (*Game, error) {
return nil, err return nil, err
} }
if row.WinningCells.Valid { if row.WinningCells != nil {
_ = g.WinningCellsFromJSON(row.WinningCells.String) _ = g.WinningCellsFromJSON(*row.WinningCells)
} }
if row.RematchGameID.Valid { if row.RematchGameID != nil {
g.RematchGameID = &row.RematchGameID.String g.RematchGameID = row.RematchGameID
} }
return g, nil return g, nil
} }
func playersFromRows(rows []repository.GamePlayer) []*Player { func playersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows)) players := make([]*Player, 0, len(rows))
for _, row := range rows { for _, row := range rows {
player := &Player{ player := &Player{
@@ -144,11 +114,11 @@ func playersFromRows(rows []repository.GamePlayer) []*Player {
Color: int(row.Color), Color: int(row.Color),
} }
if row.UserID.Valid { if row.UserID != nil {
player.UserID = &row.UserID.String player.UserID = row.UserID
player.ID = PlayerID(row.UserID.String) player.ID = PlayerID(*row.UserID)
} else if row.GuestPlayerID.Valid { } else if row.GuestPlayerID != nil {
player.ID = PlayerID(row.GuestPlayerID.String) player.ID = PlayerID(*row.GuestPlayerID)
} }
players = append(players, player) players = append(players, player)

View File

@@ -49,7 +49,7 @@ func (gs *GameStore) Create() *GameInstance {
gs.gamesMu.Unlock() gs.gamesMu.Unlock()
if gs.queries != nil { if gs.queries != nil {
gs.saveGame(gi.game) //nolint:errcheck gi.save() //nolint:errcheck
} }
return gi return gi
@@ -68,12 +68,12 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) {
return nil, false return nil, false
} }
g, err := gs.loadGame(id) g, err := loadGame(gs.queries, id)
if err != nil || g == nil { if err != nil || g == nil {
return nil, false return nil, false
} }
players, _ := gs.loadGamePlayers(id) players, _ := loadGamePlayers(gs.queries, id)
for _, p := range players { for _, p := range players {
switch p.Color { switch p.Color {
case 1: case 1:
@@ -152,8 +152,8 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool {
} }
if gi.queries != nil { if gi.queries != nil {
gi.saveGamePlayer(gi.game.ID, ps.Player, slot) //nolint:errcheck gi.savePlayer(ps.Player, slot) //nolint:errcheck
gi.saveGame(gi.game) //nolint:errcheck gi.save() //nolint:errcheck
} }
gi.notify() gi.notify()
@@ -190,7 +190,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance {
gi.game.RematchGameID = &newID gi.game.RematchGameID = &newID
if gi.queries != nil { if gi.queries != nil {
if err := gi.saveGame(gi.game); err != nil { if err := gi.save(); err != nil {
gs.Delete(newID) //nolint:errcheck gs.Delete(newID) //nolint:errcheck
gi.game.RematchGameID = nil gi.game.RematchGameID = nil
return nil return nil
@@ -224,7 +224,7 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool {
} }
if gi.queries != nil { if gi.queries != nil {
gi.saveGame(gi.game) //nolint:errcheck gi.save() //nolint:errcheck
} }
gi.notify() gi.notify()

View File

@@ -62,16 +62,14 @@ func (si *SnakeGameInstance) countdownPhase() {
si.game.Status = StatusInProgress si.game.Status = StatusInProgress
if si.queries != nil { if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
return return
} }
if si.queries != nil { // No DB save during countdown ticks — state is transient
si.saveSnakeGame(si.game) //nolint:errcheck
}
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
} }
@@ -98,6 +96,7 @@ func (si *SnakeGameInstance) gamePhase() {
defer ticker.Stop() defer ticker.Stop()
lastInput := time.Now() lastInput := time.Now()
lastSave := time.Now()
var moveAccum time.Duration var moveAccum time.Duration
for { for {
@@ -124,7 +123,7 @@ func (si *SnakeGameInstance) gamePhase() {
if time.Since(lastInput) > inactivityLimit { if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished si.game.Status = StatusFinished
if si.queries != nil { if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
@@ -175,13 +174,10 @@ func (si *SnakeGameInstance) gamePhase() {
alive := AliveCount(state) alive := AliveCount(state)
gameOver := false gameOver := false
if si.game.Mode == ModeSinglePlayer { if si.game.Mode == ModeSinglePlayer {
// Single player ends when the player dies (alive == 0)
if alive == 0 { if alive == 0 {
gameOver = true gameOver = true
// No winner in single player - just final score
} }
} else { } else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 { if alive <= 1 {
gameOver = true gameOver = true
winnerIdx := LastAlive(state) winnerIdx := LastAlive(state)
@@ -195,8 +191,10 @@ func (si *SnakeGameInstance) gamePhase() {
si.game.Status = StatusFinished si.game.Status = StatusFinished
} }
if si.queries != nil { // Throttle DB saves: persist on game over or every 2 seconds
si.saveSnakeGame(si.game) //nolint:errcheck if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
si.save() //nolint:errcheck
lastSave = time.Now()
} }
si.gameMu.Unlock() si.gameMu.Unlock()

View File

@@ -2,30 +2,44 @@ package snake
import ( import (
"context" "context"
"database/sql"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/rs/zerolog/log"
) )
// Persistence methods on SnakeStore (used during Get to hydrate from DB). func (si *SnakeGameInstance) save() error {
err := saveSnakeGame(si.queries, si.game)
if err != nil {
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake game")
}
return err
}
func (ss *SnakeStore) saveSnakeGame(sg *SnakeGame) error { func (si *SnakeGameInstance) savePlayer(player *Player) error {
ctx := context.Background() err := saveSnakePlayer(si.queries, si.game.ID, player)
if err != nil {
log.Error().Err(err).Str("game_id", si.game.ID).Msg("failed to save snake player")
}
return err
}
// saveSnakeGame persists the snake game state via upsert.
func saveSnakeGame(queries *repository.Queries, sg *SnakeGame) error {
boardJSON := "{}" boardJSON := "{}"
var gridWidth, gridHeight *int64
if sg.State != nil { if sg.State != nil {
boardJSON = sg.State.ToJSON() boardJSON = sg.State.ToJSON()
w, h := int64(sg.State.Width), int64(sg.State.Height)
gridWidth, gridHeight = &w, &h
} }
var gridWidth, gridHeight sql.NullInt64 var winnerUserID *string
if sg.State != nil { if sg.Winner != nil && sg.Winner.UserID != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true} winnerUserID = sg.Winner.UserID
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
} }
_, err := ss.queries.GetSnakeGame(ctx, sg.ID) return queries.UpsertSnakeGame(context.Background(), repository.UpsertSnakeGameParams{
if err == sql.ErrNoRows {
_, err = ss.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID, ID: sg.ID,
Board: boardJSON, Board: boardJSON,
Status: int64(sg.Status), Status: int64(sg.Status),
@@ -33,77 +47,22 @@ func (ss *SnakeStore) saveSnakeGame(sg *SnakeGame) error {
GridHeight: gridHeight, GridHeight: gridHeight,
GameMode: int64(sg.Mode), GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed), SnakeSpeed: int64(sg.Speed),
WinnerUserID: winnerUserID,
RematchGameID: sg.RematchGameID,
Score: int64(sg.Score),
}) })
return err
}
if err != nil {
return err
} }
return ss.queries.UpdateSnakeGame(ctx, updateSnakeGameParams(sg, boardJSON)) func saveSnakePlayer(queries *repository.Queries, gameID string, player *Player) error {
} var userID, guestPlayerID *string
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 { if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true} userID = player.UserID
} else { } else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true} id := string(player.ID)
guestPlayerID = &id
} }
return si.queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{ return queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
GameID: gameID, GameID: gameID,
UserID: userID, UserID: userID,
GuestPlayerID: guestPlayerID, GuestPlayerID: guestPlayerID,
@@ -113,39 +72,34 @@ func (si *SnakeGameInstance) saveSnakePlayer(gameID string, player *Player) erro
}) })
} }
// Shared helpers for domain ↔ DB mapping. func loadSnakeGame(queries *repository.Queries, id string) (*SnakeGame, error) {
row, err := queries.GetSnakeGame(context.Background(), id)
func updateSnakeGameParams(sg *SnakeGame, boardJSON string) repository.UpdateSnakeGameParams { if err != nil {
var winnerUserID sql.NullString return nil, err
if sg.Winner != nil && sg.Winner.UserID != nil { }
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true} return snakeGameFromRow(row)
} }
var rematchGameID sql.NullString func loadSnakePlayers(queries *repository.Queries, id string) ([]*Player, error) {
if sg.RematchGameID != nil { rows, err := queries.GetSnakePlayers(context.Background(), id)
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true} if err != nil {
return nil, err
}
return snakePlayersFromRows(rows), nil
} }
return repository.UpdateSnakeGameParams{ // Domain ↔ DB mapping helpers.
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
RematchGameID: rematchGameID,
Score: int64(sg.Score),
ID: sg.ID,
}
}
func snakeGameFromRow(row repository.Game) (*SnakeGame, error) { func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
state, err := GameStateFromJSON(row.Board) state, err := GameStateFromJSON(row.Board)
if err != nil { if err != nil {
state = &GameState{} state = &GameState{}
} }
if row.GridWidth.Valid { if row.GridWidth != nil {
state.Width = int(row.GridWidth.Int64) state.Width = int(*row.GridWidth)
} }
if row.GridHeight.Valid { if row.GridHeight != nil {
state.Height = int(row.GridHeight.Int64) state.Height = int(*row.GridHeight)
} }
sg := &SnakeGame{ sg := &SnakeGame{
@@ -158,14 +112,14 @@ func snakeGameFromRow(row repository.Game) (*SnakeGame, error) {
Speed: int(row.SnakeSpeed), Speed: int(row.SnakeSpeed),
} }
if row.RematchGameID.Valid { if row.RematchGameID != nil {
sg.RematchGameID = &row.RematchGameID.String sg.RematchGameID = row.RematchGameID
} }
return sg, nil return sg, nil
} }
func snakePlayersFromRows(rows []repository.GamePlayer) []*Player { func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows)) players := make([]*Player, 0, len(rows))
for _, row := range rows { for _, row := range rows {
player := &Player{ player := &Player{
@@ -173,11 +127,11 @@ func snakePlayersFromRows(rows []repository.GamePlayer) []*Player {
Slot: int(row.Slot), Slot: int(row.Slot),
} }
if row.UserID.Valid { if row.UserID != nil {
player.UserID = &row.UserID.String player.UserID = row.UserID
player.ID = PlayerID(row.UserID.String) player.ID = PlayerID(*row.UserID)
} else if row.GuestPlayerID.Valid { } else if row.GuestPlayerID != nil {
player.ID = PlayerID(row.GuestPlayerID.String) player.ID = PlayerID(*row.GuestPlayerID)
} }
players = append(players, player) players = append(players, player)

View File

@@ -2,11 +2,10 @@ package snake
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"sync" "sync"
"github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
) )
type SnakeStore struct { type SnakeStore struct {
@@ -39,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
if speed <= 0 { if speed <= 0 {
speed = DefaultSpeed speed = DefaultSpeed
} }
id := generateID(4) id := game.GenerateID(4)
sg := &SnakeGame{ sg := &SnakeGame{
ID: id, ID: id,
State: &GameState{ State: &GameState{
@@ -63,7 +62,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
ss.gamesMu.Unlock() ss.gamesMu.Unlock()
if ss.queries != nil { if ss.queries != nil {
ss.saveSnakeGame(sg) //nolint:errcheck si.save() //nolint:errcheck
} }
return si return si
@@ -82,12 +81,12 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
return nil, false return nil, false
} }
sg, err := ss.loadSnakeGame(id) sg, err := loadSnakeGame(ss.queries, id)
if err != nil || sg == nil { if err != nil || sg == nil {
return nil, false return nil, false
} }
players, _ := ss.loadSnakePlayers(id) players, _ := loadSnakePlayers(ss.queries, id)
if sg.Players == nil { if sg.Players == nil {
sg.Players = make([]*Player, 8) sg.Players = make([]*Player, 8)
} }
@@ -207,8 +206,8 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
si.game.Players[slot] = player si.game.Players[slot] = player
if si.queries != nil { if si.queries != nil {
si.saveSnakePlayer(si.game.ID, player) //nolint:errcheck si.savePlayer(player) //nolint:errcheck
si.saveSnakeGame(si.game) //nolint:errcheck si.save() //nolint:errcheck
} }
si.notify() si.notify()
@@ -294,16 +293,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
si.game.RematchGameID = &newID si.game.RematchGameID = &newID
if si.queries != nil { if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck si.save() //nolint:errcheck
} }
si.gameMu.Unlock() si.gameMu.Unlock()
si.notify() si.notify()
return newSI return newSI
} }
func generateID(size int) string {
b := make([]byte, size)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}