632 lines
21 KiB
Go
632 lines
21 KiB
Go
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
|
||
}
|