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) //nolint:errcheck } 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) //nolint:errcheck si.saveSnakeGame(si.game) //nolint:errcheck } 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) //nolint:errcheck } si.gameMu.Unlock() si.notify() return newSI } func generateID(size int) string { b := make([]byte, size) _, _ = rand.Read(b) return hex.EncodeToString(b) }