Files
henan-backend/docs/麻将胡了设计方案.md
T
2026-09-21 19:21:35 +08:00

1426 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 麻将胡了(Mahjong)电子游戏 · 设计方案 v1.0
> 目标:把「麻将胡了」(5 轴 × 4 行、1024 路、级联消除、金色符号、乘数阶梯、免费旋转)做成**玫瑰女郎(Olympus)同一套设计模式**的兄弟游戏。
> 参考实现:`olympus-py/`(回合池生成器)+ `skins-service/game-olympus-service/`(Java 服务)+ `ruoyi-ui/src/views/game/olympus/`(后台)+ `docs/玫瑰女郎接口文档.md`(接口契约)。
> 状态:**方案待评审**,未动任何代码。末尾第 13 节列出必须由你拍板的 10 个问题。
---
## 0. TL;DR
| 项 | 结论 |
|---|---|
| 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 |
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **12 个箱子**(只按投注档分,档位见 §2.8.1)、无模式倍率、无 `need_fs` 过滤 |
| 必须重写什么 | ① 中奖判定(1024 路**前缀链**:必须从第 1 轴起 1→2→3→4→5 连续,中间断链即整笔作废,替代 8+ 聚簇)② 盘面几何(5×4=20 格替代 5×6=30 格)③ 乘数介质(阶梯倍率替代炸弹球)④ **级联的消除规则**(金色中奖格**不清空**,就地留一个百搭占位) |
| 发奖形态 | **与玫瑰女郎完全一致**:游戏内**显示现金**(「共赢得 X.XX」),但**最终赢额转成一个饰品发到背包**(`settleAtStart` 按赢额落区间查饰品 → 写 `tt_box_records`),玩家在末帧选择「放入背包 / 立即分解」。→ 所以**不做**"开户即加余额"那条近路,`prize_ornament` / `prize_range` 两张表都要 |
| 新增资产 | `mahjong-py/`(生成器)+ `game-mahjong-service`(Java 模块)+ **11 张** `game_mahjong_*` 表 + 7 个后台页(含奖品管理)+ `docs/麻将胡了接口文档.md` + Cocos 面板 |
| 工作量 | 后端(py + Java + 后台 + 文档)≈ **20~26** 人日;客户端 ≈ 15~20 人日;联调/RTP 验收 ≈ 5 人日 |
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ 赔付计量单位(=总额÷20)若在规则页没说清,玩家会按"倍总额"理解而产生对账投诉 |
---
## 1. 先把"玫瑰女郎设计模式"拆成 12 条可照抄的规则
这一节是整份方案的骨架。麻将胡了必须**逐条对齐**,任何一条偷懒都会在后期以 bug 的形式回来。
| # | 规则 | 玫瑰女郎现状(证据) | 麻将胡了落法 |
|---|---|---|---|
| 1 | **服务端权威**:客户端不能决定任何结果 | 全部结果由后端挑出的回合决定 | 一致 |
| 2 | **离线预生成回合池**:Python 造 `round`+`step` 落库,Java 只挑不算 | `olympus-py/generate_rounds.py` 写 11.7 万回合 / 106 万步 | `mahjong-py/generate_rounds.py`,同结构 |
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 10 档(见 §3.1) |
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构 |
| 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` |
| 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) |
| 7 | **身份分桶** common/anchor/robot/white,白号独立库存不进真实经济 | `OlympusAudience` | 直接复用(建议上移到共享模块) |
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致,但结算动作从"写背包"改为"加余额" |
| 9 | **客户端只续播**:`dospin` 带 `boxId` = 开新局;不带 = 下一步;`state=FINISH` 才展示结算 | `OlympusGameService.continueGame():230-254` | 一致 |
| 10 | **Redis 单局缓存 30 分钟 + `hasActive` 重连**,重连要能接回累计量 | `ruoyi:olympus:{userId}` TTLL 30min | `ruoyi:mahjong:{userId}`;接回量 = 本旋波次/本局累计 |
| 11 | **一轮 cycle**:`N = 固定池/(1−收敛比)`,抽满 `resetCycle + refillFromConfig`(模板) | `OlympusCycleRepository` + `box_template` | 一致 |
| 12 | **运维面**:维护开关 + 后台 7 页 + 注水/抽水/重置(原因必填)+ 流水 source 登记 6 处聚合 | `sys_config.olympusMaintenance`、`TtAccountRecordSource:193-279` | 同名同构(见 §6.5) |
**结论:12 条全部照抄,一条都不改。**(用户 2026-09-21 定案:麻将胡了的赢额也**要变成饰品发到背包**,所以"赢额 → 落区间 → 查饰品 → 写背包 → 末帧选择放入/分解"这条链路与玫瑰女郎一模一样,连 `settleAtStart` 的 `max(totalWin, 0.01)` 兜底都能照抄。)
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值 = 饰品价格",前端同样显示金额)。这句话不是"直付余额"的意思。
---
## 2. 游戏规格(截图是唯一事实来源)
### 2.1 盘面与 1024 路判定
- 5 轴(reel)× 4 行 = 20 格;ways = 4⁵ = **1024**。
- **判定口径(用户 2026-09-21 明确澄清,必须逐字实现)**:
> 中奖符号必须**从第 1 轴开始、按 1→2→3→4→5 的顺序连续出现**,中间不许断。
> 第 1 轴是**必需**的;第 2 轴断了,后面 3/4/5 轴有再多也不能中奖。
> 每一轴上该符号可以有好几个(最多 4 个),只要求「每轴至少 1 个」。
即**前缀链(prefix run)**,不是"任意 3 轴凑齐",也不是"从第 2 轴起算":
```python
# 对每个付费符号 X(含百搭替代):
n = 0
for c in range(5): # 第 1 轴 → 第 5 轴
cnt = count(X or WILD in 轴 c) # 该轴 X 的格数(百搭计入),0~4
if cnt >= 1: n += 1
else: break # 一旦断链,后面全部作废
if n < 3: 不成奖 # n 只能是 3 / 4 / 5 才有档位
ways = Π(第 1..n 轴各自的格数) # 只乘链内的轴,链外的轴不乘
本笔 = 赔付表[X][n] × ways
```
- **反例与正例(照这张表写单测)**:
| 各轴该符号格数(轴1~轴5) | 结果 | 原因 |
|---|---|---|
| `2, 3, 1, 0, 0` | ✅ 3 连,ways=6 | 前缀 3 轴连续 |
| `2, 3, 1, 2, 0` | ✅ **4 连**,ways=12 | 第 5 轴才断,档位取 4 |
| `2, 0, 3, 4, 4` | ❌ 不成奖 | **第 2 轴断了**(用户举的例子),后面再多也没用 |
| `0, 4, 4, 4, 4` | ❌ 不成奖 | 第 1 轴没有 → 第 1 轴必需 |
| `1, 1, 0, 4, 4` | ❌ 不成奖 | 3 连必须是 1-2-3,不能跳到 1-2-4 |
| `1, 2, 3, 4, 4` | ✅ 5 连,ways=96 | 满链 |
| `1, 1, 1, 1, 1` | ✅ 最低 ways=1 | 每轴 1 个,共 1 路 |
- 单笔赢额(倍基础下注)= `赔付表[X][n] × ways × 本波乘数`。
- 多个符号各自独立成奖、**累加**(互不影响,各有各的前缀链);同一符号只按上述规则算一笔,不存在"同符号多个子路径重复叠加"。
- 参考例子(截图):符号 `?` 三连赔 10,ways=6(各轴 1/3/2)→ 本笔 60。
**赔付表**(数值抄自赔付表截图,含义见下方"计量单位"):
| 短码 | code | 中文名 | 3 连 | 4 连 | 5 连 |
|---|---|---|---|---|---|
| `A` | FAT_CHOI | 發 | 15 | 60 | 100 |
| `B` | RED_DRAGON | 中 | 10 | 40 | 80 |
| `C` | WHITE_DRAGON | 白板(紫框) | 8 | 20 | 60 |
| `D` | CHAR_8 | 八萬 | 6 | 15 | 40 |
| `E` | DOT_MULTI | 花筒(四色) | 4 | 10 | 20 |
| `F` | BAMBOO_MULTI | 索(红绿) | 4 | 10 | 20 |
| `G` | DOT_PURPLE | 筒(紫绿) | 2 | 5 | 10 |
| `H` | BAMBOO_GREEN | 索(绿) | 2 | 5 | 10 |
| `W` | WILD | 百搭(元宝) | — | — | — | **不由卷轴落下**,只能由金色符号转化产生(**每个金色格各留 1 个,无上限**),且只可能出现在第 2/3/4 轴 |
| `S` | SCATTER | 胡(夺宝符号) | — | — | — | 只触发免费旋转,不赔现金 |
> ✅ **赔付计量单位 = 投注总额 ÷ 20(已用排除法锁死,Q0)**
>
> **两个已知事实**(均来自用户确认):
> ① 赔付表里的 `15 / 60 / 100` 是**最基础的 1 倍价**,不带任何阶梯倍率(用户 2026-09-21 原话:"这个是最基础的 1 倍的价格");
> ② 规则页写着「游戏共有 1,024 个中奖路(固定)**并规定了 20 条基础投注**」——这句就是计量口径。
>
> **排除法**(用真实运行截图:投注总额 ¥8.00、横幅「共赢得 **0.80**」、阶梯条高亮 **x2**):
>
> | 假设 | 推论 | 结论 |
> |---|---|---|
> | 这 ¥0.80 **已含** x2 倍率 | 基准价 = 0.80 ÷ 2 = ¥0.40 = **1 个赔付单位** | ❌ 赔付表里**没有值为 1 的档**(最小是 2),不可能 |
> | 这 ¥0.80 **未含**倍率(x1 基准价) | 若 1 单位 = ¥0.40 → 最低档 3 连(赔付值 2)× ways 1 = **¥0.80** ✅ | ✅ 成立,且 `8.00 ÷ 20 = 0.40` **正好** |
>
> → **赔付单位 = 投注总额 ÷ 20 是唯一自洽解**;同时确认「阶梯条高亮 x2」表示的是**下一波(第 2 波)即将生效的倍率**,不是本波已生效的。
>
> 所以本游戏的完整赔付公式是:
> ```
> 本笔赢额 = 赔付表[X][连轴数] × ways × (投注总额 ÷ 20) × 本波阶梯倍率
> = 赔付表[X][n] × ways × (box.bet × betMul ÷ 20) × rotationMul
> ```
> `PAY_UNIT_DIVISOR = 20` 写进 `config.py`,**不要散落在各处**。
>
> **真机复核(30 秒,做一次就彻底放心)**:投注总额 **¥8.00** 时,若 K=20,则**所有中奖金额都必须是 ¥0.40 的整数倍**(0.80 / 1.20 / 1.60 / 2.00 …)。只要出现过 **¥0.20 / ¥0.60 / ¥1.00** 这类**不是 0.40 整数倍**的金额,就说明 K=40,回来改一个常量即可。另外:¥8.00 时最低档符号 3 连 1 路应赔 **¥0.80**。
>
> 为什么必须搞清:如果按"× 投注总额"实现,基础旋 RTP 会到 **300%~400%**(粗算:约 40% 的旋有 3+ 前缀链,均 1.7 路、均赔付 5.2 → 期望 3.5 倍/旋),游戏根本发不出去;除以 20 后基础旋自然 RTP ≈ **17%**,剩下交给免费旋转与阶梯——这正是这类游戏该有的结构。
> 规则页展示的赔付表数值保持原样(15/60/100…),**但必须在规则页同时写明"每条基础投注"**,否则玩家会按"倍总额"理解,产生对账投诉。
> 出现权重 `SYMBOL_WEIGHTS` 初值建议 `{A:5,B:6,C:8,D:9,E:11,F:11,G:13,H:13,S:2}`,最终由 §5 的 RTP 校准反推,不在方案里定死。
> ⚠️ **权重表里没有 `W`** —— 百搭不由卷轴落下(见 §2.2),`_fill` / 新旋 / 补位都必须断言不产出 `W`。
### 2.2 百搭与夺宝符号(前缀链;百搭只在第 2/3/4 轴)
- 🎯 **百搭的两条铁律(用户 2026-09-21 确认)**:
1. **原生盘面不会有百搭**——卷轴**永远不会落下**元宝,补位也不会补出元宝;**百搭的唯一来源是"金色普通符号被消除后的转化"**。
2. **几个金色格被消除,就留几个百搭**(**无数量上限**)——所以一屏**可以同时有多个百搭**(示例见 §15 第 1 波:两张金色「中」→ 留下 **2 个**百搭)。
- 🎯 **从这两条推出的关键结论(决定了整套判定的正确性)**:
> 金色符号**只出现在第 2/3/4 轴**(§2.3),而百搭的唯一来源是金色转化
> ⇒ **百搭只可能出现在第 2/3/4 轴,第 1 轴永远没有百搭**
> ⇒ 前缀链的第 1 轴**必然是自然符号**
> ⇒ **任何一笔中奖都至少含 1 个自然符号**,**"纯百搭链"在数学上不可能出现**。
这就是 Q1 永久关闭的原因(不需要 `WILD_PAYS` 开关、也不需要"每笔至少一个自然符号"的额外特判)——**由"金色只在 2/3/4 轴"自动保证**。
- **百搭(元宝)**:**唯一不能替代的是「胡/FS」**(用户二次确认:"FS 不能作为百搭符号,其他的符号都可以作为百搭符号")。除此之外**付费符号、金色符号统统能替代**(金色本质就是普通符号,见 §2.3),在链里**等同于该符号计数**。
- **盘面上只有一种百搭,就是元宝(短码 `W`)**,而且**只有金色转化这一个来源**(见 §2.3)。
- **符号权重表里没有 `W`**:生成器的卷轴权重只包含 8 个付费符号 + 胡,`_fill` / 新旋 / 补位**不允许产出 `W`**。
- 百搭能**补链中间的断点**:`發, 百搭, 發, _, _` → 3 连成立。
- 百搭**不能**把断链处的胡"算成"付费符号,也**不会**因为某个胡的存在而变成胡——即 `發, 胡, 發` **不成链**(第 2 轴该符号计数为 0,链在第 2 轴断掉)。
- **不需要** `WILD_REELS` 配置(原生不下百搭)。
- ✅ **落地规则(引擎必须实现)**:
| 场景 | 处理 |
|---|---|
| 本波有 N 个金色格中奖 | **N 个格子各自就地留下一个百搭**(N 可以是 0、1、2…,**无上限**) |
| **卷轴补位 / 新旋开局** | **永远不产生百搭**(原生不下百搭) |
| 盘面上的百搭参与中奖 | 随那一波被消除(和普通符号同权) |
| 盘面上的百搭**没有**参与任何成链 | **不消除**,留在盘面上继续等下一波(见下方第 ⑤ 行) |
> 由此不再有"名额/上限"这类规则,`WILD_SLOT_POLICY` 开关**取消**——每个金色格各转各的,行为单一确定。
- ✅ **百搭的完整生命周期(实现照这张表写,最容易错的是第 ⑤ 行)**:
| 阶段 | 规则 |
|---|---|
| ① 出现 | **只能**由"金色普通符号参与中奖被消除"时就地转化产生(原生盘面永不出现百搭) |
| ② 数量 | **每个金色格各留 1 个百搭,无上限**(同一个盘面可以有 2 个、3 个…) |
| ③ 位置 | **只可能在第 2/3/4 轴**(因为金色只在那三轴)——第 1 轴永远没有百搭 |
| ④ 作用 | **可以作为任何付费符号**(除「胡」以外),在**前缀链判定里等同于该符号计数**——能补链、能搭桥;**同一个百搭可以被多个符号同时"借用"**(每条链各算各的) |
| ⑤ **消除条件** | **必须满足消除链**:只有它**参与了某条成立的中奖链**,才会随那一波被爆炸消除;**不满足链就不消除**,继续留在盘面上等下一波(用户原话:"是可以和其他符号是可以消除的(要满足消除链)") |
| ⑥ 能和谁一起消 | 百搭**和普通符号完全同权**:它参与的那条前缀链里可以是普通符号,**也可以是金色符号**;金色符号本来就是普通符号镀金,所以**不需要任何特殊逻辑** |
| ⑦ **不跨旋** | 下一旋**整盘重发**,百搭与金色标记都不带走;**每个旋开局时盘面上 0 个百搭** |
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。
> 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。
- **胡(夺宝/SCATTER)**:不参与 1024 路(**既不计入链,也不被百搭替代**);出现在任意轴任意位置;**自身不赔现金**(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 `scatterTrigger.scatterWin` 恒为 0。
- 3 个及以上胡 → 触发免费旋转:**12 旋 + 每多 1 个额外 +2 旋**(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。
### 2.3 金色麻将符号(本游戏最独特、也最容易做错的一条)
截图原文:
1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的某些符号(不包括百搭符号和夺宝符号)将可能显示为金色。」
2. 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」
**用户补充的落地口径(本条的权威解释,2026-09-21 多次确认)**:
> 百搭符号 **就是"金色的普通符号"**——它是普通符号(比如「中」)镀了金色,**判定与赔付都按它的自然符号算**。
> 这个符号**被消除之后,该位置会留下一个真正的百搭符号**(就是元宝 `W`)——这是**百搭在整个游戏里唯一的来源**(原生盘面不会有元宝);
> 之后它就按百搭的规则参与后续波的成奖与消除。**每个金色格各留一个百搭、无上限**(见 §2.2)。
所以转化时机不是"被消除后、下一波再补成百搭",而是**本波消除的那一刻就地变成百搭**。引擎按下面这条时序实现:
```
第 n 波结算(判定与赔付都用金色符号的"自然符号"身份,例如当「中」用):
1. 算出本波所有中奖格(含金色格,金色格按它的自然符号计入 ways)
→ ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。
"消除"不等于"清空":消除之后该格才分成两类——
2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号
3. 金色的中奖格 → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W**
(这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空)
4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题")
5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画
第 n+1 波:
W 按百搭规则替代任意付费符号(可搭桥、可补链,但**不能替代胡**),若它参与成奖则随该波被消除
```
> 一句话记:**中奖格全部"被消除"(全爆),区别只在消除之后变成什么** —— 非金色格变成"空洞→补新牌",金色格变成"百搭"。这是和玫瑰女郎 `_collapse` 唯一的语义差别。
> **统计口径也要注意**:说"本波消除了 4 个符号"包含金色格;说"本波有 3 个空洞要补牌"不含金色格。两个数不一样,别混。
**必须定死的三个实现细节**:
- 🎯 **百搭的两条铁律**(用户 2026-09-21 补充,见 §2.2)。这两条**直接改写了第 3 步**:
- **原生盘面永远没有百搭**:卷轴不落元宝、补位也不补元宝,**百搭的唯一来源就是金色转化**;
- **每个金色格各留一个百搭**:同波有几个金色格中奖,就留下几个百搭(无上限)。卷轴永不落百搭。
- 副作用(好副作用):**"纯百搭链"在数学上不可能出现**,原本要拍板的 Q1 自动消失,不需要 `WILD_PAYS` 开关。
- ✅ **重力下落(Q2 已定案:不钉住)**。用户 2026-09-21 确认:"**符号是可以下落的,下面符号消除之后,上面符号会往下掉落**"——百搭也是盘面符号,**一视同仁跟着落**,不锚定。
列内重力按标准写法:**中奖格清空 → 该列幸存符号(含刚转化的百搭)保持相对顺序整体压到底 → 顶部补入新符号**。
- 百搭**下方**的格被清空 → 百搭跟着掉下去;
- 百搭**上方**的格被清空 → 百搭留在原地不动,上方的牌落到它头上。
- 如果把它做成"钉在空中",**它下方被清空的格就落不进任何符号**(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以**不能钉**。
- 客户端要注意:**百搭会移动位置**,下落动画必须用帧里的 `dropData`(`from → to`)驱动,**不能假设它原地不动**;"金色→百搭"的变形动画(`wildConverted`)播完之后,紧接着就可能是它的下落动画。
- ⚠ **"涉及中奖"的判定**:金色格被计入某笔中奖的 `ways`(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。**金色格只做自己那一种符号的替代**——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。
> 注意:由于最多只有 1 个百搭,且金色格本身是自然符号,**"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生**,不会成群出现。
**其余约定**:
- 每旋开局(基础旋与每个 FS 旋)对**第 2/3/4 轴**上的普通符号按概率 `GOLDEN_CELL_PROB`(建议 0.10,逐格独立)打金色标记,不动 `W`/`S`;新补入的符号也可以带金色标记。
- 转化后的 `W` **不再回填金色标记**(每格只转化一次)。
- 金色符号在**波次判定时用的是自然符号**,所以"金色「中」链"就是「中」的链;转化后下一波起它才变成通用百搭。
- 金色格也受"百搭同样会被消除"约束(§2.2 边界二):本波转成 W 之后,若在后面的波里参与成奖,就随那一波被消除。
### 2.4 乘数阶梯(奖金翻倍)
- **赔付表是"1 倍基准价"**(用户 2026-09-21 确认:"这个是最基础的 1 倍的价格")——表里的 15/60/100 不含任何阶梯倍率,阶梯是**在它之上再乘**的。
- 本旋内**每一波(rotation,即一次消除结算)用一个乘数**;该波所有 1024 路赔付 × 该乘数。
- **基础旋转(用户原话)**:一次消除 x1、两次消除 x2、三次消除 x3、**四次消除 x5**(第 4 波及以后都是 x5)→ `LADDER_BASE = (1, 2, 3, 5)`。
- **免费旋转**:`LADDER_FS = (2, 4, 6, 10)`(截图里 FS 模式下卷轴上方的 x2 x4 x6 x10)——**FS 的第 1 波就是 x2**。
- 第 n 波取 `ladder[min(n, 4)]`。由于"第 n 波存在"本身就等价于"第 n−1 波有中奖",无需额外判断递进条件。
- **每旋重新从第一档开始**(基础旋从 x1、FS 旋从 x2),不跨旋累计。
**完整算式串起来(一张表看懂)**:
| 环节 | 值 |
|---|---|
| 赔付表基准值 | `赔付表[X][连轴数]`(如 發 3 连 = 15) |
| × 路数 | `ways`(各轴个数的乘积) |
| × 每单位金额 | `投注总额 ÷ 20`(Q0) |
| × 本波阶梯 | 基础旋 `1/2/3/5`;FS 旋 `2/4/6/10` |
| = 本笔赢额 | `赔付值 × ways × (bet×betMul÷20) × rotationMul` |
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`2 × 1 × (bet÷20) × 1 = bet ÷ 10` —— 这条直接约束了下注档下限(见 §2.8.2)。
- 截图里那条「1024 路中奖组合」横条下方的 `x1 x2 x3 x5`(当前档位金色高亮)就是这条阶梯的 HUD。
> 实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(`accumulator_before/after`,FS 内跨旋传递)机制**原封不动复用**——麻将胡了虽然乘数不跨旋累计,但"本步乘数 / 本波序号"需要每帧下发,字段名可直接沿用 `accumulator`(语义改为"本波乘数")以免客户端大改。**建议改名为 `rotationMul`**,避免误解。
### 2.5 级联(tumble)
每一波:结算 → 中奖符号(含已转化的百搭)爆炸消除 → 该轴上方符号整体下落 → 该轴顶部补入新符号 → 进入下一波。直到本波无任何成奖为止。下一旋开始时盘面是完全新的一屏。
### 2.6 免费旋转
- 触发:3+ 胡(任意位置)。
- 赠送:12 + 2×(胡数−3);重触发同式。
- 免费旋转内乘数阶梯为 `[x2, x4, x6, x10]`。
- 可重复触发;**总旋数上限 = 30 旋**(Q3 定案)。到顶后不再落 3+ 胡(生成端保证:一旦已发旋数到 30,后续 FS 旋不再摇出胡)。
- 免费旋转内投注大小/倍数与触发时一致。
### 2.7 封顶
- 单局总倍率封顶 **500x**(Q4 定案)。到达封顶即截断:剩余旋数作废、`maxWinReached=true`、`spinsRemaining=0`,保证"动画总赢额 = 实际到账"。
- 生成端在造回合时就按封顶截断并写 `win_capped` 标记(对齐 olympus `step.win_capped`)。
### 2.8 投注模型与下注档设计(推荐方案 A)
原版:投注大小 0.02~50.00 × 投注倍数 1~10,另有现金钱包与自动旋转。
**方案 A(推荐)**:`箱子 = 投注档`,请求可带 `betMul(1~10)`:
- `price(实际扣费) = box.bet × betMul × costMultiplier`(本游戏 costMultiplier ≡ 1)
- **回合池里存的全是倍率**(`actual_mul`),与 `bet`/`betMul` 无关 → 调档/调倍数**不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
- 奖池水位按 `(boxId, audience)` 聚合,`betMul` 只影响单局金额,不影响池结构。
**方案 B(不推荐)**:给每个 `bet × betMul` 组合建一个箱子 → 100 个箱子、池子碎片化、水位口径混乱。
#### 2.8.1 设计多少个下注额、哪几个、能多大?
**先分清两件事,因为成本差一个数量级:**
| 加什么 | 池子要不要动 | 成本 |
|---|---|---|
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 贵(生成时间 + 40 条配比 + 一套水位) |
| **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **几乎为零** |
→ 所以"想把数额做大"有两条路:**改档位表**(贵)或**抬高倍数上限**(免费)。
**推荐:12 档,覆盖到 ¥1000**(在玫瑰女郎 10 档基础上加 500 / 1000 两档),betMul 保持 1~10:
| boxId | box.bet | × betMul(1~10) 实际扣费 |
|---|---|---|
| 1 | 1 | 1 ~ 10 |
| 2 | 3 | 3 ~ 30 |
| 3 | 5 | 5 ~ 50 |
| 4 | 10 | 10 ~ 100 |
| 5 | 20 | 20 ~ 200 |
| 6 | 30 | 30 ~ 300 |
| 7 | 50 | 50 ~ 500 |
| 8 | 100 | 100 ~ 1000 |
| 9 | 200 | 200 ~ 2000 |
| 10 | 300 | 300 ~ 3000 |
| **11** | **500** | **500 ~ 5000** |
| **12** | **1000** | **1000 ~ 10000** |
- 成本增量很小:12 箱 → 5.4 万回合 / ~96 万帧 / 生成 20~30 分钟 / **480 条**配比行(比 10 档只多 20%)。
- **单注上限 ¥10,000 仍在平台既有范围内**:玫瑰女郎 `buy_fs` 最高一注就是 `300 × 50 = ¥15,000`,所以这不是新量级的敞口。
- 备选(想零成本扩大):**保持 10 档,把 betMul 放到 1~20** → 实际覆盖 1~6000,池子一行不动。
**⚠️ 做大之前必须先定的两件事**
1. **单局最大赔付口径(风控)** —— **已定案:封顶 500x**:
| 组合 | 单局最大赔付 |
|---|---|
| 玫瑰女郎现状(1000x × bet 300) | ¥300,000 |
| **麻将胡了(500x × bet 10000)** | **¥5,000,000** |
| ~~麻将胡了(1000x × bet 10000)~~ | ~~¥10,000,000~~(已否) |
运行期还有奖池水位 `payoutCap` 兜底(水不够就出不了大奖),但 500x 这条线是玩家预期与风控口径,**代码里写死为 `MAX_WIN_CAP_MUL = 500`**。
2. **冷门高档位会水位饥饿**:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放 10 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
**为什么不是更多档**
1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束):
- 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
- 多 40 条**配比行**(10 桶 × 4 身份)要配、要重算、要维护模板;
- 多一套**奖池水位**(4 个身份各一条)要运营盯。
10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
2. **档位越多,冷门档越容易"饿死"**:奖池水位是**按箱独立**的,没人玩的档水位长期为 0 → `payoutCap = max(0, 水位+成本) × rtp` 被压到接近 0 → **那个档几乎出不了奖**,只能靠运营注水救。所以档位不是越多越好。
3. **玩家侧足够**:1~1000 的 12 档 + 1~10 倍 = 实际覆盖 1~10000,比原版 0.02~50 宽两个数量级,选注习惯也够密。
4. **对齐玫瑰女郎**:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。
**不推荐的做法**
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场,玫瑰女郎最低档是 ¥1;而且 §2.8.2 会证明 **0.02 档下最小中奖会丢分**(显示成 +0.00)。
- ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
- ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。
**两个附带决策**
- **档位表里 3 / 30 保留**(它们是整数,只是不是 10 的整数倍;玫瑰女郎就这么定的,玩家点"+"往上加时体验更顺),不改成 2 / 25 之类。
- **投注倍数默认 1**,只作为"快速加倍/加注"的细调;若实测玩家几乎不用,可后续在需求里砍掉(**表结构与接口预留,砍掉不用改池子**)。
> ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。
#### 2.8.2 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。
**约束一:来自赔付表 —— 最低档下注的最低中奖不能小于 ¥0.01**
赔付公式里最小的那一笔是:最低档符号(`G`/`H`,3 连赔付值 **2**)× ways **1** × 第 1 波阶梯 **x1**:
```
最小中奖 = 2 × (投注总额 ÷ 20) = 投注总额 ÷ 10
```
| 投注总额 | 最小中奖 | 结论 |
|---|---|---|
| ¥0.02(原版最低档) | ¥0.002 | ❌ 金额字段是 `DECIMAL(10,2)`,**显示成 +0.00**,玩家会认为"中了不给钱" |
| ¥0.10 | ¥0.01 | ⚠️ 理论下限,刚好不丢分 |
| **¥1(建议最低档)** | **¥0.10** | ✅ 留 10 倍余量 |
→ **`box.bet ≥ 0.10` 是硬下限**;这是"不能照抄原版 0.02"的**可证明理由**(不是我拍脑袋说社娱场面额)。
**附带的一个好性质**:档位全部取整数时,`unit = bet ÷ 20` 必是 **0.05 的整数倍**(1→0.05、3→0.15、5→0.25、20→1.00、300→15.00),所以**任何"赔付值 × ways"都是整分**,不会出现 0.0333 这种除不尽的数。`betMul` 取整也一样(3×3=9 → 0.45)。建议档位表**只放整数**,把这条性质保住。
**约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"**
"必得饰品"意味着**空转局也会发一个最低档饰品**(`win = max(totalWin, 0.01)` 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估:
```
每局保底返奖 ≈ 空转率 × 最低档饰品价格
低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额
```
| 最低档饰品价 | 空转率 | 投注 ¥1 时的保底 RTP |
|---|---|---|
| ¥0.01 | 40% | 0.4% ✅ |
| ¥0.05 | 40% | 2% ✅ |
| ¥0.50 | 40% | **20%** ⚠️ 光保底就吃掉 1/4 的目标 RTP |
→ **最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级**;饰品区间表(`prize_range`)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。
→ 对账口径也要注意:水位里的"派彩"应当用 **`record.settled_win`(饰品价)**,而不是 `total_win`(游戏内赢额)——否则这两者对不上时,水位会漂。
**结论**:下注档表要同时满足
1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起)
2. 档位取整数(保证 unit 是整分)
3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束)
模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。
| boxType | 名称 | costMultiplier | 语义 |
|---|---|---|---|
| `normal` | 普通旋转 | **1x** | 唯一的玩法:自然触发免费旋转(3+ 胡) |
> 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套):
> - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet × betMul`;
> - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶;
> - 箱数从 30 降到 **10**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
> - 高倍桶(100x 以上)**只能靠自然触发 FS 的回合供给**,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动**构造**高倍局,不能靠碰运气(见 §5.4 风险)。
---
## 3. RTP 与桶设计(数学核心)
### 3.1 倍率桶(10 档)
替代玫瑰女郎的"饰品桶"。约定:区间连续无缝、`expect_mul = (low+high)/2`、顶桶受封顶影响"匹配区间 ≠ 发放上限"。
| id | 名称 | range_low | range_high | expect_mul | 说明 |
|---|---|---|---|---|---|
| 1 | 空 | 0 | 0 | 0.00 | 完全未中 |
| 2 | 微 | 0.01 | 0.50 | 0.25 | |
| 3 | 小 | 0.50 | 2.00 | 1.25 | |
| 4 | 中 | 2.00 | 5.00 | 3.50 | |
| 5 | 中大 | 5.00 | 15.00 | 10.00 | |
| 6 | 大 | 15.00 | 40.00 | 27.50 | 自然触发的 FS 多落这里 |
| 7 | 很大 | 40.00 | 100.00 | 70.00 | |
| 8 | 巨 | 100.00 | 250.00 | 175.00 | |
| 9 | 超级 | 250.00 | 400.00 | 325.00 | |
| 10 | 封顶 | 400.00 | 700.00 | **500.00** | 区间上界故意开到 1.4× 封顶(对齐玫瑰女郎"匹配区间 ≠ 发放上限"),**实际发放截断 500** |
> 因为封顶是 **500x**(Q4),顶桶的"名义期望 500"在池内实测均值会明显低于 500(被截断的局都堆在这里),配比计算时要按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是这么用 835 顶 1000 的)。
### 3.2 RTP 恒等式
```
Σ (share_i × expect_mul_i) = rtp × costMultiplier # 本游戏 costMultiplier ≡ 1
```
| 模式 | costMultiplier | 目标 RTP | 需要的期望倍率 |
|---|---|---|---|
| normal(唯一) | 1 | 0.80 | **0.80** |
因为只有 1x 模式,**所有箱、所有身份的目标期望倍率都是同一个数**(只由 rtp 决定),收敛阶段 `targetMul = rtp × cm = rtp`——比玫瑰女郎三模式简单得多。
### 3.3 配比从哪来(`calcRounds` 算法)
两份实现必须一致:`mahjong-py/_recalc_boxes.py::calc_rounds` 与 `MahjongDistributionService::calcRounds`。
1. 基础权重 `30 × 0.6^k`(k = 桶序号,低倍桶权重高);
2. 二分搜索 `power` 使加权均值命中 `targetRtp × cm`(误差 <1%);
3. 按权重把固定池分配到各桶,并施加单调约束 `count[i] ≥ count[i+1]`(低倍桶条目多于高倍桶);
4. 400 次"任意两桶搬 1 个"微调,逼到 `|delta| < 0.01`;
5. **期望值取该箱池内实测 `AVG(actual_mul)`**(样本 ≥1),无样本才回退名义 `expect_mul`/封顶修正值(玫瑰女郎顶桶用 835 而非 1000 就是这个原因)。
### 3.4 一轮(cycle)与收敛比例
```
fixedSum = Σ count # 固定池
N = round(fixedSum / (1 - cr)) # 一轮总抽数(写进 box.total_rounds)
fixedRounds= round(N × (1 - cr))
收敛阶段: desiredNextMul = max(0, (N × targetMul − 已派彩) / max(1, N − drawn))
```
- `cr`(`rtp_converge_ratio`)默认 0.15,线上玫瑰女郎用 0.20,夹在 `[0, 0.9]`。
- 固定阶段消耗 `used`,收敛阶段**不消耗库存**——这是"RTP 自愈"的来源。
- 抽满 `drawn ≥ N` → `resetCycle` + 按模板 `refillFromConfig`。
### 3.5 奖池水位(与玫瑰女郎同口径)
```
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust
payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null
```
- bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。
- 收敛/固定两阶段都用 `payoutCap` 过滤"期望赔付超上限"的桶,捞不到就退最小桶。
- 白号(`is_blank_account=1`)`payoutCap=null`:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。
### 3.6 身份与目标 RTP(**后台可配**,不是拍板项)
各身份的 RTP **不在代码里写死**,和玫瑰女郎一样由后台配置,两层:
1. **箱子级**:`game_mahjong_box.rtp`(目标返奖率)+ `rtp_converge_ratio`(收敛比例)—— 在后台「箱子配置」页改;
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。
所以"各身份 RTP 定多少"**不是一个需要在方案阶段拍板的数**,而是运营参数:先全部按 **0.80** 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。
| audience | 判定 | 建库默认 RTP | 说明 |
|---|---|---|---|
| common | 其余全部 | 0.80 | |
| anchor | `user_type='01'` | 0.80 | 四身份互斥并集 = 全部 |
| robot | `user_type='03'` | 0.80 | |
| white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 |
### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
- 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 6~8 号桶。**生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
- **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**:
- 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋);
- 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`);
- 少了这条,客户端那段"最后一列放大"的动画**永远不会被触发**(纯自然概率太低),爽点白做。
---
## 4. 回合池数据模型(11 张表)
前缀 `game_mahjong_`,DDL 权威文件 `mahjong-py/schema.sql`(照抄 `olympus-py/schema.sql` 骨架)。
| # | 表 | 对应 olympus 表 | 关键列 | 差异 |
|---|---|---|---|---|
| 1 | `game_mahjong_box` | `game_olympus_box` | box_id, box_type, bet, price, total_rounds, rtp, rtp_converge_ratio, status | `total_ornaments`→`total_rounds` |
| 2 | `game_mahjong_bucket` | `game_olympus_ornament` | id(1-10), expect_mul, name, range_low, range_high | 10 档倍率桶 |
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)` |
| 4 | `game_mahjong_round` | `game_olympus_round` | round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss | 去 `total_cascades` 语义改为 `total_rotations`(波数);`need_fs` 不需要了(没有买 FS 模式);加 `near_miss` 标记 2 胡悬念盘 |
| 5 | `game_mahjong_step` | `game_olympus_step` | 见 §4.1 | 字段大改(ways/golden/阶梯) |
| 6 | `game_mahjong_record` | `game_olympus_record` | user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, **prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type** | **保留全部 prize_* 列**(发饰品要走区间匹配),只多一个 `bet_mul` |
| 7 | `game_mahjong_user` | `game_olympus_user` | user_id, total_spins, total_cost, total_win, max_win_mul | 去 `total_free_spins` 或保留 |
| 8 | `game_mahjong_box_template` | `game_olympus_box_template` | box_id, audience, name, total_rounds, items_json, is_active | 同构 |
| 9 | `game_mahjong_reward_log` | `game_olympus_reward_log` | box_id, audience, type, amount, balance_before/after, pool_balance, operator, remark | 同构(原因必填由前端保证) |
| 10 | **`game_mahjong_prize_ornament`** | `game_olympus_prize_ornament` | id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback | **必须有**:赢额 → 饰品 |
| 11 | **`game_mahjong_prize_range`** | `game_olympus_prize_range` | prize_id, win_low, win_high | **必须有**:赢额落区间 |
**可选**:`game_mahjong_win`(日报表)。若后台"每日统计"要独立日表则加回,否则按 `record` 聚合(Q8)。
### 4.1 `game_mahjong_step` 字段(每帧一行)
```jsonc
{
"id": 1, "round_id": 1001,
"reel_spin_id": 0, // 0=基础旋/触发盘, >0=第N次免费旋转
"rotation_seq": 1, // 本旋内第几波(= 乘数阶梯序号)
"grid_state": [["A","W","S","D","H"], ...], // 4 行 × 5 列 = 20 格短码
"golden_detail": [{"pos":7,"symbol":"B"}], // 本帧盘面上仍是"金色普通符号"的格(1 基格位)
"wild_converted": [{"pos":7,"fromSymbol":"B"}],// 本波"金色→百搭"就地转化的格(客户端播变形动画)
"ways_detail": [{"symbol":"A","reelCount":3,"counts":[1,2,1,0,0],
"loc":[1,6,11,7], // 本笔中奖的全部格位(1起),含金色格 —— 客户端按它播爆裂动画
"ways":2,"payBase":15,"rotationMul":2,"win":3.00}],
// win = 15 × 2 × (bet÷K) × 2,K 见 §2.1 计量单位
"drop_data": [{"from":12,"to":17,"symbol":"H"}],
"fill_data": [{"pos":2,"symbol":"G"}],
"scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0}, // 或 null
"retrigger_info": null,
"rotation_mul": 2, // 本波乘数(阶梯档)
"win_amount": 120.00, // 本旋累计到本步(末帧 = 本旋总额),不是增量
"spin_win": 340.00, // 本局累计到本步
"win_capped": 0,
"bigwin_level": "small", // 本旋末帧才有
"is_final": 1
}
```
**只落库不下发**:`pay_base`、`counts`(留档/对账用)。接口下发字段见 §7。
---
## 5. Python 生成器(`mahjong-py/`)
### 5.1 目录结构(对拷 `olympus-py`)
```
mahjong-py/
├── schema.sql # 11 张表 DDL + 10 档桶种子(改前缀与种子)
├── mahjong_db.py # 建表 + 批量写(executemany,死锁重试 5 次)
├── mahjong/
│ ├── config.py # ★ 唯一事实来源:符号/赔付表/桶/模式/身份/RTP/FS 参数
│ ├── models.py # frozen dataclass(Bomb → RotationMul)
│ └── engine.py # 结算状态机骨架
├── mahjong_simulator.py # ★ 核心:单回合模拟 + bias 反解 + FS 保底配额
├── generate_rounds.py # 批量落库(箱编号/池子分配/id 段切分/并行)
├── simulate_cycle.py # 整轮 RTP 仿真验收(几乎原样拷贝)
├── _recalc_boxes.py # 按目标 RTP 重算配比(calc_rounds 一行不改)
├── _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
├── _regen.py # check/snapshot/clear/restore/verify
├── _reset_boxes.py
├── README.md # 结构与 runbook(照抄并改写游戏规则段)
└── tests/
├── test_engine.py # ways 判定 / 阶梯 / 金色转化 / 封顶
└── test_simulator.py # 级联几何复刻 / FS 触发 / 池内 RTP
```
### 5.2 必须重写的四处引擎逻辑
1. **`_winners` → 1024 路前缀链判定**(`_MIN_WIN_COUNT=8` 语义作废)
```python
def ways_win(grid): # grid: 4行 × 5列
for sym in PAYING:
run, ways = 0, 1
for c in range(5): # 第1轴 → 第5轴,断了就停
cnt = count_sym_or_wild(grid, c, sym)
if cnt == 0: break
run += 1; ways *= cnt
if run < 3: continue # 1-2-4 / 2-3-4 / 第1轴为空 一律不成奖
yield Win(sym, reelCount=run, ways=ways, pay=PAYTABLE[sym][run] * ways)
```
**必须写成前缀链(`break` 就停),不是"5 轴里有 3 轴有就算 3 连"** —— 后者会把用户举的 `1,0,3,4,4` 判成中奖,方向性错误,且会系统性抬高 RTP。
注意:`连轴数 = run`(3/4/5),档位从第 1 轴起算;`ways` **只乘链内的轴**,链外轴的个数不参与。
**不需要**再写"纯百搭链不成奖"的特判 —— **百搭只可能出现在第 2/3/4 轴**(金色只在那三轴),第 1 轴必然是自然符号,所以纯百搭链不可能出现(§2.2)。生成端与校验端加一条断言:**`W` 只出现在第 2/3/4 轴**。
2. **几何**:`GRID_ROWS=4 / GRID_COLS=5 / GRID_SIZE=20`;`_force_win` 的 `random.sample(free, cnt)`、`_force_small_win` 的可用格数、`_place_scatter` 的保护集都要跟着改。
3. **乘数阶梯替换炸弹**:
- 删掉 `BOMB_CELL_PROBABILITY` / `BOMB_VALUES` / `BOMB_QUALITIES` / `FS_SCREEN_BOMB_PROB` 一整组;
- 新增 `LADDER_BASE = (1,2,3,5)` / `LADDER_FS = (2,4,6,10)`,第 n 波乘数 = `ladder[min(n,4)]`;
- `_force_win` 的目标反解改为"基础组合 × 本波阶梯乘数",即给定目标倍率 T 时,需要的基础分 = `T / ladder[n]`;
- **累计器机制不删**:`accumulator_before/after` 改成 `rotation_mul`,语义从"跨旋累加"变为"本波档位"。
4. **级联的"消除"不再是"全部清空"**(金色符号就地转百搭,替换 olympus 的纯消除):
```python
def settle_rotation(grid, golden): # golden: {pos: natural_symbol}
wins = prefix_chain_wins(grid) # 判定时金色格按 natural_symbol 计数
if not wins: return None
converted = []
for pos in win_cells(wins):
if pos in golden:
grid[pos] = WILD # ★ 就地改写,不清空、不放新符号
converted.append({"pos": pos, "fromSymbol": golden.pop(pos)})
for pos in win_cells(wins) - {c["pos"] for c in converted}:
grid[pos] = None # 其余中奖格照常清空
drop_and_fill(grid) # W 作为幸存符号一起下落(§2.3 Q2)
return converted
```
- 这一处**必须写对**:olympus 是"中奖格全清 + 补新符号",本游戏是"金色中奖格变成 W 留在盘上",会直接改变级联的密度与 RTP,也是本游戏最独特的爽点来源。
- `_collapse` 仍可复用(列式下落方向不变),只是入参从"清空后的盘面"变成"金色格已改写为 W 的盘面"。
- 落库/下发都要带 `wild_converted`(§4.1 / §7.1)。
其余可原样拷贝:`_collapse`(5 轴列式下落,方向不变)、`_fill`(去掉炸弹参数)、`fill_distribution`(阶段1 自由生成 + 阶段2 按缺口补桶 + `_calibrate_bias` 饥饿重校准)、FS 保底配额机制(`FS_SPIN_WIN_PROB` / `FS_CASCADE_CONTINUE_PROB` / `FS_GUARANTEE_COMBOS` 换成本游戏的"保底最少中奖形状")。
### 5.3 生成流水线(顺序敏感,照抄)
```bash
cd mahjong-py
python mahjong_db.py # 建 11 张表 + 10 档桶种子(幂等)
python generate_rounds.py --per-bucket 20 # 干跑,不写库
python generate_rounds.py --all --alloc config --write --workers 4 # 12 箱写库(只有 normal)
python _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
python _recalc_boxes.py --mode normal # 重算配比 + 回写 total_rounds
python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026 # RTP 验收
python _regen.py check|snapshot|clear|restore|verify # 体检/快照/清池
python -m pytest tests -q # 回归
```
**顺序不可颠倒**:重建池子后必须 `_recalc_boxes` → 清 Redis `ruoyi:mahjong:cycle:*` → `_make_templates`,否则 Java 端拿的是过期配比与旧账本(这是玫瑰女郎踩过的坑)。
### 5.4 参数与工作量预估
| 参数 | 值 | 依据 |
|---|---|---|
| `ALLOC_K` | 1.5 | 池子 = 配比 × 1.5,留抽样多样性 |
| `ALLOC_FLOOR` | 60 | 每桶保底条数 |
| `BOX_STEP_BAND` | 200000 | 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估) |
| 每箱池子 | 4000~5000 条回合 | 因为没有"买 FS"那批高倍回合兜底,**高倍桶要靠 normal 池自己凑**,所以单箱池子要比玫瑰女郎更大 |
| 总规模 | **12 箱** × ~4500 = **5.4 万回合**,每回合 8~40 帧 → **~96 万 step**(与 olympus 的 106 万同一量级) |
| 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) |
| 风险点 | **高倍桶(8/9/10 号桶)没有买 FS 兜底**:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
> 若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 `copyFromSameBucket` 复制(olympus 已有此兜底,`:179-231`)。
---
## 6. Java 服务(`skins-service/game-mahjong-service`)
### 6.1 模块接线(3 处,易漏)
1. `skins-service/game-mahjong-service/pom.xml`(拷 olympus,改 artifactId,parent = `skins-service`);
2. `skins-service/pom.xml` 的 `<modules>` 加一行;
3. `ruoyi-admin/pom.xml` 加依赖。
> 无需 `@ComponentScan`/`@MapperScan`:包路径 `com.ruoyi.*` + `mapperLocations: classpath*:mapper/**/*Mapper.xml` 自动生效(`ruoyi-admin/src/main/resources/application.yml:70-72`)。
### 6.2 包与类清单(对照 olympus 35 个类)
```
com.ruoyi.game.mahjong
├── controller/MahjongClientController # /api/mahjong
├── controller/MahjongAdminController # /admin/mahjong
├── service/MahjongGameService # ★ dospin 主链路(开局事务/结算/续播/重连)
├── service/MahjongRoundPicker # ★ 两阶段挑桶
├── service/MahjongDistributionService # 配比计算/补池/RTP 仿真
├── repository/MahjongCacheRepository # ruoyi:mahjong:{userId} TTL 30min
├── repository/MahjongCycleRepository # ruoyi:mahjong:cycle:{boxId}:{audience}
├── repository/MahjongRewardPoolCacheRepository # ruoyi:mahjong:reward:pool
├── model/PickedRound
├── model/cache/MahjongGameCache(内嵌 CachedStep)/ MahjongRewardPoolCache
├── domain/(11 张表实体)
├── mapper/(9 个接口)+ resources/mapper/mahjong/*.xml
├── enums/MahjongModeType
└── contract/request/{SpinRequest,RecordPageRequest} + response/{SpinResponse,BoxResponse,RecordItemResponse}
```
`MahjongAudience` **建议直接复用 `OlympusAudience` 并上移到共享模块**(`skins-model`),避免第 4 份身份判定逻辑漂移(`TtRechargeRecordController` 里已有一份按 audience 聚合的 SQL)。
### 6.3 开局事务(与玫瑰女郎的唯一实质差异)
```
startNewGame(userId, boxId, betMul = 1..10):
校验箱子/维护/充值(703)/余额(702)
audience = resolve(player); isBlank = audience.isWhite()
bet = box.bet × betMul // 没有模式倍率(只有 normal)
cost = bet // 实际扣费 = 下注总额
payoutCap = isBlank ? null : scale2(max(0, 水位(box,aud) + cost) × box.rtp)
picked = roundPicker.pick(boxId, audience, bet, payoutCap)
totalMul = picked.round.actualMul ; totalWin = totalMul × bet
── 事务 ────────────────────────────────────────────────
updateUserAccount(userId, -cost, MAHJONG_SPIN(68))
insert game_mahjong_record(settled=0, settle_source=null, bet_mul, audience)
if (!picked.converge) boxBucketMapper.incrUsed(boxId, bucketId, audience)
settleAtStart(): // 幂等守卫 + 发饰品(与玫瑰女郎逐行同构)
win = scale2(max(totalWin, 0.01)) // ★ 空转局也要给最低档饰品,兜底 0.01 照抄
prize = findPrizeWithFallback(win) // 精确区间 → is_fallback=1 → 最低区间 → 最高区间
guard = UPDATE record SET settled=1, total_win=?, settled_win=win, reward_type='ornament',
prize_id/name/image_url/level_img=?, settle_source=(单步?finish:start)
WHERE id=? AND settled=0
if guard == 0 → 已结算,清缓存返回
insert tt_box_records(source = MAHJONG_BOX(15), status=0, is_show=1, box_name='MAHJONG') // 写背包
→ 回填 cache.boxRecordId
upsert game_mahjong_user 累计
── 提交后(全部 try/catch 吞异常,绝不外抛 500)────────
cycleRepository.recordDraw(...)
if drawn >= cycleN → resetCycle + refillFromConfig
firstIsFinal ? clearCache : setCache(30min)
// 单步局开局即播完 → 此刻发站内信;多步局等末帧(continueGame)
```
**硬约束(照抄玫瑰女郎)**:
- **先扣费、后结算、同一事务**;提交后的 Redis/账本/补池失败必须吞掉,否则玩家看到 500 会重开一局、再扣一次钱。
- **开局就写背包**:麻将胡了允许空转(没中奖),但**空转局照样发最低档饰品**(`win = max(totalWin, 0.01)` + `findPrizeWithFallback` 的最低区间兜底)——这就是玫瑰女郎的"必得饰品",也是"无损模型"(中途退出/断线饰品已在背包)成立的前提。
- **奖励流水 `MAHJONG_REWARD(69)` 不在开局写**:和玫瑰女郎一样,只在玩家**分解饰品**时才由通用背包接口写入(`tt_box_records.source = MAHJONG_BOX(15) → MAHJONG_REWARD(69)`)。
- `settle_source` 语义不变:开局 `start`(动画还在播,战绩列表不显示)→ 末帧 `finish`。
### 6.4 续播 / 重连 / 战绩可见性(照抄)
- `dospin` 带 `boxId` = 开新局;不带 = 下一步;越界 = "游戏已结束"并清缓存。
- `COMMON_LOCK + userId`(复用平台通用用户锁,与其它游戏互斥),失败 → "请求频繁,请稍后再试"。
- 末帧:`settle_source=finish` + 返回 FINISH(`totalWin`/`totalMul`/`maxWinReached`/`prizeInfo`/`boxRecordId`)+ 清缓存;**站内信在末帧才发**(`sendWinNotice`,防剧透)。
- 末帧客户端给玩家两个选择「**放入背包 / 立即分解**」,分解走通用接口 `POST /api/userPackSack/decompose {packSackIds:[boxRecordId], isAll:false, decomposeType:"game"}`;"自动分解"是前端自己的开关,后端不参与、不回传。
- `hasActive`:返回 `stepIndex/totalSteps/spinsRemaining/rotationMul/lastStep`,客户端用 `lastStep.rotationMul` 接回当前波次(否则重连后阶梯会从第一档重算)。
- 维护开关:**只拦开新局**(带 boxId 才拦),进行中的局永远放行播完。
- **战绩列表(records)**:`settled=1 AND (settle_source != 'start' OR create_time < cutoff)`,`cutoff = now − 30min`。**cutoff 必须在 Java 侧算,不能用 SQL `NOW()`** —— DB 会话时区是 UTC,而 `create_time` 存的是本地时间,用 `NOW()` 会差 8 小时直接失效(玫瑰女郎踩过的坑)。
- `scope=0` 全服口径排除机器人(`user_type='03'`)、封禁(`status='1'`)、已删除(`del_flag!='0'`),**白号保留上榜**。
- 响应体用 `@JsonInclude(NON_NULL)`:未出现的 key 就是 null,客户端无需判空。
### 6.5 平台共享文件的改动(9 处,漏一处就少一块统计)
| # | 文件 | 改动 |
|---|---|---|
| 1 | `skins-model/.../TtAccountRecordSource.java` | 新增 `MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费")` / `MAHJONG_REWARD(69, INPUT, "麻将胡了奖励")`;并加进 `getWelfarePrizeCodes():193`、`getGameConsumeCodes():203`、`getGameBetCodes():212`、`getGameRewardCodes():224`、`isNewGameConsumeForCommission():250`(新游戏返佣口径)、`getTurnoverLotteryCodes():271` —— **共 6 处聚合** |
| 2 | **`skins-model/.../TtboxRecordSource.java`** | **新增 `MAHJONG_BOX(15, "麻将")`**(现有 13=玫瑰女郎、14=流水抽奖)—— 背包来源,漏了就写不进背包 |
| 3 | **`skins-service/service-user/.../ApiUserPackSackServiceImpl.java:612-645`** | `mapDecomposeSource` 加 `MAHJONG_BOX(15) → MAHJONG_REWARD(69)`;**漏了就会走 `DECOMPOSE_ORNAMENT(10)` 默认口径,报表全错** |
| 4 | `skins-service/service-admin/.../TtRechargeRecordController.java:155-163,253-281` | 报表 sources 列表 + `appendPoint` 单列该游戏(可选) |
| 5 | `skins-service/service-admin/.../config/RedisConstants.java` | 加 3 个键:`ruoyi:mahjong:` / `ruoyi:mahjong:cycle:` / `ruoyi:mahjong:reward:pool` |
| 6 | `skins-model/.../MessageCategoryEnum.java` | 加 `GAME_MAHJONG("GAME_MAHJONG","麻将胡了")`(前缀 `GAME_` 才进"游戏"Tab)—— **本游戏要发结算站内信,必加** |
| 7 | `sys_config` | `mahjongMaintenance` 维护开关(0 正常 1 维护) |
| 8 | `sys_menu` | 7 个后台菜单(见 §8) |
| 9 | `docs/updata_sql.md` | 新增一节「麻将胡了模块建表 / 服务接入」,格式照 1144-1225 行;**返奖饰品种子**照 1187-1200 行从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
| 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 |
> **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。
### 6.6 封顶与"匹配/发放分离"
- 回合池里桶 10 的 `actual_mul` 一律 **= 1000**(发放值),但配比计算时期望用 **835**(实测池内均值,可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
- 运行期 `payoutCap` 过滤 + 收敛阶段 `capMul = payoutCap / bet` 双保险。
- FINISH 时 `totalMul ≥ 1000` → `maxWinReached=true`、`spinsRemaining=0`。
---
## 7. 接口协议(草案,最终产物是 `docs/麻将胡了接口文档.md`)
Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| # | 接口 | 用途 |
|---|---|---|
| 1 | `POST /boxes` | 箱子列表(含 `bet`、`price`;**没有 `modeName`**,只有一个普通旋转模式) |
| 2 | `POST /dospin` | 开局(`{boxId, betMul}`)/ 续播(空 body `{}`) |
| 3 | `POST /records` | `scope=1` 我的 / `scope=0` 全服,分页,只含已结束局 |
| 4 | `POST /hasActive` | 断线重连恢复 |
| 5 | `POST /api/userPackSack/decompose` | **复用玫瑰女郎那个通用背包接口**,末帧「立即分解」(含前端自动分解) |
> `dospin` 开局请求:`{"boxId": 1001, "betMul": 1}`;`betMul` 缺省按 1 处理,范围 1~10,服务端夹取。
### 7.1 帧字段(`SpinResponse`)
| 字段 | 出现 | 说明 |
|---|---|---|
| `state` | 每帧 | `PLAYING` / `FINISH` |
| `reelSpinNum` | 每帧 | `0`=基础旋,`>0`=第 N 次免费旋转 |
| `rotationNum` | 每帧 | 本旋内第几波(级联序号) |
| `globalStep` / `globalTotalSteps` | 每帧 | 整局第几步 / 总步数 |
| `gridState` | 每帧 | **4 行 × 5 列**短码矩阵,20 格全满(权威渲染数据) |
| `goldenDetail` | 每帧 | `[{"pos":1起,"symbol":"B"}]` 本帧仍为"金色普通符号"的格(它按 `symbol` 的自然符号成奖) |
| `wildConverted` | 每帧 | `[{"pos":1起,"fromSymbol":"B"}]` 本波**就地转成百搭**的格:该格 `gridState` 已是 `W`、不清空不补新符,客户端播"金色→百搭"变形动画 |
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × (bet÷K) × rotationMul`(**K 见 §2.1 计量单位**) |
| `dropData` / `fillData` | 每帧 | 与玫瑰女郎同构(`from/to/symbol`、`pos/symbol`) |
| `rotationMul` | 每帧 | 本波乘数(阶梯档位),重连时用它接回档位 |
| `winAmount` | 每帧 | **本旋累计到本帧**(末帧 = 本旋总额),不是增量 |
| `spinWin` | 每帧 | 本局累计到本帧;末帧 = 整局实付 |
| `scatterTrigger` | 有则 | `{"scatterCount":3,"freeSpins":12,"extraSpins":0}` |
| `retriggerInfo` | 有则 | 免费旋转内重触发 |
| `spinsRemaining` | 每帧 | 剩余免费旋转次数(服务端算好,前端直接倒计) |
| `bigWinLevel` | FS 旋末帧 | `small` / `middle` / `big` |
| `maxWinReached` | FINISH | 是否触顶 |
| `totalWin` / `totalMul` | FINISH | 总赢额(**显示用现金数字**)/ 总倍率 |
| `prizeInfo` | FINISH | `{id,name,imageUrl,grade,exteriorName,isFallback}` —— **本局命中的饰品**(赢额落区间匹配所得) |
| `boxRecordId` | FINISH | 背包记录 ID:末帧「放入背包 / 立即分解」都靠它(分解时作为 `packSackIds` 传入) |
| `recordId` | FINISH | 本局记录 ID(战绩用) |
**位置约定**:`pos = 行序号(0 起) × 5 + 列序号(0 起) + 1`,1~20。
**短码**:`A~H` 付费符号 / `W` 百搭 / `S` 胡。
> 前端只需保证:**首次用 `{boxId, betMul}` 开局,之后不断调 `dospin({})` 逐步揭晓,直到 `state === "FINISH"`**,然后在末帧用 `boxRecordId` 让玩家选「放入背包 / 立即分解」。
### 7.2 错误码(照抄)
702 余额不足 / 703 未充值 / 500 维护中、请求频繁、箱子不可用、该箱子暂无可用回合、游戏已结束、boxId 不能为空。**本接口不返回 701。**
---
## 8. 后台管理(7 页)
| 菜单 | 路径 | component | perms |
|---|---|---|---|
| 麻将胡了(目录) | `mahjong` | — | |
| 箱子配置 | `box` | `game/mahjong/box/index` | `game:mahjong:box` |
| 倍率桶 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
| 回合管理 | `round` | `game/mahjong/round/index` | `game:mahjong:round` |
| 奖池水位 | `rewardPool` | `game/mahjong/rewardPool/index` | `game:mahjong:pool` |
| 玩家记录 | `record` | `game/mahjong/record/index` | `game:mahjong:record` |
| 每日统计 | `dailywin` | `game/mahjong/dailywin/index` | `game:mahjong:win` |
| **奖品管理** | `prize` | `game/mahjong/prize/index` | `game:mahjong:prize` | **本游戏要有**(赢额→饰品、返奖区间编辑、品质图) |
菜单 SQL 照抄 `docs/updata_sql.md:1165-1186` 的写法(`SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单)。**注意两个仓库现状**:
- `sql/` 目录是空的,全量 DDL 历史只在 `docs/updata_sql.md`(追加式)和生成器的 `schema.sql` 里,别去 `sql/` 找;
- 后台页面**没有任何 `v-hasPermi` 指令**,路由也**不在 `router/index.js` 里静态注册**——能否访问完全由 `sys_menu` 行决定(`component='game/mahjong/<module>/index'`)。菜单 SQL 漏一条,页面就是白屏而不是报错。
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 桶配比弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
**奖池水位页**:四身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
**奖品管理页**:返奖饰品列表(名称/品质/图片/品质图/磨损/兜底/返奖区间)+ 编辑(`prize/save` 同步写 `game_mahjong_prize_range`)——照抄 `olympus/prize/index.vue`(121 行)。
**玩家记录页**:列里带**饰品名+图**与「播放状态 start/finish」标签(和玫瑰女郎一致)。
**必做校验**:`calcRounds` 回写时同时更新 `total_rounds`,否则 `cycleN` 与实际池子对不上。
---
## 9. 客户端(Cocos,`D:\changzhou-cocos\changzhou_cocos\changzhou_cocos`)
| 文件 | 作用 | 与玫瑰女郎面板的关系 |
|---|---|---|
| `assets/Scripts/Pages/MahjongPanel/MahjongPanel.ts` | 主面板(5×4 盘面、级联动画、阶梯条、FS 模式、自动旋转) | 结构照 `CandySloterPanel.ts`(186KB),**盘面改为 4 行 5 列、去掉炸弹、加金色转化动画** |
| `MahjongBigWin.ts` | 爆奖三档 | 拷贝 |
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注大小与倍数) | 拷贝改文案 |
| `MahjongSfx.ts` / `MahjongSpineUtil.ts` | 音效 / 骨骼工具 | 拷贝 |
**主界面 HUD 布局(按用户给的真实运行截图逐块对齐)**:
| 位置 | 元素 | 数据来源 |
|---|---|---|
| 顶部横条 | 「1024 路中奖组合」标题条(两侧金环装饰) | 静态 |
| 标题条下方 | **乘数阶梯行** `x1 x2 x3 x5`(FS 内 `x2 x4 x6 x10`):**当前档金色高亮+放大,未到的档压暗** | `rotationMul` |
| 中部 | **5 列 × 4 行**牌面;**金色符号 = 普通牌 + 金色底/描边**;**本波中奖牌 = 红色边框** | `gridState` / `goldenDetail` / `waysDetail` |
| 牌面下方 | 「共赢得 X.XX」金边横幅(本局累计,跳字) | `spinWin` |
| 横幅下方三格 | 余额 ¥ / 投注 ¥ / 赢额 ¥ | 余额接口 / `bet×betMul` / 本局赢额 |
| 底部按钮 | 闪电(加速)/ − / 大 Spin / + / 自动旋转,右上角 ✕ 退出 | 静态 + 投注档联动 |
> 截图实测细节:投注 ¥8.00、阶梯高亮在 **x2**(本旋第 2 波)、横幅「共赢得 0.80」→ 说明"**每波结算后 HUD 立刻按当前波次刷新阶梯、并按本局累计刷新横幅**",不是等整旋播完才更新。这两处要按帧驱动,别做成一旋只刷一次。
动画要点:
1. 每波消除后"金色格 → 百搭"要有明确视觉反馈(用 `wildConverted` 驱动,**不要靠 diff 盘面猜**);
2. 阶梯条随波次金色高亮递进(见上表),用 `rotationMul` 驱动;
3. **悬念(near-miss)表现**:基础旋逐列落地时,若前 4 列已出现**恰好 2 个胡**,第 5 列(最后一列)要播放**放大/发光/慢转**效果吊期待——这是用户点名要的"很刺激"的爽点。判据完全在客户端(数 `gridState` 里的 `S`),服务端只负责让这种盘面**真的存在**(§3.7 的 `NEARMISS_2SCATTER_RATE`)。
4. FS 触发/重触发弹窗;剩余旋数直接显示 `spinsRemaining`,前端不要自己加减;
5. **末帧结算交互**(和玫瑰女郎一致):先播 `totalWin` 落袋动画 → 弹「**放入背包 / 立即分解**」(用 `prizeInfo` 展示饰品名与图)→ 分解走 `POST /api/userPackSack/decompose`;设置里的「自动分解」开关只是前端开关,开启后末帧自动替玩家调一次分解接口,调不动就留在背包(不会丢奖)。
---
## 10. 分期实施计划
| 阶段 | 交付物 | 验收标准 | 人日 |
|---|---|---|---|
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 20~30%、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **12 箱**池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
| **P2 Java 服务** | `game-mahjong-service` 全套 + 10 处共享文件改动 | ① `mvn -pl skins-service/game-mahjong-service -am compile` 通过;② 开局/续播/重连/封顶/空转局五条链路离线单测;③ 扣费与发奖同一事务、`settled` 幂等可重放、饰品开局即入背包、分解流水口径正确 | 6~8(含饰品/背包/站内信链路) |
| **P3 后台 + SQL 文档** | 7 个 Vue 页面(含奖品管理)+ `docs/updata_sql.md` 新节 + `docs/麻将胡了接口文档.md` | ① 菜单挂载正确、权限生效;② 一键重算与注水/抽水(原因必填)可用;③ 返奖饰品与区间可编辑;④ 接口文档与真实响应逐字段对齐 | 4~5 |
| **P4 客户端** | Mahjong 面板全套 | ① 逐帧复刻服务端盘面(含金色转化、阶梯高亮、**2 胡悬念放大**);② 断线重连回到正确波次;③ 末帧「放入背包/立即分解」+ 自动分解开关 | 15~20 |
| **P5 联调与上线** | 测试服部署 + 对账脚本 + 上线清单 | ① 真实 30 分钟对账:`game_mahjong_record.settled_win` 之和 = 背包记录数 × 对应饰品价,分解后 = `MAHJONG_REWARD(69)` 流水;② RTP 抽样 ≥1000 局落 ±5pt;③ 流水抽奖 / 消费排行 / VIP 返利三项回归通过 | 4~5 |
**总计**:后端 20~26 人日、客户端 15~20 人日、联调 4~5 人日。P0→P1 强串行,P4 可在 P2 接口冻结后并行。
---
## 11. 测试与验收清单
**Python**
- `test_engine.py`:
- **前缀链判定逐条覆盖 §2.1 的 7 个正反例**(`2,3,1,0,0` ✅ / `2,3,1,2,0` ✅ 4 连 / `2,0,3,4,4` ❌ / `0,4,4,4,4` ❌ / `1,1,0,4,4` ❌ / `1,2,3,4,4` ✅ ways=96 / `1,1,1,1,1` ✅ ways=1)——**这是全游戏最容易写错的一处,必须单测钉死**;
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `count_wild(grid) ≤ 1`**;
- **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `gridState` 变 `W` 且 `wildConverted` 有它;② 该格**没有**被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 `發,發,_,...` 变成 3 连);④ 该 W 在更后面的波参与成奖时**被正常消除**;⑤ 未被中奖涉及的金色格**不转化**、保持原样;
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**百搭不能替代胡**(`發,胡,發` 不成链)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 8/9/10 号高倍桶**——没有买 FS 兜底,最容易饿死)。
- `simulate_cycle.py`:整轮 RTP 与设计的偏差;白号池独立。
**Java**
- `RoundPicker`:固定阶段只消耗 `used`、收敛阶段不消耗;`payoutCap` 过滤生效;白号走纯概率分支。
- 幂等:同一 `recordId` 重复 `settleAtStart` 只入账一次、**只写一条背包记录**。
- 封顶局:`maxWinReached=true`、`spinsRemaining=0`、`totalWin = 500×bet`(**500x 封顶,Q4 定案**)。
- **空转局(`totalWin=0`)**:仍按 `max(totalWin,0.01)` 命中**最低区间饰品**、照样写背包、`settle_source` 照常 `finish`;**开局不写任何 `MAHJONG_REWARD` 流水**。
- **奖励流水时机**:`settleAtStart` **不写** 69;只有调 `decompose` 分解后才写 69(source 15 → 69 的映射生效),未分解就永远没有 69。
- 站内信:单步局开局发、多步局末帧发、**中途弃局不发**(对齐玫瑰女郎)。
- 并发:同一用户并发 `dospin` 只有一个能开局(`COMMON_LOCK+userId`)。
**对账 / 回归**
- `Σ record.settled_win`(已结算局) == `Σ` 对应饰品价 × 条数 == 背包里 `tt_box_records.source=15` 的条数口径。
- 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。
- 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。
- 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。
---
## 12. 风险与对策
| # | 风险 | 对策 |
|---|---|---|
| 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 |
| 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 |
| 3 | **step 表体量**(~96 万帧,12 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
| 4 | **py / Java 两份 `calcRounds` 不同步**(玫瑰女郎已因此返工) | 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比) |
| 5 | **免费旋转"演不动"**(FS 是白送的 12 旋,预算铺不开时动画会空转) | 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分 |
| 6 | ~~现金直付与平台既有"游戏必给饰品"体验差异~~ | **已定案:走饰品**(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期 |
| 7 | **客户端工作量大**(玫瑰女郎面板是 186KB 单文件) | 直接复制改造,**不做公共 slot 基类抽象**(两个游戏的盘面/动画差异大于共性) |
| 8 | **生成耗时**(本机 i3-10105F,4 物理核) | `--workers` 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意 |
| 9 | **母本代码有文档漂移**(`olympus-py/README.md:459` 声称 RoundPicker 有 `ruoyi:olympus:seen:*` 不重发去重,**实际代码里不存在**) | 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认 |
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数 |
---
## 13. 全部定案(**无待拍板项**)
> 2026-09-21 全部澄清完毕。下表保留完整决策链,方便回溯"这条为什么这么定"。
| # | 问题 | 结论 |
|---|---|---|
| ~~Q0~~ | ~~赔付值乘的基数是哪个?~~ | ✅ **投注总额 ÷ 20**(赔付表是 1 倍基准价 + 规则页"20 条基础投注" + 截图排除法,见 §2.1) |
| ~~Q1~~ | ~~纯百搭链(第1/2/3轴全百搭)算什么?~~ | ✅ **由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解**——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,**纯百搭链数学上不可能出现**,不需要单独规则、也不需要 `WILD_PAYS` 开关(见 §2.2) |
| ~~Q2~~ | ~~金色→百搭的那个百搭是否"钉住不动"?~~ | ✅ **不钉住**("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") |
| ~~Q3~~ | ~~免费旋转总旋数上限~~ | ✅ **30 旋** |
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 |
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 12 箱、无 `need_fs`、`costMultiplier≡1` |
| ~~Q6~~ | ~~各身份目标 RTP~~ | ✅ **不是拍板项**:后台配置(箱子级 `rtp` + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:12 档** `1/3/5/10/20/30/50/100/200/300/500/1000` × betMul 1~10(实际 1~10000)。零成本备选:10 档 + betMul 1~20,见 §2.8.1 |
| Q8 | 每日统计要不要独立日表 | 🔧 **技术默认:不要**,后台按 `record` 聚合 |
| ~~Q9~~ | ~~奖励形态:现金直付 vs 饰品入背包~~ | ✅ **赢额变成一个饰品发到背包** → 区间匹配 → `tt_box_records`(source=15) → 末帧放入/分解 → 分解时写 `MAHJONG_REWARD(69)` |
| Q10 | 后台"回合管理"要不要暴露金色/阶梯明细 | 🔧 **技术默认**:只读展示 `actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘`,不展示内部帧 |
| **新** | **百搭:来源 / 数量 / 位置 / 消除条件** | ✅ **已确认**:① 原生盘面**不会**出现百搭(卷轴不落、补位不补),**唯一来源是金色转化**;② **几个金色格被消除就留几个百搭,无上限**(一屏可同时有多个);③ 因此**百搭只可能在第 2/3/4 轴**,第 1 轴永远没有 ⇒ **纯百搭链不可能**(Q1 永久关闭);④ 百搭**可作为任何付费符号**,**被消除也要满足消除链**;⑤ 可与普通符号、也可与金色符号连着消除(同权,无特例) |
| ~~新~~ | ~~转化边界的策略开关~~ | ❌ **已取消**:既然"每个金色格各留一个、无上限",就不存在"已有百搭时该不该转化"的边界,`WILD_SLOT_POLICY` 不需要了 |
---
## 14. 完整规则汇总(规则页文案版 —— 可直接交客户端 / 策划 / QA)
> 本节把 §2 的游戏规格**按玩家视角重排**,既是客户端「规则」「赔付表」两个面板的文案来源,也是 QA 的验收清单。
> 文案口径:**玩家能看懂**;参数口径:见每节末尾的「开发参数」。
### 14.1 一句话
麻将胡了是一款 **5 轴 × 4 行**的视频老虎机游戏,共 **1024 个中奖路**,编入了**百搭符号**、**金色麻将符号**以及**奖金乘数递增的免费旋转**。
### 14.2 投注与结算
| 项 | 规则 |
|---|---|
| 投注构成 | 「**投注大小**」(12 档)× 「**投注倍数**」(1~10)= **投注总额**,开局即扣 |
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000` |
| 游戏单位 | 金额以 ¥ 为单位 |
| 现金钱包 | 显示当下玩家可用的现金余额 |
| 自动旋转 | 可按玩家选择的回合数量自动进行游戏 |
| 中奖派发 | 按「**赔付表**」执行;不同中奖路上的奖金**叠加** |
| 结算形态 | 本局赢额**按区间转换为一个饰品发到背包**;末帧可选择「放入背包 / 立即分解」;开启「自动分解」则末帧自动分解为金币 |
> **开发参数**:`box.bet ∈ 上表 12 档`;`price = box.bet × betMul`(`costMultiplier ≡ 1`);饰品按 `game_mahjong_prize_range` 的赢额区间匹配;开局即 `settleAtStart` 写背包(无损模型)。
### 14.3 中奖判定(1024 路 = 前缀链)
- 赔付表上的数值是「**每条基础投注**」的单价:**1 条基础投注 = 投注总额 ÷ 20**(游戏固定 20 条基础投注)。
- 一条中奖路必须**从最左(第 1 轴)开始,按 1 → 2 → 3 → 4 → 5 的顺序连续出现**该符号;
- 第 1 轴是**必需**的;
- **中间断了就整笔作废**——例如 1、2、4 有、3 没有,不算中奖;2、3、4 有、1 没有,也不算。
- 每一轴上该符号可以有**好几个**(最多 4 个),只要求「每轴至少 1 个」。
- **路数** = 链内各轴该符号个数的**乘积**。
- **本笔赢额 = 赔付值 × 路数 × (投注总额 ÷ 20) × 本波乘数**。
- 中奖路奖金**从最左至右卷轴的顺序派发**;不同符号各自独立成奖、**互相叠加**。
- 参考例子:`1 × 3 × 2 = 6` 路;赔付值 `10 × 6 = 60`(基础注),说明见游戏内「赔付表」。
> **开发参数**:`PAY_UNIT_DIVISOR = 20`;判定实现见 §5.2(`break` 就停,禁止写成"任意 3 轴凑齐")。
### 14.4 符号与赔付表
赔付值(每条基础投注,**1 倍基准价**):
| 符号 | 5 连 | 4 连 | 3 连 |
|---|---|---|---|
| 發 | 100 | 60 | 15 |
| 中 | 80 | 40 | 10 |
| 白板(紫框) | 60 | 20 | 8 |
| 八萬 | 40 | 15 | 6 |
| 花筒(四色) | 20 | 10 | 4 |
| 索(红绿) | 20 | 10 | 4 |
| 筒(紫绿) | 10 | 5 | 2 |
| 索(绿) | 10 | 5 | 2 |
| **百搭(元宝)** | — | — | — |
| **胡(夺宝符号)** | — | — | — |
### 14.5 百搭符号(元宝)
- **百搭符号可替代除「夺宝符号(胡)」以外的所有符号**。
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**一个盘面上最多只会出现 1 个**。
- 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。
- **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。
- 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。
### 14.6 金色麻将符号
**官方规则原文(游戏内「金色麻将符号」页)**:
1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的**某些符号(不包括百搭符号和夺宝符号)**将可能显示为金色。」
2. 「在**新符号跌落后**的每一轮旋转中,**上一轮赢奖中所涉及的**黄色麻将符号将被**转换为百搭符号**。」
**人话版(4 条,这是要写进规则页的文案)**:
| # | 规则 |
|---|---|
| 1 | **谁能变金**:只有**第 2、第 3、第 4 轴**上的**普通符号**会显示为金色(**百搭和胡不会变金**) |
| 2 | **金色是什么**:就是那个普通符号**镀了一层金**,判定与赔付**仍然按它的自然符号**算(金色「中」照「中」的赔付表赔) |
| 3 | **中奖后怎么变**:这个金色符号**参与中奖、被消除时**,它原来的位置**不会空掉、也不会补新牌,而是留下一个真正的百搭符号**(元宝)——**每个金色格各留一个**(同波几个金色格中奖就留几个,无上限) |
| 4 | **之后怎么用**:留下的百搭和盘面上直接落下的百搭**完全一样**——可以替代除「胡」以外的所有符号、可以补链搭桥、参与中奖时同样被消除 |
**两个最容易搞错的点**:
- 官方原文说"**在新符号跌落后**的每一轮旋转中……被转换为百搭"——这是**动画时序**的描述(视觉上要等新牌落下来才看到它变百搭);**实现上是"该格被消除的那一刻就地写入百搭"**,两者不冲突。
- 金色符号**本身就是普通符号**,所以它照样**随重力下落**(下面被消除就往下掉),**不会钉在空中**。
**一个连贯的例子**:
| 步骤 | 盘面 | 发生了什么 |
|---|---|---|
| 第 1 波 | 金色「中」参与「中」的 3 连 | 按「中」赔付;结算后该格**留下一个百搭** |
| 第 2 波 | 掉落后形成 `發, 百搭, 發 …` | 百搭**搭桥**,「發」3 连成立 |
| 第 3 波 | 这个百搭又参与了一笔中奖 | 它**随这一波被消除**(百搭也是一张普通牌) |
> **开发参数**:每旋开局对第 2/3/4 轴的普通符号按 `GOLDEN_CELL_PROB`(建议 0.10,逐格独立)打金色标记,新补入的符号也可能带金色;本波中奖涉及的金色格在消除时**不补新符号、就地写 `W`**,并随重力下落(不锚定);每格只转化一次,转化后的 `W` 不再回填金色。判定细节见 §2.3。
### 14.7 乘数阶梯(奖金翻倍)
- **在任何旋转期间,所有赢奖都将乘以卷轴上方所显示的乘数值**。
- **普通旋转**:第 1 次消除 **x1** → 第 2 次 **x2** → 第 3 次 **x3** → 第 4 次及以后 **x5**。
- **免费旋转**:乘数分别增加至 **x2、x4、x6、x10**。
- **每旋重新从第一档开始**,不跨旋累计。
> **开发参数**:`LADDER_BASE = (1,2,3,5)`、`LADDER_FS = (2,4,6,10)`;第 n 波取 `ladder[min(n,4)]`。
### 14.8 级联(翻滚)
- **每轮游戏结束并发放奖金后,所有赢奖符号都将爆炸消除,让其上方的符号向下跌落**,以进行新一轮的旋转。
- 顶部补入新符号后进入下一波,**直到没有其他中奖组合**为止。
- **额外的中奖组合都将计入每次旋转的赢奖中**。
### 14.9 免费旋转模式
- **当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转**。
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。
- **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。
- **免费旋转可以被重复触发**(免费旋转内再出现 3+ 个胡,同样按"12 + 每个额外胡 +2"追加)。
- **免费旋转的总旋数上限为 30 旋**(到顶后不再出现 3+ 个胡)。
- 免费旋转期间投注大小与投注倍数、以及触发免费旋转模式时的参数**保持一致**。
> **开发参数**:`FS_AWARD = 12 + 2 × (scatterCount − 3)`;重触发同式;**总旋数上限 30**(Q3 定案);到顶后不再落 3+ 胡。
### 14.10 悬念表现(2 个胡)
- 当卷轴上已出现 **2 个夺宝符号**时,**最后一列会播放大/发光效果**,吊起"再来一个胡"的期待感。
> **开发参数**:客户端按 `gridState` 里的 `S` 计数驱动;生成端用 `NEARMISS_2SCATTER_RATE`(1%~2%)保证这种盘面真实存在。
### 14.11 爆奖与封顶
- **单局封顶**:总倍率到达封顶即截断,剩余旋数作废,保证"动画总赢额 = 实际到账"。
- **爆奖档位**:小 / 中 / 大(按单旋赢额判档,只在免费旋转内播)。
- **所有赢奖以现金显示**(游戏内显示金额),**最终以饰品形式发放到背包**。
> **开发参数**:**封顶 500x**(Q4 定案)——`totalMul ≥ 500` 即 `maxWinReached=true`、`spinsRemaining=0`、按 500 倍截断发放;爆奖阈值见 §12 对应条目。
### 14.12 参数速查(给开发 / QA)
| 参数 | 值 |
|---|---|
| 盘面 | 5 轴 × 4 行 = 20 格 |
| 路数 | 4⁵ = 1024 |
| 赔付单位 | 投注总额 ÷ 20(`PAY_UNIT_DIVISOR = 20`) |
| 判定 | 前缀链(第 1 轴起连续,断链作废,`ways` 只乘链内轴) |
| 百搭 | **只能由金色符号转化产生**(原生盘面不落);**每个金色格各留一个(无上限)**,**只可能在第 2/3/4 轴**;可替代除「胡」外全部;满足消除链才被消除 |
| 金色符号 | 只在第 2/3/4 轴;按自然符号成奖;消除后就地留百搭 |
| 普通旋阶梯 | `1 / 2 / 3 / 5`(第 4 波及以后 x5) |
| FS 阶梯 | `2 / 4 / 6 / 10` |
| 免费旋转 | 3+ 胡 → 12 旋,每个额外胡 +2,可重触发,**上限 30 旋** |
| 悬念盘 | 2 个胡 → 第 5 轴放大;生成端占比 1%~2% |
| 投注档 | 12 档:1/3/5/10/20/30/50/100/200/300/500/1000 |
| 投注倍数 | 1~10 |
| 封顶 | **500x** |
| 发奖 | 赢额 → 区间匹配饰品 → 背包 → 可分解 |
---
## 15. 完整消除示例(逐波走一遍,用来校验规则理解)
> 场景:**普通旋转**,投注大小 ¥100 × 投注倍数 1 → **投注总额 ¥100**,故 **1 条基础投注 = 100 ÷ 20 = ¥5.00**。
> 本旋共 **4 波消除**,覆盖:两个符号同时中奖(叠加)、**两个金色格各留一个百搭**、百搭随重力下落、百搭参与成链后自己也被消除、3/4/5 连三种档位、x1→x2→x3→x5 全阶梯。
> 图例:`A`=發 `B`=中 `C`=白板 `D`=八萬 `E`=花筒 `F`=索(红绿) `G`=筒(紫绿) `H`=索(绿) `W`=百搭(元宝) `S`=胡 **加粗带 `*` = 金色**。
> ⚠️ 金色格只能出现在**第 2/3/4 轴**(本示例的两张金色都在轴2、轴3)。
### 第 1 波(基础旋 · 阶梯 x1)
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | B | C | B | E | S |
| 行2 | B | **B\*** | **B\*** | D | F |
| 行3 | D | C | C | G | E |
| 行4 | C | E | H | F | G |
**判定**(前缀链;两张金色格都按自然符号「中」算):
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 结果 |
|---|---|---|---|---|---|---|
| **中 B** | 2(行1、行2) | 1(行2金) | 2(行1、行2金) | 0(E/D/G/F) | — | **3 连,ways = 2×1×2 = 4** |
| **白板 C** | 1(行4) | 2(行1、行3) | 1(行3) | 0 | — | **3 连,ways = 1×2×1 = 2** |
| 其余 6 个 | 轴2 里都没有它们 | — | — | — | — | 轴 2 断,不成奖 |
> 本波**两个符号同时中奖**,奖金**叠加**。
**赔付**:
| 中奖 | 赔付值 | ways | 基础注 | 阶梯 | 赢额 |
|---|---|---|---|---|---|
| 中 3 连 | 10 | 4 | 40 | x1 | **¥200** |
| 白板 3 连 | 8 | 2 | 16 | x1 | **¥80** |
| | | | | 小计 | **¥280** |
**本波共 9 个符号被消除**(9 格全播爆裂动画):
| 格子 | 消除后 |
|---|---|
| **中链**:(行1,轴1)、(行2,轴1)、(行1,轴3) | 空洞 → 下落 + 补新牌 |
| **中链**:(行2,轴2)**金**、(行2,轴3)**金** | **每张金色格各留一个百搭** → 两格**都**就地变 `W`(**本波留下 2 个百搭**) |
| **白板链**:(行4,轴1)、(行1,轴2)、(行3,轴2)、(行3,轴3) | 空洞 → 下落 + 补新牌 |
> ⚠️ 两条口径一起记:
> ① **"消除 ≠ 清空"**——9 格全部"被消除"(全爆),区别只在消除**之后**:金色格写 `W`,其余格置空待补;
> ② **"几个金色格中奖,就留几个百搭"**——不是每波只留 1 个。本波留 **2 个**。
**重力 + 补位**(列内幸存符号保持顺序压到底,顶部补新牌):
| 轴 | 本波消除 | 该列变化 | 补位后(上→下) |
|---|---|---|---|
| 1 | 行1、行2、行4 | 只剩 D(行3) → 落到 **行4** | 顶部补 D、D、D → **D / D / D / D** |
| 2 | 行1、行3 | 幸存 `W`(行2)、E(行4) → **`W` 落到行3**、E 到行4 | 顶部补 F、G → **F / G / W / E** |
| 3 | 行1、行3 | 幸存 `W`(行2)、H(行4) → **`W` 落到行3**、H 到行4 | 顶部补 A、B → **A / B / W / H** |
| 4、5 | 无 | — | 原样不动 |
#### 第 1 波 → 第 2 波的盘面演变(两步)
> ⚠️ **说明**:消除之后符号**立刻**落下来(存活符号下移 + 新牌从列顶落下),“整列空洞”只是一个瞬时中间帧、不是一个静止状态,所以下面只展示**两个**关键画面:**消除瞬间** 和 **落定后**。
**步骤 ① 消除瞬间**(9 格全爆;**两张金色格就地变 `W`**,其余中奖格变空洞):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | ▢ | ▢ | E | S |
| 行2 | ▢ | **W** | **W** | D | F |
| 行3 | D | ▢ | ▢ | G | E |
| 行4 | ▢ | E | H | F | G |
> `▢` = 空洞(待补)。两张金色格**各自**变成了 `W`,盘面上现在**同时有 2 个百搭**。
**步骤 ② 符号落定(存活符号下移 + 新牌从列顶落下)→ 这就是「第 2 波消除之前的盘面」**:
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|---|---|---|---|---|---|---|
| 行1 | **D** | **F** | **A** | E | S | 轴1 整列被清空 → **落进来 4 张新牌** |
| 行2 | **D** | **G** | **B** | D | F | 轴2:`W` 与 E 下移一格 → 顶部落进 2 张新牌 |
| 行3 | D | **W** | **W** | G | E | 轴3:`W` 与 H 下移一格 → 顶部落进 2 张新牌 |
| 行4 | D | E | H | F | G | 轴4、轴5 没被消除 → 不动 |
> **粗体** = 本步落进来的牌。两个 `W` **都从行2 落到了行3**;轴1 因为四格全爆、列内没有存活符号,所以落进来的**全是新牌**(4 张)——这就是"消除完符号掉落下来"的那一步。
### 第 2 波(阶梯 x2)——**两个百搭一起帮忙成链,然后一起被消除**
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | D | F | A | E | S |
| 行2 | D | G | B | D | F |
| 行3 | D | **W** | **W** | G | E |
| 行4 | D | E | H | F | G |
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 结果 |
|---|---|---|---|---|---|---|
| **八萬 D** | 4(行1~行4) | **1(百搭当八萬)** | **1(百搭当八萬)** | 1(行2) | 0(S/F/E/G) | **4 连,ways = 4×1×1×1 = 4** |
| F / G / A / B | 轴1 没有 | — | — | — | — | 轴1 断,不成奖 |
> 本波盘面**只有轴 1 是纯八萬**(补位恰好都补成 D),所以只有八萬成链。这也顺带说明了:**有百搭的那几轴对所有符号都成立**,链能不能成完全取决于"没有百搭的那些轴"。
**赔付**:八萬 **4 连**赔付值 **15** × ways **4** = 60 基础注 = **¥300** × 阶梯 **x2** = **¥600**
**本波共 7 个符号被消除**:轴1 行1~行4 的 4 张 D、**(行3,轴2) W**、**(行3,轴3) W**、**(行2,轴4) D**。
- **两个百搭都参与了成立的中奖链** → 按"满足消除链才消除" → **两个 `W` 一起被消除**(盘面回到 0 个百搭)
- 本波中奖格**没有金色** → **不产生新百搭**
**重力 + 补位**:
| 轴 | 该列变化 | 补位后(上→下) |
|---|---|---|
| 1 | 4 格全消除 → 无幸存 | 顶部补 4 张 → **E / H / C / F** |
| 2 | 消除行3 → 幸存 F,G,E 落到行2~4 | 顶部补 C → **C / F / G / E** |
| 3 | 消除行3 → 幸存 A,B,H 落到行2~4 | 顶部补 **E\***(**这张是金色**) → **E\* / A / B / H** |
| 4 | 消除行2 → 幸存 E,G,F 落到行2~4 | 顶部补 A → **A / E / G / F** |
| 5 | 无 | 原样 **S / F / E / G** |
#### 盘面演变(第 2 波 → 第 3 波)
**① 消除瞬间**(7 格全爆,两个 `W` 都被消除;本波无金色格,不产生新百搭):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | F | A | E | S |
| 行2 | ▢ | G | B | ▢ | F |
| 行3 | ▢ | ▢ | ▢ | G | E |
| 行4 | ▢ | E | H | F | G |
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 「第 3 波消除之前的盘面」**:
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|---|---|---|---|---|---|---|
| 行1 | **E** | **C** | **E\*** | **A** | S | 轴1 整列清空 → 落进 4 张新牌 |
| 行2 | **H** | F | A | E | F | 轴2:F/G/E 下移一格 → 顶部落进 1 张 |
| 行3 | **C** | G | B | G | E | 轴3:A/B/H 下移一格 → 顶部落进 1 张(**金色**) |
| 行4 | **F** | E | H | F | G | 轴4:E/G/F 下移一格 → 顶部落进 1 张;轴5 没被消除 |
> 轴1 四格全爆 → 没有存活符号 → **落进来的全是新牌**;两个 `W` 被消除,本波不产生新百搭。
### 第 3 波(阶梯 x3)——**一张金色格中奖,留 1 个百搭**
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | E | C | **E\*** | A | S |
| 行2 | H | F | A | E | F |
| 行3 | C | G | B | G | E |
| 行4 | F | E | H | F | G |
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 结果 |
|---|---|---|---|---|---|---|
| **花筒 E** | 1(行1) | 1(行4) | **1(行1金)** | 1(行2) | 1(行3) | **5 连,ways = 1×1×1×1×1 = 1** |
| 索绿 H | 1(行2) | 0(C/F/G/E) | — | — | — | 轴2 断 |
| 白板 C | 1(行3) | 1(行1) | 0(E/A/B/H) | — | — | 轴3 断 |
| 索红绿 F | 1(行4) | 1(行2) | 0 | — | — | 轴3 断 |
| 其余 | 轴1 没有 | — | — | — | — | 不成奖 |
**赔付**:花筒 **5 连**赔付值 **20** × ways **1** = 20 基础注 = **¥100** × 阶梯 **x3** = **¥300**
**本波共 5 个符号被消除**:(行1,轴1)E、(行4,轴2)E、**(行1,轴3) 金**、(行2,轴4)E、(行3,轴5)E——5 格全爆。
其中 4 格变空洞;金色格 (行1,轴3) **原地留百搭 `W`**(本波开始时盘面 0 个百搭 ✅,本波只有 1 张金色格中奖 → **留 1 个**)。
**重力 + 补位**:
| 轴 | 该列变化 | 补位后(上→下) |
|---|---|---|
| 1 | 消除行1 → 幸存 H,C,F 落到行2~4 | 顶部补 G → **G / H / C / F** |
| 2 | 消除行4 → 幸存 C,F,G 落到行2~4 | 顶部补 A → **A / C / F / G** |
| 3 | **该列无空洞**(金色格变 `W`) | 原样 → **W / A / B / H** |
| 4 | 消除行2 → 幸存 A,G,F 落到行2~4 | 顶部补 G → **G / A / G / F** |
| 5 | 消除行3 → 幸存 S,F,G 落到行2~4 | 顶部补 C → **C / S / F / G** |
#### 盘面演变(第 3 波 → 第 4 波)
**① 消除瞬间**(5 格全爆;金色格 (行1,轴3) 就地变 `W`):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | C | **W** | A | S |
| 行2 | H | F | A | ▢ | F |
| 行3 | C | G | B | G | ▢ |
| 行4 | F | ▢ | H | F | G |
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 「第 4 波消除之前的盘面」**:
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|---|---|---|---|---|---|---|
| 行1 | **G** | **A** | **W** | **G** | **C** | 轴1:H/C/F 下移一格 → 顶部落进 1 张 |
| 行2 | H | C | A | A | S | 轴2:C/F/G 下移一格 → 顶部落进 1 张 |
| 行3 | C | F | B | G | F | **轴3 没有空洞**(金色格变 `W`)→ 整列不动 |
| 行4 | F | G | H | F | G | 轴4:A/G/F 下移一格 → 顶部落进 1 张;轴5:S/F/G 下移 → 落进 1 张 |
> `W` 停在原处(轴3 那列没被清空),其余列各自下移一格并补新牌。
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被两个符号同时借用,本旋结束**
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | G | A | **W** | G | C |
| 行2 | H | C | A | A | S |
| 行3 | C | F | B | G | F |
| 行4 | F | G | H | F | G |
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 结果 |
|---|---|---|---|---|---|---|
| **筒 G** | 1(行1) | 1(行4) | **1(百搭当筒)** | 1(行1、行3 → 2) | 1(行4) | **5 连,ways = 1×1×1×2×1 = 2** |
| **索红绿 F** | 1(行4) | 1(行3) | **1(百搭当索红绿)** | 1(行4) | 1(行3) | **5 连,ways = 1×1×1×1×1 = 1** |
| 白板 C | 1(行3) | 1(行2) | 0(W/A/B/H) | — | — | 轴3 断 |
| 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 |
| 發 A | 轴1 没有 | — | — | — | — | 不成奖 |
> 同一个百搭这波**同时被筒和索红绿两个符号借用**,两条链都成立 → **两笔并赔**。
**赔付**:
| 中奖 | 赔付值 | ways | 基础注 | 阶梯 | 赢额 |
|---|---|---|---|---|---|
| 筒 5 连 | 10 | 2 | 20 | x5 | **¥500** |
| 索红绿 5 连 | 20 | 1 | 20 | x5 | **¥500** |
| | | | | 小计 | **¥1000** |
**本波共 9 个符号被消除**(筒链 5 格 ∪ 索红绿链 5 格,重叠在 `W` 与部分格):
(行1,轴1)G、(行4,轴1)F、(行4,轴2)G、(行3,轴2)F、**(行1,轴3) W**、(行1,轴4)G、(行3,轴4)G、(行4,轴4)F、(行3,轴5)F、(行4,轴5)G——
`W` 参与了两条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
#### 盘面演变(第 4 波 → 本旋结束)
**① 消除瞬间**(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 `W`):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | A | ▢ | ▢ | C |
| 行2 | H | C | A | A | S |
| 行3 | C | ▢ | B | ▢ | ▢ |
| 行4 | ▢ | ▢ | H | ▢ | ▢ |
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**:
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|---|---|---|---|---|---|---|
| 行1 | **E** | **G** | **E** | **C** | **A** | 轴1:H/C 下移两格 → 落进 2 张 |
| 行2 | **F** | **D** | A | **D** | **B** | 轴2:A/C 下移两格 → 落进 2 张 |
| 行3 | H | A | B | **E** | C | 轴3:A/B/H 下移一格 → 落进 1 张 |
| 行4 | C | C | H | A | S | 轴4:A 落到底 + 落进 3 张;轴5:C/S 下移 + 落进 2 张 |
→ 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而**轴3 没有 C** → **无任何中奖组合,本旋结束(整局共 4 波)**。
### 本旋总账
| 波 | 中奖 | 赔付值 | ways | 基础注 | 阶梯 | 本波赢额 |
|---|---|---|---|---|---|---|
| 1 | 中 3 连 | 10 | 4 | 40 | x1 | ¥200 |
| 1 | 白板 3 连 | 8 | 2 | 16 | x1 | ¥80 |
| 2 | 八萬 4 连 | 15 | 4 | 60 | x2 | ¥600 |
| 3 | 花筒 5 连 | 20 | 1 | 20 | x3 | ¥300 |
| 4 | 筒 5 连 | 10 | 2 | 20 | x5 | ¥500 |
| 4 | 索红绿 5 连 | 20 | 1 | 20 | x5 | ¥500 |
| | | | | | 合计 | **¥2180** |
- 投注总额 ¥100 → **本局总倍率 = 2180 ÷ 100 = 21.80x**(未触及 500x 封顶)
- 本旋只有 **1 个胡**(一直挂在轴5)→ **不触发免费旋转**(需 3+)
### 百搭轨迹(这张表是重点:**几个金色格中奖,就留几个百搭**)
| 时刻 | 盘上百搭数 | 发生了什么 |
|---|---|---|
| 旋开局 | **0** | 原生盘面不落百搭 |
| 第 1 波结束 | 0 → **2** | 同波有**两张**金色「中」中奖 → **各留一个百搭**(轴2、轴3 各一个)。两个 `W` 随后**都随重力从行2 掉到行3** |
| 第 2 波结束 | 2 → **0** | **两个 `W` 都参与了八萬链**(满足消除条件)→ 一起被消除;本波无金色中奖,不补 |
| 第 3 波结束 | 0 → **1** | 本波只有 **1 张**金色「花筒」中奖 → 留 **1 个** `W`;该列没有空洞,所以它**原地不动** |
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补 |
| 旋结束 | 0 | 无后续中奖;下一旋整盘重发 |
> **百搭数量没有上限**(第 1 波就同时有 2 个),但**只可能出现在第 2/3/4 轴**——因为金色格只在那三轴,而百搭的唯一来源是金色转化。
> 这条推论很关键:**第 1 轴永远不会有百搭**,所以前缀链的第 1 轴必然是自然符号 ⇒ **任何一笔中奖都至少含 1 个自然符号** ⇒ **"纯百搭链"在数学上不可能出现**(Q1 因此永久关闭,不需要额外规则)。
> 全程每一波盘面上的百搭数都 **≤ 1** ✅,且**永远只由金色转化产生** ✅。
> 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。
---
## 附录 A:与玫瑰女郎的字段/表名映射速查
| 玫瑰女郎 | 麻将胡了 |
|---|---|
| `game_olympus_box.total_ornaments` | `game_mahjong_box.total_rounds` |
| `game_olympus_ornament`(17 档饰品桶) | `game_mahjong_bucket`(10 档倍率桶) |
| `game_olympus_box_ornament.ornament_id` | `game_mahjong_box_bucket.bucket_id` |
| `game_olympus_round.ornament_id` | `game_mahjong_round.bucket_id` |
| `game_olympus_step.symbol_links` | `game_mahjong_step.ways_detail` |
| `game_olympus_step.multiplier_detail`(炸弹) | **删除**,改为 `golden_detail` + `rotation_mul` |
| `game_olympus_step.accumulator_*` | `game_mahjong_step.rotation_mul`(语义:本波档位,不跨旋) |
| `game_olympus_step.free_spin_id` / `cascade_seq` | `reel_spin_id` / `rotation_seq` |
| `game_olympus_prize_ornament` / `_prize_range` | `game_mahjong_prize_ornament` / `_prize_range`(**要,赢额→饰品**) |
| `TtboxRecordSource.OLYMPUS_BOX(13)` | `TtboxRecordSource.MAHJONG_BOX(15)`(写背包) |
| `OLYMPUS_SPIN(60)` / `OLYMPUS_REWARD(61)` | `MAHJONG_SPIN(68)` / `MAHJONG_REWARD(69)` |
| `ruoyi:olympus:*` | `ruoyi:mahjong:*` |
| `olympusMaintenance` | `mahjongMaintenance` |
## 附录 B:关键代码坐标(新游戏要对齐的现有实现)
| 主题 | 文件:行 |
|---|---|
| 两阶段挑桶 | `game-olympus-service/.../service/OlympusRoundPicker.java:44-100` |
| 水位仓储 | `.../repository/OlympusRewardPoolCacheRepository.java:80-84` |
| 开局事务 + 提交后吞异常 | `.../service/OlympusGameService.java:173-220` |
| 开局结算 + 幂等守卫 | `.../service/OlympusGameService.java:279-306` |
| 续播 / 末帧 / 战绩可见性 | `.../service/OlympusGameService.java:230-254` |
| 封顶判定 | `.../service/OlympusGameService.java:426-429`(`MAX_WIN_CAP_MUL`) |
| 配比算法(py) | `olympus-py/_recalc_boxes.py:52-129` |
| 配比算法(Java) | `.../service/OlympusDistributionService.java:100-207` |
| 生成端补桶/饥饿重校准 | `olympus-py/olympus_simulator.py:740-805` |
| 级联几何复刻测试 | `olympus-py/tests/test_simulator.py::TestTumbleGeometry` |
| 平台流水 source 聚合 | `skins-model/.../TtAccountRecordSource.java:193-279` |
| 建表 + 菜单 SQL 写法 | `docs/updata_sql.md:1144-1225` |
| 接口契约模板 | `docs/玫瑰女郎接口文档.md`(706 行) |
## 附录 C:待新增的运维 runbook(对齐 `olympus-py/README.md`)
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 12 箱 × 4 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥500/¥1000 这两档必须单独注**)。
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode X` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
3. **重建池子**:`_regen.py snapshot` → `generate_rounds.py --clear --write` → 重算 → 清 Redis → 刷模板 → `_regen.py verify`。
4. **调水位**:后台奖池水位页按身份逐箱注水/抽水(**原因必填**)或批量按"一轮花费基数 × 比例%"。
5. **日常体检**:`_regen.py check`(每桶有条目 / 无封顶缺失 / 池内 RTP)+ `simulate_cycle.py` 抽样。