d2a9441287
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m7s
Follow-ups on the honest-AI game, same PR: - GCG export labels the robot seat "AI" instead of its pool name (ExportGCG overrides via accounts.IsRobot); the in-app 🤖 is unchanged. - vs_ai games emit no your_turn (the robot replies instantly, so it would be redundant); opponent_moved still advances the UI. - Admin console shows the AI flag: a 🤖 column in /games and an "AI game" line on the game card (GameRow/GameDetailView gain VsAI). - games_started_total / games_abandoned_total gain a vs_ai attribute; the Grafana Game-domain dashboard splits started/abandoned into human and AI panels. Tests: metrics unit (vs_ai split); integration (no your_turn, GCG "AI").
270 lines
9.1 KiB
Go
270 lines
9.1 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/notify"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// TestAIGameSuppressesYourTurn checks an honest-AI game emits opponent_moved but no your_turn:
|
|
// the robot replies instantly, so a "your turn" signal would arrive with the AI's move and be
|
|
// pointless (the human's UI advances from opponent_moved).
|
|
func TestAIGameSuppressesYourTurn(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newGameService()
|
|
pub := &capturePublisher{}
|
|
svc.SetNotifier(pub)
|
|
robots := newRobotService(t, svc)
|
|
if err := robots.EnsurePool(ctx); err != nil {
|
|
t.Fatalf("ensure pool: %v", err)
|
|
}
|
|
seed := openingSeed(t)
|
|
g, human, _ := startAIGame(t, svc, robots, true, seed) // human at seat 0, to move
|
|
|
|
hint, ok := newMirror(t, seed, 2).HintView()
|
|
if !ok || len(hint.Tiles) == 0 {
|
|
t.Fatal("no opening move for the seed")
|
|
}
|
|
if _, err := svc.SubmitPlay(ctx, g.ID, human, hint.Tiles); err != nil {
|
|
t.Fatalf("human play: %v", err)
|
|
}
|
|
|
|
var opponentMoved, yourTurn int
|
|
pub.mu.Lock()
|
|
for _, in := range pub.intents {
|
|
switch in.Kind {
|
|
case notify.KindYourTurn:
|
|
yourTurn++
|
|
case notify.KindOpponentMoved:
|
|
opponentMoved++
|
|
}
|
|
}
|
|
pub.mu.Unlock()
|
|
if yourTurn != 0 {
|
|
t.Errorf("an AI game must emit no your_turn; got %d", yourTurn)
|
|
}
|
|
if opponentMoved == 0 {
|
|
t.Error("an AI game must still emit opponent_moved so the human's UI advances")
|
|
}
|
|
}
|
|
|
|
// TestAIGameGCGLabelsRobotAI checks the GCG export of an honest-AI game labels the robot seat
|
|
// "AI" rather than leaking its human-like pool name.
|
|
func TestAIGameGCGLabelsRobotAI(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))
|
|
if _, err := svc.Resign(ctx, g.ID, human); err != nil { // finish the game so GCG export is allowed
|
|
t.Fatalf("resign: %v", err)
|
|
}
|
|
|
|
gcg, err := svc.ExportGCG(ctx, g.ID)
|
|
if err != nil {
|
|
t.Fatalf("export gcg: %v", err)
|
|
}
|
|
if !strings.Contains(gcg, " AI\n") {
|
|
t.Errorf("GCG must label the robot seat \"AI\"; got:\n%s", gcg)
|
|
}
|
|
robot, err := account.NewStore(testDB).GetByID(ctx, robotID)
|
|
if err != nil {
|
|
t.Fatalf("get robot: %v", err)
|
|
}
|
|
if strings.Contains(gcg, robot.DisplayName) {
|
|
t.Errorf("GCG must not leak the robot's pool name %q; got:\n%s", robot.DisplayName, gcg)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|