457 lines
15 KiB
Markdown
457 lines
15 KiB
Markdown
# 幸运礼物重构方案
|
||
|
||
本文档从 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 推荐接收更安全的字段:
|
||
|
||
```json
|
||
{
|
||
"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}
|
||
]
|
||
}
|
||
```
|
||
|
||
服务端内部保存:
|
||
|
||
```json
|
||
{
|
||
"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。
|
||
|
||
### 奖档配置
|
||
|
||
每个阶段独立配置奖档:
|
||
|
||
```json
|
||
{
|
||
"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 | 中反馈 |
|
||
|
||
这个表的期望返奖约为:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
base_rtp = sum(base_reward_coins) / sum(coin_spent)
|
||
```
|
||
|
||
有效 RTP:
|
||
|
||
```text
|
||
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` | 单奖池或单礼物成本健康度 |
|
||
|
||
窗口目标:
|
||
|
||
```text
|
||
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 | 成本口径混乱 |
|
||
|
||
### 权重调整
|
||
|
||
基础概率表先生成候选,再按控制模式调整权重。
|
||
|
||
示例:
|
||
|
||
```text
|
||
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 |
|
||
|
||
## 抽奖流程
|
||
|
||
新版本抽奖按以下顺序执行:
|
||
|
||
```text
|
||
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 步:止血
|
||
|
||
1. 后台把 `RTP=95` 转成 `target_rtp_ppm=950000`。
|
||
2. 后台把 `0.3x` 转成 `multiplier_ppm=300000`。
|
||
3. 后端拒绝 `target_rtp_ppm < 100000` 的配置。
|
||
4. 后端拒绝 `pool_rate_ppm < target_rtp_ppm`。
|
||
5. 文档和后台提示统一说明百分比、ppm、倍数转换。
|
||
|
||
### 第 2 步:配置 v2
|
||
|
||
1. 新增按阶段配置的 tier 表。
|
||
2. 后台支持 `novice/normal/advanced` 三套概率。
|
||
3. 后台展示概率百分比,不展示 ppm。
|
||
4. 保存时生成 `rule_version`,历史版本不可变。
|
||
|
||
### 第 3 步:模拟器
|
||
|
||
1. 模拟器读取真实配置 JSON。
|
||
2. 支持 100 金币小额礼物场景。
|
||
3. 输出 RTP、命中率、连续 0、用户分位数、奖池水位。
|
||
4. 后台发布前必须跑模拟。
|
||
|
||
### 第 4 步:抽奖引擎 v2
|
||
|
||
1. 实现候选生成。
|
||
2. 实现 control_mode。
|
||
3. 实现完整候选审计。
|
||
4. 实现随机摘要。
|
||
5. 保留旧引擎开关,按 `pool_id` 灰度。
|
||
|
||
### 第 5 步:影子验证
|
||
|
||
1. 线上送礼仍走旧逻辑。
|
||
2. 同步用 v2 引擎 shadow draw,不返奖、不发 IM。
|
||
3. 对比旧结果、v2 模拟成本和用户体验指标。
|
||
4. 连续至少一个完整结算窗口稳定后再切流。
|
||
|
||
### 第 6 步:灰度上线
|
||
|
||
1. 内部房间启用。
|
||
2. 低价礼物启用。
|
||
3. 单区域小流量启用。
|
||
4. 观察 pending outbox、base RTP、连续 0、投诉率。
|
||
5. 再开放高倍和高价礼物。
|
||
|
||
## 结论
|
||
|
||
新的幸运礼物不应该让 RTP 控制器直接决定用户每次看到什么。正确结构是:
|
||
|
||
```text
|
||
体验概率表决定玩法
|
||
RTP 控制器决定边界和纠偏
|
||
奖池风控决定能不能支付
|
||
outbox 决定返奖和房间表现最终一致
|
||
```
|
||
|
||
这样才能同时做到两个目标:平台能严格控制长期返奖率,用户在 100 金币这类小额幸运礼物上也不会长期只看到 `0x`。
|