//go:build integration package inttest import ( "context" "testing" "time" "github.com/google/uuid" "go.opentelemetry.io/otel/metric/noop" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/robot" ) // newRobotService builds a robot service over games (shared so its moves and the // test's human moves use the same live-game cache and per-game locks), a fresh // social service for nudges, and a no-op meter. func newRobotService(t *testing.T, games *game.Service) *robot.Service { t.Helper() 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 { t.Helper() return lobby.NewMatchmaker(newGameService(), robots, wait, zap.NewNop()) } // setTurnStarted rewrites a game's turn clock so a robot turn can be made due (or // idle) at a chosen instant, independent of wall time. func setTurnStarted(t *testing.T, id uuid.UUID, at time.Time) { t.Helper() if _, err := testDB.ExecContext(context.Background(), `UPDATE backend.games SET turn_started_at = $2 WHERE game_id = $1`, id, at); err != nil { t.Fatalf("set turn_started_at: %v", err) } } // isRobotAccount reports whether the account carries a robot identity. func isRobotAccount(t *testing.T, id uuid.UUID) bool { t.Helper() var n int if err := testDB.QueryRowContext(context.Background(), `SELECT count(*) FROM backend.identities WHERE account_id = $1 AND kind = 'robot'`, id).Scan(&n); err != nil { t.Fatalf("count robot identity: %v", err) } return n > 0 } // countNudges counts the nudges senderID has sent in a game. func countNudges(t *testing.T, gameID, senderID uuid.UUID) int { t.Helper() var n int if err := testDB.QueryRowContext(context.Background(), `SELECT count(*) FROM backend.chat_messages WHERE game_id = $1 AND sender_id = $2 AND kind = 'nudge'`, gameID, senderID).Scan(&n); err != nil { t.Fatalf("count nudges: %v", err) } return n } // daytime is a fixed instant whose hour is awake for every sleep drift (the // always-awake band is [10,21) local), used to drive robot moves deterministically. var daytime = time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC) // TestRobotPoolProvisionsRobotAccounts checks EnsurePool creates durable, // chat/friend-blocked robot accounts (exercising the kind='robot' migration) and // is idempotent. func TestRobotPoolProvisionsRobotAccounts(t *testing.T) { ctx := context.Background() r := newRobotService(t, newGameService()) if err := r.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } if err := r.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool (idempotent): %v", err) } id, err := r.Pick() if err != nil { t.Fatalf("pick: %v", err) } if !isRobotAccount(t, id) { t.Errorf("picked account %s is not a robot identity", id) } acc, err := account.NewStore(testDB).GetByID(ctx, id) if err != nil { t.Fatalf("get robot account: %v", err) } if acc.DisplayName == "" || !acc.BlockChat || !acc.BlockFriendRequests { t.Errorf("robot profile not set: name=%q chat=%v friends=%v", acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests) } } // TestRobotPlaysAutoMatchToEnd drives a robot through a full two-player game (the // human plays greedily) and checks it finishes with a robot statistics row. The // robot is forced due each turn by resetting the turn clock and driving at a fixed // daytime instant, so the game does not depend on wall time. func TestRobotPlaysAutoMatchToEnd(t *testing.T) { ctx := context.Background() svc := newGameService() robots := newRobotService(t, svc) if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } robotID, err := robots.Pick() if err != nil { t.Fatalf("pick: %v", err) } human := provisionAccount(t) seed := openingSeed(t) g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID}, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } robotSeat := 1 // seats = [human, robot] finished := false for i := 0; i < 400 && !finished; i++ { _, toMove, status, err := svc.Participants(ctx, g.ID) if err != nil { t.Fatalf("participants: %v", err) } if status != game.StatusActive { finished = true break } if toMove == robotSeat { setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour)) // well past any sampled delay robots.Drive(ctx, daytime) continue } playHuman(t, ctx, svc, g.ID, human) } if !finished { t.Fatal("robot game did not finish within the move budget") } if _, _, _, mg, _, ok := readStats(t, robotID); !ok || mg < 0 { t.Errorf("robot must have a statistics row after a finished game (found=%v, maxGame=%d)", ok, mg) } } // TestMatchmakerSubstitutesRobotEndToEnd checks a waiting human is paired with a // real robot account after the wait window, discoverable through Poll. func TestMatchmakerSubstitutesRobotEndToEnd(t *testing.T) { ctx := context.Background() robots := newRobotService(t, newGameService()) if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } mm := newMatchmaker(t, robots, 10*time.Second) human := provisionAccount(t) before := time.Now() r, err := mm.Enqueue(ctx, human, engine.VariantEnglish) if err != nil { t.Fatalf("enqueue: %v", err) } if r.Matched { t.Fatal("first enqueue must wait") } 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) if err != nil { t.Fatalf("participants: %v", err) } if status != game.StatusActive || len(seats) != 2 { t.Fatalf("substituted game: status %q seats %v", status, seats) } var human0, robot0 bool for _, s := range seats { switch { case s == human: human0 = true case isRobotAccount(t, s): robot0 = true } } if !human0 || !robot0 { t.Errorf("substituted seats must be the human and a robot, got %v", seats) } } // TestRobotProactiveNudge checks the robot nudges the human after the idle // threshold on the human's turn. func TestRobotProactiveNudge(t *testing.T) { ctx := context.Background() svc := newGameService() robots := newRobotService(t, svc) if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } robotID, err := robots.Pick() if err != nil { t.Fatalf("pick: %v", err) } human := provisionAccount(t) seed := openingSeed(t) // Seat the human first so it is the human's turn and the robot is the awaiter. g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robotID}, TurnTimeout: 24 * time.Hour, Seed: seed, }) if err != nil { t.Fatalf("create: %v", err) } // Midnight start, driven 13 hours later (>12h idle) at a daytime hour awake for // every drift. start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) setTurnStarted(t, g.ID, start) robots.Drive(ctx, start.Add(13*time.Hour)) if n := countNudges(t, g.ID, robotID); n != 1 { t.Errorf("robot nudges = %d, want 1 after 13h idle on the human's turn", n) } } // playHuman makes a greedy human move: the top candidate, else an exchange, else a // pass. func playHuman(t *testing.T, ctx context.Context, svc *game.Service, gameID, human uuid.UUID) { t.Helper() cands, err := svc.Candidates(ctx, gameID, human) if err != nil { t.Fatalf("human candidates: %v", err) } if len(cands) > 0 { if _, err := svc.SubmitPlay(ctx, gameID, human, cands[0].Dir, cands[0].Tiles); err != nil { t.Fatalf("human play: %v", err) } return } st, err := svc.GameState(ctx, gameID, human) if err != nil { t.Fatalf("human state: %v", err) } if len(st.Rack) > 0 && st.BagLen >= len(st.Rack) { if _, err := svc.Exchange(ctx, gameID, human, st.Rack); err != nil { t.Fatalf("human exchange: %v", err) } return } if _, err := svc.Pass(ctx, gameID, human); err != nil { t.Fatalf("human pass: %v", err) } }