Files
scrabble-game/backend/internal/lobby/matchmaker.go
T
Ilia Denisov c305363ccd
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
feat(lobby): enter the game immediately and wait for the opponent inside it
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
2026-06-12 16:00:22 +02:00

194 lines
7.0 KiB
Go

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,
}
}