feat: AI-game refinements (GCG, your_turn, admin, metrics)
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
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").
This commit is contained in:
@@ -5,13 +5,16 @@ 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"
|
||||
)
|
||||
@@ -173,6 +176,78 @@ func TestAIGameSevenDayTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user