Files
scrabble-game/backend/internal/inttest/robot_test.go
T
Ilia Denisov 26aa154547
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 37s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
R1: schema & naming reset — squash migrations, rename variants
Squash the 12 goose migrations into one 00001_baseline.sql (there is no prod
data; verified schema-identical to the chain via a pg_dump diff + the green
integration suite) and rename the game-variant labels
english/russian_scrabble/erudit -> scrabble_en/scrabble_ru/erudit_ru across the
backend, the FlatBuffers wire values and the UI.

dawg filenames and the Go enum identifiers are unchanged; the i18n display keys
are kept. Adds PRERELEASE.md (the R1-R7 pre-release tracker), linked from
CLAUDE.md. Contour DB wipe and the scrabble-dictionary tidy are follow-ups.
2026-06-09 12:09:50 +02:00

278 lines
9.0 KiB
Go

//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(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
if !isRobotAccount(t, id) {
t.Errorf("picked account %s is not a robot identity", id)
}
if ru, err := r.Pick(engine.VariantRussianScrabble); err != nil || !isRobotAccount(t, ru) {
t.Errorf("scrabble_ru pick = (%s, %v), want a robot account", ru, err)
}
acc, err := account.NewStore(testDB).GetByID(ctx, id)
if err != nil {
t.Fatalf("get robot account: %v", err)
}
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
// expires, mirroring a human who ignores it (Stage 17).
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
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(engine.VariantEnglish)
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's lengthening proactive-nudge schedule on the
// human's turn: nothing before the ~60-90 min first gap, exactly one once it has elapsed.
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(engine.VariantEnglish)
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)
}
// A daytime turn start (the robot is awake for every ±3h drift between 07:30 and 12:00). No
// nudge before the 60-min floor of the first gap; exactly one once past its 90-min ceiling.
start := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
setTurnStarted(t, g.ID, start)
robots.Drive(ctx, start.Add(30*time.Minute))
if n := countNudges(t, g.ID, robotID); n != 0 {
t.Errorf("robot nudges = %d at 30m idle, want 0 (before the first gap)", n)
}
robots.Drive(ctx, start.Add(2*time.Hour))
if n := countNudges(t, g.ID, robotID); n != 1 {
t.Errorf("robot nudges = %d at 2h idle, want 1 (after the first gap)", 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)
}
}