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:
@@ -56,11 +56,21 @@ func newRobotService(t *testing.T, games *game.Service) *robot.Service {
|
||||
return robot.NewService(games, account.NewStore(testDB), newSocialService(), noop.NewMeterProvider().Meter("robot-test"), zap.NewNop())
|
||||
}
|
||||
|
||||
// newMatchmaker builds a matchmaker starting real games and substituting from
|
||||
// robots after wait.
|
||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, wait time.Duration) *lobby.Matchmaker {
|
||||
// newMatchmaker builds a matchmaker opening real games and substituting from robots
|
||||
// after minWait plus a random jitter in [0, jitter).
|
||||
func newMatchmaker(t *testing.T, robots lobby.RobotProvider, minWait, jitter time.Duration) *lobby.Matchmaker {
|
||||
t.Helper()
|
||||
return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop())
|
||||
return lobby.NewMatchmaker(newGameService(), robots, minWait, jitter, zap.NewNop())
|
||||
}
|
||||
|
||||
// clearOpenGames deletes every open (awaiting-opponent) game so a matchmaking test
|
||||
// starts from a clean slate: the shared test database is FIFO-joined across tests, so a
|
||||
// leftover open game would otherwise be joined (or opened-into) instead of a fresh one.
|
||||
func clearOpenGames(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := testDB.ExecContext(context.Background(), `DELETE FROM backend.games WHERE status = 'open'`); err != nil {
|
||||
t.Fatalf("clear open games: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// provisionAccount creates a fresh durable account and returns its id.
|
||||
|
||||
@@ -30,31 +30,67 @@ func englishInvite() lobby.InvitationSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
|
||||
func TestMatchmakingOpensThenJoins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
// The first player opens a game and enters it immediately, still awaiting an opponent.
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait")
|
||||
t.Fatal("first enqueue must open a game awaiting an opponent, not match")
|
||||
}
|
||||
if _, _, status, err := newGameService().Participants(ctx, r1.Game.ID); err != nil || status != "open" {
|
||||
t.Fatalf("opened game status = %q err %v, want open", status, err)
|
||||
}
|
||||
|
||||
// A second player for the same variant and rule joins that open game, which starts.
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
if !r2.Matched || r2.Game.ID != r1.Game.ID {
|
||||
t.Fatalf("second enqueue = (matched %v, game %s), want it to join the open game %s", r2.Matched, r2.Game.ID, r1.Game.ID)
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != "active" || len(seats) != 2 {
|
||||
t.Fatalf("matched game state: status %q seats %v", status, seats)
|
||||
has := func(id uuid.UUID) bool {
|
||||
for _, s := range seats {
|
||||
if s == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if status != "active" || len(seats) != 2 || !has(a) || !has(b) {
|
||||
t.Fatalf("joined game: status %q seats %v (want active with a=%s and b=%s)", status, seats, a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakingReEnqueueReturnsOwnOpenGame checks a re-enqueue is idempotent: the
|
||||
// caller gets their existing open game rather than a second one.
|
||||
func TestMatchmakingReEnqueueReturnsOwnOpenGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 90*time.Second, 90*time.Second)
|
||||
a := provisionAccount(t)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
r2, err := mm.Enqueue(ctx, a, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("re-enqueue: %v", err)
|
||||
}
|
||||
if r2.Game.ID != r1.Game.ID || r2.Matched {
|
||||
t.Fatalf("re-enqueue = (game %s, matched %v), want the same open game %s unmatched", r2.Game.ID, r2.Matched, r1.Game.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/social"
|
||||
)
|
||||
|
||||
// The open-game suite covers an auto-match game that a player enters immediately and
|
||||
// waits inside (status 'open', the opponent seat empty) until a human or a robot joins.
|
||||
|
||||
// evenOpeningSeed returns an even seed (so OpenOrJoin seats the starter at seat 0, which
|
||||
// moves first) whose fresh two-player English opening rack has a legal move.
|
||||
func evenOpeningSeed(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
for seed := int64(2); seed <= 400; seed += 2 {
|
||||
g, err := engine.New(testRegistry, engine.Options{Variant: engine.VariantEnglish, Version: testDictVersion, Players: 2, Seed: seed})
|
||||
if err != nil {
|
||||
t.Fatalf("engine new: %v", err)
|
||||
}
|
||||
if _, ok := g.HintView(); ok {
|
||||
return seed
|
||||
}
|
||||
}
|
||||
t.Fatal("no even opening seed found")
|
||||
return 0
|
||||
}
|
||||
|
||||
// openParams are the casual auto-match settings with a pinned seed.
|
||||
func openParams(seed int64) game.CreateParams {
|
||||
return game.CreateParams{
|
||||
Variant: engine.VariantEnglish,
|
||||
TurnTimeout: 24 * time.Hour,
|
||||
HintsAllowed: true,
|
||||
HintsPerPlayer: 1,
|
||||
MultipleWordsPerTurn: true,
|
||||
Seed: seed,
|
||||
}
|
||||
}
|
||||
|
||||
func openGame(t *testing.T, svc *game.Service, starter uuid.UUID, seed int64) game.Game {
|
||||
t.Helper()
|
||||
g, joined, err := svc.OpenOrJoin(context.Background(), starter, openParams(seed), time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("open game: %v", err)
|
||||
}
|
||||
if joined || g.Status != game.StatusOpen {
|
||||
t.Fatalf("opened game = (joined %v, status %q), want (false, open)", joined, g.Status)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestOpenGameStarterMovesThenWaits checks the starter (seat 0) may move on their turn
|
||||
// while the game is open, after which it is the empty opponent seat's turn and the
|
||||
// starter just waits.
|
||||
func TestOpenGameStarterMovesThenWaits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
seed := evenOpeningSeed(t)
|
||||
g := openGame(t, svc, provisionAccount(t), seed)
|
||||
starter := g.Seats[0].AccountID
|
||||
|
||||
hint, ok := newMirror(t, seed, 2).HintView()
|
||||
if !ok || len(hint.Tiles) == 0 {
|
||||
t.Fatal("no opening move for the seed")
|
||||
}
|
||||
res, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles)
|
||||
if err != nil {
|
||||
t.Fatalf("starter play while open: %v", err)
|
||||
}
|
||||
if res.Game.Status != game.StatusOpen {
|
||||
t.Errorf("after the starter's move the game must stay open, got %q", res.Game.Status)
|
||||
}
|
||||
if res.Game.ToMove != 1 {
|
||||
t.Errorf("after the starter's move it must be the empty seat's turn (to_move 1), got %d", res.Game.ToMove)
|
||||
}
|
||||
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
|
||||
t.Fatalf("starter acting on the opponent's turn = %v, want ErrNotYourTurn", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameStarterWaitsWhenOpponentMovesFirst checks that when the starter is seated
|
||||
// at seat 1 (odd seed), the still-empty seat 0 is to move, so the starter cannot act.
|
||||
func TestOpenGameStarterWaitsWhenOpponentMovesFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
starter := provisionAccount(t)
|
||||
g, _, err := svc.OpenOrJoin(ctx, starter, openParams(1), time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if g.ToMove != 0 || g.Seats[0].AccountID != uuid.Nil {
|
||||
t.Fatalf("odd-seed open game: to_move %d seat0 %s, want the empty seat 0 to move", g.ToMove, g.Seats[0].AccountID)
|
||||
}
|
||||
if _, err := svc.Pass(ctx, g.ID, starter); !errors.Is(err, game.ErrNotYourTurn) {
|
||||
t.Fatalf("starter acting on the empty seat's turn = %v, want ErrNotYourTurn", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameJoinAfterStarterMoved checks a second human joins an open game even after
|
||||
// the starter has already made their first move, landing on the board mid-opening and
|
||||
// able to act on their turn.
|
||||
func TestOpenGameJoinAfterStarterMoved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
seed := evenOpeningSeed(t)
|
||||
g := openGame(t, svc, provisionAccount(t), seed)
|
||||
starter := g.Seats[0].AccountID
|
||||
|
||||
hint, ok := newMirror(t, seed, 2).HintView()
|
||||
if !ok {
|
||||
t.Fatal("no opening move")
|
||||
}
|
||||
if _, err := svc.SubmitPlay(ctx, g.ID, starter, hint.Tiles); err != nil {
|
||||
t.Fatalf("starter play: %v", err)
|
||||
}
|
||||
|
||||
joiner := provisionAccount(t)
|
||||
g2, joined, err := svc.OpenOrJoin(ctx, joiner, openParams(0), time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
if !joined || g2.ID != g.ID {
|
||||
t.Fatalf("join = (joined %v, game %s), want it to join %s", joined, g2.ID, g.ID)
|
||||
}
|
||||
if g2.Status != game.StatusActive || g2.MoveCount != 1 || g2.ToMove != 1 {
|
||||
t.Fatalf("joined game = (status %q, moves %d, to_move %d), want (active, 1, 1)", g2.Status, g2.MoveCount, g2.ToMove)
|
||||
}
|
||||
// It is the joiner's turn (seat 1); they can act.
|
||||
if _, err := svc.Pass(ctx, g.ID, joiner); err != nil {
|
||||
t.Fatalf("joiner pass on their turn: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameResignRejectedUntilOpponent checks resign is refused while the game is
|
||||
// open and allowed once an opponent (a robot here) has joined.
|
||||
func TestOpenGameResignRejectedUntilOpponent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
robots := newRobotService(t, svc)
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
|
||||
starter := g.Seats[0].AccountID
|
||||
|
||||
if _, err := svc.Resign(ctx, g.ID, starter); !errors.Is(err, game.ErrNoOpponentYet) {
|
||||
t.Fatalf("resign while open = %v, want ErrNoOpponentYet", err)
|
||||
}
|
||||
|
||||
robotID, err := robots.Pick(engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("pick: %v", err)
|
||||
}
|
||||
if _, attached, err := svc.AttachRobot(ctx, g.ID, robotID); err != nil || !attached {
|
||||
t.Fatalf("attach robot = (attached %v, err %v), want attached", attached, err)
|
||||
}
|
||||
res, err := svc.Resign(ctx, g.ID, starter)
|
||||
if err != nil {
|
||||
t.Fatalf("resign after the opponent joined: %v", err)
|
||||
}
|
||||
if res.Game.Status != game.StatusFinished {
|
||||
t.Errorf("resign must finish the game, got %q", res.Game.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameChatAndNudgeRejected checks chat and nudge are refused while the game is
|
||||
// open (no opponent to converse with).
|
||||
func TestOpenGameChatAndNudgeRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
soc := newSocialService()
|
||||
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
|
||||
starter := g.Seats[0].AccountID
|
||||
|
||||
if _, err := soc.PostMessage(ctx, g.ID, starter, "hello", ""); !errors.Is(err, social.ErrGameNotActive) {
|
||||
t.Errorf("chat while open = %v, want ErrGameNotActive", err)
|
||||
}
|
||||
if _, err := soc.Nudge(ctx, g.ID, starter); !errors.Is(err, social.ErrGameNotActive) {
|
||||
t.Errorf("nudge while open = %v, want ErrGameNotActive", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameSweeperSkips checks the turn-timeout sweeper never finishes an open game,
|
||||
// even with a long-stale turn clock (its seat is the empty opponent's).
|
||||
func TestOpenGameSweeperSkips(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
g := openGame(t, svc, provisionAccount(t), evenOpeningSeed(t))
|
||||
|
||||
setTurnStarted(t, g.ID, time.Now().Add(-1000*time.Hour))
|
||||
if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
g2, err := svc.GameByID(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get game: %v", err)
|
||||
}
|
||||
if g2.Status != game.StatusOpen {
|
||||
t.Errorf("open game must survive the sweeper, got %q", g2.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenGameLobbyShowsEmptySeat checks an open game appears in the starter's lobby
|
||||
// with two seats, one of them the still-empty opponent (uuid.Nil).
|
||||
func TestOpenGameLobbyShowsEmptySeat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
svc := newGameService()
|
||||
starter := provisionAccount(t)
|
||||
g := openGame(t, svc, starter, evenOpeningSeed(t))
|
||||
|
||||
games, err := svc.ListForAccount(ctx, starter)
|
||||
if err != nil {
|
||||
t.Fatalf("list for account: %v", err)
|
||||
}
|
||||
var found *game.Game
|
||||
for i := range games {
|
||||
if games[i].ID == g.ID {
|
||||
found = &games[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("open game must appear in the starter's lobby")
|
||||
}
|
||||
var hasStarter, hasEmpty bool
|
||||
for _, s := range found.Seats {
|
||||
switch s.AccountID {
|
||||
case starter:
|
||||
hasStarter = true
|
||||
case uuid.Nil:
|
||||
hasEmpty = true
|
||||
}
|
||||
}
|
||||
if len(found.Seats) != 2 || !hasStarter || !hasEmpty {
|
||||
t.Errorf("open game seats = %+v, want the starter and one empty (nil) seat", found.Seats)
|
||||
}
|
||||
}
|
||||
@@ -138,36 +138,29 @@ func TestRobotPlaysAutoMatchToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a
|
||||
// real robot account after the wait window, discoverable through Poll.
|
||||
// TestMatchmakerSubstitutesRobotEndToEnd checks the reaper fills an open game's empty
|
||||
// seat with a real robot account once its wait window has elapsed.
|
||||
func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
clearOpenGames(t)
|
||||
robots := newRobotService(t, newGameService())
|
||||
if err := robots.EnsurePool(ctx); err != nil {
|
||||
t.Fatalf("ensure pool: %v", err)
|
||||
}
|
||||
mm := newMatchmaker(t, robots, 10*time.Second)
|
||||
// Zero wait and jitter so the opened game is immediately due for a robot.
|
||||
mm := newMatchmaker(t, robots, 0, 0)
|
||||
human := provisionAccount(t)
|
||||
|
||||
before := time.Now()
|
||||
r, err := mm.Enqueue(ctx, human, engine.VariantEnglish, true)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if r.Matched {
|
||||
t.Fatal("first enqueue must wait")
|
||||
t.Fatal("first enqueue must open a game awaiting an opponent")
|
||||
}
|
||||
|
||||
mm.Reap(ctx, before.Add(11*time.Second))
|
||||
got, err := mm.Poll(ctx, human)
|
||||
if err != nil {
|
||||
t.Fatalf("poll: %v", err)
|
||||
}
|
||||
if !got.Matched {
|
||||
t.Fatal("expected a substituted game after the wait window")
|
||||
}
|
||||
|
||||
seats, _, status, err := newGameService().Participants(ctx, got.Game.ID)
|
||||
mm.Reap(ctx, time.Now().Add(time.Second)) // past the (zero) wait window
|
||||
seats, _, status, err := newGameService().Participants(ctx, r.Game.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user