merge: add user 24h RTP downweight
This commit is contained in:
commit
99b225b78a
File diff suppressed because it is too large
Load Diff
@ -105,6 +105,10 @@ message LuckyGiftRuleConfig {
|
||||
int64 device_daily_payout_cap = 36;
|
||||
int64 room_hourly_payout_cap = 37;
|
||||
int64 anchor_daily_payout_cap = 38;
|
||||
// 用户严格滚动 24 小时 RTP 超过此值时,普通非零奖档按下方因子降权;0 表示关闭该策略。
|
||||
int64 user_24h_rtp_threshold_ppm = 39;
|
||||
// 触发用户 24 小时 RTP 降权后保留的普通中奖权重;1000000 表示保持原概率。
|
||||
int64 user_24h_ordinary_win_factor_ppm = 40;
|
||||
}
|
||||
|
||||
message CheckLuckyGiftRequest {
|
||||
|
||||
@ -33,32 +33,34 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||
PoolRatePercent float64 `json:"pool_rate_percent"`
|
||||
ProfitRatePercent float64 `json:"profit_rate_percent"`
|
||||
AnchorRatePercent float64 `json:"anchor_rate_percent"`
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
ControlBandPercent float64 `json:"control_band_percent"`
|
||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
||||
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
|
||||
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
|
||||
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
|
||||
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||
PoolRatePercent float64 `json:"pool_rate_percent"`
|
||||
ProfitRatePercent float64 `json:"profit_rate_percent"`
|
||||
AnchorRatePercent float64 `json:"anchor_rate_percent"`
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
ControlBandPercent float64 `json:"control_band_percent"`
|
||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
|
||||
User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
|
||||
User24HourOrdinaryWinFactorPercent float64 `json:"user_24h_ordinary_win_factor_percent"`
|
||||
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
|
||||
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
|
||||
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"`
|
||||
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
|
||||
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
|
||||
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
|
||||
JackpotUserDayRTPMaxPercent float64 `json:"-"`
|
||||
@ -448,6 +450,11 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
|
||||
|
||||
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
||||
strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion))
|
||||
user24HourOrdinaryWinFactorPercent := req.User24HourOrdinaryWinFactorPercent
|
||||
if req.User24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
|
||||
// 旧 ops-center 不认识这两个字段,proto3 又会把缺失标量解成 0;0/0 统一解释为关闭并保留 100% 权重。
|
||||
user24HourOrdinaryWinFactorPercent = 100
|
||||
}
|
||||
initialPoolCoins := req.InitialPoolCoins
|
||||
if strategyVersion == "dynamic_v3" {
|
||||
// V3 启动资金已经迁移为独立人工 credit;旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
|
||||
@ -487,41 +494,43 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
|
||||
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
|
||||
}
|
||||
return &luckygiftv1.LuckyGiftRuleConfig{
|
||||
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
||||
PoolId: strings.TrimSpace(req.PoolID),
|
||||
StrategyVersion: strategyVersion,
|
||||
Enabled: req.Enabled,
|
||||
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
|
||||
PoolRatePpm: percentToPPM(req.PoolRatePercent),
|
||||
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
|
||||
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
|
||||
SettlementWindowWager: req.SettlementWindowWager,
|
||||
ControlBandPpm: percentToPPM(req.ControlBandPercent),
|
||||
GiftPriceReference: req.GiftPriceReference,
|
||||
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
|
||||
EffectiveFromMs: req.EffectiveFromMS,
|
||||
InitialPoolCoins: initialPoolCoins,
|
||||
LossStreakGuarantee: req.LossStreakGuarantee,
|
||||
LowWatermarkCoins: req.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
|
||||
HighWatermarkCoins: req.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
|
||||
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
||||
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
||||
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
||||
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
|
||||
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
|
||||
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: req.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: req.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
|
||||
Stages: stages,
|
||||
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
|
||||
PoolId: strings.TrimSpace(req.PoolID),
|
||||
StrategyVersion: strategyVersion,
|
||||
Enabled: req.Enabled,
|
||||
TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
|
||||
PoolRatePpm: percentToPPM(req.PoolRatePercent),
|
||||
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
|
||||
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
|
||||
SettlementWindowWager: req.SettlementWindowWager,
|
||||
ControlBandPpm: percentToPPM(req.ControlBandPercent),
|
||||
GiftPriceReference: req.GiftPriceReference,
|
||||
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
|
||||
EffectiveFromMs: req.EffectiveFromMS,
|
||||
InitialPoolCoins: initialPoolCoins,
|
||||
LossStreakGuarantee: req.LossStreakGuarantee,
|
||||
LowWatermarkCoins: req.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
|
||||
HighWatermarkCoins: req.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
|
||||
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
|
||||
User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
|
||||
User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
|
||||
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
|
||||
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
|
||||
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
|
||||
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
|
||||
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: req.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: req.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
|
||||
Stages: stages,
|
||||
}
|
||||
}
|
||||
|
||||
@ -555,48 +564,56 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
|
||||
// 兼容读取迁移前 V3 快照时也展示 0,避免运营误以为规则字段仍会自动形成账本资金。
|
||||
initialPoolCoins = 0
|
||||
}
|
||||
user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm())
|
||||
user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm())
|
||||
if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
|
||||
// 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态,前端无需猜测 0 因子是否表示全禁中。
|
||||
user24HourOrdinaryWinFactorPercent = 100
|
||||
}
|
||||
return configDTO{
|
||||
RuleVersion: config.GetRuleVersion(),
|
||||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
configRequest: configRequest{
|
||||
AppCode: config.GetAppCode(),
|
||||
PoolID: poolID,
|
||||
StrategyVersion: strategyVersion,
|
||||
Enabled: config.GetEnabled(),
|
||||
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
|
||||
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
|
||||
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
|
||||
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
|
||||
SettlementWindowWager: config.GetSettlementWindowWager(),
|
||||
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
|
||||
GiftPriceReference: config.GetGiftPriceReference(),
|
||||
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
||||
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||||
InitialPoolCoins: initialPoolCoins,
|
||||
LossStreakGuarantee: config.GetLossStreakGuarantee(),
|
||||
LowWatermarkCoins: config.GetLowWatermarkCoins(),
|
||||
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
|
||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
|
||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
||||
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
|
||||
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
|
||||
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
|
||||
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
|
||||
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
|
||||
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
|
||||
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
|
||||
Stages: stages,
|
||||
AppCode: config.GetAppCode(),
|
||||
PoolID: poolID,
|
||||
StrategyVersion: strategyVersion,
|
||||
Enabled: config.GetEnabled(),
|
||||
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
|
||||
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
|
||||
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
|
||||
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
|
||||
SettlementWindowWager: config.GetSettlementWindowWager(),
|
||||
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
|
||||
GiftPriceReference: config.GetGiftPriceReference(),
|
||||
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
||||
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||||
InitialPoolCoins: initialPoolCoins,
|
||||
LossStreakGuarantee: config.GetLossStreakGuarantee(),
|
||||
LowWatermarkCoins: config.GetLowWatermarkCoins(),
|
||||
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
|
||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
|
||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
|
||||
User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
|
||||
User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent,
|
||||
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
|
||||
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
|
||||
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
|
||||
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
|
||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
|
||||
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
|
||||
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
|
||||
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
|
||||
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
|
||||
Stages: stages,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,6 +92,9 @@ func runSimulation(options simulationOptions) simulationReport {
|
||||
simulateBatchCounts(config, options.Seed),
|
||||
simulateSettlementWagerWindow(),
|
||||
simulateWaterBoundaries(config),
|
||||
simulateUser24HourRTPDownweight(config, options.Seed+5),
|
||||
simulateUser24HourRTPBatchCrossing(config),
|
||||
simulateUser24HourRTPMillionDraws(config, options.Seed+6),
|
||||
simulateRechargeWindowBoundaries(config),
|
||||
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
|
||||
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
|
||||
|
||||
@ -36,6 +36,11 @@ func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||||
{ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
|
||||
{ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
|
||||
}
|
||||
// Existing scenarios isolate their original product axis. The dedicated 24h scenario below
|
||||
// enables the new control explicitly so a high payout in one legacy scenario cannot silently
|
||||
// change every subsequent expected probability.
|
||||
config.User24HourRTPThresholdPPM = 0
|
||||
config.User24HourOrdinaryWinFactorPPM = domain.StrategyPPMScale
|
||||
return config
|
||||
}
|
||||
|
||||
@ -252,6 +257,254 @@ func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
|
||||
return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7;等于1000万/2000万均为中水位;>2000万应用1.3", Data: points}
|
||||
}
|
||||
|
||||
func simulateUser24HourRTPDownweight(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||
const name = "用户滚动24小时RTP普通奖降权"
|
||||
config = baseProbabilityConfig(config)
|
||||
config.User24HourRTPThresholdPPM = 1_500_000
|
||||
config.User24HourOrdinaryWinFactorPPM = 700_000
|
||||
config.MissProtectionZeroDraws = 5
|
||||
|
||||
type boundaryRow struct {
|
||||
Case string `json:"case"`
|
||||
WagerCoins int64 `json:"wager_coins"`
|
||||
PayoutCoins int64 `json:"payout_coins"`
|
||||
PositiveWeight int64 `json:"positive_weight_ppm"`
|
||||
ZeroWeight int64 `json:"zero_weight_ppm"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
rows := make([]boundaryRow, 0, 3)
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
wager, payout int64
|
||||
wantPositive int64
|
||||
wantActive bool
|
||||
}{
|
||||
{name: "149.999%", wager: 100_000, payout: 149_999, wantPositive: 400_000},
|
||||
{name: "150%", wager: 100_000, payout: 150_000, wantPositive: 400_000},
|
||||
{name: "150.001%", wager: 100_000, payout: 150_001, wantPositive: 280_000, wantActive: true},
|
||||
} {
|
||||
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000, User24HourRTP: domain.StrategyRTP{WagerCoins: item.wager, PayoutCoins: item.payout}}
|
||||
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, state, domain.StrategyInput{GiftPriceCoins: 10})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
zero := strategyWeightTrace(traces, "0x").AdjustedWeightPPM
|
||||
positive := domain.StrategyPPMScale - zero
|
||||
active := strategyTraceHasFactor(traces, domain.StrategyConditionUser24HourRTPDownweight)
|
||||
rows = append(rows, boundaryRow{Case: item.name, WagerCoins: item.wager, PayoutCoins: item.payout, PositiveWeight: positive, ZeroWeight: zero, Active: active})
|
||||
if positive != item.wantPositive || active != item.wantActive {
|
||||
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("%s 边界权重错误: positive=%d active=%v", item.name, positive, active), Data: rows}
|
||||
}
|
||||
}
|
||||
|
||||
// Force high-water+recharge raw positive weight above 100%. The ordinary table must normalize to
|
||||
// 100% first and only then retain 70%, yielding an exact 700k/300k split.
|
||||
combined := config
|
||||
combined.HighWaterThresholdCoins = 100
|
||||
combined.HighWaterFactorPPM = 3_000_000
|
||||
combined.RechargeFactorPPM = 2_000_000
|
||||
combinedState := domain.StrategyState{PoolBalanceCoins: 101, User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}}
|
||||
_, combinedTraces, err := domain.PreviewLuckyGiftStrategyWeights(combined, combinedState, domain.StrategyInput{
|
||||
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 1_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
combinedZero := strategyWeightTrace(combinedTraces, "0x").AdjustedWeightPPM
|
||||
|
||||
// At the configured miss threshold, protection restores the pre-downweight ordinary table before
|
||||
// removing 0x. This also covers tiny retained factors whose integer apportionment rounded an
|
||||
// otherwise payable tier to zero: protection remains strictly stronger than personal downweight.
|
||||
protectedState := combinedState
|
||||
protectedState.PoolBalanceCoins = 1_000_000_000
|
||||
protectedState.ConsecutiveZeroDraws = config.MissProtectionZeroDraws
|
||||
protected, err := domain.DecideLuckyGiftStrategy(config, protectedState, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{400_000}})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
// A 1-PPM retained factor leaves exactly one integer weight unit. Largest-remainder assigns that
|
||||
// unit to the unaffordable 1000x tier and rounds the payable 1x tier to zero. Miss protection must
|
||||
// rebuild the pre-downweight 1/999999 table, where scripted point zero selects the payable 1x.
|
||||
roundingConfig := config
|
||||
roundingConfig.User24HourOrdinaryWinFactorPPM = 1
|
||||
roundingConfig.Tiers = []domain.StrategyTier{
|
||||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||||
{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1, Enabled: true},
|
||||
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 999_999, Enabled: true},
|
||||
}
|
||||
roundingState := domain.StrategyState{
|
||||
PoolBalanceCoins: 10,
|
||||
ConsecutiveZeroDraws: roundingConfig.MissProtectionZeroDraws,
|
||||
User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001},
|
||||
}
|
||||
roundingProtected, err := domain.DecideLuckyGiftStrategy(
|
||||
roundingConfig, roundingState, domain.StrategyInput{GiftPriceCoins: 10},
|
||||
&scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}},
|
||||
)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
|
||||
// The condition is visible in both jackpot traces but must not gate or rescale either independent
|
||||
// jackpot source. High personal 24h RTP therefore still permits an otherwise-qualified jackpot.
|
||||
compensationConfig := settledRoundJackpotConfig(config)
|
||||
compensationState := qualifiedSettledRoundState(1_000_000_000, 100*60*60*1000)
|
||||
compensationState.User24HourRTP = domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}
|
||||
compensation, err := domain.DecideLuckyGiftStrategy(compensationConfig, compensationState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed))
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
milestoneConfig := compensationConfig
|
||||
milestoneConfig.JackpotMechanism1Enabled = false
|
||||
milestoneConfig.JackpotMechanism2Enabled = true
|
||||
milestoneConfig.MilestoneSpendCoins = 50
|
||||
milestoneState := domain.StrategyState{
|
||||
PoolBalanceCoins: 1_000_000_000, PendingSpendJackpotTokens: 1,
|
||||
SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
|
||||
User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001},
|
||||
}
|
||||
milestone, err := domain.DecideLuckyGiftStrategy(milestoneConfig, milestoneState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1))
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
|
||||
passed := combinedZero == 300_000 && protected.PayoutCoins > 0 &&
|
||||
roundingProtected.SelectedTier.ID == "1x" && roundingProtected.PayoutCoins == 10 &&
|
||||
compensation.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation &&
|
||||
milestone.JackpotMechanism == domain.StrategyJackpotMechanismMilestone
|
||||
return scenarioResult{
|
||||
Name: name, Passed: passed,
|
||||
Summary: fmt.Sprintf("149.999%%/150%%正常、150.001%%降为70%%;叠加归一后正奖/0x=%d/%d;保底=%s且1PPM舍入仍命中%s;两类大奖=%s/%s",
|
||||
domain.StrategyPPMScale-combinedZero, combinedZero, protected.SelectedTier.ID, roundingProtected.SelectedTier.ID, compensation.JackpotMechanism, milestone.JackpotMechanism),
|
||||
Data: map[string]any{
|
||||
"boundaries": rows, "combined_weight_trace": combinedTraces,
|
||||
"miss_protection": protected.Trace, "rounding_miss_protection": roundingProtected.Trace,
|
||||
"rtp_compensation": compensation.Trace, "spend_milestone": milestone.Trace,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func strategyTraceHasFactor(traces []domain.StrategyWeightTrace, name string) bool {
|
||||
for _, trace := range traces {
|
||||
for _, factor := range trace.Factors {
|
||||
if factor.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func simulateUser24HourRTPBatchCrossing(config domain.StrategyConfig) scenarioResult {
|
||||
const name = "用户24小时RTP批内跨线"
|
||||
config = baseProbabilityConfig(config)
|
||||
config.User24HourRTPThresholdPPM = 1_500_000
|
||||
config.User24HourOrdinaryWinFactorPPM = 700_000
|
||||
config.MissProtectionZeroDraws = math.MaxInt64
|
||||
config.Tiers = []domain.StrategyTier{
|
||||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
|
||||
{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 200_000, Enabled: true},
|
||||
{ID: "2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 200_000, Enabled: true},
|
||||
}
|
||||
state := domain.StrategyState{
|
||||
PoolBalanceCoins: 1_000_000,
|
||||
User24HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 140},
|
||||
}
|
||||
// 800000 is the first point of 2x in the normal 600k/200k/200k table. The 200-coin payout
|
||||
// changes the rolling snapshot from 140/100 to 340/200 only after this selection.
|
||||
first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{800_000}, bounds: []int64{1_000_000}})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
// The next subdraw now sees 170% and its ordinary mass is 280k. Random point zero proves the
|
||||
// enlarged 720k zero tier is live, while the first draw's own probability remained untouched.
|
||||
second, err := domain.DecideLuckyGiftStrategy(config, first.NextState, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
firstCondition := strategyConditionTrace(first.Trace, domain.StrategyConditionUser24HourRTPDownweight)
|
||||
secondCondition := strategyConditionTrace(second.Trace, domain.StrategyConditionUser24HourRTPDownweight)
|
||||
secondZeroWeight := strategyWeightTrace(second.Trace.Weights, "0x").AdjustedWeightPPM
|
||||
passed := first.SelectedTier.ID == "2x" && !firstCondition.Passed &&
|
||||
first.NextState.User24HourRTP.WagerCoins == 200 && first.NextState.User24HourRTP.PayoutCoins == 340 &&
|
||||
secondCondition.Passed && secondZeroWeight == 720_000 && second.SelectedTier.ID == "0x"
|
||||
return scenarioResult{
|
||||
Name: name, Passed: passed,
|
||||
Summary: fmt.Sprintf("第一抽用140%%正常表命中%s,抽后变170%%;第二抽正奖/0x=280000/%d并命中%s", first.SelectedTier.ID, secondZeroWeight, second.SelectedTier.ID),
|
||||
Data: map[string]any{"first": first, "second": second},
|
||||
}
|
||||
}
|
||||
|
||||
func simulateUser24HourRTPMillionDraws(config domain.StrategyConfig, seed int64) scenarioResult {
|
||||
const (
|
||||
name = "用户24小时RTP百万抽概率"
|
||||
draws = 1_000_000
|
||||
)
|
||||
config = baseProbabilityConfig(config)
|
||||
config.User24HourRTPThresholdPPM = 1_500_000
|
||||
config.User24HourOrdinaryWinFactorPPM = 700_000
|
||||
config.MissProtectionZeroDraws = math.MaxInt64
|
||||
config.Tiers = []domain.StrategyTier{
|
||||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
|
||||
{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 400_000, Enabled: true},
|
||||
}
|
||||
type group struct {
|
||||
Name string `json:"name"`
|
||||
ExpectedWinPercent float64 `json:"expected_win_percent"`
|
||||
ObservedWinPercent float64 `json:"observed_win_percent"`
|
||||
AbsoluteErrorPoints float64 `json:"absolute_error_percentage_points"`
|
||||
PositiveDraws int `json:"positive_draws"`
|
||||
FinalPoolBalanceCoins int64 `json:"final_pool_balance_coins"`
|
||||
}
|
||||
run := func(groupName string, initial domain.StrategyRTP, expected float64, offset int64) (group, error) {
|
||||
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000, User24HourRTP: initial}
|
||||
random := domain.NewSeededStrategyRandom(seed + offset)
|
||||
positive := 0
|
||||
for index := 0; index < draws; index++ {
|
||||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, random)
|
||||
if err != nil {
|
||||
return group{}, err
|
||||
}
|
||||
state = decision.NextState
|
||||
if decision.PayoutCoins > 0 {
|
||||
positive++
|
||||
}
|
||||
}
|
||||
observed := float64(positive) / draws * 100
|
||||
return group{
|
||||
Name: groupName, ExpectedWinPercent: expected, ObservedWinPercent: observed,
|
||||
AbsoluteErrorPoints: math.Abs(observed - expected), PositiveDraws: positive,
|
||||
FinalPoolBalanceCoins: state.PoolBalanceCoins,
|
||||
}, nil
|
||||
}
|
||||
// Very large historical denominators keep each group on its intended side of 150% while the
|
||||
// million current draws still advance the exact StrategyState on every iteration.
|
||||
normal, err := run("normal_100_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_000_000_000_000_000}, 40, 0)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
downweighted, err := run("above_150_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_500_010_000_000_000}, 28, 1)
|
||||
if err != nil {
|
||||
return failedScenario(name, err)
|
||||
}
|
||||
passed := normal.AbsoluteErrorPoints <= 0.2 && downweighted.AbsoluteErrorPoints <= 0.2 &&
|
||||
normal.FinalPoolBalanceCoins >= 0 && downweighted.FinalPoolBalanceCoins >= 0
|
||||
return scenarioResult{
|
||||
Name: name, Passed: passed,
|
||||
Summary: fmt.Sprintf("固定种子各%d抽:正常 %.4f%%(目标40%%),超线 %.4f%%(目标28%%),误差均<=0.2个百分点", draws, normal.ObservedWinPercent, downweighted.ObservedWinPercent),
|
||||
Data: []group{normal, downweighted},
|
||||
}
|
||||
}
|
||||
|
||||
func strategyConditionTrace(trace domain.DecisionTrace, name string) domain.StrategyConditionTrace {
|
||||
for _, condition := range trace.Conditions {
|
||||
if condition.Name == name {
|
||||
return condition
|
||||
}
|
||||
}
|
||||
return domain.StrategyConditionTrace{}
|
||||
}
|
||||
|
||||
func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
|
||||
const name = "充值0/299999/300000ms"
|
||||
config.JackpotMechanism1Enabled = false
|
||||
|
||||
@ -99,6 +99,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
|
||||
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子,ppm',
|
||||
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
|
||||
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子,ppm',
|
||||
user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '用户滚动24小时RTP降权触发线,ppm;0表示关闭',
|
||||
user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT '触发后普通非零奖档保留权重,ppm;1000000表示不降权',
|
||||
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表,1x=1000000',
|
||||
jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限,ppm',
|
||||
jackpot_user_round_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户已结算轮次 RTP 严格上限,ppm',
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_lucky_gift;
|
||||
|
||||
-- 性能边界:规则版本表只增加常量默认列,强制 ALGORITHM=INSTANT,迁移不扫描或回写历史版本。
|
||||
-- 若目标 MySQL 不支持即时 DDL 则直接失败,不能退化为 COPY;执行前仍需检查长事务,避免元数据锁等待。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions'
|
||||
AND COLUMN_NAME = 'user_24h_rtp_threshold_ppm') = 0,
|
||||
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_rtp_threshold_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动24小时RTP降权触发线,ppm;0表示关闭'' AFTER recharge_boost_factor_ppm, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
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 = 'lucky_gift_rule_versions'
|
||||
AND COLUMN_NAME = 'user_24h_ordinary_win_factor_ppm') = 0,
|
||||
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT ''触发后普通非零奖档保留权重,ppm;1000000表示不降权'' AFTER user_24h_rtp_threshold_ppm, ALGORITHM=INSTANT',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -57,27 +57,30 @@ type Config struct {
|
||||
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
GlobalWindowDraws int64 `json:"global_window_draws"`
|
||||
GiftWindowDraws int64 `json:"gift_window_draws"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
HighMultiplier int64 `json:"high_multiplier"`
|
||||
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
||||
InitialBasePool int64 `json:"initial_base_pool"`
|
||||
BasePoolReserve int64 `json:"base_pool_reserve"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
GlobalWindowDraws int64 `json:"global_window_draws"`
|
||||
GiftWindowDraws int64 `json:"gift_window_draws"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
HighMultiplier int64 `json:"high_multiplier"`
|
||||
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
||||
InitialBasePool int64 `json:"initial_base_pool"`
|
||||
BasePoolReserve int64 `json:"base_pool_reserve"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||
// User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。
|
||||
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
||||
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
|
||||
// Deprecated aliases only keep in-process callers built against the previous draft source-compatible.
|
||||
// Persistence, protobuf and runtime decisions use the round/48h fields above; these aliases never create a second rule meaning.
|
||||
JackpotUserDayRTPMaxPPM int64 `json:"-"`
|
||||
@ -124,35 +127,38 @@ type RuleStage struct {
|
||||
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
|
||||
// 比例和倍率统一使用 ppm,后台百分比/倍率业务单位只在发布入口转换。
|
||||
type RuleConfig struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
||||
ProfitRatePPM int64 `json:"profit_rate_ppm"`
|
||||
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
||||
ProfitRatePPM int64 `json:"profit_rate_ppm"`
|
||||
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
|
||||
SettlementWindowWager int64 `json:"settlement_window_wager"`
|
||||
ControlBandPPM int64 `json:"control_band_ppm"`
|
||||
GiftPriceReference int64 `json:"gift_price_reference"`
|
||||
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
|
||||
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
|
||||
EffectiveFromMS int64 `json:"effective_from_ms"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
InitialPoolCoins int64 `json:"initial_pool_coins"`
|
||||
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
|
||||
LowWatermarkCoins int64 `json:"low_watermark_coins"`
|
||||
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
|
||||
HighWatermarkCoins int64 `json:"high_watermark_coins"`
|
||||
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
|
||||
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
|
||||
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
|
||||
// 0/100% 是历史兼容 sentinel;阈值大于 0 时,factor 表示触发后保留的普通中奖权重。
|
||||
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
||||
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
||||
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
|
||||
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
|
||||
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
|
||||
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
|
||||
// Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values.
|
||||
JackpotUserDayRTPMaxPPM int64 `json:"-"`
|
||||
JackpotUser72hRTPMaxPPM int64 `json:"-"`
|
||||
|
||||
@ -42,6 +42,9 @@ const (
|
||||
StrategyConditionUserRoundRTP = "user_round_rtp"
|
||||
StrategyConditionUser48HourRTP = "user_48h_rtp"
|
||||
StrategyConditionUser48HourMaturity = "user_registered_48h"
|
||||
// StrategyConditionUser24HourRTPDownweight is an ordinary-draw control only. A trace can show
|
||||
// this condition as active on a jackpot decision, but neither jackpot selection path reads it.
|
||||
StrategyConditionUser24HourRTPDownweight = "user_24h_rtp_downweight"
|
||||
// Deprecated trace names remain available to the pure legacy compatibility path only.
|
||||
StrategyConditionUserDayRTP = "user_day_rtp"
|
||||
StrategyConditionUser72HourRTP = "user_72h_rtp"
|
||||
@ -123,6 +126,10 @@ type StrategyConfig struct {
|
||||
RechargeFactorPPM int64 `json:"recharge_factor_ppm"`
|
||||
RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"`
|
||||
RechargeBoostEndMS int64 `json:"recharge_boost_end_ms"`
|
||||
// A zero threshold disables the rolling control for historical rules. When enabled, FactorPPM
|
||||
// is the retained ordinary winning weight (700000 means keep 70%, not subtract 70 points).
|
||||
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
|
||||
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
|
||||
|
||||
MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"`
|
||||
DailyJackpotLimit int64 `json:"daily_jackpot_limit"`
|
||||
@ -166,10 +173,13 @@ type StrategyState struct {
|
||||
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
|
||||
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
|
||||
// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
|
||||
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
|
||||
GlobalRTP StrategyRTP `json:"global_rtp"`
|
||||
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
||||
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
||||
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
|
||||
GlobalRTP StrategyRTP `json:"global_rtp"`
|
||||
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
|
||||
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
|
||||
// User24HourRTP is loaded once at batch entry and advanced in memory after each subdraw. The
|
||||
// current subdraw therefore cannot downweight itself, while the next subdraw sees its outcome.
|
||||
User24HourRTP StrategyRTP `json:"user_24h_rtp"`
|
||||
SettledRoundPending bool `json:"settled_round_pending"`
|
||||
QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"`
|
||||
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
|
||||
@ -244,6 +254,7 @@ type StrategyConditionTrace struct {
|
||||
Denominator int64 `json:"denominator,omitempty"`
|
||||
RatioPPM int64 `json:"ratio_ppm,omitempty"`
|
||||
LimitPPM int64 `json:"limit_ppm,omitempty"`
|
||||
FactorPPM int64 `json:"factor_ppm,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
@ -296,6 +307,11 @@ type StrategyFundSplit struct {
|
||||
type weightedStrategyTier struct {
|
||||
Tier StrategyTier
|
||||
Weight int64
|
||||
// MissProtectionWeight freezes the ordinary probability after the existing water/recharge
|
||||
// normalization but before the optional 24-hour RTP downweight. Protection is a hard product
|
||||
// guarantee: integer apportioning may round a small retained tier to zero, but that rounding must
|
||||
// not make an otherwise payable ordinary prize ineligible once the miss threshold is reached.
|
||||
MissProtectionWeight int64
|
||||
}
|
||||
|
||||
// DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its
|
||||
@ -318,6 +334,7 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig {
|
||||
LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000,
|
||||
LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000,
|
||||
RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000,
|
||||
User24HourRTPThresholdPPM: 1_500_000, User24HourOrdinaryWinFactorPPM: 700_000,
|
||||
// 累计消费门槛是不可变规则版本的动态金币配置;0 表示该内核默认值尚未配置。
|
||||
MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
|
||||
JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
|
||||
@ -412,7 +429,7 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState,
|
||||
return "", nil, err
|
||||
}
|
||||
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
|
||||
_, traces, err := buildStrategyWeights(config, stage, available, input)
|
||||
_, traces, err := buildStrategyWeights(config, state, stage, available, input)
|
||||
return stage, traces, err
|
||||
}
|
||||
|
||||
@ -432,7 +449,7 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
|
||||
weighted, weightTrace, err := buildStrategyWeights(config, stage, available, input)
|
||||
weighted, weightTrace, err := buildStrategyWeights(config, state, stage, available, input)
|
||||
if err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
@ -443,6 +460,10 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
|
||||
SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
|
||||
SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state),
|
||||
}
|
||||
// Record the personal rolling condition before either jackpot path runs. It is intentionally
|
||||
// observational for jackpots: only buildStrategyWeights consumes the active flag.
|
||||
condition, _ := user24HourRTPDownweightCondition(config, state.User24HourRTP)
|
||||
trace.Conditions = append(trace.Conditions, condition)
|
||||
|
||||
if config.SettledRoundRTPEligibility {
|
||||
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
|
||||
@ -577,7 +598,7 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
|
||||
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
|
||||
return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
|
||||
}
|
||||
for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.UserDayRTP, state.User72HourRTP} {
|
||||
for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.User24HourRTP, state.UserDayRTP, state.User72HourRTP} {
|
||||
if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
|
||||
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
|
||||
}
|
||||
@ -591,6 +612,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
|
||||
if config.LowWaterThresholdCoins < 0 || config.HighWaterThresholdCoins < config.LowWaterThresholdCoins || config.LowWaterFactorPPM <= 0 || config.HighWaterFactorPPM <= 0 || config.RechargeFactorPPM <= 0 {
|
||||
return fmt.Errorf("%w: water thresholds or dynamic factors are invalid", ErrStrategyConfig)
|
||||
}
|
||||
user24HourFactor, _ := normalizedUser24HourOrdinaryWinFactor(config)
|
||||
if config.User24HourRTPThresholdPPM < 0 || user24HourFactor <= 0 || user24HourFactor > StrategyPPMScale ||
|
||||
(config.User24HourRTPThresholdPPM == 0 && user24HourFactor != StrategyPPMScale) {
|
||||
return fmt.Errorf("%w: user 24h RTP threshold/factor must be disabled as 0/100%% or use a positive threshold and retained factor within (0,100%%]", ErrStrategyConfig)
|
||||
}
|
||||
if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 {
|
||||
return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig)
|
||||
}
|
||||
@ -667,10 +693,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildStrategyWeights(config StrategyConfig, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) {
|
||||
func buildStrategyWeights(config StrategyConfig, state StrategyState, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) {
|
||||
tiers := make([]weightedStrategyTier, 0, len(config.Tiers))
|
||||
traces := make([]StrategyWeightTrace, 0, len(config.Tiers))
|
||||
positiveIndexes := make([]int, 0, len(config.Tiers))
|
||||
ordinaryPositiveIndexes := make([]int, 0, len(config.Tiers))
|
||||
var positiveSum int64
|
||||
zeroIndex := -1
|
||||
for _, tier := range config.Tiers {
|
||||
@ -710,6 +737,9 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64,
|
||||
return nil, nil, err
|
||||
}
|
||||
positiveIndexes = append(positiveIndexes, len(tiers))
|
||||
if !tier.Jackpot {
|
||||
ordinaryPositiveIndexes = append(ordinaryPositiveIndexes, len(tiers))
|
||||
}
|
||||
}
|
||||
trace.RawAdjustedWeightPPM = weight
|
||||
tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: weight})
|
||||
@ -734,6 +764,50 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64,
|
||||
}
|
||||
tiers[zeroIndex].Weight = 0
|
||||
}
|
||||
// Snapshot the complete ordinary table at the exact point where the historical algorithm ended.
|
||||
// The 24-hour control is applied below only to normal random selection; miss protection restores
|
||||
// these weights so its eligibility cannot be weakened by the later retained-mass rounding.
|
||||
for index := range tiers {
|
||||
if tiers[index].Tier.MultiplierPPM > 0 && !tiers[index].Tier.Jackpot {
|
||||
tiers[index].MissProtectionWeight = tiers[index].Weight
|
||||
}
|
||||
}
|
||||
_, downweightActive := user24HourRTPDownweightCondition(config, state.User24HourRTP)
|
||||
if downweightActive {
|
||||
// Water-level and recharge factors first shape the relative ordinary table. Only after the
|
||||
// normal 1,000,000-PPM normalization do we shrink the complete non-zero ordinary mass, so a
|
||||
// table previously normalized to 100% becomes exactly 70% when the retained factor is 70%.
|
||||
factor, _ := normalizedUser24HourOrdinaryWinFactor(config)
|
||||
var ordinaryPositiveSum int64
|
||||
ordinaryWeights := make([]int64, 0, len(ordinaryPositiveIndexes))
|
||||
for _, index := range ordinaryPositiveIndexes {
|
||||
ordinaryWeights = append(ordinaryWeights, tiers[index].Weight)
|
||||
var err error
|
||||
ordinaryPositiveSum, err = safeAdd(ordinaryPositiveSum, tiers[index].Weight)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if ordinaryPositiveSum > 0 {
|
||||
targetPositiveSum := mulPPM(ordinaryPositiveSum, factor)
|
||||
// PPM is discrete. Preserve one selectable unit for any non-empty configured table so miss
|
||||
// protection can still guarantee a payable ordinary award even at a very small factor.
|
||||
if targetPositiveSum == 0 {
|
||||
targetPositiveSum = 1
|
||||
}
|
||||
downweighted, err := apportion(targetPositiveSum, ordinaryWeights, ordinaryPositiveSum)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for offset, index := range ordinaryPositiveIndexes {
|
||||
tiers[index].Weight = downweighted[offset]
|
||||
traces[index].Factors = append(traces[index].Factors, StrategyWeightFactor{
|
||||
Name: StrategyConditionUser24HourRTPDownweight, FactorPPM: factor,
|
||||
})
|
||||
}
|
||||
tiers[zeroIndex].Weight = StrategyPPMScale - targetPositiveSum
|
||||
}
|
||||
}
|
||||
for index := range tiers {
|
||||
traces[index].AdjustedWeightPPM = tiers[index].Weight
|
||||
}
|
||||
@ -748,6 +822,39 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
|
||||
return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS
|
||||
}
|
||||
|
||||
func normalizedUser24HourOrdinaryWinFactor(config StrategyConfig) (int64, bool) {
|
||||
factor := config.User24HourOrdinaryWinFactorPPM
|
||||
// Pure-kernel callers compiled before this field existed still produce 0/0 struct literals. The
|
||||
// published-rule adapter canonicalizes them to 0/100%, while this guard preserves source-level
|
||||
// compatibility without turning a missing factor into “remove every win”.
|
||||
if config.User24HourRTPThresholdPPM == 0 && factor == 0 {
|
||||
factor = StrategyPPMScale
|
||||
}
|
||||
enabled := config.User24HourRTPThresholdPPM > 0 && factor > 0 && factor < StrategyPPMScale
|
||||
return factor, enabled
|
||||
}
|
||||
|
||||
func user24HourRTPDownweightCondition(config StrategyConfig, rtp StrategyRTP) (StrategyConditionTrace, bool) {
|
||||
factor, enabled := normalizedUser24HourOrdinaryWinFactor(config)
|
||||
ratio, valid := rtp.RatioPPM()
|
||||
active := enabled && valid && compareRTPExact(rtp, config.User24HourRTPThresholdPPM) > 0
|
||||
reason := "at_or_below_threshold_normal_weight"
|
||||
switch {
|
||||
case !enabled:
|
||||
reason = "disabled_or_identity_factor"
|
||||
case !valid:
|
||||
reason = "denominator_zero_normal_weight"
|
||||
case active:
|
||||
reason = "above_threshold_downweighted"
|
||||
}
|
||||
return StrategyConditionTrace{
|
||||
Name: StrategyConditionUser24HourRTPDownweight,
|
||||
Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins,
|
||||
RatioPPM: ratio, LimitPPM: config.User24HourRTPThresholdPPM, FactorPPM: factor,
|
||||
Passed: active, Reason: reason,
|
||||
}, active
|
||||
}
|
||||
|
||||
func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
|
||||
if config.SettledRoundRTPEligibility {
|
||||
return settledRoundJackpotConditions(config, state)
|
||||
@ -903,7 +1010,18 @@ func selectOrdinaryTier(config StrategyConfig, state StrategyState, input Strate
|
||||
candidates := append([]weightedStrategyTier(nil), weighted...)
|
||||
if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws {
|
||||
trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection})
|
||||
candidates = withoutStrategyTier(candidates, zeroTier.ID)
|
||||
// Protection deliberately rebuilds only ordinary positive candidates from their pre-downweight
|
||||
// weights. A 1-PPM retained mass can round some tiers to zero, but if any such tier is payable the
|
||||
// guarantee still has to award one; jackpot tiers remain exclusively controlled by jackpot gates.
|
||||
protected := make([]weightedStrategyTier, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Tier.MultiplierPPM <= 0 || candidate.Tier.Jackpot || candidate.MissProtectionWeight <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate.Weight = candidate.MissProtectionWeight
|
||||
protected = append(protected, candidate)
|
||||
}
|
||||
candidates = protected
|
||||
if !hasPayableOrdinaryTier(config, state, input, available, candidates) {
|
||||
trace.FinalReason = StrategyReasonMissProtectionBlocked
|
||||
trace.BlockedReason = StrategyReasonMissProtectionBlocked
|
||||
@ -1046,6 +1164,13 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
|
||||
// This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with
|
||||
// one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
|
||||
trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next)
|
||||
if _, enabled := normalizedUser24HourOrdinaryWinFactor(config); enabled {
|
||||
// The caller supplies the pre-draw rolling snapshot. Advancing it only after selection makes
|
||||
// this draw visible to the next item in the same gift_count batch, never to its own decision.
|
||||
if next.User24HourRTP, err = next.User24HourRTP.add(input.GiftPriceCoins, payout); err != nil {
|
||||
return StrategyDecision{}, err
|
||||
}
|
||||
}
|
||||
if !config.SettledRoundRTPEligibility {
|
||||
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
|
||||
return StrategyDecision{}, err
|
||||
|
||||
@ -38,8 +38,12 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
|
||||
HighWaterNonzeroFactorPPM: 1_300_000,
|
||||
RechargeBoostWindowMS: 300_000,
|
||||
RechargeBoostFactorPPM: 1_100_000,
|
||||
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
|
||||
JackpotGlobalRTPMaxPPM: 980_000,
|
||||
// 这里只生成 rule_version=0 的未发布草稿,因此使用产品新建默认 150%/70%。已发布
|
||||
// 历史版本始终从不可变规则表读取其 0/100% sentinel,不会因本默认值被自动升级。
|
||||
User24HourRTPThresholdPPM: 1_500_000,
|
||||
User24HourOrdinaryWinFactorPPM: 700_000,
|
||||
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
|
||||
JackpotGlobalRTPMaxPPM: 980_000,
|
||||
// Deprecated aliases keep old in-process draft builders source-compatible. New API requests populate
|
||||
// the canonical round/48h fields and therefore opt into the settled-window algorithm.
|
||||
JackpotUserDayRTPMaxPPM: 960_000,
|
||||
@ -96,6 +100,11 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6
|
||||
}
|
||||
|
||||
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
|
||||
if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 {
|
||||
// proto3 标量无法区分旧客户端“未发送”和显式 0;将 0/0 收敛到唯一的禁用 sentinel,
|
||||
// 使升级前规则重发后仍保持原中奖概率,而不是意外启用或因新增字段发布失败。
|
||||
config.User24HourOrdinaryWinFactorPPM = ppmScale
|
||||
}
|
||||
// The new contract names are the only persisted/runtime meaning. During a rolling source upgrade an
|
||||
// old caller may still fill the day/72h aliases; copy them once without pretending that caller opted
|
||||
// into the settled-round algorithm. A config read from the new database columns sets the marker.
|
||||
@ -218,6 +227,13 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
|
||||
if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale {
|
||||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%")
|
||||
}
|
||||
if config.User24HourRTPThresholdPPM == 0 {
|
||||
if config.User24HourOrdinaryWinFactorPPM != ppmScale {
|
||||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 disabled 24-hour RTP downweight must keep ordinary win factor at 100%")
|
||||
}
|
||||
} else if config.User24HourRTPThresholdPPM < 0 || config.User24HourOrdinaryWinFactorPPM <= 0 || config.User24HourOrdinaryWinFactorPPM > ppmScale {
|
||||
return xerr.New(xerr.InvalidArgument, "dynamic_v3 24-hour RTP threshold must be positive and ordinary win factor must be greater than 0% and no more than 100%")
|
||||
}
|
||||
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -121,6 +121,22 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rolling24HourRTP := domain.StrategyRTP{}
|
||||
if strategyConfig.User24HourRTPThresholdPPM > 0 &&
|
||||
strategyConfig.User24HourOrdinaryWinFactorPPM > 0 &&
|
||||
strategyConfig.User24HourOrdinaryWinFactorPPM < domain.StrategyPPMScale {
|
||||
// One exact [paid_at+1ms-24h, paid_at+1ms) read covers the whole gift_count batch. Existing
|
||||
// persisted events at paid_at_ms are included, while this batch is not inserted until commit;
|
||||
// finalizeStrategyDecision advances the snapshot after every subdraw entirely in memory.
|
||||
rolling24HourRTP, err = r.getLuckyUserRolling24HourRTP(
|
||||
ctx, tx, appCode, config.PoolID, cmd.UserID, luckyExclusiveEndMS(paidAtMS),
|
||||
)
|
||||
if err != nil {
|
||||
// Failing closed here rolls back the complete draw transaction. Silently using zero would
|
||||
// let a user bypass the control whenever the aggregate source is unavailable.
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -164,6 +180,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
|
||||
UserDaySpendCoins: dayState.SpendCoins,
|
||||
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
|
||||
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
|
||||
User24HourRTP: rolling24HourRTP,
|
||||
Version: userState.PaidDraws,
|
||||
},
|
||||
}
|
||||
|
||||
@ -312,6 +312,10 @@ func (r *Repository) getLuckyUserRolling48HourRTP(ctx context.Context, tx *sql.T
|
||||
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 48)
|
||||
}
|
||||
|
||||
func (r *Repository) getLuckyUserRolling24HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
|
||||
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 24)
|
||||
}
|
||||
|
||||
// getLuckyUserRolling72HourRTP remains only for old in-package callers during rolling source upgrades.
|
||||
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
|
||||
return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 72)
|
||||
@ -349,7 +353,7 @@ func (r *Repository) getLuckyUserRollingHourRTP(ctx context.Context, tx *sql.Tx,
|
||||
var wager, payout int64
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
|
||||
FROM lucky_user_rtp_boundary_events
|
||||
FROM lucky_user_rtp_boundary_events FORCE INDEX (idx_lucky_user_rtp_boundary_range)
|
||||
WHERE app_code = ? AND pool_id = ? AND user_id = ?
|
||||
AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
|
||||
appCode, poolID, userID, boundary[0], boundary[1],
|
||||
|
||||
@ -117,28 +117,30 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
|
||||
tiers = append(tiers, jackpotByMultiplier[multiplier])
|
||||
}
|
||||
return domain.StrategyConfig{
|
||||
Tiers: tiers,
|
||||
PublicPoolRatePPM: config.PoolRatePPM,
|
||||
ProfitPoolRatePPM: config.ProfitRatePPM,
|
||||
AnchorReturnRatePPM: config.AnchorRatePPM,
|
||||
ColdStartPoolCoins: config.InitialPoolCoins,
|
||||
LowWaterThresholdCoins: config.LowWatermarkCoins,
|
||||
HighWaterThresholdCoins: config.HighWatermarkCoins,
|
||||
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
|
||||
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
|
||||
RechargeFactorPPM: config.RechargeBoostFactorPPM,
|
||||
RechargeBoostStartMS: 0,
|
||||
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
||||
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
||||
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
||||
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
|
||||
JackpotMechanism1Enabled: true,
|
||||
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
|
||||
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
||||
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
||||
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
||||
SettledRoundRTPEligibility: true,
|
||||
RechargeStages: rechargeStages,
|
||||
Tiers: tiers,
|
||||
PublicPoolRatePPM: config.PoolRatePPM,
|
||||
ProfitPoolRatePPM: config.ProfitRatePPM,
|
||||
AnchorReturnRatePPM: config.AnchorRatePPM,
|
||||
ColdStartPoolCoins: config.InitialPoolCoins,
|
||||
LowWaterThresholdCoins: config.LowWatermarkCoins,
|
||||
HighWaterThresholdCoins: config.HighWatermarkCoins,
|
||||
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
|
||||
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
|
||||
RechargeFactorPPM: config.RechargeBoostFactorPPM,
|
||||
RechargeBoostStartMS: 0,
|
||||
RechargeBoostEndMS: config.RechargeBoostWindowMS,
|
||||
User24HourRTPThresholdPPM: config.User24HourRTPThresholdPPM,
|
||||
User24HourOrdinaryWinFactorPPM: config.User24HourOrdinaryWinFactorPPM,
|
||||
MissProtectionZeroDraws: config.LossStreakGuarantee,
|
||||
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
|
||||
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
|
||||
JackpotMechanism1Enabled: true,
|
||||
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
|
||||
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
|
||||
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
|
||||
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM,
|
||||
SettledRoundRTPEligibility: true,
|
||||
RechargeStages: rechargeStages,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1047,6 +1047,21 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
|
||||
poolData["fund_split"] = input.FundSplit
|
||||
rtpData["user_round"] = input.UserRoundRTP
|
||||
rtpData["user_48h"] = input.User48HourRTP
|
||||
for _, condition := range input.StrategyDecision.Trace.Conditions {
|
||||
if condition.Name != domain.StrategyConditionUser24HourRTPDownweight {
|
||||
continue
|
||||
}
|
||||
// Persist explicit business names rather than forcing incident tooling to reinterpret the
|
||||
// generic condition numerator/denominator. This is the pre-draw snapshot; current payout is
|
||||
// added only to StrategyDecision.NextState for the next subdraw.
|
||||
rtpData["user_24h_downweight"] = map[string]any{
|
||||
"wager_coins": condition.Denominator, "payout_coins": condition.Numerator,
|
||||
"rtp_ppm": condition.RatioPPM, "threshold_ppm": condition.LimitPPM,
|
||||
"ordinary_win_factor_ppm": condition.FactorPPM,
|
||||
"active": condition.Passed, "reason": condition.Reason,
|
||||
}
|
||||
break
|
||||
}
|
||||
// Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the
|
||||
// current open window advances independently on this draw.
|
||||
rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP
|
||||
@ -2308,56 +2323,64 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
|
||||
if !hasEnabledBaseTier {
|
||||
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required")
|
||||
}
|
||||
user24HourOrdinaryWinFactorPPM := ruleConfig.User24HourOrdinaryWinFactorPPM
|
||||
if ruleConfig.User24HourRTPThresholdPPM == 0 && user24HourOrdinaryWinFactorPPM == 0 {
|
||||
// Rules published before the field existed deserialize as 0/0. Canonicalize that absence to
|
||||
// the documented disabled 0/100% sentinel so no legacy rule can become a zero-win table.
|
||||
user24HourOrdinaryWinFactorPPM = luckyPPMScale
|
||||
}
|
||||
// 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池;
|
||||
// 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。
|
||||
config := domain.Config{
|
||||
AppCode: ruleConfig.AppCode,
|
||||
GiftID: poolID,
|
||||
PoolID: poolID,
|
||||
Enabled: ruleConfig.Enabled,
|
||||
RuleVersion: ruleConfig.RuleVersion,
|
||||
StrategyVersion: ruleConfig.StrategyVersion,
|
||||
GiftPrice: referencePrice,
|
||||
TargetRTPPPM: ruleConfig.TargetRTPPPM,
|
||||
PoolRatePPM: ruleConfig.PoolRatePPM,
|
||||
ProfitRatePPM: ruleConfig.ProfitRatePPM,
|
||||
AnchorRatePPM: ruleConfig.AnchorRatePPM,
|
||||
ControlBandPPM: ruleConfig.ControlBandPPM,
|
||||
SettlementWindowWager: ruleConfig.SettlementWindowWager,
|
||||
GlobalWindowDraws: windowDraws,
|
||||
GiftWindowDraws: windowDraws,
|
||||
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
|
||||
HighMultiplier: 10,
|
||||
HighWaterPoolMultiple: 2,
|
||||
InitialBasePool: referencePrice * (950 + 500 + 2_375),
|
||||
BasePoolReserve: referencePrice * (100 + 10 + 100),
|
||||
InitialPoolCoins: ruleConfig.InitialPoolCoins,
|
||||
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
|
||||
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
|
||||
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
|
||||
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
||||
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
|
||||
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
|
||||
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
|
||||
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
|
||||
SettledRoundRTPEligibility: true,
|
||||
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: ruleConfig.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
|
||||
Tiers: tiers,
|
||||
Stages: ruleConfig.Stages,
|
||||
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
|
||||
CreatedAtMS: ruleConfig.CreatedAtMS,
|
||||
UpdatedAtMS: ruleConfig.CreatedAtMS,
|
||||
AppCode: ruleConfig.AppCode,
|
||||
GiftID: poolID,
|
||||
PoolID: poolID,
|
||||
Enabled: ruleConfig.Enabled,
|
||||
RuleVersion: ruleConfig.RuleVersion,
|
||||
StrategyVersion: ruleConfig.StrategyVersion,
|
||||
GiftPrice: referencePrice,
|
||||
TargetRTPPPM: ruleConfig.TargetRTPPPM,
|
||||
PoolRatePPM: ruleConfig.PoolRatePPM,
|
||||
ProfitRatePPM: ruleConfig.ProfitRatePPM,
|
||||
AnchorRatePPM: ruleConfig.AnchorRatePPM,
|
||||
ControlBandPPM: ruleConfig.ControlBandPPM,
|
||||
SettlementWindowWager: ruleConfig.SettlementWindowWager,
|
||||
GlobalWindowDraws: windowDraws,
|
||||
GiftWindowDraws: windowDraws,
|
||||
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
|
||||
HighMultiplier: 10,
|
||||
HighWaterPoolMultiple: 2,
|
||||
InitialBasePool: referencePrice * (950 + 500 + 2_375),
|
||||
BasePoolReserve: referencePrice * (100 + 10 + 100),
|
||||
InitialPoolCoins: ruleConfig.InitialPoolCoins,
|
||||
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
|
||||
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
|
||||
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
|
||||
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
|
||||
User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM,
|
||||
User24HourOrdinaryWinFactorPPM: user24HourOrdinaryWinFactorPPM,
|
||||
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
|
||||
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
|
||||
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
|
||||
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
|
||||
SettledRoundRTPEligibility: true,
|
||||
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: ruleConfig.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
|
||||
Tiers: tiers,
|
||||
Stages: ruleConfig.Stages,
|
||||
UpdatedByAdminID: ruleConfig.CreatedByAdminID,
|
||||
CreatedAtMS: ruleConfig.CreatedAtMS,
|
||||
UpdatedAtMS: ruleConfig.CreatedAtMS,
|
||||
}
|
||||
if config.StrategyVersion == "" {
|
||||
config.StrategyVersion = domain.StrategyFixedV2
|
||||
|
||||
@ -49,6 +49,10 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
||||
// 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。
|
||||
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero")
|
||||
}
|
||||
if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 {
|
||||
// 旧 proto 客户端不会发送新增标量;仓储兜底成 0/100% 禁用态,确保绕过 service 的内部发布也不改变历史概率。
|
||||
config.User24HourOrdinaryWinFactorPPM = 1_000_000
|
||||
}
|
||||
if config.JackpotMultiplierPPMs == nil {
|
||||
config.JackpotMultiplierPPMs = []int64{}
|
||||
}
|
||||
@ -95,12 +99,13 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
||||
initial_pool_coins, loss_streak_guarantee,
|
||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
||||
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
||||
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
||||
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
|
||||
config.ProfitRatePPM, config.AnchorRatePPM,
|
||||
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
|
||||
@ -109,7 +114,8 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
||||
config.InitialPoolCoins, config.LossStreakGuarantee,
|
||||
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
|
||||
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
|
||||
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
|
||||
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
|
||||
config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
|
||||
config.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
|
||||
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
|
||||
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
|
||||
@ -174,7 +180,8 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
|
||||
v.initial_pool_coins, v.loss_streak_guarantee,
|
||||
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
|
||||
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
|
||||
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms,
|
||||
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
|
||||
v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
|
||||
v.jackpot_global_rtp_max_ppm, v.jackpot_user_round_rtp_max_ppm, v.jackpot_user_48h_rtp_max_ppm,
|
||||
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
|
||||
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
|
||||
@ -226,7 +233,8 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
|
||||
initial_pool_coins, loss_streak_guarantee,
|
||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm,
|
||||
user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
|
||||
jackpot_global_rtp_max_ppm, jackpot_user_round_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
|
||||
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||
@ -329,6 +337,8 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
|
||||
&config.HighWaterNonzeroFactorPPM,
|
||||
&config.RechargeBoostWindowMS,
|
||||
&config.RechargeBoostFactorPPM,
|
||||
&config.User24HourRTPThresholdPPM,
|
||||
&config.User24HourOrdinaryWinFactorPPM,
|
||||
&jackpotMultiplierPPMsJSON,
|
||||
&config.JackpotGlobalRTPMaxPPM,
|
||||
&config.JackpotUserRoundRTPMaxPPM,
|
||||
|
||||
@ -295,44 +295,46 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
|
||||
})
|
||||
}
|
||||
return domain.RuleConfig{
|
||||
AppCode: config.GetAppCode(),
|
||||
PoolID: config.GetPoolId(),
|
||||
RuleVersion: config.GetRuleVersion(),
|
||||
Enabled: config.GetEnabled(),
|
||||
TargetRTPPPM: config.GetTargetRtpPpm(),
|
||||
PoolRatePPM: config.GetPoolRatePpm(),
|
||||
SettlementWindowWager: config.GetSettlementWindowWager(),
|
||||
ControlBandPPM: config.GetControlBandPpm(),
|
||||
GiftPriceReference: config.GetGiftPriceReference(),
|
||||
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
||||
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
StrategyVersion: config.GetStrategyVersion(),
|
||||
ProfitRatePPM: config.GetProfitRatePpm(),
|
||||
AnchorRatePPM: config.GetAnchorRatePpm(),
|
||||
InitialPoolCoins: config.GetInitialPoolCoins(),
|
||||
LossStreakGuarantee: config.GetLossStreakGuarantee(),
|
||||
LowWatermarkCoins: config.GetLowWatermarkCoins(),
|
||||
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
|
||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
|
||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
||||
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
|
||||
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
|
||||
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
|
||||
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
|
||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
|
||||
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
|
||||
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
|
||||
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
|
||||
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
|
||||
Stages: stages,
|
||||
AppCode: config.GetAppCode(),
|
||||
PoolID: config.GetPoolId(),
|
||||
RuleVersion: config.GetRuleVersion(),
|
||||
Enabled: config.GetEnabled(),
|
||||
TargetRTPPPM: config.GetTargetRtpPpm(),
|
||||
PoolRatePPM: config.GetPoolRatePpm(),
|
||||
SettlementWindowWager: config.GetSettlementWindowWager(),
|
||||
ControlBandPPM: config.GetControlBandPpm(),
|
||||
GiftPriceReference: config.GetGiftPriceReference(),
|
||||
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
|
||||
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
|
||||
EffectiveFromMS: config.GetEffectiveFromMs(),
|
||||
CreatedByAdminID: config.GetCreatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
StrategyVersion: config.GetStrategyVersion(),
|
||||
ProfitRatePPM: config.GetProfitRatePpm(),
|
||||
AnchorRatePPM: config.GetAnchorRatePpm(),
|
||||
InitialPoolCoins: config.GetInitialPoolCoins(),
|
||||
LossStreakGuarantee: config.GetLossStreakGuarantee(),
|
||||
LowWatermarkCoins: config.GetLowWatermarkCoins(),
|
||||
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
|
||||
HighWatermarkCoins: config.GetHighWatermarkCoins(),
|
||||
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
|
||||
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
|
||||
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
|
||||
User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(),
|
||||
User24HourOrdinaryWinFactorPPM: config.GetUser_24HOrdinaryWinFactorPpm(),
|
||||
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
|
||||
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
|
||||
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
|
||||
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
|
||||
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
|
||||
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
|
||||
MaxSinglePayout: config.GetMaxSinglePayout(),
|
||||
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
|
||||
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
|
||||
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
|
||||
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
|
||||
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
|
||||
Stages: stages,
|
||||
}
|
||||
}
|
||||
|
||||
@ -366,44 +368,46 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
|
||||
})
|
||||
}
|
||||
return &luckygiftv1.LuckyGiftRuleConfig{
|
||||
AppCode: config.AppCode,
|
||||
PoolId: config.PoolID,
|
||||
RuleVersion: config.RuleVersion,
|
||||
Enabled: config.Enabled,
|
||||
TargetRtpPpm: config.TargetRTPPPM,
|
||||
PoolRatePpm: config.PoolRatePPM,
|
||||
SettlementWindowWager: config.SettlementWindowWager,
|
||||
ControlBandPpm: config.ControlBandPPM,
|
||||
GiftPriceReference: config.GiftPriceReference,
|
||||
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
|
||||
EffectiveFromMs: config.EffectiveFromMS,
|
||||
CreatedByAdminId: config.CreatedByAdminID,
|
||||
CreatedAtMs: config.CreatedAtMS,
|
||||
StrategyVersion: config.StrategyVersion,
|
||||
ProfitRatePpm: config.ProfitRatePPM,
|
||||
AnchorRatePpm: config.AnchorRatePPM,
|
||||
InitialPoolCoins: config.InitialPoolCoins,
|
||||
LossStreakGuarantee: config.LossStreakGuarantee,
|
||||
LowWatermarkCoins: config.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
|
||||
HighWatermarkCoins: config.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
|
||||
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
||||
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
||||
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
||||
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
|
||||
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
|
||||
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: config.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: config.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
|
||||
Stages: stages,
|
||||
AppCode: config.AppCode,
|
||||
PoolId: config.PoolID,
|
||||
RuleVersion: config.RuleVersion,
|
||||
Enabled: config.Enabled,
|
||||
TargetRtpPpm: config.TargetRTPPPM,
|
||||
PoolRatePpm: config.PoolRatePPM,
|
||||
SettlementWindowWager: config.SettlementWindowWager,
|
||||
ControlBandPpm: config.ControlBandPPM,
|
||||
GiftPriceReference: config.GiftPriceReference,
|
||||
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
|
||||
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
|
||||
EffectiveFromMs: config.EffectiveFromMS,
|
||||
CreatedByAdminId: config.CreatedByAdminID,
|
||||
CreatedAtMs: config.CreatedAtMS,
|
||||
StrategyVersion: config.StrategyVersion,
|
||||
ProfitRatePpm: config.ProfitRatePPM,
|
||||
AnchorRatePpm: config.AnchorRatePPM,
|
||||
InitialPoolCoins: config.InitialPoolCoins,
|
||||
LossStreakGuarantee: config.LossStreakGuarantee,
|
||||
LowWatermarkCoins: config.LowWatermarkCoins,
|
||||
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
|
||||
HighWatermarkCoins: config.HighWatermarkCoins,
|
||||
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
|
||||
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
|
||||
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
|
||||
User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM,
|
||||
User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM,
|
||||
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
|
||||
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
|
||||
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
|
||||
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
|
||||
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
|
||||
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
|
||||
MaxSinglePayout: config.MaxSinglePayout,
|
||||
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
|
||||
UserDailyPayoutCap: config.UserDailyPayoutCap,
|
||||
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
|
||||
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
|
||||
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
|
||||
Stages: stages,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user