merge: add user 24h RTP downweight

This commit is contained in:
zhx 2026-07-16 15:38:44 +08:00
commit 99b225b78a
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 device_daily_payout_cap = 36;
int64 room_hourly_payout_cap = 37; int64 room_hourly_payout_cap = 37;
int64 anchor_daily_payout_cap = 38; 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 { message CheckLuckyGiftRequest {

View File

@ -33,32 +33,34 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
} }
type configRequest struct { type configRequest struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"` StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"` TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"` PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"` ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"` AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"` ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"` InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"` LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"` LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"` LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"` HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"` HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"` RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"` User24HourRTPThresholdPercent float64 `json:"user_24h_rtp_threshold_percent"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"` User24HourOrdinaryWinFactorPercent float64 `json:"user_24h_ordinary_win_factor_percent"`
JackpotUserRoundRTPMaxPercent float64 `json:"jackpot_user_round_rtp_max_percent"` JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"` 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 // 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. // accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
JackpotUserDayRTPMaxPercent float64 `json:"-"` JackpotUserDayRTPMaxPercent float64 `json:"-"`
@ -448,6 +450,11 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion)) 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 initialPoolCoins := req.InitialPoolCoins
if strategyVersion == "dynamic_v3" { if strategyVersion == "dynamic_v3" {
// V3 启动资金已经迁移为独立人工 credit旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。 // V3 启动资金已经迁移为独立人工 credit旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。
@ -487,41 +494,43 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent rolling48hRTPMaxPercent = req.JackpotUser72hRTPMaxPercent
} }
return &luckygiftv1.LuckyGiftRuleConfig{ return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID), PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion, StrategyVersion: strategyVersion,
Enabled: req.Enabled, Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent), TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent), PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent), ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent), AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager, SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent), ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference, GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS, EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins, InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee, LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins, LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins, HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent), HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS, RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent), RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers), User_24HRtpThresholdPpm: percentToPPM(req.User24HourRTPThresholdPercent),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent), User_24HOrdinaryWinFactorPpm: percentToPPM(user24HourOrdinaryWinFactorPercent),
JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent), JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent), JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins, JackpotUserRoundRtpMaxPpm: percentToPPM(roundRTPMaxPercent),
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay, JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
MaxSinglePayout: req.MaxSinglePayout, JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
UserHourlyPayoutCap: req.UserHourlyPayoutCap, MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
UserDailyPayoutCap: req.UserDailyPayoutCap, MaxSinglePayout: req.MaxSinglePayout,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap, UserHourlyPayoutCap: req.UserHourlyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap, UserDailyPayoutCap: req.UserDailyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap, DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
Stages: stages, RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages,
} }
} }
@ -555,48 +564,56 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
// 兼容读取迁移前 V3 快照时也展示 0避免运营误以为规则字段仍会自动形成账本资金。 // 兼容读取迁移前 V3 快照时也展示 0避免运营误以为规则字段仍会自动形成账本资金。
initialPoolCoins = 0 initialPoolCoins = 0
} }
user24HourRTPThresholdPercent := ppmToPercent(config.GetUser_24HRtpThresholdPpm())
user24HourOrdinaryWinFactorPercent := ppmToPercent(config.GetUser_24HOrdinaryWinFactorPpm())
if user24HourRTPThresholdPercent == 0 && user24HourOrdinaryWinFactorPercent == 0 {
// 读取迁移前规则或滚动升级中的旧服务时也只返回唯一禁用态,前端无需猜测 0 因子是否表示全禁中。
user24HourOrdinaryWinFactorPercent = 100
}
return configDTO{ return configDTO{
RuleVersion: config.GetRuleVersion(), RuleVersion: config.GetRuleVersion(),
CreatedByAdminID: config.GetCreatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
configRequest: configRequest{ configRequest: configRequest{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
PoolID: poolID, PoolID: poolID,
StrategyVersion: strategyVersion, StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()), ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()), AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(), SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(), GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins, InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(), LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(), LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()), LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(), HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()), HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()), RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()), User24HourRTPThresholdPercent: user24HourRTPThresholdPercent,
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()), User24HourOrdinaryWinFactorPercent: user24HourOrdinaryWinFactorPercent,
JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()), JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()), JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()), JackpotUserRoundRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()), JackpotUser48hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserRoundRtpMaxPpm()),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
MaxSinglePayout: config.GetMaxSinglePayout(), JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(), MaxSinglePayout: config.GetMaxSinglePayout(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
Stages: stages, RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
}, },
} }
} }

View File

@ -92,6 +92,9 @@ func runSimulation(options simulationOptions) simulationReport {
simulateBatchCounts(config, options.Seed), simulateBatchCounts(config, options.Seed),
simulateSettlementWagerWindow(), simulateSettlementWagerWindow(),
simulateWaterBoundaries(config), simulateWaterBoundaries(config),
simulateUser24HourRTPDownweight(config, options.Seed+5),
simulateUser24HourRTPBatchCrossing(config),
simulateUser24HourRTPMillionDraws(config, options.Seed+6),
simulateRechargeWindowBoundaries(config), simulateRechargeWindowBoundaries(config),
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN), simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN), 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_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}, {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 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} 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 { func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "充值0/299999/300000ms" const name = "充值0/299999/300000ms"
config.JackpotMechanism1Enabled = false 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', high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子ppm',
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒', recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子ppm', 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_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_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', 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"` AnchorRatePPM int64 `json:"anchor_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。 // SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"` GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"` GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
HighMultiplier int64 `json:"high_multiplier"` HighMultiplier int64 `json:"high_multiplier"`
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"` InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"` BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"` InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"` LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"` LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"` HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` // User24HourRTPThresholdPPM/Factor 只调整普通非零奖档;大奖资格和保底仍由各自策略独立决定。
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"` User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_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. // 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. // Persistence, protobuf and runtime decisions use the round/48h fields above; these aliases never create a second rule meaning.
JackpotUserDayRTPMaxPPM int64 `json:"-"` JackpotUserDayRTPMaxPPM int64 `json:"-"`
@ -124,35 +127,38 @@ type RuleStage struct {
// RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释; // RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。 // 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct { type RuleConfig struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"` StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"` PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"` ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"` AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"` InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"` LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"` LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"` LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"` HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"` HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"` RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"` RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"` // 0/100% 是历史兼容 sentinel阈值大于 0 时factor 表示触发后保留的普通中奖权重。
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"` User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"` User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_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. // Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values.
JackpotUserDayRTPMaxPPM int64 `json:"-"` JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"` JackpotUser72hRTPMaxPPM int64 `json:"-"`

View File

@ -42,6 +42,9 @@ const (
StrategyConditionUserRoundRTP = "user_round_rtp" StrategyConditionUserRoundRTP = "user_round_rtp"
StrategyConditionUser48HourRTP = "user_48h_rtp" StrategyConditionUser48HourRTP = "user_48h_rtp"
StrategyConditionUser48HourMaturity = "user_registered_48h" 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. // Deprecated trace names remain available to the pure legacy compatibility path only.
StrategyConditionUserDayRTP = "user_day_rtp" StrategyConditionUserDayRTP = "user_day_rtp"
StrategyConditionUser72HourRTP = "user_72h_rtp" StrategyConditionUser72HourRTP = "user_72h_rtp"
@ -123,6 +126,10 @@ type StrategyConfig struct {
RechargeFactorPPM int64 `json:"recharge_factor_ppm"` RechargeFactorPPM int64 `json:"recharge_factor_ppm"`
RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"` RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"`
RechargeBoostEndMS int64 `json:"recharge_boost_end_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"` MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"`
DailyJackpotLimit int64 `json:"daily_jackpot_limit"` DailyJackpotLimit int64 `json:"daily_jackpot_limit"`
@ -166,10 +173,13 @@ type StrategyState struct {
UserDaySpendCoins int64 `json:"user_day_spend_coins"` UserDaySpendCoins int64 `json:"user_day_spend_coins"`
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is // 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. // deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"` SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
GlobalRTP StrategyRTP `json:"global_rtp"` GlobalRTP StrategyRTP `json:"global_rtp"`
UserRoundRTP StrategyRTP `json:"user_round_rtp"` UserRoundRTP StrategyRTP `json:"user_round_rtp"`
User48HourRTP StrategyRTP `json:"user_48h_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"` SettledRoundPending bool `json:"settled_round_pending"`
QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"` QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"`
UserRegisteredAtMS int64 `json:"user_registered_at_ms"` UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
@ -244,6 +254,7 @@ type StrategyConditionTrace struct {
Denominator int64 `json:"denominator,omitempty"` Denominator int64 `json:"denominator,omitempty"`
RatioPPM int64 `json:"ratio_ppm,omitempty"` RatioPPM int64 `json:"ratio_ppm,omitempty"`
LimitPPM int64 `json:"limit_ppm,omitempty"` LimitPPM int64 `json:"limit_ppm,omitempty"`
FactorPPM int64 `json:"factor_ppm,omitempty"`
Passed bool `json:"passed"` Passed bool `json:"passed"`
Reason string `json:"reason"` Reason string `json:"reason"`
} }
@ -296,6 +307,11 @@ type StrategyFundSplit struct {
type weightedStrategyTier struct { type weightedStrategyTier struct {
Tier StrategyTier Tier StrategyTier
Weight int64 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 // DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its
@ -318,6 +334,7 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig {
LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000,
LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000,
RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000,
User24HourRTPThresholdPPM: 1_500_000, User24HourOrdinaryWinFactorPPM: 700_000,
// 累计消费门槛是不可变规则版本的动态金币配置0 表示该内核默认值尚未配置。 // 累计消费门槛是不可变规则版本的动态金币配置0 表示该内核默认值尚未配置。
MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0, MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true, JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
@ -412,7 +429,7 @@ func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState,
return "", nil, err return "", nil, err
} }
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) 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 return stage, traces, err
} }
@ -432,7 +449,7 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
return StrategyDecision{}, err return StrategyDecision{}, err
} }
stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) 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 { if err != nil {
return StrategyDecision{}, err return StrategyDecision{}, err
} }
@ -443,6 +460,10 @@ func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input S
SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state), SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
SpendMilestoneTokensAfter: 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 { if config.SettledRoundRTPEligibility {
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace) 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 { 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) 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 { if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput) 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 { 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) 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 { 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) 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 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)) tiers := make([]weightedStrategyTier, 0, len(config.Tiers))
traces := make([]StrategyWeightTrace, 0, len(config.Tiers)) traces := make([]StrategyWeightTrace, 0, len(config.Tiers))
positiveIndexes := make([]int, 0, len(config.Tiers)) positiveIndexes := make([]int, 0, len(config.Tiers))
ordinaryPositiveIndexes := make([]int, 0, len(config.Tiers))
var positiveSum int64 var positiveSum int64
zeroIndex := -1 zeroIndex := -1
for _, tier := range config.Tiers { for _, tier := range config.Tiers {
@ -710,6 +737,9 @@ func buildStrategyWeights(config StrategyConfig, stage string, available int64,
return nil, nil, err return nil, nil, err
} }
positiveIndexes = append(positiveIndexes, len(tiers)) positiveIndexes = append(positiveIndexes, len(tiers))
if !tier.Jackpot {
ordinaryPositiveIndexes = append(ordinaryPositiveIndexes, len(tiers))
}
} }
trace.RawAdjustedWeightPPM = weight trace.RawAdjustedWeightPPM = weight
tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: 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 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 { for index := range tiers {
traces[index].AdjustedWeightPPM = tiers[index].Weight traces[index].AdjustedWeightPPM = tiers[index].Weight
} }
@ -748,6 +822,39 @@ func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS 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) { func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
if config.SettledRoundRTPEligibility { if config.SettledRoundRTPEligibility {
return settledRoundJackpotConditions(config, state) return settledRoundJackpotConditions(config, state)
@ -903,7 +1010,18 @@ func selectOrdinaryTier(config StrategyConfig, state StrategyState, input Strate
candidates := append([]weightedStrategyTier(nil), weighted...) candidates := append([]weightedStrategyTier(nil), weighted...)
if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws { if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws {
trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection}) 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) { if !hasPayableOrdinaryTier(config, state, input, available, candidates) {
trace.FinalReason = StrategyReasonMissProtectionBlocked trace.FinalReason = StrategyReasonMissProtectionBlocked
trace.BlockedReason = 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 // 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. // one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next) 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 !config.SettledRoundRTPEligibility {
if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil { if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
return StrategyDecision{}, err return StrategyDecision{}, err

View File

@ -38,8 +38,12 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
HighWaterNonzeroFactorPPM: 1_300_000, HighWaterNonzeroFactorPPM: 1_300_000,
RechargeBoostWindowMS: 300_000, RechargeBoostWindowMS: 300_000,
RechargeBoostFactorPPM: 1_100_000, RechargeBoostFactorPPM: 1_100_000,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000}, // 这里只生成 rule_version=0 的未发布草稿,因此使用产品新建默认 150%/70%。已发布
JackpotGlobalRTPMaxPPM: 980_000, // 历史版本始终从不可变规则表读取其 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 // 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. // the canonical round/48h fields and therefore opt into the settled-window algorithm.
JackpotUserDayRTPMaxPPM: 960_000, JackpotUserDayRTPMaxPPM: 960_000,
@ -96,6 +100,11 @@ func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int6
} }
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { 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 // 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 // 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. // 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 { 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%") 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 { if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err return err
} }

View File

@ -121,6 +121,22 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil { if err != nil {
return nil, err 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) userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS)
if err != nil { if err != nil {
return nil, err return nil, err
@ -164,6 +180,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
UserDaySpendCoins: dayState.SpendCoins, UserDaySpendCoins: dayState.SpendCoins,
UserRegisteredAtMS: cmd.UserRegisteredAtMS, UserRegisteredAtMS: cmd.UserRegisteredAtMS,
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins}, UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User24HourRTP: rolling24HourRTP,
Version: userState.PaidDraws, 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) 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. // 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) { 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) 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 var wager, payout int64
if err := tx.QueryRowContext(ctx, ` if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0) 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 = ? WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND occurred_at_ms >= ? AND occurred_at_ms < ?`, AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appCode, poolID, userID, boundary[0], boundary[1], 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]) tiers = append(tiers, jackpotByMultiplier[multiplier])
} }
return domain.StrategyConfig{ return domain.StrategyConfig{
Tiers: tiers, Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM, PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM, ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM, AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins, ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins, LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins, HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM, LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM, HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM, RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0, RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS, RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee, User24HourRTPThresholdPPM: config.User24HourRTPThresholdPPM,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay, User24HourOrdinaryWinFactorPPM: config.User24HourOrdinaryWinFactorPPM,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins, MissProtectionZeroDraws: config.LossStreakGuarantee,
JackpotMechanism1Enabled: true, DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0, MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM, JackpotMechanism1Enabled: true,
UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM, JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
User48HourRTPMaxPPM: config.JackpotUser48hRTPMaxPPM, GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
SettledRoundRTPEligibility: true, UserRoundRTPMaxPPM: config.JackpotUserRoundRTPMaxPPM,
RechargeStages: rechargeStages, 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 poolData["fund_split"] = input.FundSplit
rtpData["user_round"] = input.UserRoundRTP rtpData["user_round"] = input.UserRoundRTP
rtpData["user_48h"] = input.User48HourRTP 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 // Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the
// current open window advances independently on this draw. // current open window advances independently on this draw.
rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP
@ -2308,56 +2323,64 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
if !hasEnabledBaseTier { if !hasEnabledBaseTier {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required") 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 一口基础池; // 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池;
// 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。 // 初始水位和安全水位使用旧三池默认值求和,保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。
config := domain.Config{ config := domain.Config{
AppCode: ruleConfig.AppCode, AppCode: ruleConfig.AppCode,
GiftID: poolID, GiftID: poolID,
PoolID: poolID, PoolID: poolID,
Enabled: ruleConfig.Enabled, Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion, RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion, StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice, GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM, TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM, PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM, ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM, AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM, ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager, SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws, GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws, GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws, NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
HighMultiplier: 10, HighMultiplier: 10,
HighWaterPoolMultiple: 2, HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375), InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100), BasePoolReserve: referencePrice * (100 + 10 + 100),
InitialPoolCoins: ruleConfig.InitialPoolCoins, InitialPoolCoins: ruleConfig.InitialPoolCoins,
LossStreakGuarantee: ruleConfig.LossStreakGuarantee, LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
LowWatermarkCoins: ruleConfig.LowWatermarkCoins, LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM, LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: ruleConfig.HighWatermarkCoins, HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM, HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS, RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM, RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...), User24HourRTPThresholdPPM: ruleConfig.User24HourRTPThresholdPPM,
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM, User24HourOrdinaryWinFactorPPM: user24HourOrdinaryWinFactorPPM,
JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM, JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM, JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
SettledRoundRTPEligibility: true, JackpotUserRoundRTPMaxPPM: ruleConfig.JackpotUserRoundRTPMaxPPM,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins, JackpotUser48hRTPMaxPPM: ruleConfig.JackpotUser48hRTPMaxPPM,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay, SettledRoundRTPEligibility: true,
MaxSinglePayout: ruleConfig.MaxSinglePayout, JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap, MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap, MaxSinglePayout: ruleConfig.MaxSinglePayout,
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap, UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap, UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap, DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
Tiers: tiers, RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
Stages: ruleConfig.Stages, AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
UpdatedByAdminID: ruleConfig.CreatedByAdminID, Tiers: tiers,
CreatedAtMS: ruleConfig.CreatedAtMS, Stages: ruleConfig.Stages,
UpdatedAtMS: ruleConfig.CreatedAtMS, UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS,
} }
if config.StrategyVersion == "" { if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2 config.StrategyVersion = domain.StrategyFixedV2

View File

@ -49,6 +49,10 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
// 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。 // 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。
return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero") 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 { if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{} config.JackpotMultiplierPPMs = []int64{}
} }
@ -95,12 +99,13 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
initial_pool_coins, loss_streak_guarantee, initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm, low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_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_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, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_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.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM, config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference, config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
@ -109,7 +114,8 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
config.InitialPoolCoins, config.LossStreakGuarantee, config.InitialPoolCoins, config.LossStreakGuarantee,
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM, config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM, 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.JackpotGlobalRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay, config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap, 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.initial_pool_coins, v.loss_streak_guarantee,
v.low_watermark_coins, v.low_water_nonzero_factor_ppm, v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
v.high_watermark_coins, v.high_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_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.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, 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, initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm, low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_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_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, jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap, 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.HighWaterNonzeroFactorPPM,
&config.RechargeBoostWindowMS, &config.RechargeBoostWindowMS,
&config.RechargeBoostFactorPPM, &config.RechargeBoostFactorPPM,
&config.User24HourRTPThresholdPPM,
&config.User24HourOrdinaryWinFactorPPM,
&jackpotMultiplierPPMsJSON, &jackpotMultiplierPPMsJSON,
&config.JackpotGlobalRTPMaxPPM, &config.JackpotGlobalRTPMaxPPM,
&config.JackpotUserRoundRTPMaxPPM, &config.JackpotUserRoundRTPMaxPPM,

View File

@ -295,44 +295,46 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
}) })
} }
return domain.RuleConfig{ return domain.RuleConfig{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
PoolID: config.GetPoolId(), PoolID: config.GetPoolId(),
RuleVersion: config.GetRuleVersion(), RuleVersion: config.GetRuleVersion(),
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
TargetRTPPPM: config.GetTargetRtpPpm(), TargetRTPPPM: config.GetTargetRtpPpm(),
PoolRatePPM: config.GetPoolRatePpm(), PoolRatePPM: config.GetPoolRatePpm(),
SettlementWindowWager: config.GetSettlementWindowWager(), SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPPM: config.GetControlBandPpm(), ControlBandPPM: config.GetControlBandPpm(),
GiftPriceReference: config.GetGiftPriceReference(), GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
StrategyVersion: config.GetStrategyVersion(), StrategyVersion: config.GetStrategyVersion(),
ProfitRatePPM: config.GetProfitRatePpm(), ProfitRatePPM: config.GetProfitRatePpm(),
AnchorRatePPM: config.GetAnchorRatePpm(), AnchorRatePPM: config.GetAnchorRatePpm(),
InitialPoolCoins: config.GetInitialPoolCoins(), InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(), LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(), LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(), LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
HighWatermarkCoins: config.GetHighWatermarkCoins(), HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(), HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(), RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(), RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...), User24HourRTPThresholdPPM: config.GetUser_24HRtpThresholdPpm(),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(), User24HourOrdinaryWinFactorPPM: config.GetUser_24HOrdinaryWinFactorPpm(),
JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(), JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(), JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(), JackpotUserRoundRTPMaxPPM: config.GetJackpotUserRoundRtpMaxPpm(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(), JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
MaxSinglePayout: config.GetMaxSinglePayout(), JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(), MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(), MaxSinglePayout: config.GetMaxSinglePayout(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(), UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(), UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(), DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
Stages: stages, RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages,
} }
} }
@ -366,44 +368,46 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
}) })
} }
return &luckygiftv1.LuckyGiftRuleConfig{ return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: config.AppCode, AppCode: config.AppCode,
PoolId: config.PoolID, PoolId: config.PoolID,
RuleVersion: config.RuleVersion, RuleVersion: config.RuleVersion,
Enabled: config.Enabled, Enabled: config.Enabled,
TargetRtpPpm: config.TargetRTPPPM, TargetRtpPpm: config.TargetRTPPPM,
PoolRatePpm: config.PoolRatePPM, PoolRatePpm: config.PoolRatePPM,
SettlementWindowWager: config.SettlementWindowWager, SettlementWindowWager: config.SettlementWindowWager,
ControlBandPpm: config.ControlBandPPM, ControlBandPpm: config.ControlBandPPM,
GiftPriceReference: config.GiftPriceReference, GiftPriceReference: config.GiftPriceReference,
NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: config.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws, NormalMaxEquivalentDraws: config.NormalMaxEquivalentDraws,
EffectiveFromMs: config.EffectiveFromMS, EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID, CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS, CreatedAtMs: config.CreatedAtMS,
StrategyVersion: config.StrategyVersion, StrategyVersion: config.StrategyVersion,
ProfitRatePpm: config.ProfitRatePPM, ProfitRatePpm: config.ProfitRatePPM,
AnchorRatePpm: config.AnchorRatePPM, AnchorRatePpm: config.AnchorRatePPM,
InitialPoolCoins: config.InitialPoolCoins, InitialPoolCoins: config.InitialPoolCoins,
LossStreakGuarantee: config.LossStreakGuarantee, LossStreakGuarantee: config.LossStreakGuarantee,
LowWatermarkCoins: config.LowWatermarkCoins, LowWatermarkCoins: config.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM, LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: config.HighWatermarkCoins, HighWatermarkCoins: config.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM, HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMs: config.RechargeBoostWindowMS, RechargeBoostWindowMs: config.RechargeBoostWindowMS,
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM, RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...), User_24HRtpThresholdPpm: config.User24HourRTPThresholdPPM,
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM, User_24HOrdinaryWinFactorPpm: config.User24HourOrdinaryWinFactorPPM,
JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM, JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM, JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins, JackpotUserRoundRtpMaxPpm: roundRTPMaxPPM,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay, JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
MaxSinglePayout: config.MaxSinglePayout, JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
UserHourlyPayoutCap: config.UserHourlyPayoutCap, MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
UserDailyPayoutCap: config.UserDailyPayoutCap, MaxSinglePayout: config.MaxSinglePayout,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap, UserHourlyPayoutCap: config.UserHourlyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap, UserDailyPayoutCap: config.UserDailyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap, DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
Stages: stages, RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages,
} }
} }