From d2dbc88e5f0227312358eff6331b759e90447ccd Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 01:21:33 +0800 Subject: [PATCH] Use likei profiles for game robots --- docs/全站机器人产品文档.md | 12 +- server/admin/cmd/server/main.go | 18 +- .../admin/configs/config.tencent.example.yaml | 3 + server/admin/configs/config.yaml | 3 + server/admin/internal/config/config.go | 89 ++- .../modules/gamemanagement/handler.go | 10 +- .../modules/gamemanagement/robot_avatar.go | 167 ----- .../gamemanagement/robot_avatar_test.go | 133 ---- .../modules/gamemanagement/robot_presets.go | 648 +----------------- .../gamemanagement/robot_profile_source.go | 101 +++ .../robot_profile_source_test.go | 106 +++ .../gamemanagement/self_game_handler.go | 45 +- .../deploy/mysql/initdb/001_game_service.sql | 17 +- .../mysql/dice_matchmaking_repository.go | 11 +- .../internal/storage/mysql/repository.go | 24 +- .../internal/storage/mysql/repository_test.go | 35 +- 16 files changed, 383 insertions(+), 1039 deletions(-) delete mode 100644 server/admin/internal/modules/gamemanagement/robot_avatar.go delete mode 100644 server/admin/internal/modules/gamemanagement/robot_avatar_test.go create mode 100644 server/admin/internal/modules/gamemanagement/robot_profile_source.go create mode 100644 server/admin/internal/modules/gamemanagement/robot_profile_source_test.go diff --git a/docs/全站机器人产品文档.md b/docs/全站机器人产品文档.md index 7f0e6bc4..6e77abac 100644 --- a/docs/全站机器人产品文档.md +++ b/docs/全站机器人产品文档.md @@ -23,9 +23,9 @@ ## 创建流程 -1. 运营在后台选择创建数量和昵称语言。 -2. admin-server 下载并转存头像。 -3. admin-server 调 user-service 创建 `source=game_robot` 的资料号。 +1. 运营在后台选择创建数量、国家、性别和语言元数据。 +2. admin-server 从 likei 线上 `user_base_info` 随机抽取正常用户的 `user_nickname` 和 `user_avatar`。 +3. admin-server 调 user-service 创建 `source=game_robot` 的资料号,昵称和头像直接使用 likei 资料。 4. user-service 写入用户资料、短 ID 和密码身份,但不创建登录 session。 5. admin-server 调 game-service 把这些 user_id 注册到机器人池。 6. game-service 后续只从启用状态的机器人中选择补位。 @@ -55,7 +55,7 @@ ### 批量创建 - 地址:`POST /admin/game/robots/generate` -- 参数:`count`(默认 20,最多 1000)、`nicknameLanguage`、`gender`、`country`、`avatarUrls` +- 参数:`count`(默认 20,最多 1000)、`nicknameLanguage`(只作为账号语言元数据)、`gender`、`country` - 返回值:创建数量、机器人列表 - 相关 IM:无;机器人不导入 IM 登录账号 @@ -75,7 +75,7 @@ ## 游戏补位规则 -- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会优先选择最久未使用的启用机器人加入对局。 -- 机器人被选中后会记录最近使用时间和累计使用次数;只有可用机器人轮过一遍后,才会重新使用较早出现过的机器人。 +- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会从当前 App 下所有启用且未加入本局的机器人里完全随机抽取一个加入对局。 +- 机器人被选中后会记录最近使用时间和累计使用次数;这些字段只用于后台观察命中分布,不影响下一次随机抽取。 - 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。 - 机器人禁用后不会再被新对局选中。 diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 49a8ce59..d0c4d314 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -149,6 +149,22 @@ func main() { } defer walletDB.Close() + var robotProfileSource gamemanagementmodule.RobotProfileSource + if strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) != "" { + robotProfileDB, err := sql.Open("mysql", cfg.RobotProfileSource.MySQLDSN) + if err != nil { + fatalRuntime("connect_robot_profile_mysql_failed", err) + } + robotProfileCtx, robotProfileCancel := context.WithTimeout(context.Background(), cfg.RobotProfileSource.RequestTimeout) + if err := robotProfileDB.PingContext(robotProfileCtx); err != nil { + robotProfileCancel() + fatalRuntime("ping_robot_profile_mysql_failed", err) + } + robotProfileCancel() + defer robotProfileDB.Close() + robotProfileSource = gamemanagementmodule.NewLikeiRobotProfileSource(robotProfileDB) + } + store := repository.New(db) if cfg.MySQLAutoMigrate { if err := store.AutoMigrate(); err != nil { @@ -242,7 +258,7 @@ func main() { gameclient.NewGRPC(gameConn), userclient.NewGRPC(userConn), auditHandler, - gamemanagementmodule.WithRobotAvatarUploader(objectUploader, cfg.TencentCOS.ObjectPrefix), + gamemanagementmodule.WithRobotProfileSource(robotProfileSource), ), GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler), Health: healthmodule.New(store, redisClient, jobStatus), diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index dae82a1f..5325cb89 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -11,6 +11,9 @@ http_addr: "127.0.0.1:13100" mysql_dsn: "admin_user:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "user_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "wallet_reader:REPLACE_ME@tcp(10.2.21.3:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" +robot_profile_source: + mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=UTC" + request_timeout: "5s" mysql_auto_migrate: false migrations: enabled: true diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index 9b4ac1db..d62f2416 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -11,6 +11,9 @@ http_addr: ":13100" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" +robot_profile_source: + mysql_dsn: "" + request_timeout: "5s" mysql_auto_migrate: true migrations: enabled: true diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index b0595e31..b5a2080f 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -13,33 +13,34 @@ import ( ) type Config struct { - ServiceName string `yaml:"service_name"` - NodeID string `yaml:"node_id"` - Environment string `yaml:"environment"` - Log logging.Config `yaml:"log"` - HTTPAddr string `yaml:"http_addr"` - MySQLDSN string `yaml:"mysql_dsn"` - UserMySQLDSN string `yaml:"user_mysql_dsn"` - WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` - MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` - Migrations MigrationConfig `yaml:"migrations"` - JWTSecret string `yaml:"jwt_secret"` - AccessTokenTTL time.Duration `yaml:"access_token_ttl"` - RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"` - CORSAllowedOrigins []string `yaml:"cors_allowed_origins"` - RefreshCookieSecure bool `yaml:"refresh_cookie_secure"` - RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"` - Bootstrap BootstrapConfig `yaml:"bootstrap"` - BootstrapPassword string `yaml:"bootstrap_password"` - UserService UserServiceConfig `yaml:"user_service"` - TencentCOS TencentCOSConfig `yaml:"tencent-cos"` - Redis RedisConfig `yaml:"redis"` - Jobs JobsConfig `yaml:"jobs"` - WalletService WalletServiceConfig `yaml:"wallet_service"` - RoomService RoomServiceConfig `yaml:"room_service"` - ActivityService ActivityServiceConfig `yaml:"activity_service"` - GameService GameServiceConfig `yaml:"game_service"` - StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` + ServiceName string `yaml:"service_name"` + NodeID string `yaml:"node_id"` + Environment string `yaml:"environment"` + Log logging.Config `yaml:"log"` + HTTPAddr string `yaml:"http_addr"` + MySQLDSN string `yaml:"mysql_dsn"` + UserMySQLDSN string `yaml:"user_mysql_dsn"` + WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` + RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"` + MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` + Migrations MigrationConfig `yaml:"migrations"` + JWTSecret string `yaml:"jwt_secret"` + AccessTokenTTL time.Duration `yaml:"access_token_ttl"` + RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"` + CORSAllowedOrigins []string `yaml:"cors_allowed_origins"` + RefreshCookieSecure bool `yaml:"refresh_cookie_secure"` + RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"` + Bootstrap BootstrapConfig `yaml:"bootstrap"` + BootstrapPassword string `yaml:"bootstrap_password"` + UserService UserServiceConfig `yaml:"user_service"` + TencentCOS TencentCOSConfig `yaml:"tencent-cos"` + Redis RedisConfig `yaml:"redis"` + Jobs JobsConfig `yaml:"jobs"` + WalletService WalletServiceConfig `yaml:"wallet_service"` + RoomService RoomServiceConfig `yaml:"room_service"` + ActivityService ActivityServiceConfig `yaml:"activity_service"` + GameService GameServiceConfig `yaml:"game_service"` + StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` } type MigrationConfig struct { @@ -78,6 +79,11 @@ type StatisticsServiceConfig struct { RequestTimeout time.Duration `yaml:"request_timeout"` } +type RobotProfileSourceConfig struct { + MySQLDSN string `yaml:"mysql_dsn"` + RequestTimeout time.Duration `yaml:"request_timeout"` +} + type TencentCOSConfig struct { Enabled bool `yaml:"enabled"` SecretID string `yaml:"secret-id"` @@ -115,14 +121,17 @@ type JobsConfig struct { func Default() Config { return Config{ - ServiceName: "admin-server", - NodeID: "admin-local", - Environment: "local", - Log: logging.DefaultConfig(), - HTTPAddr: ":13100", - MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", - UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", - WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", + ServiceName: "admin-server", + NodeID: "admin-local", + Environment: "local", + Log: logging.DefaultConfig(), + HTTPAddr: ":13100", + MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", + UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", + WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", + RobotProfileSource: RobotProfileSourceConfig{ + RequestTimeout: 5 * time.Second, + }, MySQLAutoMigrate: true, Migrations: MigrationConfig{ Enabled: true, @@ -290,6 +299,10 @@ func (cfg *Config) Normalize() { if cfg.StatisticsService.RequestTimeout <= 0 { cfg.StatisticsService.RequestTimeout = 3 * time.Second } + cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) + if cfg.RobotProfileSource.RequestTimeout <= 0 { + cfg.RobotProfileSource.RequestTimeout = 5 * time.Second + } cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID) cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey) cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName) @@ -399,6 +412,12 @@ func (cfg Config) Validate() error { if cfg.StatisticsService.RequestTimeout <= 0 { return errors.New("statistics_service.request_timeout must be greater than 0") } + if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { + return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") + } + if cfg.RobotProfileSource.RequestTimeout <= 0 { + return errors.New("robot_profile_source.request_timeout must be greater than 0") + } if cfg.TencentCOS.Enabled { if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" { return errors.New("tencent-cos config is incomplete") diff --git a/server/admin/internal/modules/gamemanagement/handler.go b/server/admin/internal/modules/gamemanagement/handler.go index 1a1be7a7..32772a30 100644 --- a/server/admin/internal/modules/gamemanagement/handler.go +++ b/server/admin/internal/modules/gamemanagement/handler.go @@ -14,17 +14,17 @@ import ( ) type Handler struct { - game gameclient.Client - user userclient.Client - audit shared.OperationLogger - robotAvatar robotAvatarConfig + game gameclient.Client + user userclient.Client + audit shared.OperationLogger + robotProfiles RobotProfileSource } // Option 只开放模块初始化时的基础设施注入点,避免 handler 方法里散落环境配置读取。 type Option func(*Handler) func New(game gameclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler { - h := &Handler{game: game, user: user, audit: audit, robotAvatar: defaultRobotAvatarConfig()} + h := &Handler{game: game, user: user, audit: audit} for _, option := range options { if option != nil { option(h) diff --git a/server/admin/internal/modules/gamemanagement/robot_avatar.go b/server/admin/internal/modules/gamemanagement/robot_avatar.go deleted file mode 100644 index 68e1a8ad..00000000 --- a/server/admin/internal/modules/gamemanagement/robot_avatar.go +++ /dev/null @@ -1,167 +0,0 @@ -package gamemanagement - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "hyapp-admin-server/internal/platform/idgen" -) - -const ( - robotAvatarDownloadTimeout = 10 * time.Second - robotAvatarMaxBytes = 5 << 20 - robotAvatarSniffBytes = 512 -) - -// ObjectUploader 是游戏管理模块需要的对象存储最小能力,实际生产实现由后台 COS 平台层提供。 -type ObjectUploader interface { - PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) -} - -type robotAvatarConfig struct { - uploader ObjectUploader - objectPrefix string - httpClient *http.Client -} - -func defaultRobotAvatarConfig() robotAvatarConfig { - return robotAvatarConfig{ - objectPrefix: "admin", - httpClient: &http.Client{Timeout: robotAvatarDownloadTimeout}, - } -} - -// WithRobotAvatarUploader 注入后台已装配的 COS 上传器,机器人创建只拿这个薄接口,不直接依赖 COS SDK。 -func WithRobotAvatarUploader(uploader ObjectUploader, objectPrefix string) Option { - return func(h *Handler) { - h.robotAvatar.uploader = uploader - h.robotAvatar.objectPrefix = cleanRobotAvatarObjectPrefix(objectPrefix) - } -} - -// WithRobotAvatarHTTPClient 只给测试替换下载客户端;生产默认使用带超时的标准 HTTP client。 -func WithRobotAvatarHTTPClient(client *http.Client) Option { - return func(h *Handler) { - if client != nil { - h.robotAvatar.httpClient = client - } - } -} - -func (h *Handler) uploadRobotAvatarFromSource(ctx context.Context, actorID uint, sourceURL string) (string, error) { - sourceURL = strings.TrimSpace(sourceURL) - if sourceURL == "" { - return "", fmt.Errorf("robot avatar source url is empty") - } - if h.robotAvatar.uploader == nil { - return "", fmt.Errorf("robot avatar cos uploader is not configured") - } - data, contentType, err := h.downloadRobotAvatar(ctx, sourceURL) - if err != nil { - return "", err - } - - // 机器人头像先进入后台 COS 前缀,再写入用户资料;App 端只依赖自有 CDN URL,不直接依赖第三方头像站。 - key := buildRobotAvatarObjectKey(h.robotAvatar.objectPrefix, actorID, contentType, time.Now().UTC()) - return h.robotAvatar.uploader.PutObject(ctx, key, bytes.NewReader(data), int64(len(data)), contentType) -} - -func (h *Handler) downloadRobotAvatar(ctx context.Context, sourceURL string) ([]byte, string, error) { - if !validRobotAvatarSourceURL(sourceURL) { - return nil, "", fmt.Errorf("robot avatar source url is invalid") - } - client := h.robotAvatar.httpClient - if client == nil { - client = &http.Client{Timeout: robotAvatarDownloadTimeout} - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) - if err != nil { - return nil, "", fmt.Errorf("build robot avatar request: %w", err) - } - req.Header.Set("User-Agent", "hyapp-admin-server/robot-avatar") - - resp, err := client.Do(req) - if err != nil { - return nil, "", fmt.Errorf("download robot avatar: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, "", fmt.Errorf("download robot avatar returned status %d", resp.StatusCode) - } - if resp.ContentLength > robotAvatarMaxBytes { - return nil, "", fmt.Errorf("robot avatar is too large") - } - - data, err := io.ReadAll(io.LimitReader(resp.Body, robotAvatarMaxBytes+1)) - if err != nil { - return nil, "", fmt.Errorf("read robot avatar: %w", err) - } - if len(data) == 0 || len(data) > robotAvatarMaxBytes { - return nil, "", fmt.Errorf("robot avatar size is invalid") - } - - sniffSize := len(data) - if sniffSize > robotAvatarSniffBytes { - sniffSize = robotAvatarSniffBytes - } - contentType, ok := normalizeRobotAvatarContentType(http.DetectContentType(data[:sniffSize]), resp.Header.Get("Content-Type")) - if !ok { - return nil, "", fmt.Errorf("robot avatar content type is unsupported") - } - return data, contentType, nil -} - -func validRobotAvatarSourceURL(raw string) bool { - u, err := url.ParseRequestURI(strings.TrimSpace(raw)) - if err != nil { - return false - } - return (u.Scheme == "https" || u.Scheme == "http") && strings.TrimSpace(u.Host) != "" -} - -func normalizeRobotAvatarContentType(values ...string) (string, bool) { - for _, value := range values { - contentType := strings.ToLower(strings.TrimSpace(strings.Split(value, ";")[0])) - switch contentType { - case "image/jpeg", "image/jpg": - return "image/jpeg", true - case "image/png": - return "image/png", true - case "image/webp": - return "image/webp", true - } - } - return "", false -} - -func buildRobotAvatarObjectKey(objectPrefix string, actorID uint, contentType string, now time.Time) string { - extension := ".jpg" - switch contentType { - case "image/png": - extension = ".png" - case "image/webp": - extension = ".webp" - } - return fmt.Sprintf( - "%s/game-robots/avatars/%d/%s/%s%s", - cleanRobotAvatarObjectPrefix(objectPrefix), - actorID, - now.UTC().Format("20060102"), - idgen.New("avatar"), - extension, - ) -} - -func cleanRobotAvatarObjectPrefix(value string) string { - value = strings.Trim(strings.TrimSpace(value), "/") - if value == "" { - return "admin" - } - return value -} diff --git a/server/admin/internal/modules/gamemanagement/robot_avatar_test.go b/server/admin/internal/modules/gamemanagement/robot_avatar_test.go deleted file mode 100644 index 0b8adb89..00000000 --- a/server/admin/internal/modules/gamemanagement/robot_avatar_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package gamemanagement - -import ( - "context" - "encoding/base64" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "unicode" -) - -type fakeRobotAvatarUploader struct { - key string - contentType string - data []byte -} - -func (u *fakeRobotAvatarUploader) PutObject(_ context.Context, key string, reader io.Reader, _ int64, contentType string) (string, error) { - data, err := io.ReadAll(reader) - if err != nil { - return "", err - } - u.key = key - u.contentType = contentType - u.data = data - return "https://media.example.com/" + key, nil -} - -func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) { - imageBytes, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") - if err != nil { - t.Fatalf("decode png: %v", err) - } - avatarServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(imageBytes) - })) - defer avatarServer.Close() - - uploader := &fakeRobotAvatarUploader{} - handler := New(nil, nil, nil, WithRobotAvatarUploader(uploader, "admin"), WithRobotAvatarHTTPClient(avatarServer.Client())) - - got, err := handler.uploadRobotAvatarFromSource(t.Context(), 42, avatarServer.URL+"/avatar.png") - if err != nil { - t.Fatalf("uploadRobotAvatarFromSource failed: %v", err) - } - if !strings.HasPrefix(got, "https://media.example.com/admin/game-robots/avatars/42/") { - t.Fatalf("uploaded url = %q", got) - } - if uploader.contentType != "image/png" { - t.Fatalf("contentType = %q, want image/png", uploader.contentType) - } - if string(uploader.data) != string(imageBytes) { - t.Fatalf("uploaded bytes mismatch") - } -} - -func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) { - // 预设池是批量创建机器人的基础数据,数量变化会直接影响后台一次创建 1000 个机器人时的去重效果。 - if len(defaultArabicRobotNicknames) != robotPresetTargetCount { - t.Fatalf("arabic nickname preset count = %d, want %d", len(defaultArabicRobotNicknames), robotPresetTargetCount) - } - if len(defaultEnglishRobotNicknames) != robotPresetTargetCount { - t.Fatalf("english nickname preset count = %d, want %d", len(defaultEnglishRobotNicknames), robotPresetTargetCount) - } - if len(defaultRobotAvatarSources) != robotPresetTargetCount { - t.Fatalf("avatar preset count = %d, want %d", len(defaultRobotAvatarSources), robotPresetTargetCount) - } - if uniqueCount(defaultRobotAvatarSources) != robotPresetTargetCount { - t.Fatalf("avatar preset must keep %d unique source urls", robotPresetTargetCount) - } - - englishNames := make([]string, 0, robotPresetTargetCount) - arabicNames := make([]string, 0, robotPresetTargetCount) - for index := 0; index < robotPresetTargetCount; index++ { - english := robotNickname(index, "english") - if !isPureEnglishNickname(english) { - t.Fatalf("english nickname[%d] = %q, want letters only", index, english) - } - englishNames = append(englishNames, english) - arabic := robotNickname(index, "arabic") - if !isPureArabicNickname(arabic) { - t.Fatalf("arabic nickname[%d] = %q, want arabic letters only", index, arabic) - } - arabicNames = append(arabicNames, arabic) - } - if uniqueCount(englishNames) != len(englishNames) { - t.Fatalf("english nickname presets and overflow combinations must keep %d unique names", robotPresetTargetCount) - } - if uniqueCount(arabicNames) != len(arabicNames) { - t.Fatalf("arabic nickname presets and overflow combinations must keep %d unique names", robotPresetTargetCount) - } - if got := robotNickname(robotPresetTargetCount, "english"); !isPureEnglishNickname(got) { - t.Fatalf("english overflow nickname = %q, want pure english combined name", got) - } - if got := robotNickname(robotPresetTargetCount, "arabic"); !isPureArabicNickname(got) { - t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got) - } -} - -func isPureEnglishNickname(value string) bool { - if value == "" { - return false - } - for _, item := range value { - if (item < 'A' || item > 'Z') && (item < 'a' || item > 'z') { - return false - } - } - return true -} - -func isPureArabicNickname(value string) bool { - if value == "" { - return false - } - for _, item := range value { - if !unicode.Is(unicode.Arabic, item) { - return false - } - } - return true -} - -func uniqueCount(values []string) int { - seen := make(map[string]struct{}, len(values)) - for _, value := range values { - seen[value] = struct{}{} - } - return len(seen) -} diff --git a/server/admin/internal/modules/gamemanagement/robot_presets.go b/server/admin/internal/modules/gamemanagement/robot_presets.go index 5192de66..64957651 100644 --- a/server/admin/internal/modules/gamemanagement/robot_presets.go +++ b/server/admin/internal/modules/gamemanagement/robot_presets.go @@ -1,642 +1,22 @@ package gamemanagement -import ( - "fmt" - "strings" - "unicode" -) +import "strings" const ( - robotNicknameLanguageArabic = "arabic" - robotNicknameLanguageEnglish = "english" - robotPresetTargetCount = 1000 - defaultRobotGenerateCount = 20 - maxRobotGenerateCount = 1000 + defaultRobotGenerateCount = 20 + maxRobotGenerateCount = 1000 ) -var defaultRobotAvatarSources = []string{ - "https://randomuser.me/api/portraits/men/0.jpg", - "https://randomuser.me/api/portraits/men/1.jpg", - "https://randomuser.me/api/portraits/men/2.jpg", - "https://randomuser.me/api/portraits/men/3.jpg", - "https://randomuser.me/api/portraits/men/4.jpg", - "https://randomuser.me/api/portraits/men/5.jpg", - "https://randomuser.me/api/portraits/men/6.jpg", - "https://randomuser.me/api/portraits/men/7.jpg", - "https://randomuser.me/api/portraits/men/8.jpg", - "https://randomuser.me/api/portraits/men/9.jpg", - "https://randomuser.me/api/portraits/men/10.jpg", - "https://randomuser.me/api/portraits/men/11.jpg", - "https://randomuser.me/api/portraits/men/12.jpg", - "https://randomuser.me/api/portraits/men/13.jpg", - "https://randomuser.me/api/portraits/men/14.jpg", - "https://randomuser.me/api/portraits/men/15.jpg", - "https://randomuser.me/api/portraits/men/16.jpg", - "https://randomuser.me/api/portraits/men/17.jpg", - "https://randomuser.me/api/portraits/men/18.jpg", - "https://randomuser.me/api/portraits/men/19.jpg", - "https://randomuser.me/api/portraits/men/20.jpg", - "https://randomuser.me/api/portraits/men/21.jpg", - "https://randomuser.me/api/portraits/men/22.jpg", - "https://randomuser.me/api/portraits/men/23.jpg", - "https://randomuser.me/api/portraits/men/24.jpg", - "https://randomuser.me/api/portraits/men/25.jpg", - "https://randomuser.me/api/portraits/men/26.jpg", - "https://randomuser.me/api/portraits/men/27.jpg", - "https://randomuser.me/api/portraits/men/28.jpg", - "https://randomuser.me/api/portraits/men/29.jpg", - "https://randomuser.me/api/portraits/men/30.jpg", - "https://randomuser.me/api/portraits/men/31.jpg", - "https://randomuser.me/api/portraits/men/32.jpg", - "https://randomuser.me/api/portraits/men/33.jpg", - "https://randomuser.me/api/portraits/men/34.jpg", - "https://randomuser.me/api/portraits/men/35.jpg", - "https://randomuser.me/api/portraits/men/36.jpg", - "https://randomuser.me/api/portraits/men/37.jpg", - "https://randomuser.me/api/portraits/men/38.jpg", - "https://randomuser.me/api/portraits/men/39.jpg", - "https://randomuser.me/api/portraits/men/40.jpg", - "https://randomuser.me/api/portraits/men/41.jpg", - "https://randomuser.me/api/portraits/men/42.jpg", - "https://randomuser.me/api/portraits/men/43.jpg", - "https://randomuser.me/api/portraits/men/44.jpg", - "https://randomuser.me/api/portraits/men/45.jpg", - "https://randomuser.me/api/portraits/men/46.jpg", - "https://randomuser.me/api/portraits/men/47.jpg", - "https://randomuser.me/api/portraits/men/48.jpg", - "https://randomuser.me/api/portraits/men/49.jpg", - "https://randomuser.me/api/portraits/men/50.jpg", - "https://randomuser.me/api/portraits/men/51.jpg", - "https://randomuser.me/api/portraits/men/52.jpg", - "https://randomuser.me/api/portraits/men/53.jpg", - "https://randomuser.me/api/portraits/men/54.jpg", - "https://randomuser.me/api/portraits/men/55.jpg", - "https://randomuser.me/api/portraits/men/56.jpg", - "https://randomuser.me/api/portraits/men/57.jpg", - "https://randomuser.me/api/portraits/men/58.jpg", - "https://randomuser.me/api/portraits/men/59.jpg", - "https://randomuser.me/api/portraits/men/60.jpg", - "https://randomuser.me/api/portraits/men/61.jpg", - "https://randomuser.me/api/portraits/men/62.jpg", - "https://randomuser.me/api/portraits/men/63.jpg", - "https://randomuser.me/api/portraits/men/64.jpg", - "https://randomuser.me/api/portraits/men/65.jpg", - "https://randomuser.me/api/portraits/men/66.jpg", - "https://randomuser.me/api/portraits/men/67.jpg", - "https://randomuser.me/api/portraits/men/68.jpg", - "https://randomuser.me/api/portraits/men/69.jpg", - "https://randomuser.me/api/portraits/men/70.jpg", - "https://randomuser.me/api/portraits/men/71.jpg", - "https://randomuser.me/api/portraits/men/72.jpg", - "https://randomuser.me/api/portraits/men/73.jpg", - "https://randomuser.me/api/portraits/men/74.jpg", - "https://randomuser.me/api/portraits/men/75.jpg", - "https://randomuser.me/api/portraits/men/76.jpg", - "https://randomuser.me/api/portraits/men/77.jpg", - "https://randomuser.me/api/portraits/men/78.jpg", - "https://randomuser.me/api/portraits/men/79.jpg", - "https://randomuser.me/api/portraits/men/80.jpg", - "https://randomuser.me/api/portraits/men/81.jpg", - "https://randomuser.me/api/portraits/men/82.jpg", - "https://randomuser.me/api/portraits/men/83.jpg", - "https://randomuser.me/api/portraits/men/84.jpg", - "https://randomuser.me/api/portraits/men/85.jpg", - "https://randomuser.me/api/portraits/men/86.jpg", - "https://randomuser.me/api/portraits/men/87.jpg", - "https://randomuser.me/api/portraits/men/88.jpg", - "https://randomuser.me/api/portraits/men/89.jpg", - "https://randomuser.me/api/portraits/men/90.jpg", - "https://randomuser.me/api/portraits/men/91.jpg", - "https://randomuser.me/api/portraits/men/92.jpg", - "https://randomuser.me/api/portraits/men/93.jpg", - "https://randomuser.me/api/portraits/men/94.jpg", - "https://randomuser.me/api/portraits/men/95.jpg", - "https://randomuser.me/api/portraits/men/96.jpg", - "https://randomuser.me/api/portraits/men/97.jpg", - "https://randomuser.me/api/portraits/men/98.jpg", - "https://randomuser.me/api/portraits/men/99.jpg", - "https://randomuser.me/api/portraits/women/0.jpg", - "https://randomuser.me/api/portraits/women/1.jpg", - "https://randomuser.me/api/portraits/women/2.jpg", - "https://randomuser.me/api/portraits/women/3.jpg", - "https://randomuser.me/api/portraits/women/4.jpg", - "https://randomuser.me/api/portraits/women/5.jpg", - "https://randomuser.me/api/portraits/women/6.jpg", - "https://randomuser.me/api/portraits/women/7.jpg", - "https://randomuser.me/api/portraits/women/8.jpg", - "https://randomuser.me/api/portraits/women/9.jpg", - "https://randomuser.me/api/portraits/women/10.jpg", - "https://randomuser.me/api/portraits/women/11.jpg", - "https://randomuser.me/api/portraits/women/12.jpg", - "https://randomuser.me/api/portraits/women/13.jpg", - "https://randomuser.me/api/portraits/women/14.jpg", - "https://randomuser.me/api/portraits/women/15.jpg", - "https://randomuser.me/api/portraits/women/16.jpg", - "https://randomuser.me/api/portraits/women/17.jpg", - "https://randomuser.me/api/portraits/women/18.jpg", - "https://randomuser.me/api/portraits/women/19.jpg", - "https://randomuser.me/api/portraits/women/20.jpg", - "https://randomuser.me/api/portraits/women/21.jpg", - "https://randomuser.me/api/portraits/women/22.jpg", - "https://randomuser.me/api/portraits/women/23.jpg", - "https://randomuser.me/api/portraits/women/24.jpg", - "https://randomuser.me/api/portraits/women/25.jpg", - "https://randomuser.me/api/portraits/women/26.jpg", - "https://randomuser.me/api/portraits/women/27.jpg", - "https://randomuser.me/api/portraits/women/28.jpg", - "https://randomuser.me/api/portraits/women/29.jpg", - "https://randomuser.me/api/portraits/women/30.jpg", - "https://randomuser.me/api/portraits/women/31.jpg", - "https://randomuser.me/api/portraits/women/32.jpg", - "https://randomuser.me/api/portraits/women/33.jpg", - "https://randomuser.me/api/portraits/women/34.jpg", - "https://randomuser.me/api/portraits/women/35.jpg", - "https://randomuser.me/api/portraits/women/36.jpg", - "https://randomuser.me/api/portraits/women/37.jpg", - "https://randomuser.me/api/portraits/women/38.jpg", - "https://randomuser.me/api/portraits/women/39.jpg", - "https://randomuser.me/api/portraits/women/40.jpg", - "https://randomuser.me/api/portraits/women/41.jpg", - "https://randomuser.me/api/portraits/women/42.jpg", - "https://randomuser.me/api/portraits/women/43.jpg", - "https://randomuser.me/api/portraits/women/44.jpg", - "https://randomuser.me/api/portraits/women/45.jpg", - "https://randomuser.me/api/portraits/women/46.jpg", - "https://randomuser.me/api/portraits/women/47.jpg", - "https://randomuser.me/api/portraits/women/48.jpg", - "https://randomuser.me/api/portraits/women/49.jpg", - "https://randomuser.me/api/portraits/women/50.jpg", - "https://randomuser.me/api/portraits/women/51.jpg", - "https://randomuser.me/api/portraits/women/52.jpg", - "https://randomuser.me/api/portraits/women/53.jpg", - "https://randomuser.me/api/portraits/women/54.jpg", - "https://randomuser.me/api/portraits/women/55.jpg", - "https://randomuser.me/api/portraits/women/56.jpg", - "https://randomuser.me/api/portraits/women/57.jpg", - "https://randomuser.me/api/portraits/women/58.jpg", - "https://randomuser.me/api/portraits/women/59.jpg", - "https://randomuser.me/api/portraits/women/60.jpg", - "https://randomuser.me/api/portraits/women/61.jpg", - "https://randomuser.me/api/portraits/women/62.jpg", - "https://randomuser.me/api/portraits/women/63.jpg", - "https://randomuser.me/api/portraits/women/64.jpg", - "https://randomuser.me/api/portraits/women/65.jpg", - "https://randomuser.me/api/portraits/women/66.jpg", - "https://randomuser.me/api/portraits/women/67.jpg", - "https://randomuser.me/api/portraits/women/68.jpg", - "https://randomuser.me/api/portraits/women/69.jpg", - "https://randomuser.me/api/portraits/women/70.jpg", - "https://randomuser.me/api/portraits/women/71.jpg", - "https://randomuser.me/api/portraits/women/72.jpg", - "https://randomuser.me/api/portraits/women/73.jpg", - "https://randomuser.me/api/portraits/women/74.jpg", - "https://randomuser.me/api/portraits/women/75.jpg", - "https://randomuser.me/api/portraits/women/76.jpg", - "https://randomuser.me/api/portraits/women/77.jpg", - "https://randomuser.me/api/portraits/women/78.jpg", - "https://randomuser.me/api/portraits/women/79.jpg", - "https://randomuser.me/api/portraits/women/80.jpg", - "https://randomuser.me/api/portraits/women/81.jpg", - "https://randomuser.me/api/portraits/women/82.jpg", - "https://randomuser.me/api/portraits/women/83.jpg", - "https://randomuser.me/api/portraits/women/84.jpg", - "https://randomuser.me/api/portraits/women/85.jpg", - "https://randomuser.me/api/portraits/women/86.jpg", - "https://randomuser.me/api/portraits/women/87.jpg", - "https://randomuser.me/api/portraits/women/88.jpg", - "https://randomuser.me/api/portraits/women/89.jpg", - "https://randomuser.me/api/portraits/women/90.jpg", - "https://randomuser.me/api/portraits/women/91.jpg", - "https://randomuser.me/api/portraits/women/92.jpg", - "https://randomuser.me/api/portraits/women/93.jpg", - "https://randomuser.me/api/portraits/women/94.jpg", - "https://randomuser.me/api/portraits/women/95.jpg", - "https://randomuser.me/api/portraits/women/96.jpg", - "https://randomuser.me/api/portraits/women/97.jpg", - "https://randomuser.me/api/portraits/women/98.jpg", - "https://randomuser.me/api/portraits/women/99.jpg", -} - -var defaultArabicRobotNicknames = []string{ - "أمير", - "سيف", - "مالك", - "فارس", - "ليث", - "بدر", - "ماهر", - "رامي", - "كريم", - "سامي", - "عمر", - "يوسف", - "آدم", - "ريان", - "زيد", - "نور", - "ياسين", - "أنس", - "خالد", - "طارق", - "حازم", - "نادر", - "وسيم", - "جواد", - "مروان", - "إياد", - "نبيل", - "هلال", - "تميم", - "سلمان", - "راشد", - "جاسم", - "ماجد", - "سلطان", - "عادل", - "هاني", - "بلال", - "يزن", - "غيث", - "قصي", - "سليم", - "ريان_الليل", - "نجم", - "ملك_اللعبة", - "صقر", - "ذيب", - "أسد", - "الزعيم", - "القناص", - "الأسطورة", - "ظل", - "برق", - "رعد", - "موج", - "نمر", - "شهاب", - "فهد", - "نايف", - "نوف", - "ليان", - "ريم", - "سارة", - "مريم", - "لينا", - "هنا", - "جود", - "تالا", - "دانا", - "لارا", - "ياسمين", - "نوران", - "رؤى", - "سما", - "ملك", - "جنى", - "آية", - "ندى", - "فرح", - "أمل", - "هدى", - "سلمى", - "ليلى", - "رنا", - "نورا", - "ديما", - "مايا", - "ميس", - "رهف", - "شهد", - "غلا", - "لولو", - "وردة", - "قمر", - "نجمة", - "لؤلؤة", - "الملكة", - "أميرة", - "روح", - "حلم", - "بسمة", -} - -var defaultEnglishRobotNicknames = []string{ - "Alex", - "Jordan", - "Taylor", - "Morgan", - "Riley", - "Nova", - "Phoenix", - "Hunter", - "Luna", - "Mia", - "Noah", - "Liam", - "Ava", - "Emma", - "Oliver", - "Elijah", - "James", - "William", - "Benjamin", - "Lucas", - "Mason", - "Ethan", - "Logan", - "Jackson", - "Levi", - "Sebastian", - "Mateo", - "Jack", - "Owen", - "Theodore", - "Aiden", - "Samuel", - "Joseph", - "John", - "David", - "Wyatt", - "Matthew", - "Luke", - "Asher", - "Carter", - "Julian", - "Grayson", - "Leo", - "Jayden", - "Gabriel", - "Isaac", - "Lincoln", - "Anthony", - "Hudson", - "Dylan", - "Ezra", - "Thomas", - "Charles", - "Christopher", - "Jaxon", - "Maverick", - "Josiah", - "Isaiah", - "Andrew", - "Elias", - "Joshua", - "Nathan", - "Caleb", - "Ryan", - "Adrian", - "Miles", - "Eli", - "Nolan", - "Christian", - "Aaron", - "Cameron", - "Ezekiel", - "Colton", - "Luca", - "Landon", - "Hannah", - "Sofia", - "Isabella", - "Amelia", - "Olivia", - "Charlotte", - "Harper", - "Evelyn", - "Abigail", - "Ella", - "Scarlett", - "Grace", - "Chloe", - "Victoria", - "Aria", - "Penelope", - "Layla", - "Zoey", - "Nora", - "Lily", - "Ellie", - "Violet", - "Aurora", - "Stella", - "Hazel", -} - -var defaultEnglishRobotNicknameSuffixes = []string{ - "River", - "Sky", - "Star", - "Moon", - "Storm", - "Stone", - "Vale", - "Lake", - "Light", - "Wave", - "Cloud", - "Flame", - "Forest", - "Dream", - "Shadow", - "Bloom", - "Quest", - "Spark", - "North", - "South", - "East", - "West", - "Dawn", - "Dusk", - "Peak", -} - -var defaultArabicRobotNicknameSuffixes = []string{ - "نور", - "قمر", - "نجم", - "سيف", - "بحر", - "مجد", - "حلم", - "روح", - "ورد", - "برق", - "رعد", - "شمس", - "ليل", - "فجر", - "درر", - "موج", - "فرح", - "أمل", - "غيم", - "ندى", - "سحر", - "لؤلؤ", - "ذهب", - "عز", - "وفا", -} - -func init() { - defaultRobotAvatarSources = expandRobotAvatarSources(defaultRobotAvatarSources, robotPresetTargetCount) - defaultEnglishRobotNicknames = expandRobotNicknamePool(defaultEnglishRobotNicknames, defaultEnglishRobotNicknameSuffixes, robotPresetTargetCount, robotNicknameLanguageEnglish) - defaultArabicRobotNicknames = expandRobotNicknamePool(defaultArabicRobotNicknames, defaultArabicRobotNicknameSuffixes, robotPresetTargetCount, robotNicknameLanguageArabic) -} - -func expandRobotAvatarSources(base []string, target int) []string { - // 头像池必须是固定顺序,不能运行时随机;后台批量生成 1000 个时按 index 取值,重复请求也能得到同一批源图。 - if target <= 0 { - return nil - } - result := make([]string, 0, target) - seen := make(map[string]struct{}, target) - add := func(source string) bool { - source = strings.TrimSpace(source) - if source == "" { - return len(result) >= target - } - if _, ok := seen[source]; ok { - return len(result) >= target - } - seen[source] = struct{}{} - result = append(result, source) - return len(result) >= target - } - for _, source := range base { - if add(source) { - return result - } - } - for index := 1; len(result) < target; index++ { - // DiceBear 用 seed 生成确定性头像,URL 唯一且返回 image/png;这里只存源 URL,真正头像仍会先转存到后台 COS。 - add(fmt.Sprintf("https://api.dicebear.com/9.x/thumbs/png?seed=hyapp-robot-%04d", index)) - } - return result -} - -func expandRobotNicknamePool(base []string, suffixes []string, target int, language string) []string { - // 名称池也保持固定顺序:先保留已有预设名,再用同语言后缀组合扩容,避免数字后缀和英阿混排进入用户资料。 - if target <= 0 { - return nil - } - result := make([]string, 0, target) - seen := make(map[string]struct{}, target) - add := func(name string) bool { - name = sanitizeRobotNickname(name, language) - if name == "" { - return len(result) >= target - } - if _, ok := seen[name]; ok { - return len(result) >= target - } - seen[name] = struct{}{} - result = append(result, name) - return len(result) >= target - } - for _, name := range base { - if add(name) { - return result - } - } - - cleanBase := append([]string(nil), result...) - cleanSuffixes := make([]string, 0, len(suffixes)) - for _, suffix := range suffixes { - suffix = sanitizeRobotNickname(suffix, language) - if suffix != "" { - cleanSuffixes = append(cleanSuffixes, suffix) - } - } - for _, name := range cleanBase { - for _, suffix := range cleanSuffixes { - if add(name + suffix) { - return result - } - } - } - for offset := 1; len(result) < target; offset++ { - for index, name := range cleanBase { - next := cleanBase[(index+offset)%len(cleanBase)] - if add(name + next) { - return result - } - } - } - return result -} - -func normalizeRobotNicknameLanguage(value string) string { - // 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。 +func robotAccountLanguage(value string) string { + // likei 昵称和头像是资料事实来源;这里的 language 只保留为账号元数据,兼容后台旧请求传入 ar/en 的使用习惯。 switch strings.ToLower(strings.TrimSpace(value)) { case "ar", "arabic", "arab": - return robotNicknameLanguageArabic - case "en", "english": - return robotNicknameLanguageEnglish - default: - return robotNicknameLanguageEnglish - } -} - -func robotAccountLanguage(nicknameLanguage string) string { - // 用户资料里的 language 跟昵称池保持一致,后续客户端或运营筛选机器人时不用再反推昵称字符集。 - if normalizeRobotNicknameLanguage(nicknameLanguage) == robotNicknameLanguageArabic { return "ar" + case "en", "english": + return "en" + default: + return "en" } - return "en" -} - -func robotNickname(index int, nicknameLanguage string) string { - // 机器人昵称只从单一语言池取值,不能再拼随机 hex,否则会出现数字、下划线和英阿混排。 - language := normalizeRobotNicknameLanguage(nicknameLanguage) - if language == robotNicknameLanguageArabic { - return robotNicknameFromPool(index, defaultArabicRobotNicknames, "لاعب", language) - } - return robotNicknameFromPool(index, defaultEnglishRobotNicknames, "Player", language) -} - -func robotNicknameFromPool(index int, pool []string, fallback string, language string) string { - // 前 1000 个直接使用固定预设池;超过池大小时仍用同语言两个预设名拼接,保证手工超界调用也不带数字后缀。 - if index < 0 { - index = 0 - } - if len(pool) == 0 { - return fallback - } - base := sanitizeRobotNickname(pool[index%len(pool)], language) - if base == "" { - base = fallback - } - if index < len(pool) { - return base - } - next := sanitizeRobotNickname(pool[(index%len(pool)+1)%len(pool)], language) - if next == "" || next == base { - next = sanitizeRobotNickname(pool[(index+index/len(pool)+2)%len(pool)], language) - } - if next == "" || next == base { - return base - } - return base + next -} - -func sanitizeRobotNickname(value string, language string) string { - // 预设里可能有旧的分隔符或非目标语言字符;写库前统一剔除,保证“英语纯英语、阿拉伯语纯阿拉伯语”。 - var builder strings.Builder - for _, item := range strings.TrimSpace(value) { - switch language { - case robotNicknameLanguageArabic: - if unicode.Is(unicode.Arabic, item) { - builder.WriteRune(item) - } - default: - if (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z') { - builder.WriteRune(item) - } - } - } - return builder.String() } func robotGender(index int, requested string) string { @@ -649,13 +29,3 @@ func robotGender(index int, requested string) string { } return "female" } - -func robotAvatarSource(index int, requested []string) string { - if source := pickString(requested, index); source != "" { - return source - } - if len(defaultRobotAvatarSources) == 0 { - return "" - } - return defaultRobotAvatarSources[index%len(defaultRobotAvatarSources)] -} diff --git a/server/admin/internal/modules/gamemanagement/robot_profile_source.go b/server/admin/internal/modules/gamemanagement/robot_profile_source.go new file mode 100644 index 00000000..099deb20 --- /dev/null +++ b/server/admin/internal/modules/gamemanagement/robot_profile_source.go @@ -0,0 +1,101 @@ +package gamemanagement + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strings" + "unicode/utf8" +) + +type RobotProfile struct { + Nickname string + Avatar string +} + +type RobotProfileSource interface { + RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error) +} + +type likeiRobotProfileSource struct { + db *sql.DB +} + +func NewLikeiRobotProfileSource(db *sql.DB) RobotProfileSource { + if db == nil { + return nil + } + return &likeiRobotProfileSource{db: db} +} + +// WithRobotProfileSource 注入机器人资料池;创建机器人时只消费这里返回的线上 likei 用户昵称和头像。 +func WithRobotProfileSource(source RobotProfileSource) Option { + return func(h *Handler) { + h.robotProfiles = source + } +} + +func (s *likeiRobotProfileSource) RandomRobotProfiles(ctx context.Context, count int) ([]RobotProfile, error) { + if s == nil || s.db == nil { + return nil, fmt.Errorf("likei robot profile source is not configured") + } + if count <= 0 { + return nil, nil + } + rows, err := s.db.QueryContext(ctx, ` + SELECT TRIM(user_nickname), TRIM(user_avatar) + FROM user_base_info + WHERE account_status = 'NORMAL' + AND is_del = 0 + AND origin_sys = 'LIKEI' + AND user_avatar IS NOT NULL AND TRIM(user_avatar) <> '' + AND user_nickname IS NOT NULL AND TRIM(user_nickname) <> '' + AND CHAR_LENGTH(TRIM(user_nickname)) <= 64 + AND CHAR_LENGTH(TRIM(user_avatar)) <= 512 + AND (TRIM(user_avatar) LIKE 'http://%' OR TRIM(user_avatar) LIKE 'https://%') + ORDER BY RAND() + LIMIT ? + `, count) + if err != nil { + return nil, fmt.Errorf("query likei robot profiles: %w", err) + } + defer rows.Close() + + profiles := make([]RobotProfile, 0, count) + for rows.Next() { + var profile RobotProfile + if err := rows.Scan(&profile.Nickname, &profile.Avatar); err != nil { + return nil, fmt.Errorf("scan likei robot profile: %w", err) + } + if normalized, ok := normalizeRobotProfile(profile); ok { + profiles = append(profiles, normalized) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate likei robot profiles: %w", err) + } + if len(profiles) < count { + return nil, fmt.Errorf("likei robot profile pool returned %d profiles, need %d", len(profiles), count) + } + return profiles, nil +} + +func normalizeRobotProfile(profile RobotProfile) (RobotProfile, bool) { + profile.Nickname = strings.TrimSpace(profile.Nickname) + profile.Avatar = strings.TrimSpace(profile.Avatar) + if profile.Nickname == "" || profile.Avatar == "" { + return RobotProfile{}, false + } + if utf8.RuneCountInString(profile.Nickname) > 64 || utf8.RuneCountInString(profile.Avatar) > 512 { + return RobotProfile{}, false + } + parsed, err := url.Parse(profile.Avatar) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return RobotProfile{}, false + } + if strings.ContainsAny(profile.Avatar, " \t\r\n") { + return RobotProfile{}, false + } + return profile, true +} diff --git a/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go b/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go new file mode 100644 index 00000000..baf22a82 --- /dev/null +++ b/server/admin/internal/modules/gamemanagement/robot_profile_source_test.go @@ -0,0 +1,106 @@ +package gamemanagement + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "hyapp-admin-server/internal/integration/gameclient" + "hyapp-admin-server/internal/integration/userclient" + "hyapp-admin-server/internal/middleware" + gamev1 "hyapp.local/api/proto/game/v1" + + "github.com/gin-gonic/gin" +) + +type fakeRobotProfileSource struct { + profiles []RobotProfile + requested int +} + +func (s *fakeRobotProfileSource) RandomRobotProfiles(_ context.Context, count int) ([]RobotProfile, error) { + s.requested = count + return s.profiles[:count], nil +} + +type fakeRobotUserClient struct { + userclient.Client + created []userclient.QuickCreateAccountRequest +} + +func (c *fakeRobotUserClient) QuickCreateAccount(_ context.Context, req userclient.QuickCreateAccountRequest) (*userclient.QuickCreateAccountResult, error) { + c.created = append(c.created, req) + return &userclient.QuickCreateAccountResult{UserID: int64(9000 + len(c.created)), DisplayUserID: "robot"}, nil +} + +func (c *fakeRobotUserClient) BatchGetUsers(_ context.Context, _ userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) { + users := make(map[int64]*userclient.User, len(c.created)) + for index, created := range c.created { + userID := int64(9001 + index) + users[userID] = &userclient.User{UserID: userID, DisplayUserID: "robot", Username: created.Username, Avatar: created.Avatar} + } + return users, nil +} + +type fakeRobotGameClient struct { + gameclient.Client + registered []int64 +} + +func (c *fakeRobotGameClient) RegisterDiceRobots(_ context.Context, req *gamev1.RegisterDiceRobotsRequest) (*gamev1.RegisterDiceRobotsResponse, error) { + c.registered = append(c.registered, req.GetUserIds()...) + robots := make([]*gamev1.DiceRobot, 0, len(req.GetUserIds())) + for _, userID := range req.GetUserIds() { + robots = append(robots, &gamev1.DiceRobot{AppCode: "lalu", GameId: "dice", UserId: userID, Status: "active"}) + } + return &gamev1.RegisterDiceRobotsResponse{Robots: robots}, nil +} + +func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) { + gin.SetMode(gin.TestMode) + profiles := &fakeRobotProfileSource{profiles: []RobotProfile{ + {Nickname: "likei-one", Avatar: "https://media.haiyihy.com/avatar/one.jpg"}, + {Nickname: "likei-two", Avatar: "https://media.haiyihy.com/avatar/two.jpg"}, + }} + users := &fakeRobotUserClient{} + games := &fakeRobotGameClient{} + handler := New(games, users, nil, WithRobotProfileSource(profiles)) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Set(middleware.ContextRequestID, "req-robot-test") + c.Set(middleware.ContextUserID, uint(7)) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/games/self/dice/robots:generate", bytes.NewBufferString(`{ + "count": 2, + "nicknameLanguage": "ar", + "avatarUrls": ["https://randomuser.me/api/portraits/men/1.jpg"], + "country": "SA" + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.GenerateDiceRobots(c) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if profiles.requested != 2 { + t.Fatalf("profile source requested %d profiles, want 2", profiles.requested) + } + if len(users.created) != 2 { + t.Fatalf("created users = %d, want 2", len(users.created)) + } + if users.created[0].Username != "likei-one" || users.created[0].Avatar != "https://media.haiyihy.com/avatar/one.jpg" { + t.Fatalf("first robot profile = %#v", users.created[0]) + } + if users.created[1].Username != "likei-two" || users.created[1].Avatar != "https://media.haiyihy.com/avatar/two.jpg" { + t.Fatalf("second robot profile = %#v", users.created[1]) + } + if users.created[0].Language != "ar" || users.created[0].Source != "game_robot" || users.created[0].Country != "SA" { + t.Fatalf("robot account metadata = %#v", users.created[0]) + } + if len(games.registered) != 2 || games.registered[0] != 9001 || games.registered[1] != 9002 { + t.Fatalf("registered user ids = %#v", games.registered) + } +} diff --git a/server/admin/internal/modules/gamemanagement/self_game_handler.go b/server/admin/internal/modules/gamemanagement/self_game_handler.go index c3103acc..286625c3 100644 --- a/server/admin/internal/modules/gamemanagement/self_game_handler.go +++ b/server/admin/internal/modules/gamemanagement/self_game_handler.go @@ -118,41 +118,43 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) { if req.Count > maxRobotGenerateCount { req.Count = maxRobotGenerateCount } - nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage) country := strings.TrimSpace(req.Country) if country == "" { country = "SA" } + if h.robotProfiles == nil { + response.ServerError(c, "likei 机器人资料源未配置") + return + } + // 新机器人资料只从 likei 线上用户库随机抽取;后台不再使用本地昵称池、随机头像站或 COS 转存, + // 这样每次批量创建都面对真实用户资料池,避免少量固定预设反复出现在匹配列表里。 + profiles, err := h.robotProfiles.RandomRobotProfiles(c.Request.Context(), int(req.Count)) + if err != nil { + response.BadRequest(c, "获取 likei 机器人资料失败: "+err.Error()) + return + } + if len(profiles) < int(req.Count) { + response.BadRequest(c, "likei 机器人资料数量不足") + return + } userIDs := make([]int64, 0, req.Count) requestID := middleware.CurrentRequestID(c) - actorID := middleware.CurrentUserID(c) - avatarCache := make(map[string]string) + accountLanguage := robotAccountLanguage(req.NicknameLanguage) for index := int32(0); index < req.Count; index++ { - // 批量机器人必须一次性写齐完整资料;同一请求内相同头像源只转存一次,避免 200 个账号重复下载上传同一张图。 - avatarSource := robotAvatarSource(int(index), req.AvatarURLs) - avatarURL := avatarCache[avatarSource] - if avatarURL == "" { - var err error - avatarURL, err = h.uploadRobotAvatarFromSource(c.Request.Context(), actorID, avatarSource) - if err != nil { - response.BadRequest(c, "上传机器人头像失败: "+err.Error()) - return - } - avatarCache[avatarSource] = avatarURL - } + profile := profiles[index] created, err := h.user.QuickCreateAccount(c.Request.Context(), userclient.QuickCreateAccountRequest{ RequestID: requestID, Caller: "admin-server", Password: randomRobotPassword(), - Username: robotNickname(int(index), nicknameLanguage), - Avatar: avatarURL, + Username: profile.Nickname, + Avatar: profile.Avatar, Gender: robotGender(int(index), req.Gender), Country: country, DeviceID: "game-robot-" + randomRobotHex(8), Source: "game_robot", InstallChannel: "admin", Platform: "android", - Language: robotAccountLanguage(nicknameLanguage), + Language: accountLanguage, Timezone: "UTC", }) if err != nil { @@ -248,13 +250,6 @@ func randomRobotHex(size int) string { return hex.EncodeToString(buf) } -func pickString(values []string, index int) string { - if len(values) == 0 { - return "" - } - return strings.TrimSpace(values[index%len(values)]) -} - func parseInt64Param(raw string) (int64, error) { var value int64 _, err := fmt.Sscan(strings.TrimSpace(raw), &value) diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 7c5b9911..1da0f916 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -325,10 +325,10 @@ CREATE TABLE IF NOT EXISTS game_self_game_robots ( used_count BIGINT NOT NULL DEFAULT 0 COMMENT '累计被匹配补位次数', PRIMARY KEY(app_code, game_id, user_id), KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id), - KEY idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id) + KEY idx_game_robot_random_pool(app_code, status, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表'; --- 机器人轮转依赖最近使用时间和累计使用次数;旧表补默认 0,保证存量机器人优先进入第一轮冷却排序。 +-- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 0,避免存量机器人资料读取失败。 SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND COLUMN_NAME = 'last_used_at_ms') = 0, 'ALTER TABLE game_self_game_robots ADD COLUMN last_used_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''最近一次被匹配补位时间,UTC epoch ms'' AFTER updated_at_ms', @@ -348,8 +348,17 @@ EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl := IF( - (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_rotation') = 0, - 'ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_rotation (app_code, status, last_used_at_ms, used_count, user_id)', + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_rotation') > 0, + 'ALTER TABLE game_self_game_robots DROP INDEX idx_game_robot_rotation', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND INDEX_NAME = 'idx_game_robot_random_pool') = 0, + 'ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_random_pool (app_code, status, user_id)', 'SELECT 1' ); PREPARE stmt FROM @ddl; diff --git a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go index f06d7d49..efcf4d2b 100644 --- a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go +++ b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go @@ -265,7 +265,7 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI return r.GetDiceMatch(ctx, app, match.MatchID) } -// PickActiveDiceRobot 从当前 App 的通用机器人池里挑选一个未加入本局、冷却最久的机器人。 +// PickActiveDiceRobot 从当前 App 的通用机器人池里完全随机挑选一个未加入本局的机器人。 // game_self_game_robots 仍保留 game_id 字段是为了兼容旧数据和后台接口,但机器人账号本身不再按 dice/rock 隔离; // 只要同一个 app_code 下存在 active 登记,这个真实用户就可以给任意自研游戏补位。 func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) { @@ -279,8 +279,9 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga defer func() { _ = tx.Rollback() }() var robot dicedomain.Robot - // 候选行按“最久未使用、使用次数最少、user_id 最小”稳定排序,并用 SKIP LOCKED 避开其他正在补位的事务。 - // 这样少量连续匹配会先把机器人池轮一遍;并发匹配也不会在同一瞬间都拿到同一条机器人登记。 + // 用户要求“100 个机器人就是 100 选 1”,因此候选池只过滤 app、启用状态和本局已加入机器人; + // last_used_at_ms/used_count 只做后台观察和排查,不参与排序,避免冷却或轮转把随机池缩小成少数候选。 + // SKIP LOCKED 让并发补位跳过其它事务已经锁住的候选行,减少多个等待局同一瞬间抢到同一个机器人。 err = tx.QueryRowContext(ctx, `SELECT r.app_code, ? AS game_id, r.user_id, ? AS status, r.created_by_admin_id, r.created_at_ms, r.updated_at_ms, @@ -291,7 +292,7 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga SELECT 1 FROM game_dice_participants p WHERE p.app_code = r.app_code AND p.match_id = ? AND p.user_id = r.user_id ) - ORDER BY r.last_used_at_ms ASC, r.used_count ASC, r.user_id ASC + ORDER BY RAND() LIMIT 1 FOR UPDATE SKIP LOCKED`, gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID, @@ -306,7 +307,7 @@ func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, ga return dicedomain.Robot{}, err } - // 选中后立即写入轮转水位。即使后续补位并发失败,这个账号也会短暂进入冷却,避免多个等待局同时反复命中同一个机器人。 + // 选中后仍记录使用水位,方便后台观察机器人命中分布;水位不反向影响下一次抽取,下一局仍从完整可用池随机。 res, err := tx.ExecContext(ctx, `UPDATE game_self_game_robots SET last_used_at_ms = ?, used_count = used_count + 1 diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 28f4f8fd..38b9a8b3 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -233,7 +233,7 @@ func (r *Repository) Migrate(ctx context.Context) error { used_count BIGINT NOT NULL DEFAULT 0, PRIMARY KEY(app_code, game_id, user_id), KEY idx_game_robot_status(app_code, game_id, status, updated_at_ms, user_id), - KEY idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id) + KEY idx_game_robot_random_pool(app_code, status, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS game_callback_logs ( app_code VARCHAR(32) NOT NULL, @@ -395,12 +395,32 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureIndexDefinition(ctx, "game_dice_participants", "idx_game_dice_participant_status", []string{"app_code", "match_id", "status", "seat_no"}, "ALTER TABLE game_dice_participants ADD INDEX idx_game_dice_participant_status(app_code, match_id, status, seat_no)"); err != nil { return err } - if err := r.ensureIndexDefinition(ctx, "game_self_game_robots", "idx_game_robot_rotation", []string{"app_code", "status", "last_used_at_ms", "used_count", "user_id"}, "ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_rotation(app_code, status, last_used_at_ms, used_count, user_id)"); err != nil { + if err := r.dropIndexIfExists(ctx, "game_self_game_robots", "idx_game_robot_rotation"); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, "game_self_game_robots", "idx_game_robot_random_pool", []string{"app_code", "status", "user_id"}, "ALTER TABLE game_self_game_robots ADD INDEX idx_game_robot_random_pool(app_code, status, user_id)"); err != nil { return err } return nil } +func (r *Repository) dropIndexIfExists(ctx context.Context, tableName string, indexName string) error { + var count int + if err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`, + tableName, indexName, + ).Scan(&count); err != nil { + return err + } + if count == 0 { + return nil + } + _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` DROP INDEX `+indexName) + return err +} + func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { var count int if err := r.db.QueryRowContext(ctx, diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index 10de9b59..0a631127 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -168,7 +168,7 @@ func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) { } } -func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) { +func TestPickActiveDiceRobotRandomlySamplesWholeActivePool(t *testing.T) { caller := mysqlschema.CallerFile(t, 1) schema := mysqlschema.New(t, mysqlschema.Config{ InitDBPath: mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"), @@ -183,38 +183,39 @@ func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(t *testing.T) { t.Cleanup(func() { _ = repo.Close() }) nowMS := int64(1780542672000) - userIDs := []int64{101, 102, 103, 104} + userIDs := []int64{101, 102} for index, userID := range userIDs { + usedCount := int64(0) + if userID == 101 { + usedCount = 999 + } if _, err := repo.db.ExecContext(ctx, `INSERT INTO game_self_game_robots ( app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count - ) VALUES (?, ?, ?, ?, 7, ?, ?, 0, 0)`, - "lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), + ) VALUES (?, ?, ?, ?, 7, ?, ?, 0, ?)`, + "lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), usedCount, ); err != nil { t.Fatalf("insert robot %d failed: %v", userID, err) } } - for attempt, wantUserID := range userIDs { - robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_rotate_%d", attempt), nowMS+100+int64(attempt)) + picked := map[int64]bool{} + for attempt := 0; attempt < 48; attempt++ { + robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_random_%d", attempt), nowMS+100+int64(attempt)) if err != nil { t.Fatalf("pick active dice robot failed: %v", err) } - if robot.UserID != wantUserID { - t.Fatalf("rotation pick %d mismatch: got %+v want user_id=%d", attempt, robot, wantUserID) + if !containsInt64(userIDs, robot.UserID) { + t.Fatalf("random pick %d returned unknown robot: %+v", attempt, robot) } - if robot.LastUsedAtMS != nowMS+100+int64(attempt) || robot.UsedCount != 1 { - t.Fatalf("picked robot should return updated cooldown fields, got %+v", robot) + picked[robot.UserID] = true + if robot.LastUsedAtMS != nowMS+100+int64(attempt) { + t.Fatalf("picked robot should return updated usage time, got %+v", robot) } } - - reused, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_rotate_reuse", nowMS+200) - if err != nil { - t.Fatalf("pick reusable dice robot failed: %v", err) - } - if reused.UserID != userIDs[0] || reused.UsedCount != 2 { - t.Fatalf("after every robot has cooled once, rotation should reuse the oldest robot first, got %+v", reused) + if len(picked) != len(userIDs) { + t.Fatalf("full random must keep high used_count robots in the candidate pool, got picked=%+v", picked) } if _, err := repo.db.ExecContext(ctx,