Files
games/snake/store.go
Ryan Hamamura 2aa026b1d5
Some checks failed
CI / Deploy / test (pull_request) Successful in 8s
CI / Deploy / lint (pull_request) Failing after 46s
CI / Deploy / deploy (pull_request) Has been skipped
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
2026-03-02 12:30:33 -10:00

310 lines
6.3 KiB
Go

package snake
import (
"context"
"crypto/rand"
"encoding/hex"
"sync"
"github.com/ryanhamamura/c4/db/repository"
)
type SnakeStore struct {
games map[string]*SnakeGameInstance
gamesMu sync.RWMutex
queries *repository.Queries
notifyFunc func(gameID string)
}
func NewSnakeStore(queries *repository.Queries) *SnakeStore {
return &SnakeStore{
games: make(map[string]*SnakeGameInstance),
queries: queries,
}
}
func (ss *SnakeStore) SetNotifyFunc(f func(gameID string)) {
ss.notifyFunc = f
}
func (ss *SnakeStore) makeNotify(gameID string) func() {
return func() {
if ss.notifyFunc != nil {
ss.notifyFunc(gameID)
}
}
}
func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *SnakeGameInstance {
if speed <= 0 {
speed = DefaultSpeed
}
id := generateID(4)
sg := &SnakeGame{
ID: id,
State: &GameState{
Width: width,
Height: height,
},
Players: make([]*Player, 8),
Status: StatusWaitingForPlayers,
Mode: mode,
Speed: speed,
}
si := &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
queries: ss.queries,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
if ss.queries != nil {
ss.saveSnakeGame(sg)
}
return si
}
func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
ss.gamesMu.RLock()
si, ok := ss.games[id]
ss.gamesMu.RUnlock()
if ok {
return si, true
}
if ss.queries == nil {
return nil, false
}
sg, err := ss.loadSnakeGame(id)
if err != nil || sg == nil {
return nil, false
}
players, _ := ss.loadSnakePlayers(id)
if sg.Players == nil {
sg.Players = make([]*Player, 8)
}
for _, p := range players {
if p.Slot >= 0 && p.Slot < 8 {
sg.Players[p.Slot] = p
}
}
si = &SnakeGameInstance{
game: sg,
notify: ss.makeNotify(id),
queries: ss.queries,
store: ss,
}
ss.gamesMu.Lock()
ss.games[id] = si
ss.gamesMu.Unlock()
return si, true
}
func (ss *SnakeStore) Delete(id string) error {
ss.gamesMu.Lock()
si, ok := ss.games[id]
delete(ss.games, id)
ss.gamesMu.Unlock()
if ok && si != nil {
si.Stop()
}
if ss.queries != nil {
return ss.queries.DeleteSnakeGame(context.Background(), id)
}
return nil
}
// ActiveGames returns metadata of multiplayer games that can be joined.
// Single player games are excluded. Copies game data to avoid holding nested locks.
func (ss *SnakeStore) ActiveGames() []*SnakeGame {
ss.gamesMu.RLock()
instances := make([]*SnakeGameInstance, 0, len(ss.games))
for _, si := range ss.games {
instances = append(instances, si)
}
ss.gamesMu.RUnlock()
var games []*SnakeGame
for _, si := range instances {
si.gameMu.RLock()
g := si.game
if g.Mode == ModeMultiplayer && (g.Status == StatusWaitingForPlayers || g.Status == StatusCountdown) {
games = append(games, g)
}
si.gameMu.RUnlock()
}
return games
}
type SnakeGameInstance struct {
game *SnakeGame
gameMu sync.RWMutex
pendingDirQueue [8][]Direction // queued directions per slot (max 3)
notify func()
queries *repository.Queries
store *SnakeStore
stopCh chan struct{}
loopOnce sync.Once
}
func (si *SnakeGameInstance) ID() string {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.ID
}
// GetGame returns a snapshot of the game state safe for concurrent read.
func (si *SnakeGameInstance) GetGame() *SnakeGame {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
return si.game.snapshot()
}
func (si *SnakeGameInstance) GetPlayerSlot(pid PlayerID) int {
si.gameMu.RLock()
defer si.gameMu.RUnlock()
for i, p := range si.game.Players {
if p != nil && p.ID == pid {
return i
}
}
return -1
}
func (si *SnakeGameInstance) Join(player *Player) bool {
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.Status == StatusInProgress || si.game.Status == StatusFinished {
return false
}
slot := -1
for i, p := range si.game.Players {
if p == nil {
slot = i
break
}
}
if slot == -1 {
return false
}
player.Slot = slot
si.game.Players[slot] = player
if si.queries != nil {
si.saveSnakePlayer(si.game.ID, player)
si.saveSnakeGame(si.game)
}
si.notify()
// Single player starts countdown immediately when 1 player joins
if si.game.Mode == ModeSinglePlayer && si.game.PlayerCount() >= 1 {
si.startOrResetCountdownLocked()
} else if si.game.Mode == ModeMultiplayer && si.game.PlayerCount() >= 2 {
si.startOrResetCountdownLocked()
}
return true
}
// SetDirection queues a direction change for the given slot.
// Validates against the last queued direction (or current snake dir) to prevent 180° turns.
func (si *SnakeGameInstance) SetDirection(slot int, dir Direction) {
if slot < 0 || slot >= 8 {
return
}
si.gameMu.Lock()
defer si.gameMu.Unlock()
if si.game.State == nil || slot >= len(si.game.State.Snakes) {
return
}
s := si.game.State.Snakes[slot]
if s == nil || !s.Alive {
return
}
// Validate against last queued direction, or current snake direction if queue empty
refDir := s.Dir
if len(si.pendingDirQueue[slot]) > 0 {
refDir = si.pendingDirQueue[slot][len(si.pendingDirQueue[slot])-1]
}
if !ValidateDirection(refDir, dir) {
return
}
// Cap queue at 3 to prevent unbounded growth
if len(si.pendingDirQueue[slot]) >= 3 {
return
}
si.pendingDirQueue[slot] = append(si.pendingDirQueue[slot], dir)
}
func (si *SnakeGameInstance) Stop() {
if si.stopCh != nil {
select {
case <-si.stopCh:
default:
close(si.stopCh)
}
}
}
func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
si.gameMu.Lock()
if !si.game.IsFinished() || si.game.RematchGameID != nil {
si.gameMu.Unlock()
return nil
}
// Capture state needed, then release lock before calling store.Create
// (which acquires gamesMu) to avoid lock ordering deadlock.
width := si.game.State.Width
height := si.game.State.Height
mode := si.game.Mode
speed := si.game.Speed
si.gameMu.Unlock()
newSI := si.store.Create(width, height, mode, speed)
newID := newSI.ID()
si.gameMu.Lock()
// Re-check after reacquiring lock
if si.game.RematchGameID != nil {
si.gameMu.Unlock()
return newSI
}
si.game.RematchGameID = &newID
if si.queries != nil {
si.saveSnakeGame(si.game)
}
si.gameMu.Unlock()
si.notify()
return newSI
}
func generateID(size int) string {
b := make([]byte, size)
rand.Read(b)
return hex.EncodeToString(b)
}