增加黑色必胜

This commit is contained in:
zhx 2026-06-12 20:12:47 +08:00
parent 13240f4880
commit 2556316852
17 changed files with 271 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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