Files
games/snake/store.go
Ryan Hamamura 063b03ce25 refactor: extract shared player.ID type and GenerateID to player package
Both game and snake packages had identical PlayerID types and the snake
package imported game.GenerateID. Now both use player.ID and
player.GenerateID from the shared player package.
2026-03-02 19:09:01 -10:00

303 lines
6.2 KiB
Go

package snake
import (
"context"
"sync"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/player"
)
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 := player.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 {
si.save() //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 := loadSnakeGame(ss.queries, id)
if err != nil || sg == nil {
return nil, false
}
players, _ := loadSnakePlayers(ss.queries, 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 player.ID) 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.savePlayer(player) //nolint:errcheck
si.save() //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.save() //nolint:errcheck
}
si.gameMu.Unlock()
si.notify()
return newSI
}