merge: independent lucky gift prize paths
This commit is contained in:
commit
506d49de9c
@ -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 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited),
|
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.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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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]
|
stageWeights := make(map[string]int64, len(stageNames))
|
||||||
if tier == nil {
|
for _, stageName := range stageNames {
|
||||||
stageWeights := make(map[string]int64, len(stageNames))
|
stageWeights[stageName] = 0
|
||||||
for _, stageName := range stageNames {
|
}
|
||||||
stageWeights[stageName] = 0
|
jackpotByMultiplier[multiplier] = domain.StrategyTier{
|
||||||
}
|
ID: fmt.Sprintf("jackpot_%d", multiplier),
|
||||||
tier = &domain.StrategyTier{
|
MultiplierPPM: multiplier,
|
||||||
ID: fmt.Sprintf("jackpot_%d", multiplier),
|
StageWeightPPM: stageWeights,
|
||||||
MultiplierPPM: multiplier,
|
Jackpot: true,
|
||||||
StageWeightPPM: stageWeights,
|
JackpotWeight: 1,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}
|
|
||||||
tiersByMultiplier[multiplier] = tier
|
|
||||||
}
|
}
|
||||||
tier.Jackpot = true
|
|
||||||
tier.JackpotWeight = 1
|
|
||||||
}
|
}
|
||||||
multipliers := make([]int64, 0, len(tiersByMultiplier))
|
multipliers := make([]int64, 0, len(ordinaryByMultiplier))
|
||||||
for multiplier := range tiersByMultiplier {
|
for multiplier := range ordinaryByMultiplier {
|
||||||
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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user