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 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 { // 黑色水位已经无法覆盖本档位真人赢机器人时的最大净支出;策略层直接熔断机器人补位, // service 收到 none+POOL_BLACK 后不会抽机器人,用户只继续等待真人,避免并发局把档位奖池扣穿。 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 }