//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/robot" "scrabble/backend/internal/social" ) // The AI-game suite covers the honest-AI quick game: a game created already seated with a // pooled robot (vs_ai), in which the robot moves immediately, the move clock is the 7-day // inactivity rule, chat and nudge are disabled and no statistics are recorded. // startAIGame creates an active vs_ai game seating a fresh human and a pooled robot, the human // at seat 0 when humanFirst (otherwise the robot moves first), and returns it with the two ids. func startAIGame(t *testing.T, svc *game.Service, robots *robot.Service, humanFirst bool, seed int64) (game.Game, uuid.UUID, uuid.UUID) { t.Helper() ctx := context.Background() human := provisionAccount(t) robotID, err := robots.Pick(engine.VariantEnglish) if err != nil { t.Fatalf("pick robot: %v", err) } seats := []uuid.UUID{human, robotID} if !humanFirst { seats = []uuid.UUID{robotID, human} } g, err := svc.Create(ctx, game.CreateParams{ Variant: engine.VariantEnglish, Seats: seats, VsAI: true, Seed: seed, HintsAllowed: true, HintsPerPlayer: 1, MultipleWordsPerTurn: true, }) if err != nil { t.Fatalf("create AI game: %v", err) } return g, human, robotID } // TestAIGameStartSeatsRobotActive checks the matchmaker's AI path creates an active game already // seated with a robot (no open/wait phase), flagged vs_ai and on the 7-day inactivity clock. func TestAIGameStartSeatsRobotActive(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, time.Minute, 0) human := provisionAccount(t) res, err := mm.StartVsAI(ctx, human, engine.VariantEnglish, true) if err != nil { t.Fatalf("start vs AI: %v", err) } g := res.Game if !res.Matched || g.Status != game.StatusActive { t.Fatalf("AI game = (matched %v, status %q), want (true, active)", res.Matched, g.Status) } if !g.VsAI { t.Error("an AI game must be flagged vs_ai") } if g.TurnTimeout != game.AIInactivityTimeout { t.Errorf("AI game move clock = %s, want %s", g.TurnTimeout, game.AIInactivityTimeout) } var hasHuman, hasRobot bool for _, s := range g.Seats { switch { case s.AccountID == human: hasHuman = true case s.AccountID != uuid.Nil: hasRobot = true } } if len(g.Seats) != 2 || !hasHuman || !hasRobot { t.Errorf("AI game seats = %+v, want exactly the human and a robot (no empty seat)", g.Seats) } } // TestAIRobotMovesImmediately checks the robot in a vs_ai game moves the instant it is its turn, // with no sampled delay and no sleep window (the honest-AI fast path the trigger drives). func TestAIRobotMovesImmediately(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) } g, _, _ := startAIGame(t, svc, robots, false, openingSeed(t)) // robot at seat 0, to move if err := robots.DriveGame(ctx, g.ID, time.Now()); err != nil { t.Fatalf("drive AI game: %v", err) } after, err := svc.GameByID(ctx, g.ID) if err != nil { t.Fatalf("get game: %v", err) } if after.MoveCount != 1 { t.Errorf("after one drive the robot's first move must be in (move count %d, want 1)", after.MoveCount) } if after.ToMove == 0 { t.Error("after the robot's move it must be the human's turn") } } // TestAIGameStatsSkipped checks a vs_ai game records no statistics: a human resign (a loss in a // normal game) leaves the human with no stats row. func TestAIGameStatsSkipped(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) } g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t)) if _, err := svc.Resign(ctx, g.ID, human); err != nil { t.Fatalf("resign: %v", err) } if _, _, _, _, _, found := readStats(t, human); found { t.Error("an AI game must not record statistics for the human") } } // TestAIGameSevenDayTimeout checks the reused turn-timeout sweeper enforces the 7-day inactivity // rule: nothing fires within seven days, and past it the human on the clock is resigned and loses. func TestAIGameSevenDayTimeout(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) } g, human, robotID := startAIGame(t, svc, robots, true, openingSeed(t)) // human at seat 0, to move // Six days idle: well under the 7-day clock, so the sweeper leaves it active. setTurnStarted(t, g.ID, time.Now().Add(-6*24*time.Hour)) if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil { t.Fatalf("sweep: %v", err) } if mid, _ := svc.GameByID(ctx, g.ID); mid.Status != game.StatusActive { t.Fatalf("at six days idle the AI game must still be active, got %q", mid.Status) } // Eight days idle: past the clock, so the human (on the clock) is timed out and loses. setTurnStarted(t, g.ID, time.Now().Add(-8*24*time.Hour)) if _, err := svc.SweepTimeouts(ctx, time.Now()); err != nil { t.Fatalf("sweep: %v", err) } done, err := svc.GameByID(ctx, g.ID) if err != nil { t.Fatalf("get game: %v", err) } if done.Status != game.StatusFinished || done.EndReason != "timeout" { t.Fatalf("after seven days idle: (status %q, reason %q), want (finished, timeout)", done.Status, done.EndReason) } for _, s := range done.Seats { if s.AccountID == robotID && !s.IsWinner { t.Error("the robot must win when the human abandons the game") } if s.AccountID == human && s.IsWinner { t.Error("the abandoning human must not win") } } if _, _, _, _, _, found := readStats(t, human); found { t.Error("a timed-out AI game must not record statistics") } } // TestAIGameChatAndNudgeRejected checks chat and nudge are both refused in a vs_ai game (the // opponent is a robot), even though the game reports status 'active'. func TestAIGameChatAndNudgeRejected(t *testing.T) { ctx := context.Background() svc := newGameService() soc := newSocialService() robots := newRobotService(t, svc) if err := robots.EnsurePool(ctx); err != nil { t.Fatalf("ensure pool: %v", err) } g, human, _ := startAIGame(t, svc, robots, true, openingSeed(t)) if _, err := soc.PostMessage(ctx, g.ID, human, "hi robot", ""); !errors.Is(err, social.ErrGameVsAI) { t.Errorf("chat in an AI game = %v, want ErrGameVsAI", err) } if _, err := soc.Nudge(ctx, g.ID, human); !errors.Is(err, social.ErrGameVsAI) { t.Errorf("nudge in an AI game = %v, want ErrGameVsAI", err) } }