feat(lucky-gift): downweight high 24h RTP users

This commit is contained in:
zhx 2026-07-16 15:38:03 +08:00
parent fd8bc1e608
commit 9cf34db28b
16 changed files with 1332 additions and 797 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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 又会把缺失标量解成 00/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,
},
}
}

View File

@ -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),

View File

@ -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

View File

@ -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降权触发线ppm0表示关闭',
user_24h_ordinary_win_factor_ppm BIGINT NOT NULL DEFAULT 1000000 COMMENT '触发后普通非零奖档保留权重ppm1000000表示不降权',
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',

View File

@ -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降权触发线ppm0表示关闭'' 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 ''触发后普通非零奖档保留权重ppm1000000表示不降权'' AFTER user_24h_rtp_threshold_ppm, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -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:"-"`

View File

@ -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

View File

@ -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
}

View File

@ -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,
},
}

View File

@ -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],

View File

@ -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,
}
}

View File

@ -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

View File

@ -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,

View File

@ -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,
}
}