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
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.
194 lines
7.0 KiB
Go
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,
|
|
}
|
|
}
|