Wrap free persistence functions in instance methods for cleaner call sites (gi.save() instead of saveGame(gi.queries, gi.game)). Methods log errors via zerolog before returning them.
303 lines
6.2 KiB
Go
303 lines
6.2 KiB
Go
package snake
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/ryanhamamura/c4/db/repository"
|
|
"github.com/ryanhamamura/c4/game"
|
|
)
|
|
|
|
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 := game.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 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.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
|
|
}
|