package lobby import ( "context" "math/rand" "sync" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/notify" ) // Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs // the next two humans into a two-player game, or — when no human arrives within // the wait window — substitutes a robot. 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). // // A player who is queued learns of a match — by a waiting human being paired, or // by robot substitution — through Poll, the interim delivery seam: production // delivery is a notification (session/in-app push and the platform side-service, // docs/ARCHITECTURE.md §10), wired with the gateway in a later stage. type Matchmaker struct { games GameCreator robots RobotProvider waitDelay time.Duration clock func() time.Time pub notify.Publisher log *zap.Logger mu sync.Mutex queues map[engine.Variant][]uuid.UUID queued map[uuid.UUID]engine.Variant waitingSince map[uuid.UUID]time.Time results map[uuid.UUID]game.Game rng *rand.Rand } // NewMatchmaker constructs a Matchmaker that starts matched games through games // and substitutes a robot from robots when a player waits longer than waitDelay. func NewMatchmaker(games GameCreator, robots RobotProvider, waitDelay time.Duration, log *zap.Logger) *Matchmaker { if log == nil { log = zap.NewNop() } return &Matchmaker{ games: games, robots: robots, waitDelay: waitDelay, clock: func() time.Time { return time.Now().UTC() }, pub: notify.Nop{}, log: log, queues: make(map[engine.Variant][]uuid.UUID), queued: make(map[uuid.UUID]engine.Variant), waitingSince: make(map[uuid.UUID]time.Time), results: make(map[uuid.UUID]game.Game), rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } // SetNotifier installs the live-event publisher used to push match_found to the // seated players when a pairing or robot substitution starts a game. It must be // called during startup wiring, before the reaper runs; the default is // notify.Nop (no live events; waiters still discover the game via Poll). func (m *Matchmaker) SetNotifier(p notify.Publisher) { if p != nil { m.pub = p } } // emitMatchFound pushes match_found to every seat of a freshly started game. // Emitting to a robot seat is harmless (no client subscription exists for it). func (m *Matchmaker) emitMatchFound(g game.Game) { intents := make([]notify.Intent, 0, len(g.Seats)) for _, s := range g.Seats { intents = append(intents, notify.MatchFound(s.AccountID, g.ID)) } m.pub.Publish(intents...) } // 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, and a later pairing or robot // substitution is delivered through Poll. 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.waitingSince[accountID] = m.clock() m.mu.Unlock() return EnqueueResult{}, nil } opponent := q[0] m.removeLocked(opponent, variant) 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, autoMatchParams(variant, seats)) if err != nil { return EnqueueResult{}, err } // The opponent was waiting; record the game so they can collect it via Poll. m.mu.Lock() m.results[opponent] = g m.mu.Unlock() m.emitMatchFound(g) return EnqueueResult{Matched: true, Game: g}, nil } // Poll reports whether accountID has been matched since it queued, returning the // started game once (the result is drained on read). It reports Matched=false // while the account is still waiting or has no pending result. func (m *Matchmaker) Poll(_ context.Context, accountID uuid.UUID) (EnqueueResult, error) { m.mu.Lock() defer m.mu.Unlock() if g, ok := m.results[accountID]; ok { delete(m.results, accountID) return EnqueueResult{Matched: true, Game: g}, nil } return EnqueueResult{}, nil } // Cancel removes accountID from whatever pool it waits in and drops any pending // matched result, reporting whether it was queued. Clearing the result closes the // race where the reaper substituted a robot just before the player cancelled: the // stale game must not later surface through Poll as a game the player did not want. func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool { m.mu.Lock() defer m.mu.Unlock() delete(m.results, accountID) variant, ok := m.queued[accountID] if !ok { return false } m.removeLocked(accountID, variant) 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]) } // RunReaper substitutes a robot for any player that has waited past waitDelay, // scanning every interval until ctx is cancelled. It is started once from main. func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: m.Reap(ctx, m.clock()) } } } // Reap pairs every player that has waited past waitDelay with a freshly picked // robot and starts the game, recording it for the player's Poll. RunReaper calls // it on a timer; it takes now explicitly so tests and ops can drive a single pass // at a chosen instant. A waiter is only dequeued once a robot is secured, so a // momentarily empty pool just defers substitution to a later tick. func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { type sub struct { human uuid.UUID variant engine.Variant seats []uuid.UUID } m.mu.Lock() var due []uuid.UUID for acc, since := range m.waitingSince { if now.Sub(since) >= m.waitDelay { due = append(due, acc) } } var subs []sub for _, acc := range due { variant := m.queued[acc] robotID, err := m.robots.Pick(variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } m.removeLocked(acc, variant) seats := []uuid.UUID{acc, robotID} if m.rng.Intn(2) == 0 { seats[0], seats[1] = seats[1], seats[0] } subs = append(subs, sub{human: acc, variant: variant, seats: seats}) } m.mu.Unlock() for _, s := range subs { g, err := m.games.Create(ctx, autoMatchParams(s.variant, s.seats)) if err != nil { m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err)) continue } m.mu.Lock() m.results[s.human] = g m.mu.Unlock() m.emitMatchFound(g) } } // removeLocked drops accountID from the queue, the queued index and the waiting // clock. The caller holds m.mu. func (m *Matchmaker) removeLocked(accountID uuid.UUID, variant engine.Variant) { delete(m.queued, accountID) delete(m.waitingSince, accountID) q := m.queues[variant] for i, id := range q { if id == accountID { m.queues[variant] = append(q[:i], q[i+1:]...) break } } } // autoMatchParams builds the create parameters for a two-player auto-match with // the casual defaults. func autoMatchParams(variant engine.Variant, seats []uuid.UUID) game.CreateParams { return game.CreateParams{ Variant: variant, Seats: seats, TurnTimeout: game.DefaultTurnTimeout, HintsAllowed: autoMatchHintsAllowed, HintsPerPlayer: autoMatchHintsPerPlayer, } }