package selfgame import ( "encoding/json" "fmt" "strings" ) const ( PoolLevelGreen = "green" PoolLevelYellow = "yellow" PoolLevelRed = "red" PoolLevelBlack = "black" StrategyCodePoolGuard = "pool_guard" StrategyCodeRiskGate = "risk_gate" StrategyCodeNewUserProtection = "new_user_protection" StrategyCodeNormalRandom = "normal_random" DecisionNone = "none" DecisionForceHumanWin = "force_human_win" DecisionForceHumanLose = "force_human_lose" ForceResultHumanWin = "player_win" ForceResultHumanWinWithoutProtection = "player_win_no_protection" ForceResultHumanLose = "player_lose" NewUserStrategyNone = "none" NewUserStrategyFirstNoLose = "first_no_lose" NewUserStrategySoftBoost = "soft_boost" NewUserStrategyStrongBoost = "strong_boost" RiskTierNormal = "normal" RiskTierReduced = "reduced" RiskTierWeak = "weak" RiskTierBlocked = "blocked" UserStageNew = "new" UserStageOld = "old" StageOpening = "opening" StageBoost = "boost" StageConfidence = "confidence" StageCorrection = "correction" StageNormal = "normal" DefaultNormalPhaseTargetWinRatePercent = int32(53) ) type RandomIntFunc func(max int) (int, error) type StakePool struct { AppCode string GameID string StakeCoin int64 BalanceCoin int64 ReservedCoin int64 TargetBalanceCoin int64 SafeBalanceCoin int64 WarningBalanceCoin int64 DangerBalanceCoin int64 Status string CreatedAtMS int64 UpdatedAtMS int64 } type StakePoolView struct { StakePool PoolLevel string RequiredPoolCoin int64 HumanWinCapacity int64 BotMatchEnabled bool BlackReason string } func (pool StakePool) AvailableCoin() int64 { available := pool.BalanceCoin - pool.ReservedCoin if available < 0 { return 0 } return available } func DefaultStakePool(appCode string, gameID string, stakeCoin int64, nowMS int64) StakePool { if stakeCoin < 0 { stakeCoin = 0 } return StakePool{ AppCode: strings.TrimSpace(appCode), GameID: strings.TrimSpace(gameID), StakeCoin: stakeCoin, TargetBalanceCoin: stakeCoin * 20, SafeBalanceCoin: stakeCoin * 10, WarningBalanceCoin: stakeCoin * 5, DangerBalanceCoin: stakeCoin * 2, Status: "active", CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } } func NormalizeStakePool(pool StakePool) StakePool { pool.AppCode = strings.TrimSpace(pool.AppCode) pool.GameID = strings.TrimSpace(pool.GameID) pool.Status = strings.TrimSpace(pool.Status) if pool.Status == "" { pool.Status = "active" } if pool.StakeCoin < 0 { pool.StakeCoin = 0 } if pool.BalanceCoin < 0 { pool.BalanceCoin = 0 } if pool.ReservedCoin < 0 { pool.ReservedCoin = 0 } if pool.TargetBalanceCoin < 0 { pool.TargetBalanceCoin = 0 } if pool.SafeBalanceCoin < 0 { pool.SafeBalanceCoin = 0 } if pool.WarningBalanceCoin < 0 { pool.WarningBalanceCoin = 0 } if pool.DangerBalanceCoin < 0 { pool.DangerBalanceCoin = 0 } return pool } func PoolLevelFor(pool StakePool, requiredPoolCoin int64) string { pool = NormalizeStakePool(pool) if requiredPoolCoin < 0 { requiredPoolCoin = 0 } if pool.Status != "" && pool.Status != "active" { return PoolLevelBlack } if pool.AvailableCoin() < requiredPoolCoin { return PoolLevelBlack } // 新表刚上线时运营可能只先导入余额,尚未配置 safe/warning/danger 阈值。 // 这种情况下只要当前可用余额能覆盖本局最大支出,就按绿色处理,避免阈值空值把所有档位降成黄色。 if pool.SafeBalanceCoin == 0 && pool.WarningBalanceCoin == 0 && pool.DangerBalanceCoin == 0 { return PoolLevelGreen } if pool.BalanceCoin <= pool.DangerBalanceCoin { return PoolLevelRed } if pool.BalanceCoin <= pool.WarningBalanceCoin { return PoolLevelYellow } if pool.BalanceCoin >= pool.SafeBalanceCoin { return PoolLevelGreen } return PoolLevelYellow } type NewUserPolicy struct { AppCode string GameID string Enabled bool ProtectionRounds int32 ProtectionHours int32 MaxStakeCoin int64 LifetimeSubsidyQuotaCoin int64 Day1SubsidyQuotaCoin int64 SingleRoundSubsidyCap int64 StrategyLevel string LoseStreakProtectionEnabled bool LoseStreakTrigger int32 NormalPhaseTargetWinRatePercent int32 BlackPoolRobotForceWinEnabled bool CreatedAtMS int64 UpdatedAtMS int64 } func DefaultNewUserPolicy(appCode string, gameID string, nowMS int64) NewUserPolicy { return NormalizeNewUserPolicy(NewUserPolicy{ AppCode: appCode, GameID: gameID, Enabled: true, ProtectionRounds: 30, ProtectionHours: 168, MaxStakeCoin: 5000, LifetimeSubsidyQuotaCoin: 30000, Day1SubsidyQuotaCoin: 15000, SingleRoundSubsidyCap: 5000, StrategyLevel: NewUserStrategySoftBoost, LoseStreakProtectionEnabled: true, LoseStreakTrigger: 2, NormalPhaseTargetWinRatePercent: DefaultNormalPhaseTargetWinRatePercent, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, }) } func NormalizeNewUserPolicy(policy NewUserPolicy) NewUserPolicy { policy.AppCode = strings.TrimSpace(policy.AppCode) policy.GameID = strings.TrimSpace(policy.GameID) if policy.ProtectionRounds <= 0 { policy.ProtectionRounds = 30 } if policy.ProtectionHours <= 0 { policy.ProtectionHours = 168 } if policy.MaxStakeCoin <= 0 { policy.MaxStakeCoin = 5000 } if policy.LifetimeSubsidyQuotaCoin <= 0 { policy.LifetimeSubsidyQuotaCoin = 30000 } if policy.Day1SubsidyQuotaCoin <= 0 { policy.Day1SubsidyQuotaCoin = 15000 } if policy.SingleRoundSubsidyCap <= 0 { policy.SingleRoundSubsidyCap = 5000 } if policy.LoseStreakTrigger <= 0 { policy.LoseStreakTrigger = 2 } if policy.NormalPhaseTargetWinRatePercent <= 0 { policy.NormalPhaseTargetWinRatePercent = DefaultNormalPhaseTargetWinRatePercent } if policy.NormalPhaseTargetWinRatePercent < 50 { policy.NormalPhaseTargetWinRatePercent = 50 } if policy.NormalPhaseTargetWinRatePercent > 100 { policy.NormalPhaseTargetWinRatePercent = 100 } switch strings.TrimSpace(policy.StrategyLevel) { case NewUserStrategyNone, NewUserStrategyFirstNoLose, NewUserStrategySoftBoost, NewUserStrategyStrongBoost: default: policy.StrategyLevel = NewUserStrategySoftBoost } return policy } type ProtectionState struct { AppCode string GameID string UserID int64 UsedRounds int32 LifetimeSubsidyCoin int64 Day1SubsidyCoin int64 Status string FirstAppliedAtMS int64 LastAppliedAtMS int64 CreatedAtMS int64 UpdatedAtMS int64 } func NormalizeProtectionState(state ProtectionState) ProtectionState { state.AppCode = strings.TrimSpace(state.AppCode) state.GameID = strings.TrimSpace(state.GameID) state.Status = strings.TrimSpace(state.Status) if state.Status == "" { state.Status = "active" } if state.UsedRounds < 0 { state.UsedRounds = 0 } if state.LifetimeSubsidyCoin < 0 { state.LifetimeSubsidyCoin = 0 } if state.Day1SubsidyCoin < 0 { state.Day1SubsidyCoin = 0 } return state } type ProtectionEvent struct { AppCode string GameID string MatchID string UserID int64 UsedRoundDelta int32 LifetimeSubsidyCoinDelta int64 Day1SubsidyCoinDelta int64 CreatedAtMS int64 } type RiskResult struct { RiskScore int32 RiskTier string BlockProtection bool BlockReason string ReasonJSON string } func NormalizeRiskResult(result RiskResult) RiskResult { if result.RiskScore < 0 { result.RiskScore = 0 } if result.RiskScore > 100 { result.RiskScore = 100 } if strings.TrimSpace(result.RiskTier) == "" { result.RiskTier = RiskTierForScore(result.RiskScore) } if result.RiskTier == RiskTierBlocked { result.BlockProtection = true } return result } func RiskTierForScore(score int32) string { switch { case score >= 70: return RiskTierBlocked case score >= 50: return RiskTierWeak case score >= 30: return RiskTierReduced default: return RiskTierNormal } } type UserStats struct { EffectiveBotGameCount int32 TodayRobotGameCount int32 TodayNetWinCoin int64 SevenDayNetWinCoin int64 WinStreak int32 LoseStreak int32 TodayInterventions int32 LastInterventionAtMS int64 } type StrategyInput struct { AppCode string GameID string MatchID string UserID int64 StakeCoin int64 NowMS int64 RequiredPoolCoin int64 AllowsDraw bool StakePool StakePool NewUserPolicy NewUserPolicy ProtectionState ProtectionState RiskResult RiskResult UserStats UserStats } type StrategyDecision struct { StrategyCode string Decision string ReasonCode string ReasonJSON string ForceResult string PoolLevel string RequiredPoolCoin int64 ShouldConsumeProtection bool ProtectionSubsidyCoin int64 } type StrategyLog struct { AppCode string LogID string GameID string StakeCoin int64 MatchID string UserID int64 StrategyCode string Decision string ForceResult string ReasonCode string ReasonJSON string PoolBalanceCoin int64 PoolLevel string CreatedAtMS int64 } func EvaluateRobotStrategy(input StrategyInput, randomInt RandomIntFunc) (StrategyDecision, error) { input.AppCode = strings.TrimSpace(input.AppCode) input.GameID = strings.TrimSpace(input.GameID) input.MatchID = strings.TrimSpace(input.MatchID) input.StakePool = NormalizeStakePool(input.StakePool) input.NewUserPolicy = NormalizeNewUserPolicy(input.NewUserPolicy) input.ProtectionState = NormalizeProtectionState(input.ProtectionState) input.RiskResult = NormalizeRiskResult(input.RiskResult) if input.RequiredPoolCoin < 0 { input.RequiredPoolCoin = 0 } level := PoolLevelFor(input.StakePool, input.RequiredPoolCoin) base := StrategyDecision{StrategyCode: StrategyCodeNormalRandom, Decision: DecisionNone, ReasonCode: "NORMAL_RANDOM", PoolLevel: level, RequiredPoolCoin: input.RequiredPoolCoin} if level == PoolLevelBlack { 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 { // 风控只阻断运营干预,不阻断用户正常游戏;高风险用户仍能进入真人或普通机器人随机局,但不会拿到新手补贴。 return withReason(input, base, StrategyCodeRiskGate, DecisionNone, "", "RISK_BLOCKED") } isNewCandidate := input.NewUserPolicy.Enabled && input.ProtectionState.Status == "active" && input.UserStats.EffectiveBotGameCount < input.NewUserPolicy.ProtectionRounds && input.ProtectionState.UsedRounds < input.NewUserPolicy.ProtectionRounds if isNewCandidate { return evaluateNewUser(input, base, randomInt) } return evaluateNormalUser(input, base, randomInt) } func evaluateNewUser(input StrategyInput, base StrategyDecision, randomInt RandomIntFunc) (StrategyDecision, error) { if input.NewUserPolicy.StrategyLevel == NewUserStrategyNone { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_POLICY_DISABLED") } if base.PoolLevel == PoolLevelRed { // 红色水位要求回到基础体感;这里不输出强制结果,让 dice 随机点数、rock 随机机器人手势自行结算。 return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_RED_NORMAL_RANDOM") } if input.StakeCoin > input.NewUserPolicy.MaxStakeCoin { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_STAKE_TOO_HIGH") } if input.RequiredPoolCoin > input.NewUserPolicy.SingleRoundSubsidyCap { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_SINGLE_ROUND_QUOTA_SHORT") } if input.ProtectionState.LifetimeSubsidyCoin+input.RequiredPoolCoin > input.NewUserPolicy.LifetimeSubsidyQuotaCoin { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_LIFETIME_QUOTA_SHORT") } if input.ProtectionState.Day1SubsidyCoin+input.RequiredPoolCoin > input.NewUserPolicy.Day1SubsidyQuotaCoin { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_DAY1_QUOTA_SHORT") } if input.NewUserPolicy.LoseStreakProtectionEnabled && input.UserStats.LoseStreak >= input.NewUserPolicy.LoseStreakTrigger { // 新手连输保护只在预算、档位、风控和奖池都允许之后触发;连输次数来自已结算、非平局的自研游戏, // 其中真人对战和机器人对战都计入,但这里只能影响下一次机器人补位局的 forced_result,不能改写真人对战结算。 decision := base decision.ShouldConsumeProtection = true decision.ProtectionSubsidyCoin = input.RequiredPoolCoin return withReason(input, decision, StrategyCodeNewUserProtection, DecisionForceHumanWin, ForceResultHumanWin, "NEW_USER_LOSE_STREAK_PROTECTION") } percent := newUserPositivePercent(input) if percent <= 0 { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_NEUTRAL") } if percent < 100 { if randomInt == nil { return StrategyDecision{}, fmt.Errorf("self game strategy random is not configured") } roll, err := randomInt(100) if err != nil { return StrategyDecision{}, err } if roll < 0 || roll >= 100 { return StrategyDecision{}, fmt.Errorf("self game strategy random roll is invalid") } if int32(roll) >= percent { return withReason(input, base, StrategyCodeNewUserProtection, DecisionNone, "", "NEW_USER_PROTECTION_MISS") } } decision := base decision.ShouldConsumeProtection = true decision.ProtectionSubsidyCoin = input.RequiredPoolCoin return withReason(input, decision, StrategyCodeNewUserProtection, DecisionForceHumanWin, ForceResultHumanWin, "NEW_USER_POSITIVE_FEEDBACK") } func evaluateNormalUser(input StrategyInput, base StrategyDecision, randomInt RandomIntFunc) (StrategyDecision, error) { if base.PoolLevel == PoolLevelRed { // 老用户阶段不再做“消耗/强制输”。红色水位只取消新手保护类运营干预, // 让 dice 点数和 rock 机器人手势按自然随机结算,避免把奖池保护误解成让用户必输。 return withReason(input, base, StrategyCodePoolGuard, DecisionNone, "", "POOL_RED_NORMAL_RANDOM") } forceWinBPS := normalPhaseForceWinBPS(input.NewUserPolicy.NormalPhaseTargetWinRatePercent, input.AllowsDraw) if forceWinBPS <= 0 { // 50% 目标就是完全自然随机;这里不强制用户输,也不为了贴合低目标去反向干预。 return withReason(input, base, StrategyCodeNormalRandom, DecisionNone, "", "NORMAL_PHASE_TARGET_BASELINE") } if randomInt == nil { return StrategyDecision{}, fmt.Errorf("self game normal phase random is not configured") } roll, err := randomInt(10_000) if err != nil { return StrategyDecision{}, err } if roll < 0 || roll >= 10_000 { return StrategyDecision{}, fmt.Errorf("self game normal phase random roll is invalid") } if roll >= forceWinBPS { return withReason(input, base, StrategyCodeNormalRandom, DecisionNone, "", "NORMAL_PHASE_TARGET_MISS") } decision := base // 成熟期目标胜率补正只服务长期体感,不属于新手补贴;用独立 forced_result 防止结算后误消耗保护额度。 return withReason(input, decision, StrategyCodeNormalRandom, DecisionForceHumanWin, ForceResultHumanWinWithoutProtection, "NORMAL_PHASE_TARGET_HIT") } func normalPhaseForceWinBPS(targetPercent int32, allowsDraw bool) int { if targetPercent <= 50 { return 0 } if targetPercent >= 100 { return 10_000 } if !allowsDraw { // dice 自然胜率约 50%。如果目标是 T,只有 q 比例的局需要提前强制赢: // T = q*100% + (1-q)*50%,所以 q = 2T - 100。 return int(targetPercent*2-100) * 100 } // rock 有平局,产品口径里平局不算有效机器人局。强制赢比例 q 下: // 有效胜率 = (q + (1-q)/3) / (q + 2(1-q)/3),解得 q = (2T-100)/(200-T)。 numerator := int64(targetPercent*2-100) * 10_000 denominator := int64(200 - targetPercent) return int((numerator + denominator/2) / denominator) } func newUserPositivePercent(input StrategyInput) int32 { stage := StageForEffectiveBotGameCount(input.UserStats.EffectiveBotGameCount) if input.NewUserPolicy.StrategyLevel == NewUserStrategyFirstNoLose && input.UserStats.EffectiveBotGameCount == 0 { return 100 } var percent int32 switch input.NewUserPolicy.StrategyLevel { case NewUserStrategyStrongBoost: percent = mapStagePercent(stage, 85, 75, 65, 55, 50) default: percent = mapStagePercent(stage, 70, 60, 55, 50, 50) } switch input.RiskResult.RiskTier { case RiskTierReduced: percent -= 10 case RiskTierWeak: percent -= 25 } if PoolLevelFor(input.StakePool, input.RequiredPoolCoin) == PoolLevelYellow { percent -= 10 } if percent < 0 { return 0 } if percent > 100 { return 100 } return percent } func StageForEffectiveBotGameCount(count int32) string { switch { case count <= 0: return StageOpening case count <= 5: return StageBoost case count <= 15: return StageConfidence case count <= 30: return StageCorrection default: return StageNormal } } func mapStagePercent(stage string, opening int32, boost int32, confidence int32, correction int32, normal int32) int32 { switch stage { case StageOpening: return opening case StageBoost: return boost case StageConfidence: return confidence case StageCorrection: return correction default: return normal } } func withReason(input StrategyInput, decision StrategyDecision, strategyCode string, decisionCode string, forceResult string, reasonCode string) (StrategyDecision, error) { decision.StrategyCode = strategyCode decision.Decision = decisionCode decision.ForceResult = forceResult decision.ReasonCode = reasonCode reasonJSON, err := reasonJSON(input, decision, reasonCode) if err != nil { return StrategyDecision{}, err } decision.ReasonJSON = reasonJSON return decision, nil } func reasonJSON(input StrategyInput, decision StrategyDecision, reasonCode string) (string, error) { payload := map[string]any{ "reason_code": reasonCode, "user_stage": userStage(input), "stage": StageForEffectiveBotGameCount(input.UserStats.EffectiveBotGameCount), "risk_score": input.RiskResult.RiskScore, "risk_tier": input.RiskResult.RiskTier, "risk_block_reason": input.RiskResult.BlockReason, "pool_level": decision.PoolLevel, "pool_balance_coin": input.StakePool.BalanceCoin, "pool_reserved_coin": input.StakePool.ReservedCoin, "pool_available_coin": input.StakePool.AvailableCoin(), "required_pool_coin": input.RequiredPoolCoin, "effective_bot_game_count": input.UserStats.EffectiveBotGameCount, "lifetime_subsidy_left": input.NewUserPolicy.LifetimeSubsidyQuotaCoin - input.ProtectionState.LifetimeSubsidyCoin, "day1_subsidy_left": input.NewUserPolicy.Day1SubsidyQuotaCoin - input.ProtectionState.Day1SubsidyCoin, "new_user_policy_snapshot": input.NewUserPolicy, "user_stats_snapshot": input.UserStats, "should_consume_protection": decision.ShouldConsumeProtection, "protection_subsidy_coin": decision.ProtectionSubsidyCoin, "allows_draw": input.AllowsDraw, } raw, err := json.Marshal(payload) if err != nil { return "", err } return string(raw), nil } func userStage(input StrategyInput) string { if input.UserStats.EffectiveBotGameCount < input.NewUserPolicy.ProtectionRounds && input.ProtectionState.UsedRounds < input.NewUserPolicy.ProtectionRounds { return UserStageNew } return UserStageOld }