From 25563168525951b29b5a4069e284e9ba23abd93d Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 20:12:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=BB=91=E8=89=B2=E5=BF=85?= =?UTF-8?q?=E8=83=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/game/v1/game.pb.go | 13 +++- api/proto/game/v1/game.proto | 1 + .../internal/modules/gamemanagement/dto.go | 2 + .../modules/gamemanagement/dto_test.go | 8 +++ .../modules/gamemanagement/request.go | 2 + .../deploy/mysql/initdb/001_game_service.sql | 10 +++ .../internal/domain/selfgame/strategy.go | 13 +++- .../internal/domain/selfgame/strategy_test.go | 34 +++++++++ .../internal/service/dice/service.go | 39 ++++++---- .../internal/service/dice/service_test.go | 71 +++++++++++++++++++ .../internal/storage/mysql/repository.go | 14 ++-- .../internal/storage/mysql/repository_test.go | 7 ++ .../mysql/selfgame_strategy_repository.go | 21 +++--- .../internal/transport/grpc/server.go | 2 + .../transport/http/game_handler_test.go | 48 +++++++++++++ .../transport/http/gameapi/game_handler.go | 9 ++- .../http/gameapi/hotgame_compat_handler.go | 15 ++-- 17 files changed, 271 insertions(+), 38 deletions(-) diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index dc64c473..9f5a1b13 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -4806,6 +4806,7 @@ type SelfGameNewUserPolicy struct { CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,14,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` NormalPhaseTargetWinRatePercent int32 `protobuf:"varint,15,opt,name=normal_phase_target_win_rate_percent,json=normalPhaseTargetWinRatePercent,proto3" json:"normal_phase_target_win_rate_percent,omitempty"` + BlackPoolRobotForceWinEnabled bool `protobuf:"varint,16,opt,name=black_pool_robot_force_win_enabled,json=blackPoolRobotForceWinEnabled,proto3" json:"black_pool_robot_force_win_enabled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4945,6 +4946,13 @@ func (x *SelfGameNewUserPolicy) GetNormalPhaseTargetWinRatePercent() int32 { return 0 } +func (x *SelfGameNewUserPolicy) GetBlackPoolRobotForceWinEnabled() bool { + if x != nil { + return x.BlackPoolRobotForceWinEnabled + } + return false +} + type GetSelfGameNewUserPolicyRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -6758,7 +6766,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"|\n" + "\x17UpdateDiceConfigRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x121\n" + - "\x06config\x18\x02 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\"\xce\x05\n" + + "\x06config\x18\x02 \x01(\v2\x19.hyapp.game.v1.DiceConfigR\x06config\"\x99\x06\n" + "\x15SelfGameNewUserPolicy\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\agame_id\x18\x02 \x01(\tR\x06gameId\x12\x18\n" + @@ -6775,7 +6783,8 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\x13lose_streak_trigger\x18\f \x01(\x05R\x11loseStreakTrigger\x12\"\n" + "\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rupdated_at_ms\x18\x0e \x01(\x03R\vupdatedAtMs\x12M\n" + - "$normal_phase_target_win_rate_percent\x18\x0f \x01(\x05R\x1fnormalPhaseTargetWinRatePercent\"j\n" + + "$normal_phase_target_win_rate_percent\x18\x0f \x01(\x05R\x1fnormalPhaseTargetWinRatePercent\x12I\n" + + "\"black_pool_robot_force_win_enabled\x18\x10 \x01(\bR\x1dblackPoolRobotForceWinEnabled\"j\n" + "\x1fGetSelfGameNewUserPolicyRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.game.v1.RequestMetaR\x04meta\x12\x17\n" + "\agame_id\x18\x02 \x01(\tR\x06gameId\"\x92\x01\n" + diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index 9c93066a..a83797a0 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -530,6 +530,7 @@ message SelfGameNewUserPolicy { int64 created_at_ms = 13; int64 updated_at_ms = 14; int32 normal_phase_target_win_rate_percent = 15; + bool black_pool_robot_force_win_enabled = 16; } message GetSelfGameNewUserPolicyRequest { diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index e39179d1..e2cfd444 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -116,6 +116,7 @@ type selfGameNewUserPolicyDTO struct { LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"` LoseStreakTrigger int32 `json:"loseStreakTrigger"` NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"` + BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"` CreatedAtMS int64 `json:"createdAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` } @@ -322,6 +323,7 @@ func selfGameNewUserPolicyFromProto(item *gamev1.SelfGameNewUserPolicy) selfGame LoseStreakProtectionEnabled: item.GetLoseStreakProtectionEnabled(), LoseStreakTrigger: item.GetLoseStreakTrigger(), NormalPhaseTargetWinRatePercent: item.GetNormalPhaseTargetWinRatePercent(), + BlackPoolRobotForceWinEnabled: item.GetBlackPoolRobotForceWinEnabled(), CreatedAtMS: item.GetCreatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(), } diff --git a/server/admin/internal/modules/gamemanagement/dto_test.go b/server/admin/internal/modules/gamemanagement/dto_test.go index ab32c6c3..e069ae19 100644 --- a/server/admin/internal/modules/gamemanagement/dto_test.go +++ b/server/admin/internal/modules/gamemanagement/dto_test.go @@ -36,19 +36,27 @@ func TestSelfGameNewUserPolicyKeepsNormalPhaseTargetWinRate(t *testing.T) { LoseStreakProtectionEnabled: true, LoseStreakTrigger: 2, NormalPhaseTargetWinRatePercent: 57, + BlackPoolRobotForceWinEnabled: true, }).toProto("dice") if proto.GetNormalPhaseTargetWinRatePercent() != 57 { t.Fatalf("request normal phase target = %d, want 57", proto.GetNormalPhaseTargetWinRatePercent()) } + if !proto.GetBlackPoolRobotForceWinEnabled() { + t.Fatal("request black pool robot force win switch was not preserved") + } dto := selfGameNewUserPolicyFromProto(&gamev1.SelfGameNewUserPolicy{ GameId: "dice", NormalPhaseTargetWinRatePercent: 57, + BlackPoolRobotForceWinEnabled: true, }) if dto.NormalPhaseTargetWinRatePercent != 57 { t.Fatalf("dto normal phase target = %d, want 57", dto.NormalPhaseTargetWinRatePercent) } + if !dto.BlackPoolRobotForceWinEnabled { + t.Fatal("dto black pool robot force win switch was not preserved") + } } func TestRoomRPSConfigRequestRequiresFourGiftTiers(t *testing.T) { diff --git a/server/admin/internal/modules/gamemanagement/request.go b/server/admin/internal/modules/gamemanagement/request.go index f1d2e192..9243af2e 100644 --- a/server/admin/internal/modules/gamemanagement/request.go +++ b/server/admin/internal/modules/gamemanagement/request.go @@ -92,6 +92,7 @@ type selfGameNewUserPolicyRequest struct { LoseStreakProtectionEnabled bool `json:"loseStreakProtectionEnabled"` LoseStreakTrigger int32 `json:"loseStreakTrigger"` NormalPhaseTargetWinRatePercent int32 `json:"normalPhaseTargetWinRatePercent"` + BlackPoolRobotForceWinEnabled bool `json:"blackPoolRobotForceWinEnabled"` } type diceGenerateRobotsRequest struct { @@ -195,6 +196,7 @@ func (r selfGameNewUserPolicyRequest) toProto(gameID string) *gamev1.SelfGameNew LoseStreakProtectionEnabled: r.LoseStreakProtectionEnabled, LoseStreakTrigger: r.LoseStreakTrigger, NormalPhaseTargetWinRatePercent: r.NormalPhaseTargetWinRatePercent, + BlackPoolRobotForceWinEnabled: r.BlackPoolRobotForceWinEnabled, } } diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 665f7dba..5c8712e7 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -363,6 +363,7 @@ CREATE TABLE IF NOT EXISTS game_self_game_new_user_policies ( lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用新手连输保护', lose_streak_trigger INT NOT NULL DEFAULT 2 COMMENT '触发新手连输保护的连续输局数', normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53 COMMENT '成熟期有效机器人局目标胜率百分比,50 表示完全自然随机', + black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '黑色水位是否允许机器人补位并强制机器人获胜', 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) @@ -472,6 +473,15 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_new_user_policies' AND COLUMN_NAME = 'black_pool_robot_force_win_enabled') = 0, + 'ALTER TABLE game_self_game_new_user_policies ADD COLUMN black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''黑色水位是否允许机器人补位并强制机器人获胜'' AFTER normal_phase_target_win_rate_percent', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + -- 机器人使用时间和累计次数只用于后台观察随机命中分布;旧表补默认 0,避免存量机器人资料读取失败。 SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'game_self_game_robots' AND COLUMN_NAME = 'last_used_at_ms') = 0, diff --git a/services/game-service/internal/domain/selfgame/strategy.go b/services/game-service/internal/domain/selfgame/strategy.go index 894636b1..0c5e28a7 100644 --- a/services/game-service/internal/domain/selfgame/strategy.go +++ b/services/game-service/internal/domain/selfgame/strategy.go @@ -172,6 +172,7 @@ type NewUserPolicy struct { LoseStreakProtectionEnabled bool LoseStreakTrigger int32 NormalPhaseTargetWinRatePercent int32 + BlackPoolRobotForceWinEnabled bool CreatedAtMS int64 UpdatedAtMS int64 } @@ -389,8 +390,16 @@ func EvaluateRobotStrategy(input StrategyInput, randomInt RandomIntFunc) (Strate base := StrategyDecision{StrategyCode: StrategyCodeNormalRandom, Decision: DecisionNone, ReasonCode: "NORMAL_RANDOM", PoolLevel: level, RequiredPoolCoin: input.RequiredPoolCoin} if level == PoolLevelBlack { - // 黑色水位已经无法覆盖本档位真人赢机器人时的最大净支出;策略层直接熔断机器人补位, - // service 收到 none+POOL_BLACK 后不会抽机器人,用户只继续等待真人,避免并发局把档位奖池扣穿。 + if input.StakePool.Status != "" && input.StakePool.Status != "active" { + // 档位被运营手动停用时,不论策略开关如何都不能补机器人;停用是显式运营指令,优先级高于黑色水位强制输。 + return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_BLACK") + } + if input.NewUserPolicy.BlackPoolRobotForceWinEnabled { + // 黑色水位已经无法覆盖本档位真人赢机器人时的最大净支出;开关开启后,机器人仍可补位, + // 但只能写入“机器人获胜/真人失败”的强制结果,保证本局不会继续消耗该档位奖池。 + return withReason(input, base, StrategyCodePoolGuard, DecisionForceHumanLose, ForceResultHumanLose, "POOL_BLACK_ROBOT_FORCE_WIN") + } + // 默认行为保持保守:黑色水位直接熔断机器人补位,用户继续等待真人,避免并发局把档位奖池扣穿。 return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_BLACK") } if input.RiskResult.BlockProtection { diff --git a/services/game-service/internal/domain/selfgame/strategy_test.go b/services/game-service/internal/domain/selfgame/strategy_test.go index 862c79ea..d43a641e 100644 --- a/services/game-service/internal/domain/selfgame/strategy_test.go +++ b/services/game-service/internal/domain/selfgame/strategy_test.go @@ -15,6 +15,40 @@ func TestEvaluateRobotStrategyBlackPoolSkipsRobot(t *testing.T) { } } +func TestEvaluateRobotStrategyBlackPoolCanForceRobotWin(t *testing.T) { + input := baseStrategyInput(87, 88) + input.NewUserPolicy.BlackPoolRobotForceWinEnabled = true + decision, err := EvaluateRobotStrategy(input, func(int) (int, error) { + t.Fatal("black pool robot force-win must not roll strategy probability") + return 0, nil + }) + if err != nil { + t.Fatalf("evaluate strategy failed: %v", err) + } + if decision.StrategyCode != StrategyCodePoolGuard || + decision.ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" || + decision.Decision != DecisionForceHumanLose || + decision.ForceResult != ForceResultHumanLose { + t.Fatalf("black pool force robot win decision mismatch: %+v", decision) + } +} + +func TestEvaluateRobotStrategyDisabledPoolSkipsEvenWhenBlackForceEnabled(t *testing.T) { + input := baseStrategyInput(200, 88) + input.StakePool.Status = "disabled" + input.NewUserPolicy.BlackPoolRobotForceWinEnabled = true + decision, err := EvaluateRobotStrategy(input, func(int) (int, error) { + t.Fatal("disabled pool must not roll strategy probability") + return 0, nil + }) + if err != nil { + t.Fatalf("evaluate strategy failed: %v", err) + } + if decision.StrategyCode != StrategyCodePoolGuard || decision.ReasonCode != "POOL_BLACK" || decision.Decision != DecisionNone { + t.Fatalf("disabled pool should still skip robot, got %+v", decision) + } +} + func TestEvaluateRobotStrategyNewUserProtectionHit(t *testing.T) { decision, err := EvaluateRobotStrategy(baseStrategyInput(200, 88), func(max int) (int, error) { if max != 100 { diff --git a/services/game-service/internal/service/dice/service.go b/services/game-service/internal/service/dice/service.go index ec3d404a..6088edb8 100644 --- a/services/game-service/internal/service/dice/service.go +++ b/services/game-service/internal/service/dice/service.go @@ -423,6 +423,10 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga if err != nil { return nil, 0, err } + policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, app, gameID, nowMS) + if err != nil { + return nil, 0, err + } persisted, err := s.repository.ListSelfGameStakePools(ctx, app, gameID, nowMS) if err != nil { return nil, 0, err @@ -444,7 +448,7 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga // 运营保存或加扣时才真正落库,避免只浏览页面就产生无意义写入。 pool = selfgame.DefaultStakePool(app, gameID, option.StakeCoin, nowMS) } - views = append(views, s.stakePoolView(config, pool, option.Enabled)) + views = append(views, s.stakePoolView(config, pool, option.Enabled, policy.BlackPoolRobotForceWinEnabled)) seen[option.StakeCoin] = struct{}{} } for _, pool := range persisted { @@ -452,7 +456,7 @@ func (s *Service) ListSelfGameStakePools(ctx context.Context, appCode string, ga continue } // 旧配置遗留档位仍展示出来,方便运营把非下注档位的余额迁出或停用。 - views = append(views, s.stakePoolView(config, pool, false)) + views = append(views, s.stakePoolView(config, pool, false, policy.BlackPoolRobotForceWinEnabled)) } sort.SliceStable(views, func(left int, right int) bool { return views[left].StakeCoin < views[right].StakeCoin @@ -494,6 +498,10 @@ func (s *Service) UpdateSelfGameStakePool(ctx context.Context, pool selfgame.Sta if err != nil { return selfgame.StakePoolView{}, 0, err } + policy, err := s.repository.GetSelfGameNewUserPolicy(ctx, pool.AppCode, pool.GameID, nowMS) + if err != nil { + return selfgame.StakePoolView{}, 0, err + } enabled := false for _, option := range config.StakeOptions { if option.StakeCoin == pool.StakeCoin { @@ -506,7 +514,7 @@ func (s *Service) UpdateSelfGameStakePool(ctx context.Context, pool selfgame.Sta if err != nil { return selfgame.StakePoolView{}, 0, err } - return s.stakePoolView(config, saved, enabled), nowMS, nil + return s.stakePoolView(config, saved, enabled, policy.BlackPoolRobotForceWinEnabled), nowMS, nil } func (s *Service) AdjustPool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, dicedomain.Config, int64, error) { @@ -1097,10 +1105,16 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match if err != nil { return dicedomain.Match{}, 0, err } + newUserPolicy, err := s.repository.GetSelfGameNewUserPolicy(ctx, match.AppCode, match.GameID, nowMS) + if err != nil { + return dicedomain.Match{}, 0, err + } poolLevel := selfgame.PoolLevelFor(stakePool, requiredPool) - if poolLevel == selfgame.PoolLevelBlack { + poolStatus := strings.TrimSpace(stakePool.Status) + poolDisabled := poolStatus != "" && poolStatus != "active" + if poolLevel == selfgame.PoolLevelBlack && (poolDisabled || !newUserPolicy.BlackPoolRobotForceWinEnabled) { // 黑色水位代表当前档位可用奖池已经覆盖不了本局真人赢机器人时的最大净支出。 - // 这里不抽 robot-service,也不写机器人入座,用户保持等待真人匹配;这样能从源头避免“先补位后结算打穿奖池”。 + // 默认不开机器人补位,用户保持等待真人;只有策略配置显式开启,并且档位不是手动停用时,下面才会继续抽机器人强制其获胜。 decision, err := selfgame.EvaluateRobotStrategy(selfgame.StrategyInput{ AppCode: match.AppCode, GameID: match.GameID, @@ -1111,7 +1125,7 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match RequiredPoolCoin: requiredPool, AllowsDraw: dicedomain.IsRPSGameID(match.GameID), StakePool: stakePool, - NewUserPolicy: selfgame.DefaultNewUserPolicy(match.AppCode, match.GameID, nowMS), + NewUserPolicy: newUserPolicy, ProtectionState: selfgame.ProtectionState{AppCode: match.AppCode, GameID: match.GameID, UserID: human.UserID, Status: "active"}, RiskResult: selfgame.RiskResult{}, }, s.randomInt) @@ -1130,10 +1144,6 @@ func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match } return dicedomain.Match{}, 0, err } - newUserPolicy, err := s.repository.GetSelfGameNewUserPolicy(ctx, match.AppCode, match.GameID, nowMS) - if err != nil { - return dicedomain.Match{}, 0, err - } protectionState, err := s.repository.GetSelfGameProtectionState(ctx, match.AppCode, match.GameID, human.UserID, nowMS) if err != nil { return dicedomain.Match{}, 0, err @@ -1242,7 +1252,7 @@ func requiredPoolCoinForStake(stakeCoin int64, feeBPS int32, poolBPS int32) int6 return required } -func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePool, stakeEnabled bool) selfgame.StakePoolView { +func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePool, stakeEnabled bool, blackPoolRobotForceWinEnabled bool) selfgame.StakePoolView { pool = selfgame.NormalizeStakePool(pool) required := requiredPoolCoinForStake(pool.StakeCoin, config.FeeBPS, config.PoolBPS) level := selfgame.PoolLevelFor(pool, required) @@ -1250,20 +1260,21 @@ func (s *Service) stakePoolView(config dicedomain.Config, pool selfgame.StakePoo if required > 0 { capacity = pool.AvailableCoin() / required } + poolDisabled := pool.Status != "" && pool.Status != "active" blackReason := "" - if pool.Status != "" && pool.Status != "active" { + if poolDisabled { blackReason = "status_disabled" } else if level == selfgame.PoolLevelBlack { blackReason = "available_below_required_pool_coin" } // botMatchEnabled 是后台给运营看的当前结论:档位配置关闭、奖池状态停用、或可用余额不足时都不能补机器人。 - // 黄色和红色仍允许补位,但策略会降级到缩小保护或自然随机,因此这里不把它们视为熔断。 + // 黄色和红色仍允许补位;黑色只有在策略显式开启机器人强制获胜时允许补位,手动停用始终优先熔断。 return selfgame.StakePoolView{ StakePool: pool, PoolLevel: level, RequiredPoolCoin: required, HumanWinCapacity: capacity, - BotMatchEnabled: stakeEnabled && level != selfgame.PoolLevelBlack, + BotMatchEnabled: stakeEnabled && !poolDisabled && (level != selfgame.PoolLevelBlack || blackPoolRobotForceWinEnabled), BlackReason: blackReason, } } diff --git a/services/game-service/internal/service/dice/service_test.go b/services/game-service/internal/service/dice/service_test.go index 10dd0b2c..d940c2de 100644 --- a/services/game-service/internal/service/dice/service_test.go +++ b/services/game-service/internal/service/dice/service_test.go @@ -548,6 +548,77 @@ func TestStakePoolBlackSkipsRobotAttachment(t *testing.T) { } } +func TestStakePoolBlackCanForceRobotWinWhenEnabled(t *testing.T) { + repo := newFakeDiceRepository() + repo.config.PoolBalanceCoin = 87 + repo.poolBalance = 87 + repo.stakePool.BalanceCoin = 87 + repo.newUserPolicy.BlackPoolRobotForceWinEnabled = true + repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive} + wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} + svc := New(Config{}, repo, wallet) + nowMS := int64(1700000000000) + svc.now = func() time.Time { return time.UnixMilli(nowMS) } + + created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ + AppCode: "lalu", + RequestID: "req-black-force-robot-win-create", + UserID: 101, + GameID: "dice", + StakeCoin: 100, + MinPlayers: 2, + MaxPlayers: 2, + }) + if err != nil { + t.Fatalf("CreateMatch failed: %v", err) + } + nowMS += dicedomain.DefaultRobotWaitMS + ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("GetMatch attach robot failed: %v", err) + } + if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerLose { + t.Fatalf("black pool force switch should attach robot and force player lose, got %+v", ready) + } + if len(repo.strategyLogs) != 1 || + repo.strategyLogs[0].ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" || + repo.strategyLogs[0].ForceResult != dicedomain.ForcedResultPlayerLose { + t.Fatalf("black pool force switch should write force-lose strategy log, logs=%+v", repo.strategyLogs) + } + + rolls := []struct { + max int + value int + }{ + {max: 6, value: 0}, + {max: 6, value: 5}, + {max: 14, value: 11}, + } + svc.randomInt = func(max int) (int, error) { + if len(rolls) == 0 { + t.Fatal("randomInt called too many times") + } + next := rolls[0] + rolls = rolls[1:] + if max != next.max { + t.Fatalf("randomInt max = %d, want %d", max, next.max) + } + return next.value, nil + } + settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-black-force-robot-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) + if err != nil { + t.Fatalf("RollMatch failed: %v", err) + } + human := participantByUser(settled.Participants, 101) + robot := participantByUser(settled.Participants, 9004) + if human.Result != dicedomain.ParticipantResultLose || robot.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] { + t.Fatalf("black pool force switch should settle as robot win with random losing pair, human=%+v robot=%+v", human, robot) + } + if repo.poolBalance != 182 || repo.stakePool.BalanceCoin != 182 { + t.Fatalf("black pool force robot win should put user stake net of fee into pool, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin) + } +} + func TestNewUserRobotRockChoosesLosingGestureWhenProtectionHits(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 0357743d..667be862 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -305,11 +305,12 @@ func (r *Repository) Migrate(ctx context.Context) error { day1_subsidy_quota_coin BIGINT NOT NULL DEFAULT 15000, single_round_subsidy_cap_coin BIGINT NOT NULL DEFAULT 5000, strategy_level VARCHAR(32) NOT NULL DEFAULT 'soft_boost', - lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1, - lose_streak_trigger INT NOT NULL DEFAULT 2, - normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL, + lose_streak_protection_enabled TINYINT(1) NOT NULL DEFAULT 1, + lose_streak_trigger INT NOT NULL DEFAULT 2, + normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53, + black_pool_robot_force_win_enabled TINYINT(1) 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_user_protection_states ( @@ -505,6 +506,9 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "normal_phase_target_win_rate_percent", "normal_phase_target_win_rate_percent INT NOT NULL DEFAULT 53 AFTER lose_streak_trigger"); err != nil { return err } + if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); 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 } diff --git a/services/game-service/internal/storage/mysql/repository_test.go b/services/game-service/internal/storage/mysql/repository_test.go index 95358365..85d87991 100644 --- a/services/game-service/internal/storage/mysql/repository_test.go +++ b/services/game-service/internal/storage/mysql/repository_test.go @@ -161,6 +161,7 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) { LoseStreakProtectionEnabled: true, LoseStreakTrigger: 2, NormalPhaseTargetWinRatePercent: 57, + BlackPoolRobotForceWinEnabled: true, }, nowMS+4) if err != nil { t.Fatalf("upsert new user policy failed: %v", err) @@ -168,6 +169,9 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) { if savedPolicy.NormalPhaseTargetWinRatePercent != 57 { t.Fatalf("normal phase target win rate was not persisted: %+v", savedPolicy) } + if !savedPolicy.BlackPoolRobotForceWinEnabled { + t.Fatalf("black pool robot force-win switch was not persisted: %+v", savedPolicy) + } readPolicy, err := repo.GetSelfGameNewUserPolicy(ctx, "lalu", dicedomain.DefaultGameID, nowMS+5) if err != nil { t.Fatalf("get new user policy failed: %v", err) @@ -175,6 +179,9 @@ func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) { if readPolicy.NormalPhaseTargetWinRatePercent != 57 { t.Fatalf("normal phase target win rate readback mismatch: %+v", readPolicy) } + if !readPolicy.BlackPoolRobotForceWinEnabled { + t.Fatalf("black pool robot force-win switch readback mismatch: %+v", readPolicy) + } if err := repo.RecordSelfGameStrategyLog(ctx, selfgame.StrategyLog{ AppCode: "lalu", diff --git a/services/game-service/internal/storage/mysql/selfgame_strategy_repository.go b/services/game-service/internal/storage/mysql/selfgame_strategy_repository.go index bd6d01f8..f048a248 100644 --- a/services/game-service/internal/storage/mysql/selfgame_strategy_repository.go +++ b/services/game-service/internal/storage/mysql/selfgame_strategy_repository.go @@ -113,7 +113,7 @@ func (r *Repository) GetSelfGameNewUserPolicy(ctx context.Context, appCode strin row := r.db.QueryRowContext(ctx, `SELECT app_code, game_id, enabled, protection_rounds, protection_hours, max_stake_coin, lifetime_subsidy_quota_coin, day1_subsidy_quota_coin, single_round_subsidy_cap_coin, - strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, + strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, black_pool_robot_force_win_enabled, created_at_ms, updated_at_ms FROM game_self_game_new_user_policies WHERE app_code = ? AND game_id = ?`, @@ -123,7 +123,7 @@ func (r *Repository) GetSelfGameNewUserPolicy(ctx context.Context, appCode strin if err := row.Scan( &policy.AppCode, &policy.GameID, &policy.Enabled, &policy.ProtectionRounds, &policy.ProtectionHours, &policy.MaxStakeCoin, &policy.LifetimeSubsidyQuotaCoin, &policy.Day1SubsidyQuotaCoin, &policy.SingleRoundSubsidyCap, - &policy.StrategyLevel, &policy.LoseStreakProtectionEnabled, &policy.LoseStreakTrigger, &policy.NormalPhaseTargetWinRatePercent, + &policy.StrategyLevel, &policy.LoseStreakProtectionEnabled, &policy.LoseStreakTrigger, &policy.NormalPhaseTargetWinRatePercent, &policy.BlackPoolRobotForceWinEnabled, &policy.CreatedAtMS, &policy.UpdatedAtMS, ); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -149,9 +149,9 @@ func (r *Repository) UpsertSelfGameNewUserPolicy(ctx context.Context, policy sel `INSERT INTO game_self_game_new_user_policies ( app_code, game_id, enabled, protection_rounds, protection_hours, max_stake_coin, lifetime_subsidy_quota_coin, day1_subsidy_quota_coin, single_round_subsidy_cap_coin, - strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, - created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + strategy_level, lose_streak_protection_enabled, lose_streak_trigger, normal_phase_target_win_rate_percent, black_pool_robot_force_win_enabled, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), protection_rounds = VALUES(protection_rounds), @@ -161,13 +161,14 @@ func (r *Repository) UpsertSelfGameNewUserPolicy(ctx context.Context, policy sel day1_subsidy_quota_coin = VALUES(day1_subsidy_quota_coin), single_round_subsidy_cap_coin = VALUES(single_round_subsidy_cap_coin), strategy_level = VALUES(strategy_level), - lose_streak_protection_enabled = VALUES(lose_streak_protection_enabled), - lose_streak_trigger = VALUES(lose_streak_trigger), - normal_phase_target_win_rate_percent = VALUES(normal_phase_target_win_rate_percent), - updated_at_ms = VALUES(updated_at_ms)`, + lose_streak_protection_enabled = VALUES(lose_streak_protection_enabled), + lose_streak_trigger = VALUES(lose_streak_trigger), + normal_phase_target_win_rate_percent = VALUES(normal_phase_target_win_rate_percent), + black_pool_robot_force_win_enabled = VALUES(black_pool_robot_force_win_enabled), + updated_at_ms = VALUES(updated_at_ms)`, policy.AppCode, policy.GameID, policy.Enabled, policy.ProtectionRounds, policy.ProtectionHours, policy.MaxStakeCoin, policy.LifetimeSubsidyQuotaCoin, policy.Day1SubsidyQuotaCoin, policy.SingleRoundSubsidyCap, - policy.StrategyLevel, policy.LoseStreakProtectionEnabled, policy.LoseStreakTrigger, policy.NormalPhaseTargetWinRatePercent, + policy.StrategyLevel, policy.LoseStreakProtectionEnabled, policy.LoseStreakTrigger, policy.NormalPhaseTargetWinRatePercent, policy.BlackPoolRobotForceWinEnabled, policy.CreatedAtMS, policy.UpdatedAtMS, ); err != nil { return selfgame.NewUserPolicy{}, err diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index d22cd55a..fee5c7b5 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -867,6 +867,7 @@ func selfGameNewUserPolicyToProto(policy selfgame.NewUserPolicy) *gamev1.SelfGam LoseStreakProtectionEnabled: policy.LoseStreakProtectionEnabled, LoseStreakTrigger: policy.LoseStreakTrigger, NormalPhaseTargetWinRatePercent: policy.NormalPhaseTargetWinRatePercent, + BlackPoolRobotForceWinEnabled: policy.BlackPoolRobotForceWinEnabled, CreatedAtMs: policy.CreatedAtMS, UpdatedAtMs: policy.UpdatedAtMS, } @@ -890,6 +891,7 @@ func selfGameNewUserPolicyFromProto(policy *gamev1.SelfGameNewUserPolicy) selfga LoseStreakProtectionEnabled: policy.GetLoseStreakProtectionEnabled(), LoseStreakTrigger: policy.GetLoseStreakTrigger(), NormalPhaseTargetWinRatePercent: policy.GetNormalPhaseTargetWinRatePercent(), + BlackPoolRobotForceWinEnabled: policy.GetBlackPoolRobotForceWinEnabled(), CreatedAtMS: policy.GetCreatedAtMs(), UpdatedAtMS: policy.GetUpdatedAtMs(), } diff --git a/services/gateway-service/internal/transport/http/game_handler_test.go b/services/gateway-service/internal/transport/http/game_handler_test.go index c9d1aeeb..9ea76209 100644 --- a/services/gateway-service/internal/transport/http/game_handler_test.go +++ b/services/gateway-service/internal/transport/http/game_handler_test.go @@ -623,6 +623,54 @@ func TestHotgameCompatProxiesChatapp3TokenWithoutGameCallback(t *testing.T) { } } +func TestReyouGameCallbackProxiesChatapp3TokenWithoutGameCallback(t *testing.T) { + token := chatapp3HotgameToken("v1", 99123456, "LIKEI", 4102444800000, 1700000000000) + body := `{"gameId":"20","uid":"99123456","token":"` + token + `","sign":"s"}` + var gotHost string + var gotPath string + var gotQuery string + var gotBody string + var gotContentType string + oldTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + gotHost = request.URL.Host + gotPath = request.URL.Path + gotQuery = request.URL.RawQuery + gotContentType = request.Header.Get("Content-Type") + raw, _ := io.ReadAll(request.Body) + gotBody = string(raw) + header := http.Header{} + header.Set("Content-Type", "application/json") + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(bytes.NewReader([]byte(`{"errorCode":0,"data":{"coin":1000}}`))), + Request: request, + }, nil + }) + defer func() { http.DefaultTransport = oldTransport }() + gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"errorCode":0}`), ContentType: "application/json"}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetGameClient(gameClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/game-callbacks/reyou/getUserInfo?nonce=1", bytes.NewReader([]byte(body))) + request.Header.Set("X-App-Code", "lalu") + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || recorder.Body.String() != `{"errorCode":0,"data":{"coin":1000}}` { + t.Fatalf("reyou chatapp3 proxy response mismatch: status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if gotHost != "jvapi.haiyihy.com" || gotPath != "/go/getUserInfo" || gotQuery != "nonce=1" || gotBody != body || gotContentType != "application/json" { + t.Fatalf("reyou chatapp3 proxy request mismatch: host=%q path=%q query=%q content_type=%q body=%s", gotHost, gotPath, gotQuery, gotContentType, gotBody) + } + if gameClient.lastCallback != nil { + t.Fatalf("reyou chatapp3 token must not enter hyapp game callback: %+v", gameClient.lastCallback) + } +} + func chatapp3HotgameToken(version string, userID int64, sysOrigin string, expireMs int64, releaseMs int64) string { payload := url.QueryEscape(version + ":" + strconv.FormatInt(userID, 10) + ":" + sysOrigin + ":" + strconv.FormatInt(expireMs, 10) + ":" + strconv.FormatInt(releaseMs, 10)) return "sign." + base64.StdEncoding.EncodeToString([]byte(payload)) diff --git a/services/gateway-service/internal/transport/http/gameapi/game_handler.go b/services/gateway-service/internal/transport/http/gameapi/game_handler.go index 61875e42..0566c8b5 100644 --- a/services/gateway-service/internal/transport/http/gameapi/game_handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/game_handler.go @@ -750,7 +750,14 @@ func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.R http.Error(writer, "invalid request body", http.StatusBadRequest) return } - h.handleGameCallbackRaw(writer, request, raw, strings.TrimSpace(request.PathValue("platform_code")), strings.TrimSpace(request.PathValue("operation"))) + platformCode := strings.TrimSpace(request.PathValue("platform_code")) + operation := strings.TrimSpace(request.PathValue("operation")) + // reyou 的线上回调现在存在两种入口:根路径兼容入口和 /game-callbacks/reyou/* 通用入口。 + // 通用入口同样先按 token 归属做分流,chatapp3 token 原样回源 chatapp3,其他 token 才交给 hyapp 自己的游戏服务。 + if h.tryProxyChatapp3HotgameCallback(writer, request, raw, platformCode, operation) { + return + } + h.handleGameCallbackRaw(writer, request, raw, platformCode, operation) } func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) { diff --git a/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go b/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go index 830833ca..bd9a5854 100644 --- a/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go +++ b/services/gateway-service/internal/transport/http/gameapi/hotgame_compat_handler.go @@ -35,16 +35,23 @@ func (h *Handler) handleHotgameCompatCallback(writer http.ResponseWriter, reques http.Error(writer, "invalid request body", http.StatusBadRequest) return } - // 热游固定回调地址无法按域名区分 hyapp 和 chatapp3;这里用 token 结构做最小路由键。 - // chatapp3 的用户 token 是 sign.base64(version:userId:sysOrigin:expire:release),hyapp 当前热游 token 是 JWT 或本服务 session。 - if isChatapp3HotgameToken(hotgameCompatCallbackToken(raw)) { - h.proxyChatapp3HotgameCallback(writer, request, raw, operation) + if h.tryProxyChatapp3HotgameCallback(writer, request, raw, hotgameCompatPlatformCode, operation) { return } // 非 chatapp3 token 全部继续走 hyapp 自己的 reyou 平台配置,避免 hyapp 自有热游游戏误转到 chatapp3。 h.handleGameCallbackRaw(writer, request, raw, hotgameCompatPlatformCode, operation) } +func (h *Handler) tryProxyChatapp3HotgameCallback(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) bool { + // 热游固定回调地址无法按域名区分 hyapp 和 chatapp3;这里用 token 结构做最小路由键。 + // chatapp3 的用户 token 是 sign.base64(version:userId:sysOrigin:expire:release),hyapp 当前热游 token 是 JWT 或本服务 session。 + if !strings.EqualFold(strings.TrimSpace(platformCode), hotgameCompatPlatformCode) || !isChatapp3HotgameToken(hotgameCompatCallbackToken(raw)) { + return false + } + h.proxyChatapp3HotgameCallback(writer, request, raw, operation) + return true +} + func (h *Handler) proxyChatapp3HotgameCallback(writer http.ResponseWriter, request *http.Request, raw []byte, operation string) { target, err := hotgameCompatProxyURL(operation, request.URL.RawQuery) if err != nil {