package game import ( "sync" "time" "github.com/google/uuid" "scrabble/backend/internal/engine" ) // keyedMutex hands out one mutex per game id, serialising every operation on a // single game (engine.Game is not safe for concurrent use) while letting // different games proceed in parallel. Locks are reference-counted and removed // once no caller holds or awaits them. type keyedMutex struct { mu sync.Mutex locks map[uuid.UUID]*lockRef } type lockRef struct { mu sync.Mutex refs int } func newKeyedMutex() *keyedMutex { return &keyedMutex{locks: make(map[uuid.UUID]*lockRef)} } // lock acquires the mutex for id and returns its release function. func (k *keyedMutex) lock(id uuid.UUID) func() { k.mu.Lock() ref := k.locks[id] if ref == nil { ref = &lockRef{} k.locks[id] = ref } ref.refs++ k.mu.Unlock() ref.mu.Lock() return func() { ref.mu.Unlock() k.mu.Lock() ref.refs-- if ref.refs == 0 { delete(k.locks, id) } k.mu.Unlock() } } // gameCache holds live engine.Game values keyed by game id and evicts an entry // once it has been idle for ttl. An evicted game is transparently rebuilt from // the journal on next access, so eviction never affects correctness. It is safe // for concurrent use. type gameCache struct { mu sync.Mutex entries map[uuid.UUID]*cachedGame ttl time.Duration now func() time.Time } type cachedGame struct { game *engine.Game lastAccess time.Time } func newGameCache(ttl time.Duration, now func() time.Time) *gameCache { return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now} } // get returns the live game for id and refreshes its idle timer, or (nil, false). func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) { c.mu.Lock() defer c.mu.Unlock() e, ok := c.entries[id] if !ok { return nil, false } e.lastAccess = c.now() return e.game, true } // put stores g as the live game for id. func (c *gameCache) put(id uuid.UUID, g *engine.Game) { c.mu.Lock() defer c.mu.Unlock() c.entries[id] = &cachedGame{game: g, lastAccess: c.now()} } // remove drops id from the cache (used on a finished game and after a failed // persist, so the next access rebuilds from the journal). func (c *gameCache) remove(id uuid.UUID) { c.mu.Lock() defer c.mu.Unlock() delete(c.entries, id) } // sweep evicts every entry idle longer than ttl and returns how many were // dropped. func (c *gameCache) sweep() int { c.mu.Lock() defer c.mu.Unlock() cutoff := c.now().Add(-c.ttl) var n int for id, e := range c.entries { if e.lastAccess.Before(cutoff) { delete(c.entries, id) n++ } } return n } // size reports the number of resident games (for diagnostics and tests). func (c *gameCache) size() int { c.mu.Lock() defer c.mu.Unlock() return len(c.entries) }