2026-06-12 20:12:47 +08:00

632 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}