Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
- internal/robot: durable kind='robot' account pool (migration 00004); every per-game and per-turn choice derived deterministically from the game seed (restart-stable FNV mix); a background move driver; margin targeting (band 1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m); opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive 12h nudge; friend/chat blocked via profile toggles. - engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns; social.LastNudgeAt. - matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam. - config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT, BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning. - metrics: robot account_stats (authoritative balance) + robot_games_finished_total OTel counter + per-finish log. - docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment. - tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest robot full-game / substitution / proactive-nudge.
This commit is contained in:
@@ -32,7 +32,7 @@ func englishInvite() lobby.InvitationSettings {
|
||||
|
||||
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := lobby.NewMatchmaker(newGameService())
|
||||
mm := newMatchmaker(t, newRobotService(t, newGameService()), 10*time.Second)
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user