Files
scrabble-game/backend/internal/robot/robot.go
T
Ilia Denisov 183e08ec80
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
feat(robot): per-game display names from a wide name corpus
Decouple the displayed opponent name from the small pool of durable robot
accounts: the disguised auto-match robot now gets a freshly composed name each
game, stamped on a new game_players.display_name seat snapshot. The snapshot
also captures humans' names, freezing what an opponent sees for the life of a
game (a later rename no longer rewrites past games); readers fall back to the
account's current name for pre-migration rows.

Names come from a wide composed corpus (internal/robot/namevariety.go): Western
locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian
game draws Cyrillic + <=20% Latin and never a CJK script; an English game the
full corpus -- via robot.PickNamed.

Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run
of up to five digits, so "Player2007"-style handles are valid for humans too
and the disguised robot stays indistinguishable.

Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT '');
jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
2026-06-16 12:28:04 +02:00

203 lines
7.8 KiB
Go

// 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)
RobotTurn(ctx context.Context, gameID uuid.UUID, robotIDs []uuid.UUID) (game.RobotTurn, bool, 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, 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
}
// PickNamed selects a robot account like Pick and, alongside it, composes a fresh,
// variant-appropriate per-game display name (composeRandomName) for the matchmaker to
// stamp on the robot's seat. Unlike the account's seeded fallback name, this name is
// generated anew for every match, so the small pool of durable accounts presents as an
// ever-changing crowd of opponents; it stays within account.ValidateDisplayName's
// format so a disguised robot remains indistinguishable from a human
// (docs/ARCHITECTURE.md §7).
func (s *Service) PickNamed(variant engine.Variant) (uuid.UUID, string, error) {
id, err := s.Pick(variant)
if err != nil {
return uuid.Nil, "", err
}
return id, composeRandomName(variant), 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
}