diff --git a/docs/全站机器人产品文档.md b/docs/全站机器人产品文档.md index 965ea608..7f0e6bc4 100644 --- a/docs/全站机器人产品文档.md +++ b/docs/全站机器人产品文档.md @@ -55,7 +55,7 @@ ### 批量创建 - 地址:`POST /admin/game/robots/generate` -- 参数:`count`、`nicknameLanguage`、`gender`、`country`、`avatarUrls` +- 参数:`count`(默认 20,最多 1000)、`nicknameLanguage`、`gender`、`country`、`avatarUrls` - 返回值:创建数量、机器人列表 - 相关 IM:无;机器人不导入 IM 登录账号 @@ -75,6 +75,7 @@ ## 游戏补位规则 -- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会选择启用机器人加入对局。 +- 自研游戏配置里开启机器人补位后,玩家等待超过配置时间,game-service 会优先选择最久未使用的启用机器人加入对局。 +- 机器人被选中后会记录最近使用时间和累计使用次数;只有可用机器人轮过一遍后,才会重新使用较早出现过的机器人。 - 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。 - 机器人禁用后不会再被新对局选中。 diff --git a/scripts/mysql/045_remove_default_self_games.sql b/scripts/mysql/045_remove_default_self_games.sql deleted file mode 100644 index e58af010..00000000 --- a/scripts/mysql/045_remove_default_self_games.sql +++ /dev/null @@ -1,31 +0,0 @@ -SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; - -USE hyapp_game; - --- 清理旧初始化脚本写入的自研游戏展示入口;不删除对局、钱包、outbox 和奖池流水,避免破坏历史业务事实。 -DELETE FROM game_display_rules -WHERE app_code = 'lalu' - AND game_id IN ('dice', 'rock'); - -DELETE FROM game_self_game_configs -WHERE app_code = 'lalu' - AND game_id IN ('dice', 'rock'); - -DELETE FROM game_self_game_pools -WHERE app_code = 'lalu' - AND game_id IN ('dice', 'rock'); - -DELETE FROM game_catalog -WHERE app_code = 'lalu' - AND game_id IN ('dice', 'rock'); - -DELETE p -FROM game_platforms p -WHERE p.app_code = 'lalu' - AND p.platform_code = 'dice' - AND NOT EXISTS ( - SELECT 1 - FROM game_catalog c - WHERE c.app_code = p.app_code - AND c.platform_code = p.platform_code - ); diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index aabc4613..dae82a1f 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -24,6 +24,7 @@ cors_allowed_origins: - "http://43.128.56.40" - "http://hyapp-platform.global-interaction.com" - "https://hyapp-platform.global-interaction.com" + - "https://api-acc.global-interaction.com" refresh_cookie_secure: true refresh_cookie_same_site: "none" bootstrap: diff --git a/server/admin/internal/modules/gamemanagement/robot_avatar_test.go b/server/admin/internal/modules/gamemanagement/robot_avatar_test.go index aafe6112..0b8adb89 100644 --- a/server/admin/internal/modules/gamemanagement/robot_avatar_test.go +++ b/server/admin/internal/modules/gamemanagement/robot_avatar_test.go @@ -58,23 +58,23 @@ func TestUploadRobotAvatarFromSourceUploadsDownloadedImageToCOS(t *testing.T) { } func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) { - // 预设池是批量创建机器人的基础数据,数量变化会直接影响前端一次创建 200 个机器人时的去重效果。 - if len(defaultArabicRobotNicknames) != 100 { - t.Fatalf("arabic nickname preset count = %d, want 100", len(defaultArabicRobotNicknames)) + // 预设池是批量创建机器人的基础数据,数量变化会直接影响后台一次创建 1000 个机器人时的去重效果。 + if len(defaultArabicRobotNicknames) != robotPresetTargetCount { + t.Fatalf("arabic nickname preset count = %d, want %d", len(defaultArabicRobotNicknames), robotPresetTargetCount) } - if len(defaultEnglishRobotNicknames) != 100 { - t.Fatalf("english nickname preset count = %d, want 100", len(defaultEnglishRobotNicknames)) + if len(defaultEnglishRobotNicknames) != robotPresetTargetCount { + t.Fatalf("english nickname preset count = %d, want %d", len(defaultEnglishRobotNicknames), robotPresetTargetCount) } - if len(defaultRobotAvatarSources) != 200 { - t.Fatalf("avatar preset count = %d, want 200", len(defaultRobotAvatarSources)) + if len(defaultRobotAvatarSources) != robotPresetTargetCount { + t.Fatalf("avatar preset count = %d, want %d", len(defaultRobotAvatarSources), robotPresetTargetCount) } - if uniqueCount(defaultRobotAvatarSources) != 200 { - t.Fatalf("avatar preset must keep 200 unique source urls") + if uniqueCount(defaultRobotAvatarSources) != robotPresetTargetCount { + t.Fatalf("avatar preset must keep %d unique source urls", robotPresetTargetCount) } - englishNames := make([]string, 0, 200) - arabicNames := make([]string, 0, 200) - for index := 0; index < 200; index++ { + 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) @@ -87,15 +87,15 @@ func TestRobotNicknameUsesSingleLanguagePresetWithoutDigits(t *testing.T) { arabicNames = append(arabicNames, arabic) } if uniqueCount(englishNames) != len(englishNames) { - t.Fatalf("english nickname presets and overflow combinations must keep 200 unique names") + 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 200 unique names") + t.Fatalf("arabic nickname presets and overflow combinations must keep %d unique names", robotPresetTargetCount) } - if got := robotNickname(100, "english"); got != "AlexJordan" { + if got := robotNickname(robotPresetTargetCount, "english"); !isPureEnglishNickname(got) { t.Fatalf("english overflow nickname = %q, want pure english combined name", got) } - if got := robotNickname(100, "arabic"); got != "أميرسيف" { + if got := robotNickname(robotPresetTargetCount, "arabic"); !isPureArabicNickname(got) { t.Fatalf("arabic overflow nickname = %q, want pure arabic combined name", got) } } diff --git a/server/admin/internal/modules/gamemanagement/robot_presets.go b/server/admin/internal/modules/gamemanagement/robot_presets.go index aafcb85b..5192de66 100644 --- a/server/admin/internal/modules/gamemanagement/robot_presets.go +++ b/server/admin/internal/modules/gamemanagement/robot_presets.go @@ -1,6 +1,7 @@ package gamemanagement import ( + "fmt" "strings" "unicode" ) @@ -8,6 +9,9 @@ import ( const ( robotNicknameLanguageArabic = "arabic" robotNicknameLanguageEnglish = "english" + robotPresetTargetCount = 1000 + defaultRobotGenerateCount = 20 + maxRobotGenerateCount = 1000 ) var defaultRobotAvatarSources = []string{ @@ -419,6 +423,150 @@ var defaultEnglishRobotNicknames = []string{ "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 { // 后台只开放英语和阿拉伯语两类昵称池;未知值回退英语,避免历史请求继续落到英阿混合池。 switch strings.ToLower(strings.TrimSpace(value)) { @@ -449,7 +597,7 @@ func robotNickname(index int, nicknameLanguage string) string { } func robotNicknameFromPool(index int, pool []string, fallback string, language string) string { - // 前 100 个直接使用预设名;超过预设数量时用同语言两个预设名拼接,保证批量 200 个仍然不带数字后缀。 + // 前 1000 个直接使用固定预设池;超过池大小时仍用同语言两个预设名拼接,保证手工超界调用也不带数字后缀。 if index < 0 { index = 0 } diff --git a/server/admin/internal/modules/gamemanagement/self_game_handler.go b/server/admin/internal/modules/gamemanagement/self_game_handler.go index ded1f34a..c3103acc 100644 --- a/server/admin/internal/modules/gamemanagement/self_game_handler.go +++ b/server/admin/internal/modules/gamemanagement/self_game_handler.go @@ -113,10 +113,10 @@ func (h *Handler) GenerateDiceRobots(c *gin.Context) { return } if req.Count <= 0 { - req.Count = 20 + req.Count = defaultRobotGenerateCount } - if req.Count > 200 { - req.Count = 200 + if req.Count > maxRobotGenerateCount { + req.Count = maxRobotGenerateCount } nicknameLanguage := normalizeRobotNicknameLanguage(req.NicknameLanguage) country := strings.TrimSpace(req.Country) 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 5ad0cc0a..7c5b9911 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS game_dice_matches ( fee_bps INT NOT NULL DEFAULT 500 COMMENT '本局平台抽水万分比快照', pool_bps INT NOT NULL DEFAULT 100 COMMENT '本局奖池入池万分比快照', match_mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT '匹配模式 human/robot', - forced_result VARCHAR(32) NOT NULL DEFAULT '' COMMENT '机器人奖池不足时的强制结果', + forced_result VARCHAR(32) NOT NULL DEFAULT '' COMMENT '机器人局强制结果,首胜保护或奖池不足时使用', pool_delta_coin BIGINT NOT NULL DEFAULT 0 COMMENT '本局对奖池的净变化', PRIMARY KEY(app_code, match_id), KEY idx_game_dice_room_active(app_code, room_id, status, created_at_ms), @@ -216,7 +216,7 @@ DEALLOCATE PREPARE stmt; SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_dice_matches' AND COLUMN_NAME = 'forced_result') = 0, - 'ALTER TABLE game_dice_matches ADD COLUMN forced_result VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''机器人奖池不足时的强制结果'' AFTER match_mode', + 'ALTER TABLE game_dice_matches ADD COLUMN forced_result VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''机器人局强制结果,首胜保护或奖池不足时使用'' AFTER match_mode', 'SELECT 1' ); PREPARE stmt FROM @ddl; @@ -321,10 +321,41 @@ CREATE TABLE IF NOT EXISTS game_self_game_robots ( created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + last_used_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次被匹配补位时间,UTC epoch ms', + 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_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表'; +-- 机器人轮转依赖最近使用时间和累计使用次数;旧表补默认 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', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND COLUMN_NAME = 'used_count') = 0, + 'ALTER TABLE game_self_game_robots ADD COLUMN used_count BIGINT NOT NULL DEFAULT 0 COMMENT ''累计被匹配补位次数'' AFTER last_used_at_ms', + '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_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 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS game_explore_winners ( app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', win_id VARCHAR(96) NOT NULL COMMENT 'Explore Square 播报 ID', diff --git a/services/game-service/internal/domain/dice/dice.go b/services/game-service/internal/domain/dice/dice.go index 674c0113..ff832b23 100644 --- a/services/game-service/internal/domain/dice/dice.go +++ b/services/game-service/internal/domain/dice/dice.go @@ -70,6 +70,7 @@ const ( MatchModeHuman = "human" MatchModeRobot = "robot" + ForcedResultPlayerWin = "player_win" ForcedResultPlayerLose = "player_lose" ConfigStatusActive = "active" @@ -119,6 +120,8 @@ type Robot struct { CreatedByAdminID int64 CreatedAtMS int64 UpdatedAtMS int64 + LastUsedAtMS int64 + UsedCount int64 } // ExploreWinner 是 Explore Square 展示用的轻量获胜事实;只由真实游戏结算写入,读接口不能主动制造默认播报。 @@ -383,6 +386,20 @@ func RPSWinningGestureFor(gesture string) (string, bool) { } } +// RPSLosingGestureFor 返回会输给入参的手势;新手局需要玩家必赢时只改机器人预选,不改真人已经提交的手势。 +func RPSLosingGestureFor(gesture string) (string, bool) { + switch strings.TrimSpace(gesture) { + case RPSGestureRock: + return RPSGestureScissors, true + case RPSGesturePaper: + return RPSGestureRock, true + case RPSGestureScissors: + return RPSGesturePaper, true + default: + return "", false + } +} + // rpsCompare 返回 left 相对 right 的胜负:1 表示 left 赢,-1 表示 left 输,0 表示和局。 func rpsCompare(left string, right string) (int, error) { left = strings.TrimSpace(left) diff --git a/services/game-service/internal/service/dice/service.go b/services/game-service/internal/service/dice/service.go index 1640deac..fab8588a 100644 --- a/services/game-service/internal/service/dice/service.go +++ b/services/game-service/internal/service/dice/service.go @@ -42,7 +42,8 @@ type Repository interface { FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error) - PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error) + PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) + HasSelfGameUserWin(ctx context.Context, appCode string, gameID string, userID int64) (bool, error) AdjustDicePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error) RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error) @@ -608,6 +609,9 @@ func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dice if match.ForcedResult == dicedomain.ForcedResultPlayerLose { // 奖池不足的机器人局在匹配完成时已经冻结强制结果;roll 阶段只负责生成与结果一致的点数。 s.forceHumanLose(match.Participants) + } else if match.ForcedResult == dicedomain.ForcedResultPlayerWin { + // 首胜保护的机器人局在匹配完成时已经确认奖池够付净赢额;roll 阶段只改点数,不绕过统一结算和奖池扣减。 + s.forceHumanWin(match.Participants) } settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS)) if err != nil { @@ -866,7 +870,7 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match if !config.RobotEnabled || nowMS < match.CreatedAtMS+config.RobotMatchWaitMS { return match, nowMS, nil } - robot, err := s.repository.PickActiveDiceRobot(ctx, match.AppCode, match.GameID, match.MatchID) + robot, err := s.repository.PickActiveDiceRobot(ctx, match.AppCode, match.GameID, match.MatchID, nowMS) if err != nil { if xerr.IsCode(err, xerr.NotFound) { return match, nowMS, nil @@ -874,9 +878,22 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match return dicedomain.Match{}, 0, err } forcedResult := "" - requiredPool := robotRequiredPoolCoin(match) + requiredPool := robotHumanWinRequiredPoolCoin(match) if config.PoolBalanceCoin < requiredPool { forcedResult = dicedomain.ForcedResultPlayerLose + } else { + firstWinUserID, ok := firstWinGuaranteeUser(match) + if ok { + hasWon, err := s.repository.HasSelfGameUserWin(ctx, match.AppCode, match.GameID, firstWinUserID) + if err != nil { + return dicedomain.Match{}, 0, err + } + if !hasWon { + // 新手保护只在“一个真人等机器人补位”的机器人局生效;奖池够付净赢额时强制本局真人胜利, + // 一旦该用户在当前 game_id 有任意已结算真人胜场,后续局立即回到随机/手势的正常流程。 + forcedResult = dicedomain.ForcedResultPlayerWin + } + } } robotRPSGesture := "" if dicedomain.IsRPSGameID(match.GameID) { @@ -892,6 +909,24 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match return joined, nowMS, nil } +func firstWinGuaranteeUser(match dicedomain.Match) (int64, bool) { + var userID int64 + for _, participant := range match.Participants { + if participant.ParticipantType == dicedomain.ParticipantTypeRobot { + continue + } + if participant.UserID <= 0 { + continue + } + if userID != 0 { + // 多真人等待局没有明确“保谁赢”的产品语义;这类局保持正常结算,避免新手保护误伤真人对战公平性。 + return 0, false + } + userID = participant.UserID + } + return userID, userID > 0 +} + func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult string) (string, error) { var humanGesture string for _, participant := range match.Participants { @@ -916,6 +951,14 @@ func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult s } return gesture, nil } + if forcedResult == dicedomain.ForcedResultPlayerWin { + // 新手局奖池足够时,机器人只选择会输给真人预选手势的那一拳,真人的原始选择和落库事实不被篡改。 + gesture, ok := dicedomain.RPSLosingGestureFor(humanGesture) + if !ok { + return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid") + } + return gesture, nil + } index, err := s.randomInt(3) if err != nil { return "", err @@ -927,6 +970,17 @@ func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult s return gestures[index], nil } +func (s *Service) forceHumanWin(participants []dicedomain.Participant) { + for index := range participants { + if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot { + // 新手保护只在奖池可承担净派奖时开启;机器人落最低点,正常结算公式自然把本金外的净赢额从奖池扣出。 + participants[index].DicePoints = []int32{1} + continue + } + participants[index].DicePoints = []int32{6} + } +} + func (s *Service) forceHumanLose(participants []dicedomain.Participant) { for index := range participants { if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot { @@ -971,13 +1025,30 @@ func normalizeMatchEconomicSnapshot(match *dicedomain.Match) { } } -func robotRequiredPoolCoin(match dicedomain.Match) int64 { +func robotHumanWinRequiredPoolCoin(match dicedomain.Match) int64 { normalizeMatchEconomicSnapshot(&match) - robotNetStake := match.StakeCoin * (10_000 - int64(match.FeeBPS) - int64(match.PoolBPS)) / 10_000 - if robotNetStake < 0 { + var totalStake int64 + var firstHumanStake int64 + for _, participant := range match.Participants { + if participant.StakeCoin > 0 { + totalStake += participant.StakeCoin + } + if participant.ParticipantType != dicedomain.ParticipantTypeRobot && firstHumanStake == 0 { + firstHumanStake = participant.StakeCoin + } + } + if firstHumanStake <= 0 { return 0 } - return robotNetStake + // 机器人不真实扣本金;真人赢时奖池只承担“派奖额 - 真人已扣本金”的净支出,和 dicePoolDelta 的 out 口径保持一致。 + // 这里运行在机器人入座前,所以总奖池需要把即将补位的机器人 stake 也算进去,才能得到本局真实派奖额。 + totalStake += match.StakeCoin + payoutPool := totalStake * (10_000 - int64(match.FeeBPS) - int64(match.PoolBPS)) / 10_000 + required := payoutPool - firstHumanStake + if required < 0 { + return 0 + } + return required } func dicePoolDelta(match dicedomain.Match) (string, int64, int64) { diff --git a/services/game-service/internal/service/dice/service_test.go b/services/game-service/internal/service/dice/service_test.go index 8b6118d6..05990694 100644 --- a/services/game-service/internal/service/dice/service_test.go +++ b/services/game-service/internal/service/dice/service_test.go @@ -2,6 +2,7 @@ package dice import ( "context" + "strconv" "strings" "testing" "time" @@ -376,6 +377,199 @@ func TestRPSRollRejectsGestureMismatchBeforeMutation(t *testing.T) { } } +func TestFirstRobotDiceMatchForcesHumanWinWhenPoolIsEnough(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 88 + repo.poolBalance = 88 + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9001, Status: dicedomain.RobotStatusActive} + wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} + svc := New(Config{}, repo, wallet) + nowMS := int64(1700000000000) + svc.now = func() time.Time { return time.UnixMilli(nowMS) } + rolls := []int{0, 5} + svc.randomInt = func(max int) (int, error) { + if len(rolls) == 0 { + t.Fatal("randomInt called too many times") + } + value := rolls[0] + rolls = rolls[1:] + return value, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-first-dice-create", + UserID: 101, + GameID: "dice", + StakeCoin: 100, + MinPlayers: 2, + MaxPlayers: 2, + }) + if err != nil { + t.Fatalf("CreateMatch failed: %v", err) + } + nowMS += dicedomain.DefaultRobotWaitMS + ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("GetMatch attach robot failed: %v", err) + } + if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerWin { + t.Fatalf("first robot match should force player win, got %+v", ready) + } + + settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-dice-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("RollMatch failed: %v", err) + } + human := participantByUser(settled.Participants, 101) + robot := participantByUser(settled.Participants, 9001) + if human.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || human.DicePoints[0] != 6 || human.PayoutCoin != 188 || human.BalanceAfter != 1088 { + t.Fatalf("first dice human should win from forced points: %+v", human) + } + if robot.Result != dicedomain.ParticipantResultLose || len(robot.DicePoints) != 1 || robot.DicePoints[0] != 1 { + t.Fatalf("first dice robot should lose from forced points: %+v", robot) + } + if repo.poolBalance != 0 { + t.Fatalf("pool should pay only human net win coin, got balance=%d", repo.poolBalance) + } + if wallet.calls != 2 { + t.Fatalf("robot match should debit and pay only human wallet, calls=%d", wallet.calls) + } +} + +func TestFirstRobotDiceMatchForcesHumanLoseWhenPoolIsShort(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 87 + repo.poolBalance = 87 + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive} + wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} + svc := New(Config{}, repo, wallet) + nowMS := int64(1700000000000) + svc.now = func() time.Time { return time.UnixMilli(nowMS) } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-short-pool-create", + UserID: 101, + GameID: "dice", + StakeCoin: 100, + MinPlayers: 2, + MaxPlayers: 2, + }) + if err != nil { + t.Fatalf("CreateMatch failed: %v", err) + } + nowMS += dicedomain.DefaultRobotWaitMS + ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("GetMatch attach robot failed: %v", err) + } + if ready.ForcedResult != dicedomain.ForcedResultPlayerLose { + t.Fatalf("short pool should force player lose, got forced_result=%q", ready.ForcedResult) + } +} + +func TestFirstRobotRockMatchForcesRobotLosingGestureWhenPoolIsEnough(t *testing.T) { + repo := newFakeDiceRepository() + configureFakeRock(repo) + repo.config.PoolBalanceCoin = 88 + repo.poolBalance = 88 + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "rock", UserID: 9002, Status: dicedomain.RobotStatusActive} + wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} + svc := New(Config{}, repo, wallet) + nowMS := int64(1700000000000) + svc.now = func() time.Time { return time.UnixMilli(nowMS) } + svc.randomInt = func(max int) (int, error) { + t.Fatalf("forced first rock match must not randomize robot gesture, max=%d", max) + return 0, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-first-rock-create", + UserID: 101, + GameID: "rock", + StakeCoin: 100, + MinPlayers: 2, + MaxPlayers: 2, + RPSGesture: "rock", + }) + if err != nil { + t.Fatalf("CreateMatch failed: %v", err) + } + nowMS += dicedomain.DefaultRobotWaitMS + ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) + if err != nil { + t.Fatalf("GetMatch attach robot failed: %v", err) + } + if ready.ForcedResult != dicedomain.ForcedResultPlayerWin || repo.joinRobotRPSGesture != dicedomain.RPSGestureScissors { + t.Fatalf("first rock robot should choose losing gesture, forced=%q gesture=%q", ready.ForcedResult, repo.joinRobotRPSGesture) + } + + settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-rock-roll", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) + if err != nil { + t.Fatalf("RollMatch failed: %v", err) + } + human := participantByUser(settled.Participants, 101) + robot := participantByUser(settled.Participants, 9002) + if human.Result != dicedomain.ParticipantResultWin || human.RPSGesture != dicedomain.RPSGestureRock || human.PayoutCoin != 188 || human.BalanceAfter != 1088 { + t.Fatalf("first rock human should keep selected gesture and win: %+v", human) + } + if robot.Result != dicedomain.ParticipantResultLose || robot.RPSGesture != dicedomain.RPSGestureScissors { + t.Fatalf("first rock robot should lose with losing gesture: %+v", robot) + } +} + +func TestRobotMatchUsesNormalFlowAfterUserHasSelfGameWin(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 200 + repo.poolBalance = 200 + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9003, Status: dicedomain.RobotStatusActive} + repo.userWins = map[string]bool{"dice:101": true} + wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} + svc := New(Config{}, repo, wallet) + nowMS := int64(1700000000000) + svc.now = func() time.Time { return time.UnixMilli(nowMS) } + rolls := []int{0, 5} + svc.randomInt = func(max int) (int, error) { + if len(rolls) == 0 { + t.Fatal("randomInt called too many times") + } + value := rolls[0] + rolls = rolls[1:] + return value, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-normal-after-win-create", + UserID: 101, + GameID: "dice", + StakeCoin: 100, + MinPlayers: 2, + MaxPlayers: 2, + }) + if err != nil { + t.Fatalf("CreateMatch failed: %v", err) + } + nowMS += dicedomain.DefaultRobotWaitMS + ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("GetMatch attach robot failed: %v", err) + } + if ready.ForcedResult != "" { + t.Fatalf("user with prior win should use normal flow, got forced_result=%q", ready.ForcedResult) + } + settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-normal-after-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("RollMatch failed: %v", err) + } + human := participantByUser(settled.Participants, 101) + if human.Result != dicedomain.ParticipantResultLose || len(human.DicePoints) != 1 || human.DicePoints[0] != 1 || human.PayoutCoin != 0 { + t.Fatalf("after first win dice should follow random points, got %+v", human) + } +} + func TestExploreGameCodeForMatchUsesSelfGameIDs(t *testing.T) { tests := []struct { gameID string @@ -461,13 +655,16 @@ func TestSelfGameOperationsRejectWrongGameIDBeforeMutation(t *testing.T) { } type fakeDiceRepository struct { - game gamedomain.LaunchableGame - match dicedomain.Match - config dicedomain.Config - poolBalance int64 - orders map[string]gamedomain.GameOrder - levelDebitOrders int - exploreWinners []dicedomain.ExploreWinner + game gamedomain.LaunchableGame + match dicedomain.Match + config dicedomain.Config + robot dicedomain.Robot + poolBalance int64 + orders map[string]gamedomain.GameOrder + userWins map[string]bool + joinRobotRPSGesture string + levelDebitOrders int + exploreWinners []dicedomain.ExploreWinner } func newFakeDiceRepository() *fakeDiceRepository { @@ -706,8 +903,36 @@ func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found") } -func (r *fakeDiceRepository) JoinDiceRobot(context.Context, string, string, int64, string, string, int64) (dicedomain.Match, error) { - return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found") +func (r *fakeDiceRepository) JoinDiceRobot(_ context.Context, _ string, _ string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) { + if robotUserID <= 0 { + return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found") + } + for _, participant := range r.match.Participants { + if participant.UserID == robotUserID { + return cloneDiceMatch(r.match), nil + } + } + seatNo := int32(len(r.match.Participants) + 1) + r.match.Participants = append(r.match.Participants, dicedomain.Participant{ + AppCode: r.match.AppCode, + MatchID: r.match.MatchID, + UserID: robotUserID, + ParticipantType: dicedomain.ParticipantTypeRobot, + SeatNo: seatNo, + Status: dicedomain.ParticipantStatusJoined, + StakeCoin: r.match.StakeCoin, + RPSGesture: strings.TrimSpace(rpsGesture), + JoinedAtMS: nowMs, + UpdatedAtMS: nowMs, + }) + r.match.CurrentPlayers = int32(len(r.match.Participants)) + r.match.MatchMode = dicedomain.MatchModeRobot + r.match.ForcedResult = strings.TrimSpace(forcedResult) + r.match.Status = dicedomain.MatchStatusReady + r.match.ReadyAtMS = nowMs + r.match.UpdatedAtMS = nowMs + r.joinRobotRPSGesture = strings.TrimSpace(rpsGesture) + return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) CancelDiceMatch(context.Context, string, string, int64, int64) (dicedomain.Match, error) { @@ -715,10 +940,20 @@ func (r *fakeDiceRepository) CancelDiceMatch(context.Context, string, string, in return cloneDiceMatch(r.match), nil } -func (r *fakeDiceRepository) PickActiveDiceRobot(context.Context, string, string, string) (dicedomain.Robot, error) { +func (r *fakeDiceRepository) PickActiveDiceRobot(context.Context, string, string, string, int64) (dicedomain.Robot, error) { + if r.robot.UserID > 0 { + return r.robot, nil + } return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } +func (r *fakeDiceRepository) HasSelfGameUserWin(_ context.Context, _ string, gameID string, userID int64) (bool, error) { + if r.userWins == nil { + return false, nil + } + return r.userWins[strings.TrimSpace(gameID)+":"+strconv.FormatInt(userID, 10)], nil +} + func (r *fakeDiceRepository) AdjustDicePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) { if adjustment.Direction == dicedomain.PoolDirectionOut { r.poolBalance -= adjustment.AmountCoin 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 1622112b..f06d7d49 100644 --- a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go +++ b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go @@ -2,12 +2,10 @@ package mysql import ( "context" - cryptorand "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" - "math/big" "strings" "time" @@ -267,69 +265,99 @@ 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) (dicedomain.Robot, error) { +func (r *Repository) PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string, nowMs int64) (dicedomain.Robot, error) { app := appcode.Normalize(appCode) gameID = normalizeDiceGameID(gameID) matchID = strings.TrimSpace(matchID) - var candidateCount int64 - if err := r.db.QueryRowContext(ctx, - `SELECT COUNT(*) - FROM ( - SELECT r.user_id - FROM game_self_game_robots r - WHERE r.app_code = ? AND r.status = ? - AND NOT EXISTS ( - 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 - ) - GROUP BY r.user_id - ) candidates`, - app, dicedomain.RobotStatusActive, matchID, - ).Scan(&candidateCount); err != nil { - return dicedomain.Robot{}, err - } - if candidateCount <= 0 { - return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") - } - randomOffset, err := randomDiceRobotOffset(candidateCount) + tx, err := r.db.BeginTx(ctx, nil) if err != nil { return dicedomain.Robot{}, err } + defer func() { _ = tx.Rollback() }() + var robot dicedomain.Robot - err = r.db.QueryRowContext(ctx, + // 候选行按“最久未使用、使用次数最少、user_id 最小”稳定排序,并用 SKIP LOCKED 避开其他正在补位的事务。 + // 这样少量连续匹配会先把机器人池轮一遍;并发匹配也不会在同一瞬间都拿到同一条机器人登记。 + err = tx.QueryRowContext(ctx, `SELECT r.app_code, ? AS game_id, r.user_id, ? AS status, - MIN(r.created_by_admin_id), MIN(r.created_at_ms), MAX(r.updated_at_ms) + r.created_by_admin_id, r.created_at_ms, r.updated_at_ms, + r.last_used_at_ms, r.used_count FROM game_self_game_robots r WHERE r.app_code = ? AND r.status = ? AND NOT EXISTS ( 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 ) - GROUP BY r.app_code, r.user_id - ORDER BY MAX(r.updated_at_ms) ASC, r.user_id ASC - LIMIT 1 OFFSET ?`, - gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID, randomOffset, - ).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS) + ORDER BY r.last_used_at_ms ASC, r.used_count ASC, r.user_id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED`, + gameID, dicedomain.RobotStatusActive, app, dicedomain.RobotStatusActive, matchID, + ).Scan( + &robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, + &robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount, + ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } return dicedomain.Robot{}, err } + + // 选中后立即写入轮转水位。即使后续补位并发失败,这个账号也会短暂进入冷却,避免多个等待局同时反复命中同一个机器人。 + res, err := tx.ExecContext(ctx, + `UPDATE game_self_game_robots + SET last_used_at_ms = ?, used_count = used_count + 1 + WHERE app_code = ? AND user_id = ? AND status = ?`, + nowMs, app, robot.UserID, dicedomain.RobotStatusActive, + ) + if err != nil { + return dicedomain.Robot{}, err + } + affected, _ := res.RowsAffected() + if affected == 0 { + return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") + } + if err := tx.Commit(); err != nil { + return dicedomain.Robot{}, err + } + robot.LastUsedAtMS = nowMs + robot.UsedCount++ return robot, nil } -func randomDiceRobotOffset(candidateCount int64) (int64, error) { - // 机器人选择不能再依赖固定排序,否则只要后台没有更新机器人状态,玩家就会反复匹配到同一个账号;这里先用候选总数限定范围, - // 再取随机 offset 保留“本局已加入机器人不再参与”的过滤语义,同时让每次补位都从全部可用机器人中抽样。 - offset, err := cryptorand.Int(cryptorand.Reader, big.NewInt(candidateCount)) - if err != nil { - return 0, err +// HasSelfGameUserWin 只把当前 app+game_id 下已结算的真人胜场算作“赢过一次”; +// 机器人胜负、未完成局和其它自研游戏不会消耗当前游戏的新手保护资格。 +func (r *Repository) HasSelfGameUserWin(ctx context.Context, appCode string, gameID string, userID int64) (bool, error) { + if userID <= 0 { + return false, xerr.New(xerr.InvalidArgument, "self game user id is invalid") } - return offset.Int64(), nil + app := appcode.Normalize(appCode) + gameID = normalizeDiceGameID(gameID) + var exists int + err := r.db.QueryRowContext(ctx, + `SELECT 1 + FROM game_dice_matches m + JOIN game_dice_participants p + ON p.app_code = m.app_code AND p.match_id = m.match_id + WHERE m.app_code = ? + AND m.game_id = ? + AND m.status = ? + AND p.user_id = ? + AND p.participant_type = ? + AND p.result = ? + LIMIT 1`, + app, gameID, dicedomain.MatchStatusSettled, userID, dicedomain.ParticipantTypeUser, dicedomain.ParticipantResultWin, + ).Scan(&exists) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + return true, nil } // AdjustDicePool 用一把奖池行锁串行更新余额并写流水,防止多个机器人局并发时把余额扣成负数。 @@ -450,13 +478,15 @@ func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID // 机器人后台展示的是“账号池”而不是“游戏池”;同一个 user_id 即使历史上登记过多个 game_id,也只展示一行。 // 聚合状态按 active 优先,避免旧的 dice active + rock disabled 脏数据让同一个账号在列表里出现两种状态。 args := []any{gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app} - query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms + query := `SELECT app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count FROM ( SELECT app_code, ? AS game_id, user_id, IF(SUM(status = ?) > 0, ?, ?) AS status, MIN(created_by_admin_id) AS created_by_admin_id, MIN(created_at_ms) AS created_at_ms, - MAX(updated_at_ms) AS updated_at_ms + MAX(updated_at_ms) AS updated_at_ms, + MAX(last_used_at_ms) AS last_used_at_ms, + MAX(used_count) AS used_count FROM game_self_game_robots WHERE app_code = ?` if cursorID > 0 { @@ -478,7 +508,10 @@ func (r *Repository) ListDiceRobots(ctx context.Context, appCode string, gameID robots := []dicedomain.Robot{} for rows.Next() { var robot dicedomain.Robot - if err := rows.Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS); err != nil { + if err := rows.Scan( + &robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, + &robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount, + ); err != nil { return nil, "", err } robots = append(robots, robot) @@ -607,8 +640,9 @@ func (r *Repository) RegisterDiceRobots(ctx context.Context, appCode string, gam for _, userID := range uniquePositiveInt64(userIDs) { if _, err := tx.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 - ) VALUES (?, ?, ?, ?, ?, ?, ?) + app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, + last_used_at_ms, used_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0) ON DUPLICATE KEY UPDATE status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, app, gameID, userID, dicedomain.RobotStatusActive, adminID, nowMs, nowMs, ); err != nil { @@ -671,12 +705,16 @@ func (r *Repository) getUniversalDiceRobot(ctx context.Context, appCode string, err := r.db.QueryRowContext(ctx, `SELECT app_code, ? AS game_id, user_id, IF(SUM(status = ?) > 0, ?, ?) AS status, - MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms) + MIN(created_by_admin_id), MIN(created_at_ms), MAX(updated_at_ms), + MAX(last_used_at_ms), MAX(used_count) FROM game_self_game_robots WHERE app_code = ? AND user_id = ? GROUP BY app_code, user_id`, gameID, dicedomain.RobotStatusActive, dicedomain.RobotStatusActive, dicedomain.RobotStatusDisabled, app, userID, - ).Scan(&robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, &robot.CreatedAtMS, &robot.UpdatedAtMS) + ).Scan( + &robot.AppCode, &robot.GameID, &robot.UserID, &robot.Status, &robot.CreatedByAdminID, + &robot.CreatedAtMS, &robot.UpdatedAtMS, &robot.LastUsedAtMS, &robot.UsedCount, + ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 8a3478ee..28f4f8fd 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -221,6 +221,20 @@ func (r *Repository) Migrate(ctx context.Context) error { KEY idx_game_explore_winners_time(app_code, won_at_ms, win_id), KEY idx_game_explore_winners_game(app_code, game_code, won_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS game_self_game_robots ( + app_code VARCHAR(32) NOT NULL, + game_id VARCHAR(96) NOT NULL, + user_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_by_admin_id BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + last_used_at_ms BIGINT NOT NULL DEFAULT 0, + 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) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS game_callback_logs ( app_code VARCHAR(32) NOT NULL, callback_id VARCHAR(96) NOT NULL, @@ -327,6 +341,12 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureColumn(ctx, "game_dice_participants", "rps_gesture", "rps_gesture VARCHAR(32) NOT NULL DEFAULT '' AFTER dice_points_json"); err != nil { return err } + if err := r.ensureColumn(ctx, "game_self_game_robots", "last_used_at_ms", "last_used_at_ms BIGINT NOT NULL DEFAULT 0 AFTER updated_at_ms"); err != nil { + return err + } + if err := r.ensureColumn(ctx, "game_self_game_robots", "used_count", "used_count BIGINT NOT NULL DEFAULT 0 AFTER last_used_at_ms"); err != nil { + return err + } if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil { return err } @@ -375,6 +395,9 @@ 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 { + return err + } return nil } diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index 9a1de4dc..10de9b59 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "fmt" "strings" "testing" "time" @@ -167,7 +168,7 @@ func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) { } } -func TestPickActiveDiceRobotSelectsRandomCandidateInsteadOfFixedFirstRow(t *testing.T) { +func TestPickActiveDiceRobotRotatesByCooldownBeforeReusing(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"), @@ -186,13 +187,36 @@ func TestPickActiveDiceRobotSelectsRandomCandidateInsteadOfFixedFirstRow(t *test for index, userID := range userIDs { 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 - ) VALUES (?, ?, ?, ?, 7, ?, ?)`, + 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), ); 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)) + 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 robot.LastUsedAtMS != nowMS+100+int64(attempt) || robot.UsedCount != 1 { + t.Fatalf("picked robot should return updated cooldown fields, 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 _, err := repo.db.ExecContext(ctx, `INSERT INTO game_dice_participants ( app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, joined_at_ms, updated_at_ms @@ -202,22 +226,15 @@ func TestPickActiveDiceRobotSelectsRandomCandidateInsteadOfFixedFirstRow(t *test t.Fatalf("insert existing robot participant failed: %v", err) } - picked := map[int64]bool{} - for attempt := 0; attempt < 32; attempt++ { - robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_random_robot") - if err != nil { - t.Fatalf("pick active dice robot failed: %v", err) - } - if robot.UserID == 101 { - t.Fatalf("picked robot already in this match: %+v", robot) - } - picked[robot.UserID] = true + excluded, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_random_robot", nowMS+300) + if err != nil { + t.Fatalf("pick active dice robot with existing participant failed: %v", err) } - if len(picked) < 2 { - t.Fatalf("robot picking still behaves like fixed first-row selection, got picked user ids: %+v", picked) + if excluded.UserID == 101 { + t.Fatalf("picked robot already in this match: %+v", excluded) } - rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock") + rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock", nowMS+400) if err != nil { t.Fatalf("rock should reuse the app-wide robot pool: %v", err) } @@ -327,6 +344,87 @@ func TestUniversalDiceRobotManagementIgnoresGameID(t *testing.T) { } } +func TestHasSelfGameUserWinOnlyCountsSettledHumanWinsInSameGame(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"), + DatabasePrefix: "hy_game_test", + }) + + ctx := appcode.WithContext(context.Background(), "lalu") + repo, err := Open(ctx, schema.DSN) + if err != nil { + t.Fatalf("open repository failed: %v", err) + } + t.Cleanup(func() { _ = repo.Close() }) + + nowMS := int64(1780542672000) + rows := []struct { + matchID string + gameID string + status string + userID int64 + participantType string + result string + }{ + {matchID: "settled_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 301, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, + {matchID: "rock_human_win", gameID: dicedomain.SelfGameIDRock, status: dicedomain.MatchStatusSettled, userID: 302, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, + {matchID: "robot_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusSettled, userID: 303, participantType: dicedomain.ParticipantTypeRobot, result: dicedomain.ParticipantResultWin}, + {matchID: "pending_human_win", gameID: dicedomain.DefaultGameID, status: dicedomain.MatchStatusPayoutApplying, userID: 304, participantType: dicedomain.ParticipantTypeUser, result: dicedomain.ParticipantResultWin}, + } + for index, row := range rows { + if _, err := repo.db.ExecContext(ctx, + `INSERT INTO game_dice_matches ( + app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, + min_players, max_players, current_players, stake_coin, round_no, status, result, + join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms, + fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin + ) VALUES (?, ?, ?, 'dice', ?, '', 0, 2, 2, 2, 100, 1, ?, ?, 0, ?, ?, ?, ?, 0, 500, 100, 'robot', '', 0)`, + "lalu", row.matchID, row.gameID, row.gameID, row.status, row.result, + nowMS+int64(index), nowMS+int64(index), nowMS+int64(index), nowMS+int64(index), + ); err != nil { + t.Fatalf("insert match %+v failed: %v", row, err) + } + if _, err := repo.db.ExecContext(ctx, + `INSERT INTO game_dice_participants ( + app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json, + rps_gesture, result, payout_coin, joined_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 1, ?, 100, CAST('[6]' AS JSON), '', ?, 188, ?, ?)`, + "lalu", row.matchID, row.userID, row.participantType, dicedomain.ParticipantStatusSettled, row.result, nowMS+int64(index), nowMS+int64(index), + ); err != nil { + t.Fatalf("insert participant %+v failed: %v", row, err) + } + } + + hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", dicedomain.DefaultGameID, 301) + if err != nil { + t.Fatalf("query settled human win failed: %v", err) + } + if !hasWin { + t.Fatalf("settled human win in same game should count") + } + for _, tt := range []struct { + name string + gameID string + userID int64 + }{ + {name: "other game", gameID: dicedomain.DefaultGameID, userID: 302}, + {name: "robot win", gameID: dicedomain.DefaultGameID, userID: 303}, + {name: "unfinished win", gameID: dicedomain.DefaultGameID, userID: 304}, + {name: "missing user", gameID: dicedomain.DefaultGameID, userID: 305}, + } { + t.Run(tt.name, func(t *testing.T) { + hasWin, err := repo.HasSelfGameUserWin(ctx, "lalu", tt.gameID, tt.userID) + if err != nil { + t.Fatalf("query win failed: %v", err) + } + if hasWin { + t.Fatalf("%s should not count as existing win", tt.name) + } + }) + } +} + func containsInt64(values []int64, target int64) bool { for _, value := range values { if value == target {