15 KiB
幸运礼物重构方案
本文档从 0 重新定义幸运礼物玩法和实现方案。目标是在严格控制长期返奖率的前提下,让用户在小额礼物、高频送礼和不同阶段里获得稳定、可解释、可调优的体验。
当前线上实现的问题不是单个 bug,而是玩法模型不完整:它把 RTP 控制器当成了主随机逻辑。新方案必须改成“体验概率表决定用户看到的结果,RTP 控制器只做边界约束和温和纠偏”。
目标
| 目标 | 说明 |
|---|---|
| 严格控制成本 | 基础返奖长期贴近配置 RTP,不能被补贴、房间气氛或运营表现污染 |
| 用户体验稳定 | 小额礼物不能长期只出 0x;不同阶段要有可配置的中奖率、小奖率和高倍率 |
| 配置可理解 | 后台使用百分比和倍数,服务端内部再转换成 ppm,避免 95 被当成 0.0095% |
| 抽奖可审计 | 每次抽奖保存完整候选、过滤原因、权重、随机落点和控制模式 |
| 失败可收敛 | 钱包返奖和房间 IM 通过 outbox 补偿,客户端不能提前本地加余额 |
非目标
| 非目标 | 边界 |
|---|---|
| 不承诺单用户盈利 | RTP 是奖池级成本目标,不是单用户保底 |
| 不在 room-service 做抽奖 | Room Cell 只负责房间状态、送礼表现、热度和榜单 |
| 不让客户端参与概率 | 客户端只能提交送礼命令和 pool_id,不能提交价格、概率、倍率或奖励 |
| 不用风控硬压体验 | 风控是最后防线,不应该替代概率和 RTP 设计 |
当前问题
| 问题 | 现象 | 根因 |
|---|---|---|
| RTP 单位容易错 | 后台填 95 后 100 金币一直 0x |
后端字段是 target_rtp_ppm,需要 950000 表示 95% |
| 倍率没有概率 | 配了多个倍率,但不是按这些倍率随机 | 当前服务把倍率当 RTP 离散候选点,权重运行时自动生成 |
| RTP 控制过强 | 没有缺口时优先 0x,缺口出现时只在 1 到 2 个倍率间抽 |
RTP 控制器替代了体验概率表 |
| 新手/中级/高级体验弱 | 三个阶段使用同一倍率集合 | 后台不能分别配置各阶段中奖率和倍率分布 |
| 审计不足 | 后台难以解释“为什么这次是 0x” | 当前只保存命中候选和部分限制标记,不保存完整候选表 |
| 小额礼物 rounding 明显 | 100 金币这类小额更容易被整数截断影响 | 目标返奖、carry 和倍率候选没有按小额场景单独设计 |
核心模型
幸运礼物拆成四层:
| 层 | 职责 | 是否影响基础 RTP |
|---|---|---|
| 体验概率层 | 决定用户看到的倍率分布、中奖率、小奖率、高倍率 | 是,基础返奖档计入 |
| RTP 控制层 | 监控长期返奖率,按控制模式微调候选权重或过滤高倍 | 是 |
| 奖池与风控层 | 判断当前是否有钱支付、是否超过用户/设备/房间/主播上限 | 是,硬约束 |
| 表现补贴层 | 阶段反馈、气氛特效、房间播报、活动补贴 | 补贴不计入基础 RTP |
新逻辑中,用户体验由配置表决定;RTP 控制器只在返奖率偏离目标时介入,不能每一抽都把候选压成 0x 或两个档位。
单位规则
后台展示和服务端存储必须分离:
| 后台展示 | 服务端存储 | 示例 |
|---|---|---|
| RTP 百分比 | target_rtp_ppm |
95% -> 950000 |
| 入池百分比 | pool_rate_ppm |
96% -> 960000 |
| 倍数 | multiplier_ppm |
0.3x -> 300000,1x -> 1000000 |
| 概率百分比 | weight_ppm 或权重 |
12.5% -> 125000 |
后台 API 推荐接收更安全的字段:
{
"target_rtp_percent": 95,
"pool_rate_percent": 96,
"tiers": [
{"multiplier": 0, "probability_percent": 45},
{"multiplier": 0.3, "probability_percent": 18},
{"multiplier": 0.5, "probability_percent": 14}
]
}
服务端内部保存:
{
"target_rtp_ppm": 950000,
"pool_rate_ppm": 960000,
"tiers": [
{"multiplier_ppm": 0, "weight_ppm": 450000},
{"multiplier_ppm": 300000, "weight_ppm": 180000},
{"multiplier_ppm": 500000, "weight_ppm": 140000}
]
}
服务端必须增加防错校验:
| 校验 | 规则 |
|---|---|
| RTP 下限 | target_rtp_ppm >= 100000,防止把 95 当 ppm |
| RTP 上限 | target_rtp_ppm <= 990000,超过需明确运营审批 |
| 入池比例 | pool_rate_ppm >= target_rtp_ppm |
| 倍率 | multiplier_ppm >= 0,高倍需要单独开关 |
| 概率 | 同一体验阶段的基础概率总和必须等于 1000000 |
配置结构
一个幸运礼物奖池配置由 7 类参数组成。
基础配置
| 字段 | 说明 |
|---|---|
pool_id |
奖池 ID,例如 lucky_100、super_lucky |
enabled |
是否启用 |
target_rtp_ppm |
基础返奖目标 |
pool_rate_ppm |
每笔扣费进入基础奖池的比例 |
settlement_window_wager |
RTP 结算窗口,建议按金币流水,不只按抽数 |
control_band_ppm |
正常波动带,例如 10000 表示 ±1% |
gift_price_reference |
配置参考价格,真实抽奖按 coin_spent 等比缩放 |
体验阶段
| 阶段 | 进入规则 | 体验目标 |
|---|---|---|
novice |
用户在该 pool_id 下前 N 次付费抽 |
高频小反馈,降低连续空感 |
normal |
常规用户 | 稳定小奖、中低倍为主 |
advanced |
高频或高价用户 | 高波动,允许低频高倍 |
体验阶段只影响概率表和表现,不改变奖池 owner。
奖档配置
每个阶段独立配置奖档:
{
"stage": "novice",
"tiers": [
{
"tier_id": "novice_none",
"multiplier_ppm": 0,
"base_weight_ppm": 350000,
"reward_source": "base_rtp",
"max_control_mode": "normal"
},
{
"tier_id": "novice_0_3x",
"multiplier_ppm": 300000,
"base_weight_ppm": 220000,
"reward_source": "base_rtp"
},
{
"tier_id": "novice_1x",
"multiplier_ppm": 1000000,
"base_weight_ppm": 90000,
"reward_source": "base_rtp"
}
]
}
字段语义:
| 字段 | 说明 |
|---|---|
multiplier_ppm |
返奖倍率,reward = coin_spent * multiplier_ppm / 1000000 |
base_weight_ppm |
正常模式下的体验概率 |
reward_source |
base_rtp、activity_subsidy、presentation_only |
high_water_only |
是否只在高水位开放 |
min_pool_capacity |
该档最低奖池容量 |
broadcast_level |
不播报、房间播报、区域播报 |
100 金币普通幸运礼物建议表
这是体验样例,不是固定线上参数:
| 阶段 | 倍率 | 正常权重 | 100 金币返奖 | 体验作用 |
|---|---|---|---|---|
| novice | 0x |
30% | 0 | 保留成本空间 |
| novice | 0.3x |
22% | 30 | 小反馈 |
| novice | 0.5x |
18% | 50 | 小反馈 |
| novice | 0.8x |
12% | 80 | 接近返还 |
| novice | 1x |
9% | 100 | 回本 |
| novice | 1.3x |
5% | 130 | 小惊喜 |
| novice | 1.5x |
3% | 150 | 小惊喜 |
| novice | 2x |
1% | 200 | 中反馈 |
这个表的期望返奖约为:
0*30% + 0.3*22% + 0.5*18% + 0.8*12% + 1*9% + 1.3*5% + 1.5*3% + 2*1%
= 0.382
它只表示新手阶段的基础体验期望,不等于最终奖池 RTP。最终 RTP 还需要 normal/advanced 阶段、高倍档、补贴和控制器共同校验。如果目标是 95%,常规阶段或周期纠偏必须承担更多返奖;如果不想提高返奖成本,则新手表不能承诺太高中奖体验。
RTP 控制方式
RTP 控制器只做状态判断和权重调整,不直接替代概率表。
统计口径
基础 RTP:
base_rtp = sum(base_reward_coins) / sum(coin_spent)
有效 RTP:
effective_rtp = sum(base_reward_coins + activity_subsidy_coins + room_atmosphere_reward_coins) / sum(coin_spent)
成本控制只看 base_rtp。活动补贴和房间气氛不能用来补平基础 RTP。
控制窗口
建议使用流水窗口,而不是只用抽数窗口:
| 窗口 | 作用 |
|---|---|
short_window_wager |
观察短期波动,不强行追平 |
settlement_window_wager |
基础 RTP 结算窗口,到窗口末尾必须收敛 |
gift_window_wager |
单奖池或单礼物成本健康度 |
窗口目标:
target_payout = floor((wager_coins * target_rtp_ppm + carry_ppm) / 1000000)
控制模式
| 模式 | 触发 | 处理 |
|---|---|---|
normal |
实际 RTP 在目标波动带内 | 使用基础概率表 |
boost |
实际 RTP 低于目标下沿 | 提高小奖和中低奖权重,降低 0x 权重 |
cooldown |
实际 RTP 高于目标上沿 | 降低高倍和中倍权重,提高 0x 和表现型反馈 |
high_lock |
高倍水位不足 | 过滤高倍,保留小奖 |
hard_lock |
奖池或风控不足以支付基础小奖 | 停止抽奖或只允许普通送礼,不允许先扣费后给不可解释结果 |
控制器不能做的事:
| 禁止行为 | 原因 |
|---|---|
没有缺口时强制只选 0x |
用户体验极差,尤其小额高频 |
| 缺口出现时只保留两个倍率 | 配置表失去意义,运营无法设计体验 |
| 随机后发现超预算再改结果 | 不可审计,不可信 |
| 用活动补贴补基础 RTP | 成本口径混乱 |
权重调整
基础概率表先生成候选,再按控制模式调整权重。
示例:
normal:
使用 base_weight_ppm
boost:
0x 权重 * 0.7
0.3x~1.5x 权重 * 1.2
高倍不因 boost 自动开放
cooldown:
0x 权重 * 1.3
0.3x~1x 权重 * 0.9
2x 以上权重 * 0.5
调整后必须重新归一化到 1000000。如果调整后预计会突破窗口上限,继续过滤高倍,而不是改随机结果。
用户体验约束
用户体验不能只靠金币返奖解决。需要区分金币命中和表现反馈。
| 体验项 | 规则 |
|---|---|
| 最大连续无反馈 | 可以用 presentation_only 打断,成本为 0 |
| 最大连续无金币 | 如果要配置,必须纳入基础概率和 RTP 成本模拟 |
| 阶段反馈 | 默认只做表现,不发金币;发金币必须占预算 |
| 高倍动效 | 只在真实命中高倍后播放 |
| 余额变化 | 只以 WalletBalanceChanged 或余额查询为准 |
推荐策略:
| 场景 | 处理 |
|---|---|
| 小额高频礼物 | 提高低倍小奖和表现反馈频率,降低超高倍 |
| 高价礼物 | 降低中奖频率,提高中高倍表现 |
| 新手期 | 多小奖、多表现、少高倍 |
| 高频用户 | 进入更稳定的 normal/advanced 概率,不做单用户保底 RTP |
抽奖流程
新版本抽奖按以下顺序执行:
1. room-service 校验人在房间、目标合法
2. room-service 调 wallet-service 扣费
3. wallet-service 返回 coin_spent、gift_type、price_version
4. room-service 调 activity-service ExecuteLuckyGiftDraw
5. activity-service 用 command_id 做幂等
6. 锁定 active rule、用户阶段、RTP 窗口、奖池、风控计数
7. 根据用户阶段读取基础概率表
8. 根据当前 RTP 状态选择 control_mode
9. 生成候选列表
10. 过滤奖池不足、风控不足、高水位不足的档位
11. 按 control_mode 调整权重并归一化
12. 写完整候选快照
13. 使用安全随机选择结果
14. 更新 RTP、奖池、风控、用户状态和 draw fact
15. 写 activity_outbox
16. 提交事务
17. outbox worker 调 wallet-service 返奖
18. outbox worker 投递 room lucky_gift_drawn IM 和区域播报
事务内禁止调用钱包返奖、房间 RPC 或腾讯云 IM。
数据表调整
建议新增 v2 表,不直接在旧表上打补丁。
lucky_gift_rule_versions
| 字段 | 说明 |
|---|---|
app_code |
App |
pool_id |
奖池 ID |
rule_version |
规则版本 |
enabled |
是否启用 |
target_rtp_ppm |
基础 RTP |
pool_rate_ppm |
入池比例 |
control_band_ppm |
RTP 正常波动带 |
settlement_window_wager |
结算窗口流水 |
effective_from_ms |
生效时间 |
created_at_ms |
创建时间 |
lucky_gift_stage_tiers
| 字段 | 说明 |
|---|---|
pool_id |
奖池 ID |
rule_version |
规则版本 |
stage |
novice/normal/advanced |
tier_id |
奖档 ID |
multiplier_ppm |
倍率 |
base_weight_ppm |
基础概率 |
reward_source |
预算来源 |
high_water_only |
高水位开关 |
enabled |
是否启用 |
lucky_draw_records_v2
必须保存:
| 字段 | 说明 |
|---|---|
draw_id |
抽奖 ID |
command_id |
幂等键 |
pool_id |
奖池 |
gift_id |
实际礼物 |
coin_spent |
实际扣费 |
stage |
用户阶段 |
control_mode |
RTP 控制模式 |
selected_tier_id |
命中奖档 |
multiplier_ppm |
命中倍率 |
candidate_table_json |
完整候选、权重、过滤原因 |
random_index |
随机落点 |
random_digest |
随机摘要 |
rtp_snapshot_json |
RTP 快照 |
pool_snapshot_json |
奖池快照 |
risk_snapshot_json |
风控快照 |
后台发布校验
后台保存前必须模拟和校验。
| 校验 | 失败处理 |
|---|---|
| RTP 单位合法 | 拒绝保存 |
| 每个阶段权重和为 100% | 拒绝保存 |
| 期望基础 RTP 与目标差距过大 | 警告或拒绝 |
| 高倍超过单次上限 | 拒绝保存 |
| 风控 cap 小于期望返奖 | 拒绝保存 |
| 连续无反馈超过阈值 | 警告 |
| 100k / 1m 模拟偏差超标 | 拒绝发布 |
发布必须生成模拟报告:
| 指标 | 要求 |
|---|---|
base_rtp |
贴近目标 |
effective_rtp |
可高于基础 RTP,但必须展示补贴成本 |
hit_rate |
符合产品配置 |
zero_streak_p95 |
不超过体验阈值 |
tier_hit_rate |
各倍率命中率可解释 |
pool_min_balance |
不低于 reserve |
risk_reject_count |
不应成为主要控制手段 |
实施步骤
第 1 步:止血
- 后台把
RTP=95转成target_rtp_ppm=950000。 - 后台把
0.3x转成multiplier_ppm=300000。 - 后端拒绝
target_rtp_ppm < 100000的配置。 - 后端拒绝
pool_rate_ppm < target_rtp_ppm。 - 文档和后台提示统一说明百分比、ppm、倍数转换。
第 2 步:配置 v2
- 新增按阶段配置的 tier 表。
- 后台支持
novice/normal/advanced三套概率。 - 后台展示概率百分比,不展示 ppm。
- 保存时生成
rule_version,历史版本不可变。
第 3 步:模拟器
- 模拟器读取真实配置 JSON。
- 支持 100 金币小额礼物场景。
- 输出 RTP、命中率、连续 0、用户分位数、奖池水位。
- 后台发布前必须跑模拟。
第 4 步:抽奖引擎 v2
- 实现候选生成。
- 实现 control_mode。
- 实现完整候选审计。
- 实现随机摘要。
- 保留旧引擎开关,按
pool_id灰度。
第 5 步:影子验证
- 线上送礼仍走旧逻辑。
- 同步用 v2 引擎 shadow draw,不返奖、不发 IM。
- 对比旧结果、v2 模拟成本和用户体验指标。
- 连续至少一个完整结算窗口稳定后再切流。
第 6 步:灰度上线
- 内部房间启用。
- 低价礼物启用。
- 单区域小流量启用。
- 观察 pending outbox、base RTP、连续 0、投诉率。
- 再开放高倍和高价礼物。
结论
新的幸运礼物不应该让 RTP 控制器直接决定用户每次看到什么。正确结构是:
体验概率表决定玩法
RTP 控制器决定边界和纠偏
奖池风控决定能不能支付
outbox 决定返奖和房间表现最终一致
这样才能同时做到两个目标:平台能严格控制长期返奖率,用户在 100 金币这类小额幸运礼物上也不会长期只看到 0x。