From cee48232f82971678ede5dfefa78477a3548d408 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 03:08:18 +0800 Subject: [PATCH] update self game catalog handling --- api/proto/game/v1/game.pb.go | 47 ++++-- api/proto/game/v1/game.proto | 1 + .../internal/modules/dashboard/handler.go | 16 ++ .../internal/modules/dashboard/routes.go | 1 + .../internal/modules/dashboard/service.go | 38 ++++- .../internal/modules/gamemanagement/dto.go | 54 +++--- .../modules/gamemanagement/request.go | 21 ++- .../deploy/mysql/initdb/001_game_service.sql | 11 ++ .../game-service/internal/domain/dice/dice.go | 22 ++- .../internal/domain/dice/dice_test.go | 32 ++++ .../internal/service/dice/service.go | 44 ++++- .../internal/service/dice/service_test.go | 159 ++++++++++++++++-- .../mysql/dice_matchmaking_repository.go | 18 +- .../internal/storage/mysql/repository.go | 59 +++++++ .../internal/storage/mysql/repository_test.go | 149 ++++++++++++++++ .../internal/transport/grpc/server.go | 50 +++--- 16 files changed, 614 insertions(+), 108 deletions(-) create mode 100644 services/game-service/internal/domain/dice/dice_test.go diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index d282dc0c..6721bc17 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -1794,22 +1794,23 @@ func (x *DiceStakeOption) GetSortOrder() int32 { } type DiceConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - GameId string `protobuf:"bytes,2,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"` - Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - StakeOptions []*DiceStakeOption `protobuf:"bytes,4,rep,name=stake_options,json=stakeOptions,proto3" json:"stake_options,omitempty"` - FeeBps int32 `protobuf:"varint,5,opt,name=fee_bps,json=feeBps,proto3" json:"fee_bps,omitempty"` - PoolBps int32 `protobuf:"varint,6,opt,name=pool_bps,json=poolBps,proto3" json:"pool_bps,omitempty"` - MinPlayers int32 `protobuf:"varint,7,opt,name=min_players,json=minPlayers,proto3" json:"min_players,omitempty"` - MaxPlayers int32 `protobuf:"varint,8,opt,name=max_players,json=maxPlayers,proto3" json:"max_players,omitempty"` - RobotEnabled bool `protobuf:"varint,9,opt,name=robot_enabled,json=robotEnabled,proto3" json:"robot_enabled,omitempty"` - RobotMatchWaitMs int64 `protobuf:"varint,10,opt,name=robot_match_wait_ms,json=robotMatchWaitMs,proto3" json:"robot_match_wait_ms,omitempty"` - PoolBalanceCoin int64 `protobuf:"varint,11,opt,name=pool_balance_coin,json=poolBalanceCoin,proto3" json:"pool_balance_coin,omitempty"` - CreatedAtMs int64 `protobuf:"varint,12,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` - UpdatedAtMs int64 `protobuf:"varint,13,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + GameId string `protobuf:"bytes,2,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + StakeOptions []*DiceStakeOption `protobuf:"bytes,4,rep,name=stake_options,json=stakeOptions,proto3" json:"stake_options,omitempty"` + FeeBps int32 `protobuf:"varint,5,opt,name=fee_bps,json=feeBps,proto3" json:"fee_bps,omitempty"` + PoolBps int32 `protobuf:"varint,6,opt,name=pool_bps,json=poolBps,proto3" json:"pool_bps,omitempty"` + MinPlayers int32 `protobuf:"varint,7,opt,name=min_players,json=minPlayers,proto3" json:"min_players,omitempty"` + MaxPlayers int32 `protobuf:"varint,8,opt,name=max_players,json=maxPlayers,proto3" json:"max_players,omitempty"` + RobotEnabled bool `protobuf:"varint,9,opt,name=robot_enabled,json=robotEnabled,proto3" json:"robot_enabled,omitempty"` + RobotMatchWaitMs int64 `protobuf:"varint,10,opt,name=robot_match_wait_ms,json=robotMatchWaitMs,proto3" json:"robot_match_wait_ms,omitempty"` + PoolBalanceCoin int64 `protobuf:"varint,11,opt,name=pool_balance_coin,json=poolBalanceCoin,proto3" json:"pool_balance_coin,omitempty"` + CreatedAtMs int64 `protobuf:"varint,12,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,13,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + RobotUserWinRatePercent *int32 `protobuf:"varint,14,opt,name=robot_user_win_rate_percent,json=robotUserWinRatePercent,proto3,oneof" json:"robot_user_win_rate_percent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DiceConfig) Reset() { @@ -1933,6 +1934,13 @@ func (x *DiceConfig) GetUpdatedAtMs() int64 { return 0 } +func (x *DiceConfig) GetRobotUserWinRatePercent() int32 { + if x != nil && x.RobotUserWinRatePercent != nil { + return *x.RobotUserWinRatePercent + } + return 0 +} + type GetDiceConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -5758,7 +5766,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "stake_coin\x18\x01 \x01(\x03R\tstakeCoin\x12\x18\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1d\n" + "\n" + - "sort_order\x18\x03 \x01(\x05R\tsortOrder\"\xdb\x03\n" + + "sort_order\x18\x03 \x01(\x05R\tsortOrder\"\xbe\x04\n" + "\n" + "DiceConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + @@ -5776,7 +5784,9 @@ const file_proto_game_v1_game_proto_rawDesc = "" + " \x01(\x03R\x10robotMatchWaitMs\x12*\n" + "\x11pool_balance_coin\x18\v \x01(\x03R\x0fpoolBalanceCoin\x12\"\n" + "\rcreated_at_ms\x18\f \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\r \x01(\x03R\vupdatedAtMs\"_\n" + + "\rupdated_at_ms\x18\r \x01(\x03R\vupdatedAtMs\x12A\n" + + "\x1brobot_user_win_rate_percent\x18\x0e \x01(\x05H\x00R\x17robotUserWinRatePercent\x88\x01\x01B\x1e\n" + + "\x1c_robot_user_win_rate_percent\"_\n" + "\x14GetDiceConfigRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" + "\agame_id\x18\x02 \x01(\tR\x06gameId\"m\n" + @@ -6364,6 +6374,7 @@ func file_proto_game_v1_game_proto_init() { if File_proto_game_v1_game_proto != nil { return } + file_proto_game_v1_game_proto_msgTypes[18].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index 89299096..e5f181ec 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -216,6 +216,7 @@ message DiceConfig { int64 pool_balance_coin = 11; int64 created_at_ms = 12; int64 updated_at_ms = 13; + optional int32 robot_user_win_rate_percent = 14; } message GetDiceConfigRequest { diff --git a/server/admin/internal/modules/dashboard/handler.go b/server/admin/internal/modules/dashboard/handler.go index f4ca4b40..6c3a36e6 100644 --- a/server/admin/internal/modules/dashboard/handler.go +++ b/server/admin/internal/modules/dashboard/handler.go @@ -43,6 +43,22 @@ func (h *Handler) StatisticsOverview(c *gin.Context) { response.OK(c, overview) } +func (h *Handler) SelfGameStatisticsOverview(c *gin.Context) { + overview, err := h.service.SelfGameStatisticsOverview(c.Request.Context(), SelfGameStatisticsQuery{ + AppCode: c.DefaultQuery("app_code", "lalu"), + StartMS: parseInt64(c.Query("start_ms")), + EndMS: parseInt64(c.Query("end_ms")), + RegionID: parseInt64(c.Query("region_id")), + CountryID: parseInt64(c.Query("country_id")), + GameID: c.Query("game_id"), + }) + if err != nil { + response.ServerError(c, "获取自研游戏统计失败") + return + } + response.OK(c, overview) +} + func parseInt64(value string) int64 { parsed, _ := strconv.ParseInt(value, 10, 64) return parsed diff --git a/server/admin/internal/modules/dashboard/routes.go b/server/admin/internal/modules/dashboard/routes.go index 8edd6f0d..5095b8f0 100644 --- a/server/admin/internal/modules/dashboard/routes.go +++ b/server/admin/internal/modules/dashboard/routes.go @@ -9,4 +9,5 @@ import ( func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview) protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview) + protected.GET("/statistics/self-games/overview", middleware.RequirePermission("overview:view"), h.SelfGameStatisticsOverview) } diff --git a/server/admin/internal/modules/dashboard/service.go b/server/admin/internal/modules/dashboard/service.go index 704118c6..f58dc8ba 100644 --- a/server/admin/internal/modules/dashboard/service.go +++ b/server/admin/internal/modules/dashboard/service.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/repository" @@ -28,6 +29,15 @@ type StatisticsQuery struct { CountryID int64 } +type SelfGameStatisticsQuery struct { + AppCode string + StartMS int64 + EndMS int64 + RegionID int64 + CountryID int64 + GameID string +} + func NewService(store *repository.Store, cfg config.Config) *DashboardService { return &DashboardService{ store: store, @@ -41,7 +51,6 @@ func (s *DashboardService) Overview() (*repository.DashboardOverview, error) { } func (s *DashboardService) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { - baseURL := s.cfg.StatisticsService.BaseURL + "/internal/v1/statistics/overview" values := url.Values{} values.Set("app_code", query.AppCode) if query.StartMS > 0 { @@ -62,6 +71,33 @@ func (s *DashboardService) StatisticsOverview(ctx context.Context, query Statist if query.CountryID > 0 { values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) } + return s.statisticsGET(ctx, "/internal/v1/statistics/overview", values) +} + +func (s *DashboardService) SelfGameStatisticsOverview(ctx context.Context, query SelfGameStatisticsQuery) (map[string]any, error) { + values := url.Values{} + values.Set("app_code", query.AppCode) + if query.StartMS > 0 { + values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) + } + if query.EndMS > 0 { + values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) + } + if query.RegionID > 0 { + values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) + } + if query.CountryID > 0 { + values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) + } + if gameID := strings.TrimSpace(query.GameID); gameID != "" && gameID != "all" { + values.Set("game_id", gameID) + } + // 数据大屏只通过 statistics-service 读取自研游戏聚合指标,避免 admin 服务绕过聚合层去扫业务明细表。 + return s.statisticsGET(ctx, "/internal/v1/statistics/self-games/overview", values) +} + +func (s *DashboardService) statisticsGET(ctx context.Context, path string, values url.Values) (map[string]any, error) { + baseURL := s.cfg.StatisticsService.BaseURL + path req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"?"+values.Encode(), nil) if err != nil { return nil, err diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index 830d0d6d..f3a314c5 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -48,19 +48,20 @@ type catalogDTO struct { } type diceConfigDTO struct { - AppCode string `json:"appCode"` - GameID string `json:"gameId"` - Status string `json:"status"` - StakeOptions []diceStakeDTO `json:"stakeOptions"` - FeeBPS int32 `json:"feeBps"` - PoolBPS int32 `json:"poolBps"` - MinPlayers int32 `json:"minPlayers"` - MaxPlayers int32 `json:"maxPlayers"` - RobotEnabled bool `json:"robotEnabled"` - RobotMatchWaitMS int64 `json:"robotMatchWaitMs"` - PoolBalanceCoin int64 `json:"poolBalanceCoin"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` + AppCode string `json:"appCode"` + GameID string `json:"gameId"` + Status string `json:"status"` + StakeOptions []diceStakeDTO `json:"stakeOptions"` + FeeBPS int32 `json:"feeBps"` + PoolBPS int32 `json:"poolBps"` + MinPlayers int32 `json:"minPlayers"` + MaxPlayers int32 `json:"maxPlayers"` + RobotEnabled bool `json:"robotEnabled"` + RobotMatchWaitMS int64 `json:"robotMatchWaitMs"` + RobotUserWinRatePercent int32 `json:"robotUserWinRatePercent"` + PoolBalanceCoin int64 `json:"poolBalanceCoin"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` } type diceStakeDTO struct { @@ -205,19 +206,20 @@ func diceConfigFromProto(item *gamev1.DiceConfig) diceConfigDTO { }) } return diceConfigDTO{ - AppCode: item.GetAppCode(), - GameID: item.GetGameId(), - Status: item.GetStatus(), - StakeOptions: options, - FeeBPS: item.GetFeeBps(), - PoolBPS: item.GetPoolBps(), - MinPlayers: item.GetMinPlayers(), - MaxPlayers: item.GetMaxPlayers(), - RobotEnabled: item.GetRobotEnabled(), - RobotMatchWaitMS: item.GetRobotMatchWaitMs(), - PoolBalanceCoin: item.GetPoolBalanceCoin(), - CreatedAtMS: item.GetCreatedAtMs(), - UpdatedAtMS: item.GetUpdatedAtMs(), + AppCode: item.GetAppCode(), + GameID: item.GetGameId(), + Status: item.GetStatus(), + StakeOptions: options, + FeeBPS: item.GetFeeBps(), + PoolBPS: item.GetPoolBps(), + MinPlayers: item.GetMinPlayers(), + MaxPlayers: item.GetMaxPlayers(), + RobotEnabled: item.GetRobotEnabled(), + RobotMatchWaitMS: item.GetRobotMatchWaitMs(), + RobotUserWinRatePercent: item.GetRobotUserWinRatePercent(), + PoolBalanceCoin: item.GetPoolBalanceCoin(), + CreatedAtMS: item.GetCreatedAtMs(), + UpdatedAtMS: item.GetUpdatedAtMs(), } } diff --git a/server/admin/internal/modules/gamemanagement/request.go b/server/admin/internal/modules/gamemanagement/request.go index 8d80b361..834b6fda 100644 --- a/server/admin/internal/modules/gamemanagement/request.go +++ b/server/admin/internal/modules/gamemanagement/request.go @@ -57,6 +57,8 @@ type diceConfigRequest struct { MaxPlayers int32 `json:"maxPlayers"` RobotEnabled bool `json:"robotEnabled"` RobotMatchWaitMS int64 `json:"robotMatchWaitMs"` + // RobotUserWinRatePercent 用指针保留“没传”和“明确传 0”的区别;0 代表老用户机器人局必输,是合法运营配置。 + RobotUserWinRatePercent *int32 `json:"robotUserWinRatePercent"` } type diceStakeReq struct { @@ -146,15 +148,16 @@ func (r diceConfigRequest) toProto(gameID string) *gamev1.DiceConfig { }) } return &gamev1.DiceConfig{ - GameId: strings.TrimSpace(gameID), - Status: strings.TrimSpace(r.Status), - StakeOptions: options, - FeeBps: r.FeeBPS, - PoolBps: r.PoolBPS, - MinPlayers: r.MinPlayers, - MaxPlayers: r.MaxPlayers, - RobotEnabled: r.RobotEnabled, - RobotMatchWaitMs: r.RobotMatchWaitMS, + GameId: strings.TrimSpace(gameID), + Status: strings.TrimSpace(r.Status), + StakeOptions: options, + FeeBps: r.FeeBPS, + PoolBps: r.PoolBPS, + MinPlayers: r.MinPlayers, + MaxPlayers: r.MaxPlayers, + RobotEnabled: r.RobotEnabled, + RobotMatchWaitMs: r.RobotMatchWaitMS, + RobotUserWinRatePercent: r.RobotUserWinRatePercent, } } 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 1da0f916..ee8f838b 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -282,6 +282,7 @@ CREATE TABLE IF NOT EXISTS game_self_game_configs ( max_players INT NOT NULL DEFAULT 2 COMMENT '最多参与人数', robot_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用机器人补位', robot_match_wait_ms BIGINT NOT NULL DEFAULT 3000 COMMENT '等待真人后机器人补位毫秒数', + robot_user_win_rate_percent INT NOT NULL DEFAULT 50 COMMENT '非新手保护机器人局真人胜率,50 表示不干预正常规则', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY(app_code, game_id) @@ -328,6 +329,16 @@ CREATE TABLE IF NOT EXISTS game_self_game_robots ( KEY idx_game_robot_random_pool(app_code, status, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏机器人登记表'; +-- 机器人胜率 50 表示不写 forced_result;旧表补默认 50,避免存量配置上线后突然变成 0% 必胜/必输控制。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_configs' AND COLUMN_NAME = 'robot_user_win_rate_percent') = 0, + 'ALTER TABLE game_self_game_configs ADD COLUMN robot_user_win_rate_percent INT NOT NULL DEFAULT 50 COMMENT ''非新手保护机器人局真人胜率,50 表示不干预正常规则'' AFTER robot_match_wait_ms', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 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, diff --git a/services/game-service/internal/domain/dice/dice.go b/services/game-service/internal/domain/dice/dice.go index ff832b23..b67e4da9 100644 --- a/services/game-service/internal/domain/dice/dice.go +++ b/services/game-service/internal/domain/dice/dice.go @@ -17,8 +17,10 @@ const ( DefaultPoolBPS = int64(100) DefaultJoinTTLMillis = int64(60_000) DefaultRobotWaitMS = int64(3_000) - DefaultGameID = "dice" - SelfGameIDRock = "rock" + // DefaultRobotUserWinRatePercent 为 50 时不改写 forced_result,机器人局继续按游戏自身随机/手势规则结算。 + DefaultRobotUserWinRatePercent = int32(50) + DefaultGameID = "dice" + SelfGameIDRock = "rock" // 首版禁止 1 人局,避免单人场景下“自己赢自己”的派奖语义不清;3 人局只是多一行 participant。 DefaultMinPlayers = int32(2) DefaultMaxPlayers = int32(2) @@ -106,9 +108,12 @@ type Config struct { MaxPlayers int32 RobotEnabled bool RobotMatchWaitMS int64 - PoolBalanceCoin int64 - CreatedAtMS int64 - UpdatedAtMS int64 + // RobotUserWinRatePercent 只控制非新手保护机器人局的强制胜负;0 是合法配置,所以用 Set 标记区分“未配置”和“明确配置 0”。 + RobotUserWinRatePercent int32 + RobotUserWinRatePercentSet bool + PoolBalanceCoin int64 + CreatedAtMS int64 + UpdatedAtMS int64 } // Robot 是 game-service 对“哪些真实 App 用户可作为机器人”的最小登记,不复制用户头像昵称。 @@ -239,6 +244,13 @@ func NormalizeConfig(config Config) (Config, error) { if config.RobotMatchWaitMS <= 0 { config.RobotMatchWaitMS = DefaultRobotWaitMS } + if !config.RobotUserWinRatePercentSet { + config.RobotUserWinRatePercent = DefaultRobotUserWinRatePercent + config.RobotUserWinRatePercentSet = true + } + if config.RobotUserWinRatePercent < 0 || config.RobotUserWinRatePercent > 100 { + return Config{}, xerr.New(xerr.InvalidArgument, "dice robot_user_win_rate_percent is invalid") + } config.StakeOptions = NormalizeStakeOptions(config.StakeOptions) return config, nil } diff --git a/services/game-service/internal/domain/dice/dice_test.go b/services/game-service/internal/domain/dice/dice_test.go new file mode 100644 index 00000000..f4f31f79 --- /dev/null +++ b/services/game-service/internal/domain/dice/dice_test.go @@ -0,0 +1,32 @@ +package dice + +import "testing" + +func TestNormalizeConfigDefaultsRobotUserWinRatePercent(t *testing.T) { + config, err := NormalizeConfig(Config{}) + if err != nil { + t.Fatalf("NormalizeConfig failed: %v", err) + } + if config.RobotUserWinRatePercent != DefaultRobotUserWinRatePercent || !config.RobotUserWinRatePercentSet { + t.Fatalf("robot win rate default mismatch: rate=%d set=%v", config.RobotUserWinRatePercent, config.RobotUserWinRatePercentSet) + } +} + +func TestNormalizeConfigKeepsExplicitRobotUserWinRatePercentZero(t *testing.T) { + config, err := NormalizeConfig(Config{RobotUserWinRatePercent: 0, RobotUserWinRatePercentSet: true}) + if err != nil { + t.Fatalf("NormalizeConfig failed: %v", err) + } + if config.RobotUserWinRatePercent != 0 { + t.Fatalf("explicit zero win rate should be kept, got %d", config.RobotUserWinRatePercent) + } +} + +func TestNormalizeConfigRejectsInvalidRobotUserWinRatePercent(t *testing.T) { + tests := []int32{-1, 101} + for _, rate := range tests { + if _, err := NormalizeConfig(Config{RobotUserWinRatePercent: rate, RobotUserWinRatePercentSet: true}); err == nil { + t.Fatalf("NormalizeConfig should reject robot win rate %d", rate) + } + } +} diff --git a/services/game-service/internal/service/dice/service.go b/services/game-service/internal/service/dice/service.go index fab8588a..3ba0e3b3 100644 --- a/services/game-service/internal/service/dice/service.go +++ b/services/game-service/internal/service/dice/service.go @@ -822,15 +822,17 @@ func (s *Service) configForMatch(ctx context.Context, appCode string, gameID str } // 没有后台配置时把默认配置写成 MySQL 事实,再重新读取;这样 H5、大厅后台和机器人奖池判断都使用同一份配置/奖池快照。 normalized, err := dicedomain.NormalizeConfig(dicedomain.Config{ - AppCode: appcode.Normalize(appCode), - GameID: strings.TrimSpace(gameID), - Status: dicedomain.ConfigStatusActive, - FeeBPS: int32(dicedomain.DefaultFeeBPS), - PoolBPS: int32(dicedomain.DefaultPoolBPS), - MinPlayers: dicedomain.DefaultMinPlayers, - MaxPlayers: dicedomain.DefaultMaxPlayers, - RobotEnabled: true, - RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, + AppCode: appcode.Normalize(appCode), + GameID: strings.TrimSpace(gameID), + Status: dicedomain.ConfigStatusActive, + FeeBPS: int32(dicedomain.DefaultFeeBPS), + PoolBPS: int32(dicedomain.DefaultPoolBPS), + MinPlayers: dicedomain.DefaultMinPlayers, + MaxPlayers: dicedomain.DefaultMaxPlayers, + RobotEnabled: true, + RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, + RobotUserWinRatePercent: dicedomain.DefaultRobotUserWinRatePercent, + RobotUserWinRatePercentSet: true, }) if err != nil { return dicedomain.Config{}, err @@ -894,6 +896,12 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match forcedResult = dicedomain.ForcedResultPlayerWin } } + if forcedResult == "" { + forcedResult, err = s.forcedRobotResultByWinRate(config.RobotUserWinRatePercent) + if err != nil { + return dicedomain.Match{}, 0, err + } + } } robotRPSGesture := "" if dicedomain.IsRPSGameID(match.GameID) { @@ -909,6 +917,24 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match return joined, nowMS, nil } +func (s *Service) forcedRobotResultByWinRate(winRatePercent int32) (string, error) { + if winRatePercent == dicedomain.DefaultRobotUserWinRatePercent { + return "", nil + } + roll, err := s.randomInt(100) + if err != nil { + return "", err + } + if roll < 0 || roll >= 100 { + return "", xerr.New(xerr.InvalidArgument, "random robot win rate roll is invalid") + } + if int32(roll) < winRatePercent { + // 非新手保护机器人局按后台配置直接写入强制结果;骰子在结算时改点数,RPS 在机器人入座时反推手势。 + return dicedomain.ForcedResultPlayerWin, nil + } + return dicedomain.ForcedResultPlayerLose, nil +} + func firstWinGuaranteeUser(match dicedomain.Match) (int64, bool) { var userID int64 for _, participant := range match.Participants { diff --git a/services/game-service/internal/service/dice/service_test.go b/services/game-service/internal/service/dice/service_test.go index 05990694..bdfb0ffd 100644 --- a/services/game-service/internal/service/dice/service_test.go +++ b/services/game-service/internal/service/dice/service_test.go @@ -306,6 +306,7 @@ func TestRPSRequiresGestureBeforeMatchmaking(t *testing.T) { }, } for _, tt := range tests { + tt := tt t.Run(tt.name, func(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) @@ -570,6 +571,142 @@ func TestRobotMatchUsesNormalFlowAfterUserHasSelfGameWin(t *testing.T) { } } +func TestRobotMatchAppliesConfiguredWinRateAfterUserHasSelfGameWin(t *testing.T) { + tests := []struct { + name string + winRate int32 + randomRoll int + wantForced string + randomAllowed bool + }{ + {name: "configured_100_forces_human_win", winRate: 100, randomRoll: 99, wantForced: dicedomain.ForcedResultPlayerWin, randomAllowed: true}, + {name: "configured_0_forces_human_lose", winRate: 0, randomRoll: 0, wantForced: dicedomain.ForcedResultPlayerLose, randomAllowed: true}, + {name: "configured_50_keeps_normal_flow", winRate: 50, wantForced: "", randomAllowed: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 200 + repo.poolBalance = 200 + repo.config.RobotUserWinRatePercent = tt.winRate + repo.config.RobotUserWinRatePercentSet = true + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9100, 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) } + svc.randomInt = func(max int) (int, error) { + if !tt.randomAllowed { + t.Fatalf("50 percent robot win rate should keep normal flow without attach-time random, max=%d", max) + } + if max != 100 { + t.Fatalf("robot win rate random should use 100 slots, got %d", max) + } + return tt.randomRoll, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-win-rate-create-" + tt.name, + 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 != tt.wantForced { + t.Fatalf("forced_result = %q, want %q", ready.ForcedResult, tt.wantForced) + } + }) + } +} + +func TestRobotWinRateDoesNotOverrideFirstWinGuarantee(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 200 + repo.poolBalance = 200 + repo.config.RobotUserWinRatePercent = 0 + repo.config.RobotUserWinRatePercentSet = true + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9101, 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("first-win guarantee must skip configured robot win rate random, max=%d", max) + return 0, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-first-win-over-rate-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.ForcedResultPlayerWin { + t.Fatalf("first-win guarantee should force player win before configured rate, got %q", ready.ForcedResult) + } +} + +func TestRobotWinRateDoesNotOverrideShortPoolProtection(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 87 + repo.poolBalance = 87 + repo.config.RobotUserWinRatePercent = 100 + repo.config.RobotUserWinRatePercentSet = true + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9102, 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) } + svc.randomInt = func(max int) (int, error) { + t.Fatalf("short pool protection must skip configured robot win rate random, max=%d", max) + return 0, nil + } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-short-pool-over-rate-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 before configured rate, got %q", ready.ForcedResult) + } +} + func TestExploreGameCodeForMatchUsesSelfGameIDs(t *testing.T) { tests := []struct { gameID string @@ -681,16 +818,18 @@ func newFakeDiceRepository() *fakeDiceRepository { PlatformStatus: gamedomain.StatusActive, }, config: dicedomain.Config{ - AppCode: "lalu", - GameID: "dice", - Status: dicedomain.ConfigStatusActive, - StakeOptions: dicedomain.NormalizeStakeOptions(nil), - FeeBPS: int32(dicedomain.DefaultFeeBPS), - PoolBPS: int32(dicedomain.DefaultPoolBPS), - MinPlayers: dicedomain.DefaultMinPlayers, - MaxPlayers: dicedomain.DefaultMaxPlayers, - RobotEnabled: true, - RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, + AppCode: "lalu", + GameID: "dice", + Status: dicedomain.ConfigStatusActive, + StakeOptions: dicedomain.NormalizeStakeOptions(nil), + FeeBPS: int32(dicedomain.DefaultFeeBPS), + PoolBPS: int32(dicedomain.DefaultPoolBPS), + MinPlayers: dicedomain.DefaultMinPlayers, + MaxPlayers: dicedomain.DefaultMaxPlayers, + RobotEnabled: true, + RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, + RobotUserWinRatePercent: dicedomain.DefaultRobotUserWinRatePercent, + RobotUserWinRatePercentSet: true, }, orders: map[string]gamedomain.GameOrder{}, } 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 efcf4d2b..3414b85c 100644 --- a/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go +++ b/services/game-service/internal/storage/mysql/dice_matchmaking_repository.go @@ -21,7 +21,7 @@ func (r *Repository) GetDiceConfig(ctx context.Context, appCode string, gameID s gameID = normalizeDiceGameID(gameID) query := `SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'), c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled, - c.robot_match_wait_ms, c.created_at_ms, c.updated_at_ms, + c.robot_match_wait_ms, c.robot_user_win_rate_percent, c.created_at_ms, c.updated_at_ms, COALESCE(p.balance_coin, 0) FROM game_self_game_configs c LEFT JOIN game_self_game_pools p ON p.app_code = c.app_code AND p.game_id = c.game_id @@ -31,13 +31,14 @@ func (r *Repository) GetDiceConfig(ctx context.Context, appCode string, gameID s if err := r.db.QueryRowContext(ctx, query, app, gameID).Scan( &config.AppCode, &config.GameID, &config.Status, &optionsJSON, &config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled, - &config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin, + &config.RobotMatchWaitMS, &config.RobotUserWinRatePercent, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin, ); err != nil { if errors.Is(err, sql.ErrNoRows) { return dicedomain.Config{}, xerr.New(xerr.NotFound, "dice config not found") } return dicedomain.Config{}, err } + config.RobotUserWinRatePercentSet = true config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON) return dicedomain.NormalizeConfig(config) } @@ -48,7 +49,8 @@ func (r *Repository) ListSelfGameConfigs(ctx context.Context, appCode string) ([ rows, err := r.db.QueryContext(ctx, `SELECT c.app_code, c.game_id, c.status, COALESCE(CAST(c.stake_options_json AS CHAR), '[]'), c.fee_bps, c.pool_bps, c.min_players, c.max_players, c.robot_enabled, - c.robot_match_wait_ms, c.created_at_ms, c.updated_at_ms, COALESCE(p.balance_coin, 0) + c.robot_match_wait_ms, c.robot_user_win_rate_percent, + c.created_at_ms, c.updated_at_ms, COALESCE(p.balance_coin, 0) FROM game_self_game_configs c LEFT JOIN game_self_game_pools p ON p.app_code = c.app_code AND p.game_id = c.game_id WHERE c.app_code = ? @@ -67,10 +69,11 @@ func (r *Repository) ListSelfGameConfigs(ctx context.Context, appCode string) ([ if err := rows.Scan( &config.AppCode, &config.GameID, &config.Status, &optionsJSON, &config.FeeBPS, &config.PoolBPS, &config.MinPlayers, &config.MaxPlayers, &config.RobotEnabled, - &config.RobotMatchWaitMS, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin, + &config.RobotMatchWaitMS, &config.RobotUserWinRatePercent, &config.CreatedAtMS, &config.UpdatedAtMS, &config.PoolBalanceCoin, ); err != nil { return nil, err } + config.RobotUserWinRatePercentSet = true config.StakeOptions = dicedomain.ParseStakeOptions(optionsJSON) normalized, err := dicedomain.NormalizeConfig(config) if err != nil { @@ -97,8 +100,8 @@ func (r *Repository) UpsertDiceConfig(ctx context.Context, config dicedomain.Con if _, err := tx.ExecContext(ctx, `INSERT INTO game_self_game_configs ( app_code, game_id, status, stake_options_json, fee_bps, pool_bps, - min_players, max_players, robot_enabled, robot_match_wait_ms, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?) + min_players, max_players, robot_enabled, robot_match_wait_ms, robot_user_win_rate_percent, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), stake_options_json = VALUES(stake_options_json), @@ -108,10 +111,11 @@ func (r *Repository) UpsertDiceConfig(ctx context.Context, config dicedomain.Con max_players = VALUES(max_players), robot_enabled = VALUES(robot_enabled), robot_match_wait_ms = VALUES(robot_match_wait_ms), + robot_user_win_rate_percent = VALUES(robot_user_win_rate_percent), updated_at_ms = VALUES(updated_at_ms)`, normalized.AppCode, normalized.GameID, normalized.Status, dicedomain.StakeOptionsJSON(normalized.StakeOptions), normalized.FeeBPS, normalized.PoolBPS, normalized.MinPlayers, normalized.MaxPlayers, normalized.RobotEnabled, - normalized.RobotMatchWaitMS, nowMs, nowMs, + normalized.RobotMatchWaitMS, normalized.RobotUserWinRatePercent, nowMs, nowMs, ); err != nil { return dicedomain.Config{}, err } diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 38b9a8b3..fade8d8b 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -221,6 +221,46 @@ 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_configs ( + app_code VARCHAR(32) NOT NULL, + game_id VARCHAR(96) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + stake_options_json JSON NULL, + fee_bps INT NOT NULL DEFAULT 500, + pool_bps INT NOT NULL DEFAULT 100, + min_players INT NOT NULL DEFAULT 2, + max_players INT NOT NULL DEFAULT 2, + robot_enabled TINYINT(1) NOT NULL DEFAULT 1, + robot_match_wait_ms BIGINT NOT NULL DEFAULT 3000, + robot_user_win_rate_percent INT NOT NULL DEFAULT 50, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY(app_code, game_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS game_self_game_pools ( + app_code VARCHAR(32) NOT NULL, + game_id VARCHAR(96) NOT NULL, + balance_coin BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY(app_code, game_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS game_self_game_pool_transactions ( + app_code VARCHAR(32) NOT NULL, + adjustment_id VARCHAR(96) NOT NULL, + game_id VARCHAR(96) NOT NULL, + match_id VARCHAR(96) NOT NULL DEFAULT '', + user_id BIGINT NOT NULL DEFAULT 0, + direction VARCHAR(16) NOT NULL, + amount_coin BIGINT NOT NULL, + reason VARCHAR(128) NOT NULL DEFAULT '', + balance_after BIGINT NOT NULL, + created_by BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + PRIMARY KEY(app_code, adjustment_id), + KEY idx_game_pool_tx_game_time(app_code, game_id, created_at_ms), + KEY idx_game_pool_tx_match(app_code, match_id) + ) 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, @@ -347,6 +387,9 @@ func (r *Repository) Migrate(ctx context.Context) error { 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_self_game_configs", "robot_user_win_rate_percent", "robot_user_win_rate_percent INT NOT NULL DEFAULT 50 AFTER robot_match_wait_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 } @@ -485,7 +528,17 @@ func equalStringSlices(left []string, right []string) bool { return true } +func isBuiltinSelfGameID(gameID string) bool { + switch strings.TrimSpace(gameID) { + case "dice", "rock": + return true + default: + return false + } +} + func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) { + // dice/rock 需要保留 catalog 行给专用匹配入口做可玩性校验,但 App 普通游戏列表只展示三方 H5 游戏,避免自研入口混进运营列表后被误删。 rows, err := r.db.QueryContext(ctx, `SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category, c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status, @@ -496,6 +549,7 @@ func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQ WHERE c.app_code = ? AND c.status IN ('active', 'maintenance') AND p.status IN ('active', 'maintenance') + AND c.game_id NOT IN ('dice', 'rock') AND r.scene = ? AND r.enabled = 1 AND r.visible = 1 @@ -551,6 +605,7 @@ func (r *Repository) ListRecentGames(ctx context.Context, query gameservice.List JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id WHERE c.status IN ('active', 'maintenance') AND p.status IN ('active', 'maintenance') + AND c.game_id NOT IN ('dice', 'rock') AND r.scene = ? AND r.enabled = 1 AND r.visible = 1 @@ -1455,6 +1510,10 @@ func (r *Repository) DeleteCatalog(ctx context.Context, appCode string, gameID s if id == "" { return xerr.New(xerr.InvalidArgument, "game_id is required") } + if isBuiltinSelfGameID(id) { + // 内置自研游戏的 catalog 是专用 /games/dice、/games/rps 入口的运行时前置校验,不属于普通三方游戏目录,不能通过删除目录下架。 + return xerr.New(xerr.InvalidArgument, "self game catalog cannot be deleted") + } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index 0a631127..caa738ac 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -9,10 +9,137 @@ import ( "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" + "hyapp/pkg/xerr" dicedomain "hyapp/services/game-service/internal/domain/dice" gamedomain "hyapp/services/game-service/internal/domain/game" + gameservice "hyapp/services/game-service/internal/service/game" ) +func TestDiceConfigPersistsRobotUserWinRatePercent(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() }) + + config := dicedomain.Config{ + AppCode: "lalu", + GameID: dicedomain.DefaultGameID, + Status: dicedomain.ConfigStatusActive, + StakeOptions: dicedomain.NormalizeStakeOptions(nil), + FeeBPS: int32(dicedomain.DefaultFeeBPS), + PoolBPS: int32(dicedomain.DefaultPoolBPS), + MinPlayers: dicedomain.DefaultMinPlayers, + MaxPlayers: dicedomain.DefaultMaxPlayers, + RobotEnabled: true, + RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, + RobotUserWinRatePercent: 70, + RobotUserWinRatePercentSet: true, + } + saved, err := repo.UpsertDiceConfig(ctx, config, 1700000000000) + if err != nil { + t.Fatalf("upsert dice config failed: %v", err) + } + if saved.RobotUserWinRatePercent != 70 { + t.Fatalf("saved robot win rate = %d, want 70", saved.RobotUserWinRatePercent) + } + fetched, err := repo.GetDiceConfig(ctx, "lalu", dicedomain.DefaultGameID) + if err != nil { + t.Fatalf("get dice config failed: %v", err) + } + if fetched.RobotUserWinRatePercent != 70 { + t.Fatalf("fetched robot win rate = %d, want 70", fetched.RobotUserWinRatePercent) + } + items, err := repo.ListSelfGameConfigs(ctx, "lalu") + if err != nil { + t.Fatalf("list self game configs failed: %v", err) + } + if len(items) != 1 || items[0].RobotUserWinRatePercent != 70 { + t.Fatalf("listed robot win rate mismatch: %+v", items) + } + + config.RobotUserWinRatePercent = 0 + saved, err = repo.UpsertDiceConfig(ctx, config, 1700000001000) + if err != nil { + t.Fatalf("upsert zero robot win rate failed: %v", err) + } + if saved.RobotUserWinRatePercent != 0 { + t.Fatalf("explicit zero robot win rate should be persisted, got %d", saved.RobotUserWinRatePercent) + } +} + +func TestAppGameListsExcludeBuiltInSelfGames(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(1781200000000) + for _, platform := range []gamedomain.Platform{ + {AppCode: "lalu", PlatformCode: "dice", PlatformName: "Dice Internal", Status: gamedomain.StatusActive, AdapterType: "dice_internal", CreatedAtMS: nowMS, UpdatedAtMS: nowMS}, + {AppCode: "lalu", PlatformCode: "demo", PlatformName: "Demo", Status: gamedomain.StatusActive, AdapterType: gamedomain.AdapterDemo, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}, + } { + if _, err := repo.UpsertPlatform(ctx, platform); err != nil { + t.Fatalf("upsert platform %s failed: %v", platform.PlatformCode, err) + } + } + for _, item := range []gamedomain.CatalogItem{ + {AppCode: "lalu", GameID: dicedomain.DefaultGameID, PlatformCode: "dice", ProviderGameID: "dice", GameName: "Dice", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 1}, + {AppCode: "lalu", GameID: dicedomain.SelfGameIDRock, PlatformCode: "dice", ProviderGameID: "rock", GameName: "Rock Paper Scissors", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 2}, + {AppCode: "lalu", GameID: "demo_slot_001", PlatformCode: "demo", ProviderGameID: "slot_001", GameName: "Demo Slot", Category: "slot", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 3}, + } { + if _, err := repo.UpsertCatalog(ctx, item); err != nil { + t.Fatalf("upsert catalog %s failed: %v", item.GameID, err) + } + } + for _, session := range []gamedomain.LaunchSession{ + {AppCode: "lalu", SessionID: "sess_dice", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.DefaultGameID, ProviderGameID: "dice", LaunchTokenHash: "hash_dice", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 10, UpdatedAtMS: nowMS + 10}, + {AppCode: "lalu", SessionID: "sess_rock", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.SelfGameIDRock, ProviderGameID: "rock", LaunchTokenHash: "hash_rock", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 20, UpdatedAtMS: nowMS + 20}, + {AppCode: "lalu", SessionID: "sess_demo", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "demo", GameID: "demo_slot_001", ProviderGameID: "slot_001", LaunchTokenHash: "hash_demo", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 30, UpdatedAtMS: nowMS + 30}, + } { + if err := repo.CreateLaunchSession(ctx, session); err != nil { + t.Fatalf("create launch session %s failed: %v", session.SessionID, err) + } + } + + query := gameservice.ListGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom} + games, err := repo.ListGames(ctx, query) + if err != nil { + t.Fatalf("list games failed: %v", err) + } + if len(games) != 1 || games[0].GameID != "demo_slot_001" { + t.Fatalf("app game list should hide built-in self games, got %+v", games) + } + recent, err := repo.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PageSize: 10}) + if err != nil { + t.Fatalf("list recent games failed: %v", err) + } + if len(recent) != 1 || recent[0].GameID != "demo_slot_001" { + t.Fatalf("recent game list should hide built-in self games, got %+v", recent) + } + if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.DefaultGameID); err != nil { + t.Fatalf("dice catalog must remain launchable for dedicated endpoints: %v", err) + } + if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.SelfGameIDRock); err != nil { + t.Fatalf("rock catalog must remain launchable for dedicated endpoints: %v", err) + } +} + func TestMarkOrderSucceededCreatesClaimableLevelOutboxEvent(t *testing.T) { caller := mysqlschema.CallerFile(t, 1) schema := mysqlschema.New(t, mysqlschema.Config{ @@ -545,3 +672,25 @@ func TestDeleteCatalogRemovesGameAndDisplayRule(t *testing.T) { t.Fatalf("game_display_rules count = %d, want 0", ruleCount) } } + +func TestDeleteCatalogRejectsBuiltInSelfGames(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() }) + + for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} { + err := repo.DeleteCatalog(ctx, "lalu", gameID) + if !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("delete built-in self game %s should be rejected, got %v", gameID, err) + } + } +} diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 7eb760cc..ee5d429c 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -755,20 +755,22 @@ func diceConfigToProto(config dicedomain.Config) *gamev1.DiceConfig { for _, option := range config.StakeOptions { options = append(options, &gamev1.DiceStakeOption{StakeCoin: option.StakeCoin, Enabled: option.Enabled, SortOrder: option.SortOrder}) } + robotUserWinRatePercent := config.RobotUserWinRatePercent return &gamev1.DiceConfig{ - AppCode: config.AppCode, - GameId: config.GameID, - Status: config.Status, - StakeOptions: options, - FeeBps: config.FeeBPS, - PoolBps: config.PoolBPS, - MinPlayers: config.MinPlayers, - MaxPlayers: config.MaxPlayers, - RobotEnabled: config.RobotEnabled, - RobotMatchWaitMs: config.RobotMatchWaitMS, - PoolBalanceCoin: config.PoolBalanceCoin, - CreatedAtMs: config.CreatedAtMS, - UpdatedAtMs: config.UpdatedAtMS, + AppCode: config.AppCode, + GameId: config.GameID, + Status: config.Status, + StakeOptions: options, + FeeBps: config.FeeBPS, + PoolBps: config.PoolBPS, + MinPlayers: config.MinPlayers, + MaxPlayers: config.MaxPlayers, + RobotEnabled: config.RobotEnabled, + RobotMatchWaitMs: config.RobotMatchWaitMS, + PoolBalanceCoin: config.PoolBalanceCoin, + CreatedAtMs: config.CreatedAtMS, + UpdatedAtMs: config.UpdatedAtMS, + RobotUserWinRatePercent: &robotUserWinRatePercent, } } @@ -781,16 +783,18 @@ func diceConfigFromProto(config *gamev1.DiceConfig) dicedomain.Config { options = append(options, dicedomain.StakeOption{StakeCoin: option.GetStakeCoin(), Enabled: option.GetEnabled(), SortOrder: option.GetSortOrder()}) } return dicedomain.Config{ - AppCode: config.GetAppCode(), - GameID: config.GetGameId(), - Status: config.GetStatus(), - StakeOptions: options, - FeeBPS: config.GetFeeBps(), - PoolBPS: config.GetPoolBps(), - MinPlayers: config.GetMinPlayers(), - MaxPlayers: config.GetMaxPlayers(), - RobotEnabled: config.GetRobotEnabled(), - RobotMatchWaitMS: config.GetRobotMatchWaitMs(), + AppCode: config.GetAppCode(), + GameID: config.GetGameId(), + Status: config.GetStatus(), + StakeOptions: options, + FeeBPS: config.GetFeeBps(), + PoolBPS: config.GetPoolBps(), + MinPlayers: config.GetMinPlayers(), + MaxPlayers: config.GetMaxPlayers(), + RobotEnabled: config.GetRobotEnabled(), + RobotMatchWaitMS: config.GetRobotMatchWaitMs(), + RobotUserWinRatePercent: config.GetRobotUserWinRatePercent(), + RobotUserWinRatePercentSet: config.RobotUserWinRatePercent != nil, } }