update self game catalog handling
This commit is contained in:
parent
1b8241c3d2
commit
cee48232f8
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
32
services/game-service/internal/domain/dice/dice_test.go
Normal file
32
services/game-service/internal/domain/dice/dice_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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{},
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user