9d52885a6e
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
A second "random opponent" enqueue with the same variant and per-turn rule, while the caller's first game was still open (awaiting an opponent), returned that same open game, so a player could never start a fresh random game while one was still searching. Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a re-enqueue now joins another player's open game or opens a fresh one. Accumulation stays bounded by MaxActiveQuickGames, which counts open games. Update the matchmaker/service/store doc comments and ARCHITECTURE.md, and flip the pinning test to assert the new behavior.
235 lines
8.9 KiB
Go
235 lines
8.9 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, displayName string) (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)
|
|
// Create seats the given accounts in an active game at once; the AI-match path uses
|
|
// it to start a game already seated with a robot (no open/wait phase).
|
|
Create(ctx context.Context, params game.CreateParams) (game.Game, 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, or another player's open game they just joined. A
|
|
// re-enqueue while already waiting opens another game rather than returning the
|
|
// caller's own. 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
|
|
}
|
|
|
|
// StartVsAI creates an honest-AI game for accountID against a pooled robot, returning
|
|
// the game the player enters at once. Unlike Enqueue it never opens or waits: it picks
|
|
// a robot for the variant, seats the human and the robot in a random order (seat 0
|
|
// moves first), and creates an active game flagged vs_ai. The flag makes the robot
|
|
// reply immediately and suppresses chat, nudge and statistics; the robot's first move
|
|
// (when it is seated first) follows on the game service's fast-move trigger.
|
|
func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
|
robotID, err := m.robots.Pick(variant)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
params := aiMatchParams(variant, multipleWords)
|
|
params.Seats = []uuid.UUID{accountID, robotID}
|
|
if rand.IntN(2) == 1 {
|
|
params.Seats = []uuid.UUID{robotID, accountID}
|
|
}
|
|
g, err := m.games.Create(ctx, params)
|
|
if err != nil {
|
|
return EnqueueResult{}, err
|
|
}
|
|
return EnqueueResult{Matched: true, 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, name, err := m.robots.PickNamed(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, name)
|
|
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,
|
|
}
|
|
}
|
|
|
|
// aiMatchParams builds the create parameters for an honest-AI two-player game: the
|
|
// same casual auto-match defaults plus the vs_ai flag. The 7-day inactivity clock is
|
|
// applied by the game service for vs_ai games, so TurnTimeout is left zero; the caller
|
|
// fills Seats with the human and the picked robot.
|
|
func aiMatchParams(variant engine.Variant, multipleWords bool) game.CreateParams {
|
|
return game.CreateParams{
|
|
Variant: variant,
|
|
HintsAllowed: autoMatchHintsAllowed,
|
|
HintsPerPlayer: autoMatchHintsPerPlayer,
|
|
MultipleWordsPerTurn: multipleWords,
|
|
VsAI: true,
|
|
}
|
|
}
|