merge: independent lucky gift prize paths

This commit is contained in:
zhx 2026-07-16 11:25:08 +08:00
commit 506d49de9c
10 changed files with 670 additions and 78 deletions

View File

@ -56,7 +56,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。 因此该表的名义 `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. 一次抽奖的确定顺序 ## 3. 一次抽奖的确定顺序
@ -69,7 +69,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
5. 按 `app_code + pool_id + rule_version` 读取不可变规则锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。 5. 按 `app_code + pool_id + rule_version` 读取不可变规则锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。 6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。 7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功、奖池不足或风控阻断都会消费该轮资格,本抽随后按普通规则完成,旧资格不能等待补池或重复触发 8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed` 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。 原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。 奖池不足、风险额度不足和补偿大奖次数达到上限是不同的审计原因。只有普通候选的 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。普通奖档不读取补偿大奖次数,即使倍率相同也仍按普通 P/W 和六维风控执行。
## 6. 大奖资格:已结算轮次的下一次尝试 ## 6. 大奖资格:已结算轮次的下一次尝试
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。 大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这个等权只决定补偿路径内部如何选大奖,与普通基础概率相互独立。相同倍率可以同时出现在普通概率表和大奖集合,审计分别记录 `multiplier_*``jackpot_*` tier ID不能用倍率反推命中来源。若产品需要补偿大奖非等权必须先新增显式权重契约和成本验收不能借用普通奖档概率猜算。
### 6.1 RTP、注册年龄和安全门必须同时通过 ### 6.1 RTP、注册年龄和安全门必须同时通过
@ -141,7 +141,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
- 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过; - 该用户在同一个刚关闭轮次内的 RTP **严格 `< jackpot_user_round_rtp_max_ppm`**,默认严格小于 96%;等于 96% 不通过;
- 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长; - 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长;
- `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96% - `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` 且不超过六维风控的最小剩余额度。 - 候选 `W<=P` 且不超过六维风控的最小剩余额度。
用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过。 用户两条 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_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 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分钟按实际总额计算零样本不触发 | | 已结算轮次资格 | 注册47h、恰48h、只玩最后5分钟、用户轮次=96%、滚动48h=96%、全局=98%/略高于98%、任一用户门单独失败、零样本 | 注册年龄按账号创建时间两个用户RTP严格<96%且AND大盘允许=98%只玩5分钟按实际总额计算零样本不触发 |
| 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 | | 一次性尝试 | 资格成立但奖池不足,随后补池再次送礼 | 首次送礼不强发大奖且消费资格;补池后旧轮次资格不能再次使用 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 | | 大奖集合 | 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` | 等于允许,超过任一维拒绝,原因可审计 | | 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 | | 组合优先级 | 已结算轮次资格 + 连 0 + 低水位 + 充值 | 已结算轮次资格先尝试,再进入普通/保底;奖池与安全门永远裁决且消费本轮资格 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 | | 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |

View File

@ -99,6 +99,7 @@ func runSimulation(options simulationOptions) simulationReport {
simulatePoolBlockedQualificationConsumption(config, options.Seed+40), simulatePoolBlockedQualificationConsumption(config, options.Seed+40),
simulateJackpotSet(config, options.Seed+50), simulateJackpotSet(config, options.Seed+50),
simulateDailyJackpotLimit(config, options.Seed+60), simulateDailyJackpotLimit(config, options.Seed+60),
simulateDualSourceOverlap(config),
simulateSixRiskCapacities(config), simulateSixRiskCapacities(config),
simulateRechargeStages(config), simulateRechargeStages(config),
simulateCombinationPriority(config, options.Seed+70), simulateCombinationPriority(config, options.Seed+70),

View File

@ -7,6 +7,7 @@ import (
"sync" "sync"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql"
) )
type scriptedRandom struct { type scriptedRandom struct {
@ -28,9 +29,12 @@ func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_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: "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: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, 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 return config
} }
@ -515,10 +519,10 @@ func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult
} }
limited[decision.SelectedTier.ID]++ limited[decision.SelectedTier.ID]++
} }
_, has200 := all["200x"] _, has200 := all["jackpot_200x"]
_, has500 := all["500x"] _, has500 := all["jackpot_500x"]
_, has1000 := all["1000x"] _, has1000 := all["jackpot_1000x"]
passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40 passed := has200 && has500 && has1000 && len(limited) == 1 && limited["jackpot_200x"] == 40
return scenarioResult{ return scenarioResult{
Name: name, Passed: passed, Name: name, Passed: passed,
Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%vP=4999时只可能200x=%v", has200 && has500 && has1000, limited), Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%vP=4999时只可能200x=%v", has200 && has500 && has1000, limited),
@ -545,20 +549,321 @@ func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenari
} }
state.SettledRoundPending = true state.SettledRoundPending = true
state.QualificationClosedAtMS += 60 * 60 * 1000 state.QualificationClosedAtMS += 60 * 60 * 1000
// Force ordinary 200x after the special path is blocked, then force 0x on the // 大奖路径达到上限后仍回落普通随机;同倍率 ordinary 200x 不属于大奖,不能被 cap 删除。
// 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}, bounds: []int64{1_000_000}})
sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}})
if err != nil { if err != nil {
return failedScenario(name, err) 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{ return scenarioResult{
Name: name, Passed: passed, 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}, 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 { func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
const name = "六维risk capacity代表边界" const name = "六维risk capacity代表边界"
config = baseProbabilityConfig(config) config = baseProbabilityConfig(config)

View File

@ -81,9 +81,10 @@ func (r *seededStrategyRandom) Int63n(n int64) (int64, error) {
return r.source.Int63n(n), nil return r.source.Int63n(n), nil
} }
// StrategyTier is one configured multiplier. BaseWeightPPM is the ordinary draw // StrategyTier is one source-specific selectable tier, not a multiplier identity.
// weight; JackpotWeight only chooses among the configured large-prize set after a // The same multiplier may have an ordinary tier and a separate jackpot tier:
// jackpot mechanism has passed all RTP, pool, daily-limit and risk gates. // BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after
// all RTP, pool, daily-limit and risk gates pass.
type StrategyTier struct { type StrategyTier struct {
ID string `json:"id"` ID string `json:"id"`
MultiplierPPM int64 `json:"multiplier_ppm"` 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 { 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) 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 seen[tier.ID] = true
if tier.MultiplierPPM == 0 { if tier.MultiplierPPM == 0 {
zeroCount++ zeroCount++
@ -555,6 +561,9 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if !seenStages[stage] || weight < 0 { if !seenStages[stage] || weight < 0 {
return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID) 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 { if len(seen) == 0 || zeroCount != 1 {

View File

@ -34,9 +34,12 @@ func strategyTestConfig() StrategyConfig {
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true}, {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_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: "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: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, 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 // Core P/W tests isolate the base distribution; dynamic factors have dedicated
// boundary tests below and must not silently move scripted intervals here. // 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 := strategyTestConfig()
config.JackpotMechanism2Enabled = true config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5 config.DailyJackpotLimit = 5
state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5} state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
// Milestone is blocked first; ordinary draw then selects 200x, removes it for // 大奖 token 受每日上限阻断,但普通概率里的同倍率 200x 仍按原权重命中;
// the same hard daily limit, removes zero, and redraws an allowed 5x. // 倍率相同不代表来源相同,也不能消耗大奖次数或 token。
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}} random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random) decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("daily-limit decision=%+v", decision)
} }
if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit { if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" {
t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws) 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) { func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
config := strategyTestConfig() config := strategyTestConfig()
config.JackpotMechanism1Enabled = true config.JackpotMechanism1Enabled = true

View File

@ -221,20 +221,8 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil { if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err 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 // 运行适配器会生成两个独立 tier避免普通随机绕过补偿门槛或误占每日大奖次数。
// 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")
}
}
}
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale || if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM || config.JackpotUserRoundRTPMaxPPM <= 0 || config.JackpotUserRoundRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM { config.JackpotUser48hRTPMaxPPM <= 0 || config.JackpotUser48hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {

View File

@ -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) { func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky") config := DefaultRuleConfig("lalu", "lucky")
config.StrategyVersion = "" config.StrategyVersion = ""

View File

@ -543,6 +543,11 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
} }
func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 { 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 { for _, item := range decision.Trace.Weights {
if item.TierID == decision.SelectedTier.ID { if item.TierID == decision.SelectedTier.ID {
return item.AdjustedWeightPPM return item.AdjustedWeightPPM

View File

@ -497,3 +497,230 @@ func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
Stages: stages, 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/200xadvanced
// 重叠 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)
}
}

View File

@ -26,9 +26,23 @@ func luckyUsesDynamicStrategy(config domain.Config) bool {
return config.StrategyVersion == domain.StrategyDynamicV3 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 把不可变规则快照转换为纯策略输入。 // luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。
// 同一倍率在三个充值阶段可能使用不同 tier_id运行内核按倍率合并并保存 stage->weight // 同一倍率在三个充值阶段可能使用不同 tier_id普通路径按倍率合并并保存 stage->weight
// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。 // 大奖路径则始终生成独立 jackpot tier。倍率只决定金额ordinary/jackpot tier ID 才决定
// 命中来源,这样普通 200x 与补偿 200x 可以共存且不会共享每日大奖计数语义。
func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig { func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
stageNames := make([]string, 0, len(config.Stages)) stageNames := make([]string, 0, len(config.Stages))
rechargeStages := make([]domain.StrategyRechargeStage, 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 { for _, multiplier := range config.JackpotMultiplierPPMs {
jackpotSet[multiplier] = struct{}{} jackpotSet[multiplier] = struct{}{}
} }
tiersByMultiplier := make(map[int64]*domain.StrategyTier) ordinaryByMultiplier := make(map[int64]*domain.StrategyTier)
for _, stage := range config.Stages { for _, stage := range config.Stages {
for _, configured := range stage.Tiers { for _, configured := range stage.Tiers {
if !configured.Enabled { if !configured.Enabled {
continue continue
} }
if _, jackpot := jackpotSet[configured.MultiplierPPM]; jackpot { tier := ordinaryByMultiplier[configured.MultiplierPPM]
// 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]
if tier == nil { if tier == nil {
stageWeights := make(map[string]int64, len(stageNames)) stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames { for _, stageName := range stageNames {
stageWeights[stageName] = 0 stageWeights[stageName] = 0
} }
tier = &domain.StrategyTier{ tier = &domain.StrategyTier{
// 保留既有普通奖档审计 ID来源隔离由普通 multiplier_ 与大奖 jackpot_ 前缀完成。
ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM), ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM),
MultiplierPPM: configured.MultiplierPPM, MultiplierPPM: configured.MultiplierPPM,
StageWeightPPM: stageWeights, StageWeightPPM: stageWeights,
Enabled: true, Enabled: true,
} }
tiersByMultiplier[configured.MultiplierPPM] = tier ordinaryByMultiplier[configured.MultiplierPPM] = tier
} }
tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM
} }
} }
// 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。 // 大奖集合即使与普通倍率重合也另建 tier其普通阶段权重固定为 0只能在 RTP
// 图片没有提供大奖之间的权重,因此统一使用 1这一选择会完整进入 decision trace不能被误认为基础概率。 // 补偿条件通过后由 selectPayableJackpot 选中。当前协议没有大奖内部权重,统一记 1。
jackpotByMultiplier := make(map[int64]domain.StrategyTier, len(jackpotSet))
for multiplier := range jackpotSet { for multiplier := range jackpotSet {
tier := tiersByMultiplier[multiplier]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames)) stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames { for _, stageName := range stageNames {
stageWeights[stageName] = 0 stageWeights[stageName] = 0
} }
tier = &domain.StrategyTier{ jackpotByMultiplier[multiplier] = domain.StrategyTier{
ID: fmt.Sprintf("jackpot_%d", multiplier), ID: fmt.Sprintf("jackpot_%d", multiplier),
MultiplierPPM: multiplier, MultiplierPPM: multiplier,
StageWeightPPM: stageWeights, StageWeightPPM: stageWeights,
Jackpot: true,
JackpotWeight: 1,
Enabled: true, Enabled: true,
} }
tiersByMultiplier[multiplier] = tier
} }
tier.Jackpot = true multipliers := make([]int64, 0, len(ordinaryByMultiplier))
tier.JackpotWeight = 1 for multiplier := range ordinaryByMultiplier {
}
multipliers := make([]int64, 0, len(tiersByMultiplier))
for multiplier := range tiersByMultiplier {
multipliers = append(multipliers, multiplier) multipliers = append(multipliers, multiplier)
} }
sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] }) 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 { 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{ return domain.StrategyConfig{
Tiers: tiers, Tiers: tiers,