package lobby import ( "context" "math/rand/v2" "time" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/notify" ) // GameMatcher is the slice of the game domain the matchmaker drives: opening or // joining an auto-match game, substituting a robot into one whose wait elapsed, and // reading a player's view to enrich the opponent_joined event. game.Service satisfies // it. type GameMatcher interface { OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (game.Game, bool, error) ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error) InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error) } // Matchmaker turns an auto-match enqueue into a real game the player enters at once: // it opens a game with an empty opponent seat, or joins the caller into another // player's waiting one. A background reaper substitutes a pooled robot for any open // game whose wait window has elapsed, guaranteeing every game gets an opponent. All // matchmaking state is the open games in the database, so it survives a restart; the // Matchmaker holds only the wait policy and the live-event publisher, and is safe for // concurrent use. // // Auto-match is anonymous, so it does not consult per-user blocks (those govern // friends, chat and invitations between known players). type Matchmaker struct { games GameMatcher robots RobotProvider minWait time.Duration jitter time.Duration clock func() time.Time pub notify.Publisher log *zap.Logger } // NewMatchmaker constructs a Matchmaker that opens auto-match games through games and, // after a per-game wait of minWait plus a random jitter in [0, jitter), substitutes a // pooled robot from robots when no human has joined. func NewMatchmaker(games GameMatcher, robots RobotProvider, minWait, jitter time.Duration, log *zap.Logger) *Matchmaker { if log == nil { log = zap.NewNop() } return &Matchmaker{ games: games, robots: robots, minWait: minWait, jitter: jitter, clock: func() time.Time { return time.Now().UTC() }, pub: notify.Nop{}, log: log, } } // SetNotifier installs the live-event publisher used to push opponent_joined to a // waiting starter when a human or a robot takes the empty seat. It must be called // during startup wiring, before the reaper runs; the default is notify.Nop (no live // events). func (m *Matchmaker) SetNotifier(p notify.Publisher) { if p != nil { m.pub = p } } // EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays // in, and whether it already had an opponent (they joined a waiting game) rather than // being freshly opened and still awaiting one. type EnqueueResult struct { Matched bool Game game.Game } // Enqueue resolves an auto-match request for accountID under variant and the per-turn // word rule (multipleWords) into the game they enter immediately — a freshly opened // game awaiting an opponent, the caller's own still-open game (a re-enqueue is // idempotent), or another player's open game they just joined. When the caller joins // an existing game, opponent_joined is pushed to that game's waiting starter. func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) { g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline()) if err != nil { return EnqueueResult{}, err } if joined { m.announceOpponent(ctx, g, accountID) } return EnqueueResult{Matched: joined, Game: g}, nil } // RunReaper substitutes a robot for any open game past its wait window, 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 substitutes a robot into every open game whose wait window elapsed by now and // pushes opponent_joined to its starter. RunReaper calls it on a timer; it takes now // explicitly so tests and ops can drive a single pass at a chosen instant. A game for // which no robot is available is left for a later tick. func (m *Matchmaker) Reap(ctx context.Context, now time.Time) { due, err := m.games.ExpiredOpen(ctx, now) if err != nil { m.log.Warn("scan open games", zap.Error(err)) return } for _, og := range due { robotID, err := m.robots.Pick(og.Variant) if err != nil { m.log.Warn("robot substitution deferred", zap.Error(err)) continue } g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID) if err != nil { m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err)) continue } if !attached { continue // a human joined first between the scan and the substitution } m.announceOpponent(ctx, g, robotID) } } // announceOpponent pushes opponent_joined to the game's waiting starter — the seat // that is not joinerID — so its client fills the opponent card and re-enables resign // and chat in place. Routed by the game's language, like every game push. func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID uuid.UUID) { starter, ok := otherSeat(g, joinerID) if !ok { return } state, err := m.games.InitialState(ctx, g.ID, starter) if err != nil { m.log.Warn("opponent_joined initial state", zap.String("game", g.ID.String()), zap.String("account", starter.String()), zap.Error(err)) return } intent := notify.OpponentJoined(starter, g.ID, state) intent.Language = g.Variant.Language() m.pub.Publish(intent) } // openDeadline is when the reaper substitutes a robot for a game opened now: a fixed // minimum wait plus a random jitter, so the substitution time varies per game. func (m *Matchmaker) openDeadline() time.Time { d := m.minWait if m.jitter > 0 { d += rand.N(m.jitter) } return m.clock().Add(d) } // otherSeat returns the account at the seat that is not accountID — the open game's // starter when accountID is the joiner — and false when no seat differs or it is still // empty. func otherSeat(g game.Game, accountID uuid.UUID) (uuid.UUID, bool) { for _, s := range g.Seats { if s.AccountID != accountID && s.AccountID != uuid.Nil { return s.AccountID, true } } return uuid.Nil, false } // autoMatchParams builds the create parameters for a two-player auto-match with the // casual defaults; the game service assembles the seats and pins the bag seed. func autoMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams { return game.CreateParams{ Variant: variant, TurnTimeout: game.DefaultTurnTimeout, HintsAllowed: autoMatchHintsAllowed, HintsPerPlayer: autoMatchHintsPerPlayer, MultipleWordsPerTurn: multipleWords, } }