Rename module path github.com/ryanhamamura/c4 to github.com/ryanhamamura/games across go.mod, all source files, and golangci config.
303 lines
6.2 KiB
Go
303 lines
6.2 KiB
Go
package snake
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
|
|
"github.com/ryanhamamura/games/db/repository"
|
|
"github.com/ryanhamamura/games/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
|
|
}
|