Files
scrabble-game/backend/internal/lobby/matchmaker_test.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

210 lines
6.6 KiB
Go

package lobby
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/notify"
)
// 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 (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
}
if s.attached {
s.attachedGames = append(s.attachedGames, gameID)
}
return s.attachGame, s.attached, nil
}
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.
type fakeRobots struct {
id uuid.UUID
err error
lastVariant engine.Variant
}
func (f *fakeRobots) Pick(variant engine.Variant) (uuid.UUID, error) {
f.lastVariant = variant
if f.err != nil {
return uuid.Nil, f.err
}
return f.id, nil
}
// capturePub records every published intent.
type capturePub struct{ intents []notify.Intent }
func (c *capturePub) Publish(intents ...notify.Intent) { c.intents = append(c.intents, intents...) }
// 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},
},
}
}
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)
res, err := mm.Enqueue(context.Background(), starter, engine.VariantEnglish, true)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if res.Matched {
t.Error("opening a game must report Matched=false")
}
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))
}
}
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: %v", 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 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 }
if _, err := mm.Enqueue(context.Background(), uuid.New(), engine.VariantEnglish, true); err != nil {
t.Fatalf("enqueue: %v", 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 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,
}
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)
}
}
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)
mm.Reap(context.Background(), time.Now())
if len(m.attachedGames) != 0 {
t.Errorf("no robot available: must not attach; attached %v", m.attachedGames)
}
if len(pub.intents) != 0 {
t.Errorf("no robot available: must not emit; got %d intents", len(pub.intents))
}
}
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)
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))
}
}