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:
@@ -5,22 +5,30 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config configures the matchmaking pool's robot substitution.
|
||||
// Config configures auto-match robot substitution: how long an open game waits for a
|
||||
// human opponent before a robot is substituted, and how often the reaper scans.
|
||||
type Config struct {
|
||||
// RobotWait is how long an auto-match player waits for a human before a robot
|
||||
// is substituted. Sourced from BACKEND_LOBBY_ROBOT_WAIT.
|
||||
// RobotWait is the fixed minimum an open auto-match game waits for a human
|
||||
// opponent before it is eligible for robot substitution. Sourced from
|
||||
// BACKEND_LOBBY_ROBOT_WAIT.
|
||||
RobotWait time.Duration
|
||||
// ReaperInterval is how often the substitution reaper scans for over-waited
|
||||
// players. Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
|
||||
// RobotWaitJitter is a random extra wait in [0, RobotWaitJitter) added on top of
|
||||
// RobotWait per game, so the substitution time varies. Sourced from
|
||||
// BACKEND_LOBBY_ROBOT_WAIT_JITTER.
|
||||
RobotWaitJitter time.Duration
|
||||
// ReaperInterval is how often the reaper scans for open games due for a robot.
|
||||
// Sourced from BACKEND_LOBBY_REAPER_INTERVAL.
|
||||
ReaperInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns the matchmaking defaults: a 10-second wait
|
||||
// (docs/ARCHITECTURE.md §7) scanned every second.
|
||||
// DefaultConfig returns the matchmaking defaults: a guaranteed 90-second wait for a
|
||||
// human plus up to 90 random seconds (90–180 s total) before a robot substitutes
|
||||
// (docs/ARCHITECTURE.md §7), scanned every five seconds.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
RobotWait: 10 * time.Second,
|
||||
ReaperInterval: time.Second,
|
||||
RobotWait: 90 * time.Second,
|
||||
RobotWaitJitter: 90 * time.Second,
|
||||
ReaperInterval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +37,9 @@ func (c Config) Validate() error {
|
||||
if c.RobotWait <= 0 {
|
||||
return fmt.Errorf("lobby: robot wait must be positive, got %s", c.RobotWait)
|
||||
}
|
||||
if c.RobotWaitJitter < 0 {
|
||||
return fmt.Errorf("lobby: robot wait jitter must not be negative, got %s", c.RobotWaitJitter)
|
||||
}
|
||||
if c.ReaperInterval <= 0 {
|
||||
return fmt.Errorf("lobby: reaper interval must be positive, got %s", c.ReaperInterval)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans
|
||||
// for an auto-match, and friend-game invitations (invite -> accept) that start a
|
||||
// 2-4 player game once every invitee has accepted. Both produce a game through the
|
||||
// game domain (a GameCreator); neither imports the engine. The matchmaking pool
|
||||
// is in-memory and lost on restart (players re-queue); the robot that substitutes
|
||||
// for a missing human after a short wait is added in a later stage.
|
||||
// Package lobby forms games: an auto-match maker that drops a player straight into a
|
||||
// game with an empty opponent seat (or joins them into another player's waiting one),
|
||||
// and friend-game invitations (invite -> accept) that start a 2-4 player game once
|
||||
// every invitee has accepted. Both produce games through the game domain; neither
|
||||
// imports the engine. Auto-match state is the open games in the database, so it
|
||||
// survives a restart; a background reaper substitutes a pooled robot for any open game
|
||||
// that waits too long, guaranteeing every game gets an opponent.
|
||||
package lobby
|
||||
|
||||
import (
|
||||
@@ -22,8 +23,8 @@ import (
|
||||
type GameCreator interface {
|
||||
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
|
||||
// InitialState returns a seated player's full initial view of a started game, used
|
||||
// to enrich the match_found / game_started events so the client renders the new game
|
||||
// without a follow-up fetch.
|
||||
// to enrich the game_started event so the client renders the new game without a
|
||||
// follow-up fetch.
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
}
|
||||
|
||||
@@ -51,8 +52,6 @@ const (
|
||||
|
||||
// Sentinel errors returned by the lobby.
|
||||
var (
|
||||
// ErrAlreadyQueued is returned when an account already waits in a pool.
|
||||
ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool")
|
||||
// ErrInvalidInvitation is returned for a malformed invitation (bad player
|
||||
// count, duplicate or self invitee, or unacceptable settings).
|
||||
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,51 @@ import (
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
// fakeCreator records the games a matchmaker asks it to start.
|
||||
type fakeCreator struct {
|
||||
created []game.CreateParams
|
||||
err error
|
||||
// stubMatcher is a fake GameMatcher: it returns canned games and records the calls the
|
||||
// matchmaker makes, so the unit tests cover delegation, the opponent_joined emit and
|
||||
// the wait-window math without a database. The DB-backed open/join/substitute logic is
|
||||
// covered by the integration suite.
|
||||
type stubMatcher struct {
|
||||
openGame game.Game
|
||||
openJoined bool
|
||||
openErr error
|
||||
openCalls int
|
||||
lastDeadline time.Time
|
||||
|
||||
expired []game.OpenGame
|
||||
|
||||
attachGame game.Game
|
||||
attached bool
|
||||
attachErr error
|
||||
attachedGames []uuid.UUID
|
||||
}
|
||||
|
||||
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
|
||||
if f.err != nil {
|
||||
return game.Game{}, f.err
|
||||
func (s *stubMatcher) OpenOrJoin(_ context.Context, _ uuid.UUID, _ game.CreateParams, deadline time.Time) (game.Game, bool, error) {
|
||||
s.openCalls++
|
||||
s.lastDeadline = deadline
|
||||
return s.openGame, s.openJoined, s.openErr
|
||||
}
|
||||
|
||||
func (s *stubMatcher) AttachRobot(_ context.Context, gameID, _ uuid.UUID) (game.Game, bool, error) {
|
||||
if s.attachErr != nil {
|
||||
return game.Game{}, false, s.attachErr
|
||||
}
|
||||
f.created = append(f.created, p)
|
||||
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
|
||||
if s.attached {
|
||||
s.attachedGames = append(s.attachedGames, gameID)
|
||||
}
|
||||
return s.attachGame, s.attached, nil
|
||||
}
|
||||
|
||||
// InitialState satisfies GameCreator; the matchmaker reads it to enrich match_found. The pairing
|
||||
// tests assert on matching behaviour, not the payload, so an empty state is enough.
|
||||
func (f *fakeCreator) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
func (s *stubMatcher) ExpiredOpen(_ context.Context, _ time.Time) ([]game.OpenGame, error) {
|
||||
return s.expired, nil
|
||||
}
|
||||
|
||||
func (s *stubMatcher) InitialState(_ context.Context, _, _ uuid.UUID) (notify.PlayerState, error) {
|
||||
return notify.PlayerState{}, nil
|
||||
}
|
||||
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model
|
||||
// an empty pool. It records the variant of the last substitution request.
|
||||
// fakeRobots is a RobotProvider returning a fixed robot id, or an error to model an
|
||||
// empty pool. It records the variant of the last substitution request.
|
||||
type fakeRobots struct {
|
||||
id uuid.UUID
|
||||
err error
|
||||
@@ -50,294 +73,137 @@ func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
|
||||
return f.id, nil
|
||||
}
|
||||
|
||||
// testWaitDelay is long enough that the reaper never fires in the pairing tests
|
||||
// (which do not run it); the substitution tests drive reap directly.
|
||||
const testWaitDelay = 10 * time.Second
|
||||
// capturePub records every published intent.
|
||||
type capturePub struct{ intents []notify.Intent }
|
||||
|
||||
func newTestMatchmaker(creator GameCreator, robotID uuid.UUID) *Matchmaker {
|
||||
return NewMatchmaker(creator, &fakeRobots{id: robotID}, testWaitDelay, zap.NewNop())
|
||||
}
|
||||
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
|
||||
|
||||
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
|
||||
for _, w := range want {
|
||||
found := false
|
||||
for _, s := range seats {
|
||||
if s == w {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
// twoSeatGame is a two-player game seating starter at seat 0 and opponent at seat 1
|
||||
// (uuid.Nil for a still-empty opponent seat).
|
||||
func twoSeatGame(starter, opponent uuid.UUID) game.Game {
|
||||
return game.Game{
|
||||
ID: uuid.New(),
|
||||
Variant: engine.VariantEnglish,
|
||||
Seats: []game.Seat{
|
||||
{Seat: 0, AccountID: starter},
|
||||
{Seat: 1, AccountID: opponent},
|
||||
},
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMatchmakerPairsTwoHumans(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b := uuid.New(), uuid.New()
|
||||
func TestEnqueueOpensGameWithoutOpponent(t *testing.T) {
|
||||
starter := uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(starter, uuid.Nil)}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait, not match")
|
||||
if res.Matched {
|
||||
t.Error("opening a game must report Matched=false")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
|
||||
if m.openCalls != 1 {
|
||||
t.Errorf("OpenOrJoin calls = %d, want 1", m.openCalls)
|
||||
}
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("opening a game must not emit opponent_joined; got %d intents", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
func TestEnqueueJoinEmitsOpponentJoinedToStarter(t *testing.T) {
|
||||
starter, joiner := uuid.New(), uuid.New()
|
||||
m := &stubMatcher{openGame: twoSeatGame(starter, joiner), openJoined: true}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
res, err := mm.Enqueue(context.Background(), joiner, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
p := creator.created[0]
|
||||
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
|
||||
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
|
||||
}
|
||||
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
|
||||
t.Errorf("auto-match defaults not applied: %+v", p)
|
||||
}
|
||||
|
||||
// The waiting opponent learns of the match through Poll, exactly once.
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil {
|
||||
t.Fatalf("poll a: %v", err)
|
||||
}
|
||||
if !got.Matched || got.Game.ID != r2.Game.ID {
|
||||
t.Errorf("poll a = %+v, want the matched game %s", got, r2.Game.ID)
|
||||
}
|
||||
if again, _ := mm.Poll(ctx, a); again.Matched {
|
||||
t.Error("poll result must drain after the first read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerAlreadyQueued(t *testing.T) {
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); !errors.Is(err, ErrAlreadyQueued) {
|
||||
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
|
||||
if !res.Matched {
|
||||
t.Error("joining a waiting game must report Matched=true")
|
||||
}
|
||||
if len(pub.intents) != 1 {
|
||||
t.Fatalf("joining must emit one opponent_joined; got %d", len(pub.intents))
|
||||
}
|
||||
if got := pub.intents[0]; got.Kind != notify.KindOpponentJoined || got.UserID != starter {
|
||||
t.Errorf("opponent_joined = (kind %q, user %s), want (%q, starter %s)", got.Kind, got.UserID, notify.KindOpponentJoined, starter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerCancel(t *testing.T) {
|
||||
mm := newTestMatchmaker(&fakeCreator{}, uuid.New())
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if !mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of a queued account must report true")
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
if mm.Cancel(ctx, a) {
|
||||
t.Fatal("cancel of an unqueued account must report false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue en: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
|
||||
t.Fatalf("enqueue ru: %v", err)
|
||||
}
|
||||
if len(creator.created) != 0 {
|
||||
t.Fatalf("different variants must not match; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
|
||||
t.Errorf("each variant pool should hold one waiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerFIFO(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
a, b, c := uuid.New(), uuid.New(), uuid.New()
|
||||
for _, id := range []uuid.UUID{a, b, c} {
|
||||
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// a waited, b matched a (oldest), c waits.
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, b) {
|
||||
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSubstitutesRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
robotID := uuid.New()
|
||||
mm := newTestMatchmaker(creator, robotID)
|
||||
func TestEnqueueDeadlineWithinWindow(t *testing.T) {
|
||||
base := time.Now()
|
||||
m := &stubMatcher{openGame: twoSeatGame(uuid.New(), uuid.Nil)}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, 90*time.Second, 90*time.Second, zap.NewNop())
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(5*time.Second)) // before the wait window
|
||||
if len(creator.created) != 0 || mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Fatalf("must not substitute before the wait: created=%d queued=%d", len(creator.created), mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // past the wait window
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1 after substitution", len(creator.created))
|
||||
}
|
||||
if !seatsContain(creator.created[0].Seats, a, robotID) {
|
||||
t.Errorf("substituted game seats = %v, want human %s and robot %s", creator.created[0].Seats, a, robotID)
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 0 {
|
||||
t.Errorf("waiter should be dequeued after substitution")
|
||||
}
|
||||
got, err := mm.Poll(ctx, a)
|
||||
if err != nil || !got.Matched {
|
||||
t.Errorf("poll after substitution = %+v err=%v, want matched", got, err)
|
||||
lo, hi := base.Add(90*time.Second), base.Add(180*time.Second)
|
||||
if m.lastDeadline.Before(lo) || !m.lastDeadline.Before(hi) {
|
||||
t.Errorf("deadline %s not in [%s, %s)", m.lastDeadline, lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
func TestReapSubstitutesRobotAndEmits(t *testing.T) {
|
||||
human, robotID := uuid.New(), uuid.New()
|
||||
og := game.OpenGame{ID: uuid.New(), Variant: engine.VariantRussianScrabble}
|
||||
m := &stubMatcher{
|
||||
expired: []game.OpenGame{og},
|
||||
attachGame: twoSeatGame(human, robotID),
|
||||
attached: true,
|
||||
}
|
||||
mm.Cancel(ctx, a)
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("a cancelled waiter must not be substituted; created %d", len(creator.created))
|
||||
robots := &fakeRobots{id: robotID}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, robots, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if robots.lastVariant != engine.VariantRussianScrabble {
|
||||
t.Errorf("robot picked for %v, want the open game's variant", robots.lastVariant)
|
||||
}
|
||||
if len(m.attachedGames) != 1 || m.attachedGames[0] != og.ID {
|
||||
t.Errorf("attached games = %v, want [%s]", m.attachedGames, og.ID)
|
||||
}
|
||||
if len(pub.intents) != 1 || pub.intents[0].Kind != notify.KindOpponentJoined || pub.intents[0].UserID != human {
|
||||
t.Errorf("reap must emit opponent_joined to the human starter; got %+v", pub.intents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
|
||||
// robot just before the player cancels: Cancel must drop the pending result so the
|
||||
// abandoned game never surfaces through Poll.
|
||||
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
func TestReapDefersWithoutRobot(t *testing.T) {
|
||||
m := &stubMatcher{expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}}}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{err: errors.New("empty pool")}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if len(m.attachedGames) != 0 {
|
||||
t.Errorf("no robot available: must not attach; attached %v", m.attachedGames)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second)) // substitution stores a pending result
|
||||
mm.Cancel(ctx, a) // ... then the player cancels
|
||||
if got, _ := mm.Poll(ctx, a); got.Matched {
|
||||
t.Error("cancel must drop the pending substituted game; Poll still matched")
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakerReaperDefersWithoutRobot(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := NewMatchmaker(creator, &fakeRobots{err: errors.New("empty pool")}, testWaitDelay, zap.NewNop())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
a := uuid.New()
|
||||
func TestReapSkipsWhenHumanJoinedFirst(t *testing.T) {
|
||||
m := &stubMatcher{
|
||||
expired: []game.OpenGame{{ID: uuid.New(), Variant: engine.VariantEnglish}},
|
||||
attached: false, // AttachRobot reports the game already filled by a human
|
||||
}
|
||||
pub := &capturePub{}
|
||||
mm := NewMatchmaker(m, &fakeRobots{id: uuid.New()}, time.Minute, time.Minute, zap.NewNop())
|
||||
mm.SetNotifier(pub)
|
||||
|
||||
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 0 {
|
||||
t.Errorf("no robot available: must not create a game; created %d", len(creator.created))
|
||||
}
|
||||
if mm.QueueLen(engine.VariantEnglish) != 1 {
|
||||
t.Errorf("waiter must stay queued when substitution is deferred; len %d", mm.QueueLen(engine.VariantEnglish))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerRulesAreSeparate confirms two players who chose the same variant but a
|
||||
// different per-turn word rule are not paired, and that the rule reaches the started game.
|
||||
func TestMatchmakerRulesAreSeparate(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
ctx := context.Background()
|
||||
|
||||
// Same variant, opposite rules: they must not match.
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
|
||||
t.Fatalf("enqueue single-word: %v", err)
|
||||
}
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, true); err != nil {
|
||||
t.Fatalf("enqueue standard: %v", err)
|
||||
}
|
||||
if len(creator.created) != 0 {
|
||||
t.Fatalf("different rules must not match; created %d", len(creator.created))
|
||||
}
|
||||
|
||||
// A second single-word player pairs with the first; the game carries the rule.
|
||||
r, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue single-word opponent: %v", err)
|
||||
}
|
||||
if !r.Matched {
|
||||
t.Fatal("same variant and rule must match")
|
||||
}
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if creator.created[0].MultipleWordsPerTurn {
|
||||
t.Error("single-word match must create a game with MultipleWordsPerTurn=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerReaperKeepsRule confirms a robot substitution carries the waiter's rule.
|
||||
func TestMatchmakerReaperKeepsRule(t *testing.T) {
|
||||
creator := &fakeCreator{}
|
||||
mm := newTestMatchmaker(creator, uuid.New())
|
||||
base := time.Now()
|
||||
mm.clock = func() time.Time { return base }
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble, false); err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
mm.Reap(ctx, base.Add(testWaitDelay+time.Second))
|
||||
if len(creator.created) != 1 {
|
||||
t.Fatalf("created %d games, want 1", len(creator.created))
|
||||
}
|
||||
if creator.created[0].MultipleWordsPerTurn {
|
||||
t.Error("robot substitution must keep the waiter's single-word rule")
|
||||
mm.Reap(context.Background(), time.Now())
|
||||
|
||||
if len(pub.intents) != 0 {
|
||||
t.Errorf("a human-filled game must not emit opponent_joined; got %d", len(pub.intents))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user