diff --git a/docs/幸运礼物动态策略V3实现与模拟.md b/docs/幸运礼物动态策略V3实现与模拟.md index e0b7b941..8435a9d8 100644 --- a/docs/幸运礼物动态策略V3实现与模拟.md +++ b/docs/幸运礼物动态策略V3实现与模拟.md @@ -56,7 +56,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent 因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。 -`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV:`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`。`200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。 +`dynamic_v3` 的安全发布草稿采用 98% 静态 EV:`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x`。普通概率表可以配置 `200x/500x/1000x`,也可以与大奖集合使用相同倍率,但这些普通高倍档必须与其他普通档一起纳入静态 EV 和控制带校验,不能因为倍率也出现在大奖集合就从成本计算中扣除。运行时为同倍率建立独立的 `multiplier_*` 普通 tier 和 `jackpot_*` 补偿 tier:前者按阶段概率随机,后者普通权重固定为 0,只能通过大奖资格路径命中。 ## 3. 一次抽奖的确定顺序 @@ -69,7 +69,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent 5. 按 `app_code + pool_id + rule_version` 读取不可变规则,锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。 6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子;0 档接收剩余概率。 7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。 -8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功、奖池不足或风控阻断都会消费该轮资格,本抽随后按普通规则完成,旧资格不能等待补池或重复触发。 +8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数。 9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`。 这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。 @@ -127,11 +127,11 @@ adjusted_weight = base_weight × water_factor × recharge_factor 原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。 -奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。 +奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 `W>P` 触发原图规定的“删中奖档并同时删 0”;其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。 ## 6. 大奖资格:已结算轮次的下一次尝试 -大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。 +大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 `multiplier_*` 与 `jackpot_*` tier ID,不能用倍率反推命中来源。若产品需要补偿大奖非等权,必须先新增显式权重契约和成本验收,不能借用普通奖档概率猜算。 ### 6.1 RTP、注册年龄和安全门必须同时通过 @@ -141,7 +141,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor - 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过; - 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长; - `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96%; -- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5; +- 用户本 UTC 日通过补偿路径命中的大奖总次数 `< max_jackpot_hits_per_user_day`,默认 5;所有 `jackpot_*` 倍率合计计数,`multiplier_*` 普通命中不计数; - 候选 `W<=P` 且不超过六维风控的最小剩余额度。 用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过。 @@ -256,7 +256,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor | `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 | | `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 | -每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。 +每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、来源轮次及关闭时间、大盘 RTP、用户同轮次 RTP、滚动 48 小时总消费/总返奖、注册年龄、一次性资格是否已尝试、最终原因和阻断原因。同倍率双来源必须保存实际 `selected_tier_id` 和该来源的实际选择权重:普通奖记录调整后的阶段权重,补偿大奖记录大奖集合内权重,不能把补偿 tier 的普通权重 0 写成命中权重。 状态边界: @@ -284,7 +284,8 @@ adjusted_weight = base_weight × water_factor × recharge_factor | 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间;两个用户RTP严格<96%且AND;大盘允许=98%;只玩5分钟按实际总额计算;零样本不触发 | | 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 | | 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 | -| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 | +| 日 5 次 | 已中 4 次、5 次;UTC 日边界 | 第 5 次补偿大奖可中,第 6 次补偿大奖被阻断,新 UTC 日重新计数;普通奖不占次数 | +| 同倍率双来源 | 100/200/500/1000x 分别配置普通与补偿路径,并在达到日上限后再抽普通 1000x | 每个倍率的普通命中都不计大奖、补偿命中都计数;补偿达到上限后同倍率普通奖仍可命中,tier ID 和选择权重可区分来源 | | 六维风控 | 每一维分别取 `W=remaining` 和 `W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 | | 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 | | 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 | diff --git a/services/lucky-gift-service/cmd/strategy-sim/main.go b/services/lucky-gift-service/cmd/strategy-sim/main.go index 85e46f19..b3f6f9fc 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/main.go +++ b/services/lucky-gift-service/cmd/strategy-sim/main.go @@ -99,6 +99,7 @@ func runSimulation(options simulationOptions) simulationReport { simulatePoolBlockedQualificationConsumption(config, options.Seed+40), simulateJackpotSet(config, options.Seed+50), simulateDailyJackpotLimit(config, options.Seed+60), + simulateDualSourceOverlap(config), simulateSixRiskCapacities(config), simulateRechargeStages(config), simulateCombinationPriority(config, options.Seed+70), diff --git a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go index 27094858..d1f81551 100644 --- a/services/lucky-gift-service/cmd/strategy-sim/scenarios.go +++ b/services/lucky-gift-service/cmd/strategy-sim/scenarios.go @@ -7,6 +7,7 @@ import ( "sync" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" + mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql" ) type scriptedRandom struct { @@ -28,9 +29,12 @@ func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig { {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true}, {ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true}, - {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, - {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, - {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, + {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true}, + {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true}, + {ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, 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}, } return config } @@ -515,10 +519,10 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult } limited[decision.SelectedTier.ID]++ } - _, has200 := all["200x"] - _, has500 := all["500x"] - _, has1000 := all["1000x"] - passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40 + _, has200 := all["jackpot_200x"] + _, has500 := all["jackpot_500x"] + _, has1000 := all["jackpot_1000x"] + passed := has200 && has500 && has1000 && len(limited) == 1 && limited["jackpot_200x"] == 40 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited), @@ -545,20 +549,321 @@ func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenari } state.SettledRoundPending = true state.QualificationClosedAtMS += 60 * 60 * 1000 - // Force ordinary 200x after the special path is blocked, then force 0x on the - // retry. This proves the daily ceiling also constrains accidental base jackpots. - sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}}) + // 大奖路径达到上限后仍回落普通随机;同倍率 ordinary 200x 不属于大奖,不能被 cap 删除。 + sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}}) if err != nil { return failedScenario(name, err) } - passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending + passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.SelectedTier.ID == "200x" && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending return scenarioResult{ Name: name, Passed: passed, - Summary: fmt.Sprintf("5个独立已结算轮次命中后计数=%d;第6个轮次受日上限阻断且该轮资格仍按一次尝试消费", state.UserDailyJackpotWins), + Summary: fmt.Sprintf("5个独立补偿大奖后计数=%d;第6个轮次补偿受限但普通200x仍可命中且不增加大奖次数", state.UserDailyJackpotWins), Data: map[string]any{"sixth": sixth}, } } +type laluLuckyV5TierFixture struct { + id string + multiplier int64 + weight int64 + high bool +} + +// laluLuckyV5RuleFixture freezes the disabled test rule lalu/lucky/v5 that exposed the +// overlap bug. Values are intentionally explicit rather than generated from a curve: +// a later test-rule edit must produce a visible fixture diff before it can change this +// regression report. The simulator never publishes or enables this snapshot. +func laluLuckyV5RuleFixture() domain.RuleConfig { + stage := func(name string, min7D, min30D int64, rows []laluLuckyV5TierFixture) domain.RuleStage { + out := domain.RuleStage{Stage: name, MinRecharge7DCoins: min7D, MinRecharge30DCoins: min30D, Tiers: make([]domain.RuleTier, 0, len(rows))} + for _, row := range rows { + out.Tiers = append(out.Tiers, domain.RuleTier{ + Stage: name, TierID: name + "_" + row.id, MultiplierPPM: row.multiplier, + BaseWeightPPM: row.weight, HighWaterOnly: row.high, Enabled: true, + }) + } + return out + } + return domain.RuleConfig{ + AppCode: "lalu", PoolID: "lucky", RuleVersion: 5, StrategyVersion: domain.StrategyDynamicV3, Enabled: false, + TargetRTPPPM: 890_000, PoolRatePPM: 890_000, ProfitRatePPM: 10_000, AnchorRatePPM: 100_000, + SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 100, + NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, EffectiveFromMS: 1_780_032_226_505, + InitialPoolCoins: 0, LossStreakGuarantee: 4, + LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, + HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000, + RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, + JackpotMultiplierPPMs: []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000}, + JackpotGlobalRTPMaxPPM: 980_000, JackpotUserRoundRTPMaxPPM: 950_000, JackpotUser48hRTPMaxPPM: 950_000, + SettledRoundRTPEligibility: true, JackpotSpendThresholdCoins: 800_000, MaxJackpotHitsPerUserDay: 10, + MaxSinglePayout: 4_000_000_000, UserHourlyPayoutCap: 4_000_000_000, UserDailyPayoutCap: 4_000_000_000, + DeviceDailyPayoutCap: 4_000_000_000, RoomHourlyPayoutCap: 4_000_000_000, AnchorDailyPayoutCap: 4_000_000_000, + Stages: []domain.RuleStage{ + stage(domain.StageNovice, 0, 0, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 508_000}, + {id: "0_5x", multiplier: 500_000, weight: 254_261}, + {id: "1x", multiplier: 1_000_000, weight: 127_131}, + {id: "2x", multiplier: 2_000_000, weight: 63_567}, + {id: "5x", multiplier: 5_000_000, weight: 25_427}, + {id: "10x", multiplier: 10_000_000, weight: 12_713, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 6_357, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 2_544, high: true}, + }), + stage(domain.StageNormal, 8, 100, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 530_785}, + {id: "0_5x", multiplier: 500_000, weight: 161_748}, + {id: "0_8x", multiplier: 800_000, weight: 101_094}, + {id: "1x", multiplier: 1_000_000, weight: 80_875}, + {id: "1_5x", multiplier: 1_500_000, weight: 53_917}, + {id: "2x", multiplier: 2_000_000, weight: 40_438, high: true}, + {id: "5x", multiplier: 5_000_000, weight: 16_175, high: true}, + {id: "10x", multiplier: 10_000_000, weight: 8_088, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 4_046, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 1_619, high: true}, + {id: "100x", multiplier: 100_000_000, weight: 810, high: true}, + {id: "200x", multiplier: 200_000_000, weight: 405, high: true}, + }), + stage(domain.StageAdvanced, 10, 200, []laluLuckyV5TierFixture{ + {id: "none", multiplier: 0, weight: 658_929}, + {id: "0_5x", multiplier: 500_000, weight: 126_588}, + {id: "1x", multiplier: 1_000_000, weight: 63_295}, + {id: "1_2x", multiplier: 1_200_000, weight: 52_746}, + {id: "1_5x", multiplier: 1_500_000, weight: 42_197}, + {id: "2x", multiplier: 2_000_000, weight: 31_648}, + {id: "5x", multiplier: 5_000_000, weight: 12_659, high: true}, + {id: "10x", multiplier: 10_000_000, weight: 6_331, high: true}, + {id: "20x", multiplier: 20_000_000, weight: 3_166, high: true}, + {id: "50x", multiplier: 50_000_000, weight: 1_266, high: true}, + {id: "100x", multiplier: 100_000_000, weight: 634, high: true}, + {id: "200x", multiplier: 200_000_000, weight: 317, high: true}, + {id: "500x", multiplier: 500_000_000, weight: 127, high: true}, + {id: "1000x", multiplier: 1_000_000_000, weight: 64, high: true}, + {id: "2000x", multiplier: 2_000_000_000, weight: 33, high: true}, + }), + }, + } +} + +func v5FixtureInput(config domain.Config, stage string, risk domain.StrategyRiskCapacity) domain.StrategyInput { + input := domain.StrategyInput{GiftPriceCoins: config.GiftPrice, PoolContributionCoins: 89, RiskCapacity: risk} + switch stage { + case domain.StageNormal: + input.Recharge7DCoins, input.Recharge30DCoins = 8, 100 + case domain.StageAdvanced: + input.Recharge7DCoins, input.Recharge30DCoins = 10, 200 + } + return input +} + +func v5FixtureRiskCapacity(limit int64) domain.StrategyRiskCapacity { + return domain.StrategyRiskCapacity{ + Enabled: true, SingleDrawCoins: limit, UserHourCoins: limit, UserDayCoins: limit, + DeviceDayCoins: limit, RoomHourCoins: limit, AnchorDayCoins: limit, + } +} + +func qualifiedV5FixtureState(pool int64) domain.StrategyState { + const closedAtMS = int64(100 * 60 * 60 * 1000) + return domain.StrategyState{ + PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, + UserRegisteredAtMS: closedAtMS - 49*60*60*1000, + GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900}, + UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + } +} + +func strategyOrdinaryDrawPoint(config domain.StrategyConfig, state domain.StrategyState, input domain.StrategyInput, tierID string) (int64, int64, int64, error) { + _, weights, err := domain.PreviewLuckyGiftStrategyWeights(config, state, input) + if err != nil { + return 0, 0, 0, err + } + var index, bound int64 + tierWeight := int64(0) + for _, item := range weights { + if item.TierID == tierID { + tierWeight = item.AdjustedWeightPPM + index = bound + } + bound += item.AdjustedWeightPPM + } + if tierWeight <= 0 || bound <= 0 { + return 0, 0, 0, fmt.Errorf("ordinary tier %s has no selectable weight", tierID) + } + return index, bound, tierWeight, nil +} + +func fixtureStageExpectedRTP(stage domain.RuleStage) int64 { + var expected int64 + for _, tier := range stage.Tiers { + if tier.Enabled { + expected += tier.MultiplierPPM * tier.BaseWeightPPM / domain.StrategyPPMScale + } + } + return expected +} + +func removalCount(trace domain.DecisionTrace, reason string) int { + count := 0 + for _, removed := range trace.Removed { + if removed.Reason == reason { + count++ + } + } + return count +} + +func simulateDualSourceOverlap(_ domain.StrategyConfig) scenarioResult { + const name = "test lalu/lucky/v5 同倍率双来源" + rule := laluLuckyV5RuleFixture() + runtimeConfig, config, err := mysqlstorage.BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + return failedScenario(name, err) + } + passed := runtimeConfig.TargetRTPPPM == 890_000 && config.DailyJackpotLimit == 10 + stageRTP := make(map[string]int64, len(rule.Stages)) + for _, stage := range rule.Stages { + stageRTP[stage.Stage] = fixtureStageExpectedRTP(stage) + passed = passed && stageRTP[stage.Stage] == rule.TargetRTPPPM + } + + const poolBefore = int64(15_000_000) // 真实低/高水位之间,基础权重不被水位因子改变。 + fullRisk := v5FixtureRiskCapacity(4_000_000_000) + type overlapCase struct { + label string + stage string + multiplier int64 + } + overlaps := []overlapCase{ + {label: "100x", stage: domain.StageNormal, multiplier: 100_000_000}, + {label: "200x", stage: domain.StageNormal, multiplier: 200_000_000}, + {label: "500x", stage: domain.StageAdvanced, multiplier: 500_000_000}, + {label: "1000x", stage: domain.StageAdvanced, multiplier: 1_000_000_000}, + } + ordinaryEvidence := make(map[string]any, len(overlaps)) + jackpotEvidence := make(map[string]any, len(overlaps)) + for jackpotIndex, overlap := range overlaps { + ordinaryID := fmt.Sprintf("multiplier_%d", overlap.multiplier) + ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + ordinaryInput := v5FixtureInput(runtimeConfig, overlap.stage, fullRisk) + ordinaryIndex, ordinaryBound, ordinaryWeight, pointErr := strategyOrdinaryDrawPoint(config, ordinaryState, ordinaryInput, ordinaryID) + if pointErr != nil { + return failedScenario(name, pointErr) + } + ordinary, drawErr := domain.DecideLuckyGiftStrategy(config, ordinaryState, ordinaryInput, &scriptedRandom{indexes: []int64{ordinaryIndex}, bounds: []int64{ordinaryBound}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + ordinaryPayout := runtimeConfig.GiftPrice * overlap.multiplier / domain.StrategyPPMScale + ordinaryOK := ordinary.SelectedTier.ID == ordinaryID && !ordinary.Jackpot && ordinary.NextState.UserDailyJackpotWins == 0 && + ordinary.PayoutCoins == ordinaryPayout && ordinary.PoolAfterCoins == poolBefore+ordinaryInput.PoolContributionCoins-ordinaryPayout && len(ordinary.Trace.Draws) == 1 + passed = passed && ordinaryOK + ordinaryEvidence[overlap.label] = map[string]any{"tier_id": ordinary.SelectedTier.ID, "stage": ordinary.Stage, "weight": ordinaryWeight, "payout": ordinary.PayoutCoins, "pool_after": ordinary.PoolAfterCoins, "jackpot_hits": ordinary.NextState.UserDailyJackpotWins} + + jackpotState := qualifiedV5FixtureState(poolBefore) + jackpotInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) + jackpot, drawErr := domain.DecideLuckyGiftStrategy(config, jackpotState, jackpotInput, &scriptedRandom{indexes: []int64{int64(jackpotIndex)}, bounds: []int64{int64(len(overlaps))}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + jackpotID := fmt.Sprintf("jackpot_%d", overlap.multiplier) + jackpotOK := jackpot.SelectedTier.ID == jackpotID && jackpot.Jackpot && jackpot.NextState.UserDailyJackpotWins == 1 && + jackpot.PayoutCoins == ordinaryPayout && jackpot.PoolAfterCoins == poolBefore+jackpotInput.PoolContributionCoins-ordinaryPayout && + len(jackpot.Trace.Draws) == 1 && jackpot.Trace.Draws[0].Kind == domain.StrategyDrawKindRTPCompensation + passed = passed && jackpotOK + jackpotEvidence[overlap.label] = map[string]any{"tier_id": jackpot.SelectedTier.ID, "weight": jackpot.SelectedTier.JackpotWeight, "payout": jackpot.PayoutCoins, "pool_after": jackpot.PoolAfterCoins, "jackpot_hits": jackpot.NextState.UserDailyJackpotWins, "draw_count": len(jackpot.Trace.Draws)} + } + + // 真实日 cap=10 时补偿路径被挡住,但同倍率普通 1000x 仍按 advanced 的 64ppm 命中; + // scriptedRandom 只提供一次随机数,若实现错误地“先发大奖再发普通奖”会立刻耗尽并失败。 + capState := qualifiedV5FixtureState(poolBefore) + capState.UserDailyJackpotWins = rule.MaxJackpotHitsPerUserDay + capInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) + capIndex, capBound, capWeight, err := strategyOrdinaryDrawPoint(config, capState, capInput, "multiplier_1000000000") + if err != nil { + return failedScenario(name, err) + } + afterCap, err := domain.DecideLuckyGiftStrategy(config, capState, capInput, &scriptedRandom{indexes: []int64{capIndex}, bounds: []int64{capBound}}) + if err != nil { + return failedScenario(name, err) + } + passed = passed && afterCap.SelectedTier.ID == "multiplier_1000000000" && !afterCap.Jackpot && len(afterCap.Trace.Draws) == 1 && + afterCap.NextState.UserDailyJackpotWins == rule.MaxJackpotHitsPerUserDay && !afterCap.NextState.SettledRoundPending + + // 奖池不足会一次性消费本轮补偿资格并回落普通 1x;普通路径仍使用本抽 89 金币入池后的 P/W。 + fallbackState := qualifiedV5FixtureState(100) + fallbackInput := v5FixtureInput(runtimeConfig, domain.StageNormal, fullRisk) + fallbackIndex, fallbackBound, _, err := strategyOrdinaryDrawPoint(config, fallbackState, fallbackInput, "multiplier_1000000") + if err != nil { + return failedScenario(name, err) + } + fallback, err := domain.DecideLuckyGiftStrategy(config, fallbackState, fallbackInput, &scriptedRandom{indexes: []int64{fallbackIndex}, bounds: []int64{fallbackBound}}) + if err != nil { + return failedScenario(name, err) + } + passed = passed && fallback.SelectedTier.ID == "multiplier_1000000" && !fallback.Jackpot && len(fallback.Trace.Draws) == 1 && + removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient) == 4 && !fallback.NextState.SettledRoundPending && fallback.PoolAfterCoins == 89 + + // 六个维度逐个压到 9,999:所有补偿候选最小为 100x=10,000,必须被同一个硬门挡住; + // 同时强抽普通 100x,证明普通奖也不能绕开该门,只是不会读取大奖日 cap。 + type riskDimension struct { + name string + set func(*domain.StrategyRiskCapacity, int64) + } + riskDimensions := []riskDimension{ + {name: "single", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, + {name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, + {name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, + {name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, + {name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, + {name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, + } + riskEvidence := make(map[string]bool, len(riskDimensions)) + for _, dimension := range riskDimensions { + risk := fullRisk + dimension.set(&risk, 9_999) + blockedSpecialState := qualifiedV5FixtureState(poolBefore) + blockedSpecialInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) + oneXIndex, oneXBound, _, pointErr := strategyOrdinaryDrawPoint(config, blockedSpecialState, blockedSpecialInput, "multiplier_1000000") + if pointErr != nil { + return failedScenario(name, pointErr) + } + blockedSpecial, drawErr := domain.DecideLuckyGiftStrategy(config, blockedSpecialState, blockedSpecialInput, &scriptedRandom{indexes: []int64{oneXIndex}, bounds: []int64{oneXBound}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + + blockedOrdinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + blockedOrdinaryInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) + highIndex, highBound, highWeight, pointErr := strategyOrdinaryDrawPoint(config, blockedOrdinaryState, blockedOrdinaryInput, "multiplier_100000000") + if pointErr != nil { + return failedScenario(name, pointErr) + } + blockedOrdinary, drawErr := domain.DecideLuckyGiftStrategy(config, blockedOrdinaryState, blockedOrdinaryInput, &scriptedRandom{indexes: []int64{highIndex, 0}, bounds: []int64{highBound, highBound - highWeight}}) + if drawErr != nil { + return failedScenario(name, drawErr) + } + dimensionOK := blockedSpecial.SelectedTier.ID == "multiplier_1000000" && !blockedSpecial.Jackpot && + removalCount(blockedSpecial.Trace, domain.StrategyReasonRiskCapacity) == 4 && + blockedOrdinary.SelectedTier.ID == "multiplier_0" && len(blockedOrdinary.Trace.Draws) == 2 && + blockedOrdinary.Trace.Draws[0].TierID == "multiplier_100000000" && blockedOrdinary.Trace.Draws[0].RemovedReason == domain.StrategyReasonRiskCapacity + riskEvidence[dimension.name] = dimensionOK + passed = passed && dimensionOK + } + + return scenarioResult{ + Name: name, Passed: passed, + Summary: "真实v5三阶段RTP=89%;100/200/500/1000x普通与补偿独立,补偿优先且单抽单发,阻断/cap后回落普通,池与六维门共用", + Data: map[string]any{ + "fixture": map[string]any{"app": rule.AppCode, "pool": rule.PoolID, "version": rule.RuleVersion, "enabled": rule.Enabled, "target_rtp_ppm": rule.TargetRTPPPM, "stage_expected_rtp_ppm": stageRTP, "daily_jackpot_cap": rule.MaxJackpotHitsPerUserDay}, + "ordinary_by_multiplier": ordinaryEvidence, "compensation_by_multiplier": jackpotEvidence, + "ordinary_after_cap": map[string]any{"tier_id": afterCap.SelectedTier.ID, "weight": capWeight, "jackpot_hits": afterCap.NextState.UserDailyJackpotWins, "draw_count": len(afterCap.Trace.Draws)}, + "pool_blocked_fallback": map[string]any{"tier_id": fallback.SelectedTier.ID, "pool_after": fallback.PoolAfterCoins, "removed_jackpots": removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient)}, + "six_risk_dimensions": riskEvidence, + }, + } +} + func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult { const name = "六维risk capacity代表边界" config = baseProbabilityConfig(config) diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index 900cfcbe..ab5104e7 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -81,9 +81,10 @@ func (r *seededStrategyRandom) Int63n(n int64) (int64, error) { return r.source.Int63n(n), nil } -// StrategyTier is one configured multiplier. BaseWeightPPM is the ordinary draw -// weight; JackpotWeight only chooses among the configured large-prize set after a -// jackpot mechanism has passed all RTP, pool, daily-limit and risk gates. +// StrategyTier is one source-specific selectable tier, not a multiplier identity. +// The same multiplier may have an ordinary tier and a separate jackpot tier: +// BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after +// all RTP, pool, daily-limit and risk gates pass. type StrategyTier struct { ID string `json:"id"` MultiplierPPM int64 `json:"multiplier_ppm"` @@ -547,6 +548,11 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy if strings.TrimSpace(tier.ID) == "" || seen[tier.ID] || tier.MultiplierPPM < 0 || tier.BaseWeightPPM < 0 || tier.JackpotWeight < 0 { return fmt.Errorf("%w: enabled tiers require unique IDs and non-negative multiplier/weights", ErrStrategyConfig) } + // 大奖身份属于受 RTP 条件控制的独立路径,不能同时携带普通随机权重; + // 同倍率普通奖应使用另一个 Jackpot=false tier,避免每日 cap 和审计来源再次耦合。 + if tier.Jackpot && tier.BaseWeightPPM > 0 { + return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary base weight", ErrStrategyConfig, tier.ID) + } seen[tier.ID] = true if tier.MultiplierPPM == 0 { zeroCount++ @@ -555,6 +561,9 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy if !seenStages[stage] || weight < 0 { return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID) } + if tier.Jackpot && weight > 0 { + return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary stage weight", ErrStrategyConfig, tier.ID) + } } } if len(seen) == 0 || zeroCount != 1 { diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go index 346772fb..39c79c0d 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy_test.go @@ -34,9 +34,12 @@ func strategyTestConfig() StrategyConfig { {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true}, {ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true}, - {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, - {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, - {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, + {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true}, + {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true}, + {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true}, + {ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, 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}, } // Core P/W tests isolate the base distribution; dynamic factors have dedicated // boundary tests below and must not silently move scripted intervals here. @@ -291,23 +294,23 @@ func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) { } } -func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) { +func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5} - // Milestone is blocked first; ordinary draw then selects 200x, removes it for - // the same hard daily limit, removes zero, and redraws an allowed 5x. - random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}} + // 大奖 token 受每日上限阻断,但普通概率里的同倍率 200x 仍按原权重命中; + // 倍率相同不代表来源相同,也不能消耗大奖次数或 token。 + random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}} decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random) if err != nil { t.Fatal(err) } - if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 { + if decision.Jackpot || decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 { t.Fatalf("daily-limit decision=%+v", decision) } - if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit { - t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws) + if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" { + t.Fatalf("ordinary overlap must not be removed by jackpot cap: %+v", decision.Trace.Draws) } } @@ -422,6 +425,24 @@ func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing } } +func TestLuckyGiftStrategyRejectsJackpotTierWithOrdinaryWeight(t *testing.T) { + for _, mutate := range []func(*StrategyTier){ + func(tier *StrategyTier) { tier.BaseWeightPPM = 1 }, + func(tier *StrategyTier) { tier.StageWeightPPM = map[string]int64{StageNovice: 1} }, + } { + config := DefaultLuckyGiftStrategyConfig() + for index := range config.Tiers { + if config.Tiers[index].Jackpot { + mutate(&config.Tiers[index]) + break + } + } + if _, _, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{}, strategyTestInput(10)); err == nil { + t.Fatal("jackpot tier carrying an ordinary weight should be rejected") + } + } +} + func TestLuckyGiftStrategyCombinationPriority(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism1Enabled = true diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 8f7f2c7d..1152ad7e 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -221,20 +221,8 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil { return err } - // Jackpot multipliers belong exclusively to the compensation path. Allowing - // the same multiplier to carry an ordinary stage weight would let a draw reach - // a jackpot without satisfying both user RTP gates. - jackpotMultipliers := make(map[int64]struct{}, len(config.JackpotMultiplierPPMs)) - for _, multiplierPPM := range config.JackpotMultiplierPPMs { - jackpotMultipliers[multiplierPPM] = struct{}{} - } - for _, stage := range stages { - for _, tier := range stage.Tiers { - if _, jackpot := jackpotMultipliers[tier.MultiplierPPM]; jackpot && tier.Enabled && tier.BaseWeightPPM > 0 { - return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers cannot also have ordinary stage probability") - } - } - } + // 倍率只是返奖金额,不再承担“大奖身份”。同一倍率可以同时存在于普通概率表和补偿大奖集合; + // 运行适配器会生成两个独立 tier,避免普通随机绕过补偿门槛或误占每日大奖次数。 if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale || config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM || config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM { diff --git a/services/lucky-gift-service/internal/service/luckygift/config_test.go b/services/lucky-gift-service/internal/service/luckygift/config_test.go index a86a3317..940e7d3a 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/config_test.go @@ -106,6 +106,27 @@ func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) { } } +func TestValidateDynamicV3AllowsOrdinaryProbabilityAtJackpotMultiplier(t *testing.T) { + config := DefaultRuleConfig("lalu", "lucky") + for stageIndex := range config.Stages { + stage := &config.Stages[stageIndex] + for tierIndex := range stage.Tiers { + if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 { + stage.Tiers[tierIndex].BaseWeightPPM -= 100 + } + } + // 200x 同时位于 jackpot_multipliers 与普通概率表是合法配置;这里用极小权重 + // 保持静态 RTP 位于控制带内,确保测试只验证“来源可重叠”这一条边界。 + stage.Tiers = append(stage.Tiers, domain.RuleTier{ + Stage: stage.Stage, TierID: stage.Stage + "_ordinary_200x", + MultiplierPPM: 200_000_000, BaseWeightPPM: 100, HighWaterOnly: true, Enabled: true, + }) + } + if err := validateRuleConfig(config); err != nil { + t.Fatalf("ordinary and jackpot sources at the same multiplier should validate independently: %v", err) + } +} + func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) { config := DefaultRuleConfig("lalu", "lucky") config.StrategyVersion = "" diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 79c934ba..8b04352c 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -543,6 +543,11 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a } func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 { + // 大奖 tier 的 stage weight 按设计恒为 0;审计必须记录大奖集合内的选择权重, + // 不能从普通概率 trace 读到 0 后误报为“零概率命中”。 + if decision.Jackpot { + return decision.SelectedTier.JackpotWeight + } for _, item := range decision.Trace.Weights { if item.TierID == decision.SelectedTier.ID { return item.AdjustedWeightPPM diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index 8d373b30..560b4fa9 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -497,3 +497,230 @@ func dynamicMySQLTestRule(poolID string) domain.RuleConfig { Stages: stages, } } + +func dualSourceV5ShapeRule(poolID string) domain.RuleConfig { + rule := dynamicMySQLTestRule(poolID) + // 运行参数与测试服 lalu/lucky/v5 对齐;阶段基础表仍沿用轻量测试表,下面只把 + // v5 的真实重叠档追加进去,避免这个 repository 单测复制整份 35 档 fixture。 + rule.TargetRTPPPM, rule.PoolRatePPM, rule.ProfitRatePPM, rule.AnchorRatePPM = 890_000, 890_000, 10_000, 100_000 + rule.GiftPriceReference = 100 + rule.JackpotMultiplierPPMs = []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000} + rule.JackpotGlobalRTPMaxPPM, rule.JackpotUserRoundRTPMaxPPM, rule.JackpotUser48hRTPMaxPPM = 980_000, 950_000, 950_000 + rule.MaxJackpotHitsPerUserDay = 10 + rule.MaxSinglePayout, rule.UserHourlyPayoutCap, rule.UserDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000 + rule.DeviceDailyPayoutCap, rule.RoomHourlyPayoutCap, rule.AnchorDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000 + // 回归测试服 lalu/lucky v5 的真实重叠形状:normal 重叠 100/200x,advanced + // 重叠 100/200/500/1000x。这里仅验证运行适配,不依赖停用规则的空奖池开奖。 + overlaps := map[string][]struct { + multiplier int64 + weight int64 + }{ + domain.StageNormal: { + {multiplier: 100_000_000, weight: 810}, + {multiplier: 200_000_000, weight: 405}, + }, + domain.StageAdvanced: { + {multiplier: 100_000_000, weight: 634}, + {multiplier: 200_000_000, weight: 317}, + {multiplier: 500_000_000, weight: 127}, + {multiplier: 1_000_000_000, weight: 64}, + }, + } + for stageIndex := range rule.Stages { + stage := &rule.Stages[stageIndex] + var overlapWeight int64 + for _, overlap := range overlaps[stage.Stage] { + overlapWeight += overlap.weight + } + for tierIndex := range stage.Tiers { + if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 { + stage.Tiers[tierIndex].BaseWeightPPM -= overlapWeight + } + } + for _, overlap := range overlaps[stage.Stage] { + stage.Tiers = append(stage.Tiers, domain.RuleTier{ + Stage: stage.Stage, TierID: fmt.Sprintf("%s_ordinary_%d", stage.Stage, overlap.multiplier), + MultiplierPPM: overlap.multiplier, BaseWeightPPM: overlap.weight, HighWaterOnly: true, Enabled: true, + }) + } + } + return rule +} + +func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing.T) { + rule := dualSourceV5ShapeRule("dual-source") + runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + t.Fatal(err) + } + if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 { + t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit) + } + ordinary := make(map[int64]*domain.StrategyTier) + jackpot := make(map[int64]*domain.StrategyTier) + for index := range strategy.Tiers { + tier := &strategy.Tiers[index] + if tier.Jackpot { + jackpot[tier.MultiplierPPM] = tier + } else if tier.MultiplierPPM >= 100_000_000 { + ordinary[tier.MultiplierPPM] = tier + } + } + wantWeights := map[int64]map[string]int64{ + 100_000_000: {domain.StageNormal: 810, domain.StageAdvanced: 634}, + 200_000_000: {domain.StageNormal: 405, domain.StageAdvanced: 317}, + 500_000_000: {domain.StageAdvanced: 127}, + 1_000_000_000: {domain.StageAdvanced: 64}, + } + for multiplier, stageWeights := range wantWeights { + ordinaryTier, jackpotTier := ordinary[multiplier], jackpot[multiplier] + if ordinaryTier == nil || ordinaryTier.Jackpot || ordinaryTier.ID != fmt.Sprintf("multiplier_%d", multiplier) { + t.Fatalf("ordinary tier multiplier=%d = %+v", multiplier, ordinaryTier) + } + if jackpotTier == nil || !jackpotTier.Jackpot || jackpotTier.ID != fmt.Sprintf("jackpot_%d", multiplier) || jackpotTier.JackpotWeight != 1 { + t.Fatalf("jackpot tier multiplier=%d = %+v", multiplier, jackpotTier) + } + for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} { + if ordinaryTier.StageWeightPPM[stageName] != stageWeights[stageName] || jackpotTier.StageWeightPPM[stageName] != 0 { + t.Fatalf("multiplier=%d stage=%s ordinary/jackpot weights=%d/%d want=%d/0", multiplier, stageName, ordinaryTier.StageWeightPPM[stageName], jackpotTier.StageWeightPPM[stageName], stageWeights[stageName]) + } + } + } +} + +func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T) { + rule := dualSourceV5ShapeRule("dual-source-accounting") + runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule) + if err != nil { + t.Fatal(err) + } + const ( + poolBefore = int64(15_000_000) + paidAtMS = int64(1_784_000_000_000) + ) + fullRisk := domain.StrategyRiskCapacity{ + Enabled: true, SingleDrawCoins: 4_000_000_000, UserHourCoins: 4_000_000_000, UserDayCoins: 4_000_000_000, + DeviceDayCoins: 4_000_000_000, RoomHourCoins: 4_000_000_000, AnchorDayCoins: 4_000_000_000, + } + input := domain.StrategyInput{ + GiftPriceCoins: runtimeConfig.GiftPrice, PoolContributionCoins: 89, + Recharge30DCoins: 1, RiskCapacity: fullRisk, + } + ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} + _, preview, err := domain.PreviewLuckyGiftStrategyWeights(strategy, ordinaryState, input) + if err != nil { + t.Fatal(err) + } + ordinaryIndex, ordinaryBound, ordinaryWeight := int64(0), int64(0), int64(0) + for _, item := range preview { + if item.TierID == "multiplier_100000000" { + ordinaryIndex, ordinaryWeight = ordinaryBound, item.AdjustedWeightPPM + } + ordinaryBound += item.AdjustedWeightPPM + } + if ordinaryWeight <= 0 { + t.Fatal("normal ordinary 100x is not selectable") + } + ordinaryRandomCalls := 0 + ordinary, err := domain.DecideLuckyGiftStrategy(strategy, ordinaryState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) { + ordinaryRandomCalls++ + if bound != ordinaryBound { + t.Fatalf("ordinary random bound=%d want=%d", bound, ordinaryBound) + } + return ordinaryIndex, nil + })) + if err != nil { + t.Fatal(err) + } + + const closedAtMS = int64(100 * 60 * 60 * 1000) + jackpotState := domain.StrategyState{ + PoolBalanceCoins: poolBefore, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, + UserRegisteredAtMS: closedAtMS - 49*60*60*1000, + GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900}, + UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, + } + jackpotRandomCalls := 0 + jackpot, err := domain.DecideLuckyGiftStrategy(strategy, jackpotState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) { + jackpotRandomCalls++ + if bound != 4 { + t.Fatalf("jackpot random bound=%d want=4", bound) + } + return 0, nil + })) + if err != nil { + t.Fatal(err) + } + if ordinaryRandomCalls != 1 || jackpotRandomCalls != 1 || ordinary.SelectedTier.ID != "multiplier_100000000" || ordinary.Jackpot || + jackpot.SelectedTier.ID != "jackpot_100000000" || !jackpot.Jackpot || ordinary.PayoutCoins != jackpot.PayoutCoins { + t.Fatalf("ordinary/jackpot decisions not source-isolated: ordinary=%+v jackpot=%+v calls=%d/%d", ordinary, jackpot, ordinaryRandomCalls, jackpotRandomCalls) + } + + // stageLuckyDynamicDecision is the production transaction accumulator. Running both + // sources through it proves source identity changes only jackpot_hits/audit weight; + // public pool, every RTP numerator and all six risk counters still receive the same payout. + assertStaged := func(label string, before domain.StrategyState, decision domain.StrategyDecision, wantJackpotHits, wantWeight int64) { + t.Helper() + riskCounters := make(map[string]luckyDynamicRiskCounter, 6) + for _, key := range []string{"single", "user_hour", "user_day", "device_day", "room_hour", "anchor_day"} { + riskCounters[key] = luckyDynamicRiskCounter{ScopeType: key} + } + state := luckyDynamicBatchState{ + BasePool: luckyPool{Balance: before.PoolBalanceCoins}, + GlobalWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"}, + GiftWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"}, + UserWindow: luckyUserRTPWindow{PoolID: runtimeConfig.PoolID, RuleVersion: runtimeConfig.RuleVersion}, + RiskCounters: riskCounters, + Strategy: before, + } + command := domain.DrawCommand{ + CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1, + UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5", + CoinSpent: 100, GiftIncomeCoins: 10, PaidAtMS: paidAtMS, + } + split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10} + if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil { + t.Fatalf("%s stage decision: %v", label, err) + } + if state.PublicIn != 89 || state.ProfitIn != 1 || state.AnchorIn != 10 || state.Payout != decision.PayoutCoins || + state.BasePool.Balance != decision.PoolAfterCoins || state.BasePool.TotalIn != 89 || state.BasePool.TotalOut != decision.PayoutCoins || + state.GlobalWindow.WagerCoins != 100 || state.GlobalWindow.ActualPayoutCoins != decision.PayoutCoins || + state.GiftWindow.WagerCoins != 100 || state.GiftWindow.ActualPayoutCoins != decision.PayoutCoins || + state.UserWindow.WagerCoins != 100 || state.UserWindow.PayoutCoins != decision.PayoutCoins || + state.DayState.WagerCoins != 100 || state.DayState.PayoutCoins != decision.PayoutCoins || state.DayState.JackpotHits != wantJackpotHits || + state.HourState.WagerCoins != 100 || state.HourState.PayoutCoins != decision.PayoutCoins { + t.Fatalf("%s shared accounting mismatch: %+v", label, state) + } + for key, counter := range state.RiskCounters { + if counter.Payout != decision.PayoutCoins { + t.Fatalf("%s risk %s payout=%d want=%d", label, key, counter.Payout, decision.PayoutCoins) + } + } + if len(state.Records) != 1 || state.Records[0].Candidate.TierID != decision.SelectedTier.ID || state.Records[0].Candidate.Weight != wantWeight || + state.Records[0].StrategyDecision == nil || state.Records[0].StrategyDecision.Jackpot != decision.Jackpot { + t.Fatalf("%s audit record mismatch: %+v", label, state.Records) + } + } + assertStaged("ordinary-v5-100x", ordinaryState, ordinary, 0, ordinaryWeight) + assertStaged("jackpot-v5-100x", jackpotState, jackpot, 1, 1) +} + +func TestLuckyDynamicSelectedWeightUsesWinningSource(t *testing.T) { + ordinary := domain.StrategyDecision{ + SelectedTier: domain.StrategyTier{ID: "multiplier_200000000", MultiplierPPM: 200_000_000}, + Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "multiplier_200000000", AdjustedWeightPPM: 1_000}}}, + } + if got := luckyDynamicSelectedWeight(ordinary); got != 1_000 { + t.Fatalf("ordinary audit weight=%d want=1000", got) + } + jackpot := domain.StrategyDecision{ + SelectedTier: domain.StrategyTier{ID: "jackpot_200000000", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 1}, + Jackpot: true, + // 普通权重预览会保留 jackpot tier 的零权重事实;大奖审计必须使用独立选择权重 1。 + Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "jackpot_200000000", AdjustedWeightPPM: 0}}}, + } + if got := luckyDynamicSelectedWeight(jackpot); got != 1 { + t.Fatalf("jackpot audit weight=%d want=1", got) + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go index 1c9a6cef..624bde35 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go @@ -26,9 +26,23 @@ func luckyUsesDynamicStrategy(config domain.Config) bool { return config.StrategyVersion == domain.StrategyDynamicV3 } +// BuildLuckyDynamicStrategyForRule exposes the same read-only RuleConfig -> runtime -> +// pure-kernel adapter used by production. Offline simulators must enter through this +// boundary instead of hand-assembling StrategyTier values, otherwise a report can pass +// while silently omitting stage merging, generated source IDs or a newly added rule field. +// The function performs no database I/O and does not enable or publish the supplied rule. +func BuildLuckyDynamicStrategyForRule(rule domain.RuleConfig) (domain.Config, domain.StrategyConfig, error) { + config, err := luckyRuntimeConfigFromRuleConfig(rule) + if err != nil { + return domain.Config{}, domain.StrategyConfig{}, err + } + return config, luckyDynamicStrategyConfig(config), nil +} + // luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。 -// 同一倍率在三个充值阶段可能使用不同 tier_id;运行内核按倍率合并并保存 stage->weight, -// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。 +// 同一倍率在三个充值阶段可能使用不同 tier_id;普通路径按倍率合并并保存 stage->weight, +// 大奖路径则始终生成独立 jackpot tier。倍率只决定金额,ordinary/jackpot tier ID 才决定 +// 命中来源,这样普通 200x 与补偿 200x 可以共存且不会共享每日大奖计数语义。 func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { stageNames := make([]string, 0, len(config.Stages)) rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages)) @@ -44,63 +58,63 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { for _, multiplier := range config.JackpotMultiplierPPMs { jackpotSet[multiplier] = struct{}{} } - tiersByMultiplier := make(map[int64]*domain.StrategyTier) + ordinaryByMultiplier := make(map[int64]*domain.StrategyTier) for _, stage := range config.Stages { for _, configured := range stage.Tiers { if !configured.Enabled { continue } - if _, jackpot := jackpotSet[configured.MultiplierPPM]; jackpot { - // A jackpot multiplier is reachable only through the settled-round and - // rolling-48h eligibility gates. Ignore historical overlapping ordinary - // weights here as a runtime backstop; new configs are rejected earlier. - continue - } - tier := tiersByMultiplier[configured.MultiplierPPM] + tier := ordinaryByMultiplier[configured.MultiplierPPM] if tier == nil { stageWeights := make(map[string]int64, len(stageNames)) for _, stageName := range stageNames { stageWeights[stageName] = 0 } tier = &domain.StrategyTier{ + // 保留既有普通奖档审计 ID;来源隔离由普通 multiplier_ 与大奖 jackpot_ 前缀完成。 ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM), MultiplierPPM: configured.MultiplierPPM, StageWeightPPM: stageWeights, Enabled: true, } - tiersByMultiplier[configured.MultiplierPPM] = tier + ordinaryByMultiplier[configured.MultiplierPPM] = tier } tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM } } - // 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。 - // 图片没有提供大奖之间的权重,因此统一使用 1;这一选择会完整进入 decision trace,不能被误认为基础概率。 + // 大奖集合即使与普通倍率重合也另建 tier;其普通阶段权重固定为 0,只能在 RTP + // 补偿条件通过后由 selectPayableJackpot 选中。当前协议没有大奖内部权重,统一记 1。 + jackpotByMultiplier := make(map[int64]domain.StrategyTier, len(jackpotSet)) for multiplier := range jackpotSet { - tier := tiersByMultiplier[multiplier] - if tier == nil { - stageWeights := make(map[string]int64, len(stageNames)) - for _, stageName := range stageNames { - stageWeights[stageName] = 0 - } - tier = &domain.StrategyTier{ - ID: fmt.Sprintf("jackpot_%d", multiplier), - MultiplierPPM: multiplier, - StageWeightPPM: stageWeights, - Enabled: true, - } - tiersByMultiplier[multiplier] = tier + stageWeights := make(map[string]int64, len(stageNames)) + for _, stageName := range stageNames { + stageWeights[stageName] = 0 + } + jackpotByMultiplier[multiplier] = domain.StrategyTier{ + ID: fmt.Sprintf("jackpot_%d", multiplier), + MultiplierPPM: multiplier, + StageWeightPPM: stageWeights, + Jackpot: true, + JackpotWeight: 1, + Enabled: true, } - tier.Jackpot = true - tier.JackpotWeight = 1 } - multipliers := make([]int64, 0, len(tiersByMultiplier)) - for multiplier := range tiersByMultiplier { + multipliers := make([]int64, 0, len(ordinaryByMultiplier)) + for multiplier := range ordinaryByMultiplier { multipliers = append(multipliers, multiplier) } sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] }) - tiers := make([]domain.StrategyTier, 0, len(multipliers)) + jackpotMultipliers := make([]int64, 0, len(jackpotByMultiplier)) + for multiplier := range jackpotByMultiplier { + jackpotMultipliers = append(jackpotMultipliers, multiplier) + } + sort.Slice(jackpotMultipliers, func(i, j int) bool { return jackpotMultipliers[i] < jackpotMultipliers[j] }) + tiers := make([]domain.StrategyTier, 0, len(multipliers)+len(jackpotMultipliers)) for _, multiplier := range multipliers { - tiers = append(tiers, *tiersByMultiplier[multiplier]) + tiers = append(tiers, *ordinaryByMultiplier[multiplier]) + } + for _, multiplier := range jackpotMultipliers { + tiers = append(tiers, jackpotByMultiplier[multiplier]) } return domain.StrategyConfig{ Tiers: tiers,