improve self game robot matching
This commit is contained in:
parent
2e6ff83aec
commit
84bba92611
@ -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 会优先选择最久未使用的启用机器人加入对局。
|
||||
- 机器人被选中后会记录最近使用时间和累计使用次数;只有可用机器人轮过一遍后,才会重新使用较早出现过的机器人。
|
||||
- 机器人对局资金由游戏奖池结算,不从机器人钱包扣款。
|
||||
- 机器人禁用后不会再被新对局选中。
|
||||
|
||||
@ -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
|
||||
);
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user