1426 lines
106 KiB
Markdown
1426 lines
106 KiB
Markdown
# 麻将胡了(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` 抽样。
|