Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn') - #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback - #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin) - #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4) - #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only - #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal) - ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
This commit is contained in:
@@ -55,13 +55,6 @@ type Nudger interface {
|
||||
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
|
||||
@@ -91,8 +84,9 @@ type Service struct {
|
||||
clock func() time.Time
|
||||
log *zap.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
pool []uuid.UUID
|
||||
mu sync.RWMutex
|
||||
poolEN []uuid.UUID
|
||||
poolRU []uuid.UUID
|
||||
}
|
||||
|
||||
// NewService constructs a robot Service. games and social are the domain seams it
|
||||
@@ -120,58 +114,73 @@ func NewService(games GameDriver, accounts *account.Store, soc Nudger, meter met
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
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)
|
||||
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.pool = ids
|
||||
s.poolEN, s.poolRU = en, ru
|
||||
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
|
||||
// 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 s.pool[rand.IntN(len(s.pool))], nil
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// poolIDs returns a snapshot of the pool for the driver scan.
|
||||
// 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()
|
||||
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
|
||||
ids := make([]uuid.UUID, 0, len(s.poolEN)+len(s.poolRU))
|
||||
ids = append(ids, s.poolEN...)
|
||||
ids = append(ids, s.poolRU...)
|
||||
return ids
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user