//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) } }