update self game catalog handling

This commit is contained in:
zhx 2026-06-12 03:08:18 +08:00
parent 1b8241c3d2
commit cee48232f8
16 changed files with 614 additions and 108 deletions

View File

@ -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{

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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(),
}
}

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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{},
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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,
}
}