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:
@@ -0,0 +1,177 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user