// 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) } // 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 poolEN []uuid.UUID poolRU []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 robot accounts (one per slot of each // language's composed name pool) and records their ids. Each robot is a durable // account bound to a stable, index-keyed 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 { en, err := s.provisionPool(ctx, "en", robotDisplayNamesEN()) if err != nil { return err } ru, err := s.provisionPool(ctx, "ru", robotDisplayNamesRU()) if err != nil { return err } s.mu.Lock() s.poolEN, s.poolRU = en, ru s.mu.Unlock() return nil } // provisionPool provisions one durable robot account per name and returns their ids // in order. The identity is keyed by language and slot index (stable across restarts // and independent of the composed display name); account.ProvisionRobot sets the // display name and social blocks and is idempotent, so EnsurePool can run every boot. func (s *Service) provisionPool(ctx context.Context, lang string, names []string) ([]uuid.UUID, error) { ids := make([]uuid.UUID, 0, len(names)) for i, name := range names { acc, err := s.accounts.ProvisionRobot(ctx, fmt.Sprintf("robot-%s-%d", lang, i), name) if err != nil { return nil, fmt.Errorf("robot: provision %s #%d (%q): %w", lang, i, name, err) } ids = append(ids, acc.ID) } return ids, nil } // Pick returns a random robot account for the matchmaker to substitute into an // auto-match of the given variant. An English game draws from the Latin pool; a // Russian game (Russian Scrabble or Эрудит) draws from the Russian pool, mixing in a // Latin name about latinShareInRussian% of the time; either side falls back to the // other when its pool is empty. It satisfies lobby.RobotProvider. func (s *Service) Pick(variant engine.Variant) (uuid.UUID, error) { s.mu.RLock() defer s.mu.RUnlock() primary, secondary := s.poolEN, s.poolRU if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit { primary, secondary = s.poolRU, s.poolEN if len(primary) > 0 && len(secondary) > 0 && rand.IntN(100) < latinShareInRussian { primary, secondary = secondary, primary } } if len(primary) == 0 { primary = secondary } if len(primary) == 0 { return uuid.Nil, ErrNoRobotAvailable } return primary[rand.IntN(len(primary))], nil } // poolIDs returns a snapshot of the whole pool (both languages) for the driver scan, // which is variant-agnostic — it acts on every robot's active games. func (s *Service) poolIDs() []uuid.UUID { s.mu.RLock() defer s.mu.RUnlock() ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU)) ids = append(ids, s.poolEN...) ids = append(ids, s.poolRU...) return ids }