// Package robot is the human-like computer opponent. It substitutes for a missing // human in two-player auto-match: a pool of durable accounts (one robot identity // each) is provisioned at startup, and a background driver makes their moves with // human-like timing, a night sleep window and nudge behaviour // (docs/ARCHITECTURE.md §7). // // The robot consumes the public game API as an ordinary seated player and works // on decoded values only, so it never imports the solver (only internal/engine // does). All of a robot's per-game and per-turn choices are derived // deterministically from the game's bag seed (see strategy.go), so the driver // holds no per-game state and is restart-safe. package robot import ( "context" "errors" "fmt" "math/rand/v2" "sync" "time" "github.com/google/uuid" "go.opentelemetry.io/otel/metric" "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/social" ) // ErrNoRobotAvailable is returned by Pick when the pool is empty (EnsurePool has // not run or failed). var ErrNoRobotAvailable = errors.New("robot: no robot available in the pool") // GameDriver is the slice of the game domain the robot needs: scanning its active // games, reading a turn's candidates and state, and making moves as a seated // player. game.Service satisfies it. type GameDriver interface { RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]game.RobotTurn, error) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) GameState(ctx context.Context, gameID, accountID uuid.UUID) (game.StateView, error) SubmitPlay(ctx context.Context, gameID, accountID uuid.UUID, dir engine.Direction, tiles []engine.TileRecord) (game.MoveResult, error) Pass(ctx context.Context, gameID, accountID uuid.UUID) (game.MoveResult, error) Exchange(ctx context.Context, gameID, accountID uuid.UUID, tiles []string) (game.MoveResult, error) } // Nudger is the slice of the social domain the robot needs: sending a proactive // nudge and reading the opponent's last nudge to answer it. social.Service // satisfies it. type Nudger interface { Nudge(ctx context.Context, gameID, senderID uuid.UUID) (social.Message, error) LastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) } // robotNames is the curated, human-like name pool. Each name backs one durable // robot account, addressed by a stable robot identity (its lower-cased name). var robotNames = []string{ "Alex", "Sam", "Jordan", "Riley", "Casey", "Taylor", "Jamie", "Morgan", "Robin", "Quinn", "Avery", "Drew", "Skyler", "Reese", "Harper", "Sage", } // Config configures the robot subsystem. type Config struct { // DriveInterval is how often the driver scans for robot turns. Sourced from // BACKEND_ROBOT_DRIVE_INTERVAL. DriveInterval time.Duration } // DefaultConfig returns the robot configuration defaults. func DefaultConfig() Config { return Config{DriveInterval: 30 * time.Second} } // Validate reports whether the configuration is usable. func (c Config) Validate() error { if c.DriveInterval <= 0 { return fmt.Errorf("robot: drive interval must be positive, got %s", c.DriveInterval) } return nil } // Service owns the robot pool and the move driver. It is safe for concurrent use. type Service struct { games GameDriver accounts *account.Store social Nudger finished metric.Int64Counter clock func() time.Time log *zap.Logger mu sync.RWMutex pool []uuid.UUID } // NewService constructs a robot Service. games and social are the domain seams it // drives; accounts provisions the pool and resolves opponent timezones; meter // records the balance counter; log carries driver diagnostics. func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter metric.Meter, log *zap.Logger) *Service { if log == nil { log = zap.NewNop() } counter, err := meter.Int64Counter( "robot_games_finished_total", metric.WithDescription("Robot games finished, labelled by result from the robot's view (win/loss/draw)."), ) if err != nil { log.Warn("robot: create finished counter", zap.Error(err)) counter, _ = noop.NewMeterProvider().Meter("robot").Int64Counter("robot_games_finished_total") } return &Service{ games: games, accounts: accounts, social: soc, finished: counter, clock: func() time.Time { return time.Now().UTC() }, log: log, } } // EnsurePool idempotently provisions the named robot accounts and records their // ids as the pool. Each robot is a durable account bound to a robot identity, // with chat and friend requests blocked so it never engages socially // (docs/ARCHITECTURE.md §7). It is a startup dependency, like the dictionary // registry: a failure fails the boot. func (s *Service) EnsurePool(ctx context.Context) error { ids := make([]uuid.UUID, 0, len(robotNames)) for _, name := range robotNames { acc, err := s.accounts.ProvisionByIdentity(ctx, account.KindRobot, externalID(name)) if err != nil { return fmt.Errorf("robot: provision %q: %w", name, err) } if acc.DisplayName != name || !acc.BlockChat || !acc.BlockFriendRequests { if _, err := s.accounts.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{ DisplayName: name, PreferredLanguage: acc.PreferredLanguage, TimeZone: acc.TimeZone, AwayStart: acc.AwayStart, AwayEnd: acc.AwayEnd, BlockChat: true, BlockFriendRequests: true, }); err != nil { return fmt.Errorf("robot: profile %q: %w", name, err) } } ids = append(ids, acc.ID) } s.mu.Lock() s.pool = ids s.mu.Unlock() return nil } // Pick returns a random robot account from the pool, for the matchmaker to // substitute into an auto-match. It satisfies lobby.RobotProvider. func (s *Service) Pick() (uuid.UUID, error) { s.mu.RLock() defer s.mu.RUnlock() if len(s.pool) == 0 { return uuid.Nil, ErrNoRobotAvailable } return s.pool[rand.IntN(len(s.pool))], nil } // poolIDs returns a snapshot of the pool for the driver scan. func (s *Service) poolIDs() []uuid.UUID { s.mu.RLock() defer s.mu.RUnlock() return append([]uuid.UUID(nil), s.pool...) } // externalID is the stable robot identity for a pool name. func externalID(name string) string { return "robot-" + name }