92f48a3b12
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 44s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m9s
A single tile that only extended a word perpendicular to the client-declared direction was rejected: the UI always sent dir=H for one-tile plays (the dirOverride/Controls toggle was orphaned in the Stage 7 game rework), so placing "А" above "БАК" to form "АБАК" failed the solver's main-word-length check even though the word is in the dictionary. Make the backend infer a play's orientation from the placed tiles and the board (internal/engine.resolveDirection): two or more tiles by the line they share, a lone tile by the axis it abuts (longer word wins, horizontal on a tie). Direction becomes an output, not an input: drop dir from the SubmitPlay/Eval wire requests and add it to EvalResult. Journal replay keeps trusting the stored "H"/"V" (SubmitPlayDir) so a rebuilt game matches the one committed. UI: stop computing/sending direction; the preview now shows the words a move forms with its total score (game.previewWords); the make-move control is disabled until the play is confirmed legal; the "your turn" label hides while tiles are pending. Delete the orphaned Controls.svelte. Regenerate the FlatBuffers bindings (Go + TS) and update the gateway transcode and the loadtest edge client to the new contract. Bake the decision into ARCHITECTURE.md (§5/§9.1), FUNCTIONAL.md (+ _ru) and the backend README.
187 lines
6.9 KiB
Go
187 lines
6.9 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)
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|