feat(lobby): enter the game immediately and wait for the opponent inside it
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
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.
This commit is contained in:
@@ -2,8 +2,7 @@ package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -14,182 +13,91 @@ import (
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// matchKey buckets the auto-match pool: two players are paired only when they chose
|
||||
// the same variant and the same per-turn word rule (multipleWords), so a game always
|
||||
// starts under a rule both players asked for.
|
||||
type matchKey struct {
|
||||
variant engine.Variant
|
||||
multipleWords bool
|
||||
// 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 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.
|
||||
// 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 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.
|
||||
// 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 GameCreator
|
||||
robots RobotProvider
|
||||
waitDelay time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
log *zap.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
queues map[matchKey][]uuid.UUID
|
||||
queued map[uuid.UUID]matchKey
|
||||
waitingSince map[uuid.UUID]time.Time
|
||||
results map[uuid.UUID]game.Game
|
||||
rng *rand.Rand
|
||||
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 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 {
|
||||
// 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,
|
||||
waitDelay: waitDelay,
|
||||
clock: func() time.Time { return time.Now().UTC() },
|
||||
pub: notify.Nop{},
|
||||
log: log,
|
||||
queues: make(map[matchKey][]uuid.UUID),
|
||||
queued: make(map[uuid.UUID]matchKey),
|
||||
waitingSince: make(map[uuid.UUID]time.Time),
|
||||
results: make(map[uuid.UUID]game.Game),
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
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 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).
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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(ctx context.Context, g game.Game) {
|
||||
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
|
||||
intents := make([]notify.Intent, 0, len(g.Seats))
|
||||
for _, s := range g.Seats {
|
||||
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
|
||||
if err != nil {
|
||||
// A waiter still discovers the game through Poll (the ws-down fallback), so skip the
|
||||
// enriched push for this seat rather than failing the match.
|
||||
m.log.Warn("match_found initial state",
|
||||
zap.String("game", g.ID.String()), zap.String("account", s.AccountID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
mf := notify.MatchFound(s.AccountID, g.ID, state)
|
||||
mf.Language = lang
|
||||
intents = append(intents, mf)
|
||||
}
|
||||
m.pub.Publish(intents...)
|
||||
}
|
||||
|
||||
// EnqueueResult reports the outcome of joining the pool: either a started game or a
|
||||
// queued ticket awaiting an opponent.
|
||||
// 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 joins accountID to the auto-match pool for variant under the chosen
|
||||
// per-turn word rule (multipleWords). If an opponent already waits for the same
|
||||
// variant and rule, 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.
|
||||
// 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) {
|
||||
key := matchKey{variant: variant, multipleWords: multipleWords}
|
||||
m.mu.Lock()
|
||||
if _, ok := m.queued[accountID]; ok {
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, ErrAlreadyQueued
|
||||
}
|
||||
q := m.queues[key]
|
||||
if len(q) == 0 {
|
||||
m.queues[key] = append(q, accountID)
|
||||
m.queued[accountID] = key
|
||||
m.waitingSince[accountID] = m.clock()
|
||||
m.mu.Unlock()
|
||||
return EnqueueResult{}, nil
|
||||
}
|
||||
opponent := q[0]
|
||||
m.removeLocked(opponent, key)
|
||||
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(key, seats))
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
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(ctx, 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
|
||||
if joined {
|
||||
m.announceOpponent(ctx, g, accountID)
|
||||
}
|
||||
return EnqueueResult{}, nil
|
||||
return EnqueueResult{Matched: joined, Game: g}, 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)
|
||||
key, ok := m.queued[accountID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
m.removeLocked(accountID, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// QueueLen returns the number of accounts waiting in the variant pool, summed across
|
||||
// both per-turn word rules.
|
||||
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.queues[matchKey{variant: variant, multipleWords: false}]) +
|
||||
len(m.queues[matchKey{variant: variant, multipleWords: true}])
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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()
|
||||
@@ -203,77 +111,83 @@ func (m *Matchmaker) RunReaper(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
type sub struct {
|
||||
human uuid.UUID
|
||||
key matchKey
|
||||
seats []uuid.UUID
|
||||
due, err := m.games.ExpiredOpen(ctx, now)
|
||||
if err != nil {
|
||||
m.log.Warn("scan open games", zap.Error(err))
|
||||
return
|
||||
}
|
||||
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 {
|
||||
key := m.queued[acc]
|
||||
robotID, err := m.robots.Pick(key.variant)
|
||||
for _, og := range due {
|
||||
robotID, err := m.robots.Pick(og.Variant)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution deferred", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
m.removeLocked(acc, key)
|
||||
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, key: key, seats: seats})
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, s := range subs {
|
||||
g, err := m.games.Create(ctx, autoMatchParams(s.key, s.seats))
|
||||
g, attached, err := m.games.AttachRobot(ctx, og.ID, robotID)
|
||||
if err != nil {
|
||||
m.log.Warn("robot substitution failed", zap.String("human", s.human.String()), zap.Error(err))
|
||||
m.log.Warn("robot substitution failed", zap.String("game", og.ID.String()), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.results[s.human] = g
|
||||
m.mu.Unlock()
|
||||
m.emitMatchFound(ctx, g)
|
||||
if !attached {
|
||||
continue // a human joined first between the scan and the substitution
|
||||
}
|
||||
m.announceOpponent(ctx, g, robotID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, key matchKey) {
|
||||
delete(m.queued, accountID)
|
||||
delete(m.waitingSince, accountID)
|
||||
q := m.queues[key]
|
||||
for i, id := range q {
|
||||
if id == accountID {
|
||||
m.queues[key] = append(q[:i], q[i+1:]...)
|
||||
break
|
||||
// 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.
|
||||
func autoMatchParams(key matchKey, seats []uuid.UUID) game.CreateParams {
|
||||
// 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: key.variant,
|
||||
Seats: seats,
|
||||
Variant: variant,
|
||||
TurnTimeout: game.DefaultTurnTimeout,
|
||||
HintsAllowed: autoMatchHintsAllowed,
|
||||
HintsPerPlayer: autoMatchHintsPerPlayer,
|
||||
MultipleWordsPerTurn: key.multipleWords,
|
||||
MultipleWordsPerTurn: multipleWords,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user