package lobby import ( "context" "math/rand" "sync" "time" "github.com/google/uuid" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" ) // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs // the next two humans into a two-player game. It holds no database state and is // lost on restart (players simply re-queue). It is safe for concurrent use. // // Auto-match is anonymous, so the pool does not consult per-user blocks (those // govern friends, chat and invitations between known players). Robot substitution // for a missing human is added in a later stage. type Matchmaker struct { games GameCreator mu sync.Mutex queues map[engine.Variant][]uuid.UUID queued map[uuid.UUID]engine.Variant rng *rand.Rand } // NewMatchmaker constructs a Matchmaker that starts matched games through games. func NewMatchmaker(games GameCreator) *Matchmaker { return &Matchmaker{ games: games, queues: make(map[engine.Variant][]uuid.UUID), queued: make(map[uuid.UUID]engine.Variant), rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } // EnqueueResult reports the outcome of joining the pool: either a started game or a // queued ticket awaiting an opponent. type EnqueueResult struct { Matched bool Game game.Game } // Enqueue joins accountID to the variant pool. If an opponent already waits, the // two are paired (seat order randomised for first-move fairness) and a game starts // immediately; otherwise the account waits. An account already waiting in any pool // gets ErrAlreadyQueued. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) { m.mu.Lock() if _, ok := m.queued[accountID]; ok { m.mu.Unlock() return EnqueueResult{}, ErrAlreadyQueued } q := m.queues[variant] if len(q) == 0 { m.queues[variant] = append(q, accountID) m.queued[accountID] = variant m.mu.Unlock() return EnqueueResult{}, nil } opponent := q[0] m.queues[variant] = q[1:] delete(m.queued, opponent) seats := []uuid.UUID{opponent, accountID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } m.mu.Unlock() g, err := m.games.Create(ctx, game.CreateParams{ Variant: variant, Seats: seats, TurnTimeout: game.DefaultTurnTimeout, HintsAllowed: autoMatchHintsAllowed, HintsPerPlayer: autoMatchHintsPerPlayer, }) if err != nil { return EnqueueResult{}, err } return EnqueueResult{Matched: true, Game: g}, nil } // Cancel removes accountID from whatever pool it waits in, reporting whether it // was queued. func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { m.mu.Lock() defer m.mu.Unlock() variant, ok := m.queued[accountID] if !ok { return false } delete(m.queued, accountID) q := m.queues[variant] for i, id := range q { if id == accountID { m.queues[variant] = append(q[:i], q[i+1:]...) break } } return true } // QueueLen returns the number of accounts waiting in the variant pool. func (m *Matchmaker) QueueLen(variant engine.Variant) int { m.mu.Lock() defer m.mu.Unlock() return len(m.queues[variant]) }