2102 lines
152 KiB
Markdown
2102 lines
152 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`(接口契约)。
|
||
> **文档来源**:本方案是随同源项目从 **`changzhou_backend`** 搬到本仓的(本仓 `docs/修改记录.md` 已把它归为"旧项目文档"),**通篇是 changzhou 语境**。所以文内的 `olympus-py/`、`skins-service/game-olympus-service/`、`docs/玫瑰女郎接口文档.md`、`docs/updata_sql.md`、`D:\changzhou-cocos\...` 都是**那个仓库**的资产:
|
||
> - 在 `D:\changzhou_app\changzhou_backend` 里它们**完整存在**(含上面两个 docs 模板);
|
||
> - 在本仓(henan-backend)里**没有**——`olympus-py/` 与 `game-olympus-service/` 未随拷贝带过来,`docs/updata_sql.md` 已清空、`docs/玫瑰女郎接口文档.md` 已删(均为有意为之)。
|
||
> - **落地仓库已定:本仓 henan-backend**。因此:① 路径类引用一律按**本仓现状**读(§3.8 / §6.5 / §8 / 附录 B 已就地标注);② 开工前先把母本从 `D:\changzhou_app\changzhou_backend` 拷过来当参照(`olympus-py/` → 本方案 §5 的照抄对象;`skins-service/game-olympus-service/` → §6 的照抄对象);③ 注意本仓 `skins-service/pom.xml:22` 仍声明 `game-olympus-service` 而该目录不存在,**Maven 从根构建会以 "Child module does not exist" 失败**(`ruoyi-admin/pom.xml:100` 也依赖它),要么先拷母本、要么先摘掉这行。数学与规则部分(§2 全篇)与仓库无关,两边通用。
|
||
> 状态:**方案待评审**,未动任何代码。第 13 节是全部定案与决策链(原列的 10 个待拍板项已全部澄清)。
|
||
|
||
---
|
||
|
||
## 0. TL;DR
|
||
|
||
| 项 | 结论 |
|
||
|---|---|
|
||
| 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 |
|
||
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **11 个箱子**(只按投注档分,档位见 §2.8.2)、无模式倍率、无 `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` 历史性不同步;④ **`prize_range` 只管"发哪件饰品"、不是 RTP 旋钮**(12 件/12 段已导入且连续无缝、上界 99,999,999,见 §3.8;饰品价格 = 本局赢额,空转兜底只有 ¥0.01)—— RTP 全部在桶配额里,别再往饰品价格上找旋钮 |
|
||
|
||
---
|
||
|
||
## 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` 16 档(见 §3.1) |
|
||
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构(**3 身份**,见 §3.6) |
|
||
| 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` |
|
||
| 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) |
|
||
| 7 | **身份分桶** common/anchor/white(**3 个**;原 robot 维度已按用户要求去掉,`user_type='03'` 归 common),白号独立库存不进真实经济 | `OlympusAudience`(它那份是 4 个) | 本游戏自带 3 个身份的判定,见 §3.6 |
|
||
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致(结算动作就是"写背包":赢额落 `prize_range` 区间匹配饰品,见 §6.3) |
|
||
| 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` 就是"本局价值",而背包记录里的价格字段 `ornaments_price` 存的就是这个赢额——前端同样显示金额)。这句话不是"直付余额"的意思。
|
||
|
||
---
|
||
|
||
## 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 路 |
|
||
|
||
- 单笔赢额 = `符号倍率(赔付表) × ways × 投注总额 × 本波阶梯倍率`。
|
||
- 多个符号各自独立成奖、**累加**(互不影响,各有各的前缀链);同一符号只按上述规则算一笔,不存在"同符号多个子路径重复叠加"。
|
||
- 参考例子(截图):符号 `?` 三连赔 10(原版点数,= **0.5 倍**),ways=6(各轴 1/3/2)→ 本笔 3.00 倍。
|
||
|
||
#### 2.1.1 赔付算例(**原样复现截图里那个 `1×3×2=6` 的例子**)
|
||
|
||
**盘面**(投注总额 **¥20**):
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | **B** | **B** | **B** | E | H |
|
||
| 行2 | D | **B** | **B** | G | F |
|
||
| 行3 | E | **B** | D | A | G |
|
||
| 行4 | H | C | E | F | D |
|
||
|
||
(**`B` = 中**,本波的中奖符号;其余是干扰符号,故意让它们在第 2 轴就断)
|
||
|
||
**第 1 步 — 逐轴数「中」的个数**(从第 1 轴往右数,**断了就停**):
|
||
|
||
| 轴 | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 「中」的个数 | **1** | **3** | **2** | **0** | 不看 |
|
||
| 判定 | ✓ | ✓ | ✓ | ✗ **断** | — |
|
||
|
||
→ 连续了 **3 个轴** ⇒ 档位取 **3 连**
|
||
|
||
**第 2 步 — 算路数**:`ways = 1 × 3 × 2 = 6`
|
||
|
||
**第 3 步 — 查赔付表**:「中」的 3 连 = **0.5 倍**
|
||
|
||
**第 4 步 — 算金额**(**赔付表已经是倍率,直接乘,没有中间商**):
|
||
|
||
```
|
||
本笔赢额 = 符号倍率 × ways × 投注总额 × 本波阶梯倍率
|
||
= 0.5 × 6 × ¥20 × 1
|
||
= 3.00 倍 × ¥20 = ¥60
|
||
```
|
||
|
||
→ **本笔赢额 ¥60**,也就是 **3.00 倍投注总额**。
|
||
|
||
**换个投注档位看**(同一个盘面、同一笔奖):
|
||
|
||
| 投注总额 | 计算 | 本笔金额 | 本笔倍率 |
|
||
|---|---|---|---|
|
||
| ¥1 | 0.5 × 6 × 1 × 1 | ¥3.00 | **3.00x** |
|
||
| ¥20 | 0.5 × 6 × 20 × 1 | ¥60 | **3.00x** |
|
||
| ¥100 | 0.5 × 6 × 100 × 1 | ¥300 | **3.00x** |
|
||
| ¥500 | 0.5 × 6 × 500 × 1 | ¥1500 | **3.00x** |
|
||
|
||
> 倍率**恒定 3.00x**,金额随投注线性放大 —— 这就是我们要的性质。
|
||
|
||
**和原版截图对照**:截图写 `1 × 3 × 2 = 6`(第 2 步)→ `10 × 6 = 60`(第 3、4 步)。
|
||
原版用的是"**每条基础投注的点数**",我们用"**倍率**",两者差一个固定系数 20:
|
||
`10 点 ÷ 20 = 0.5 倍`;`60 点 ÷ 20 = 3.00 倍` —— **结果完全一致,只是换了说法**。
|
||
|
||
**反例(截图右边那个 ✗)**:只要**中间某一轴一个「中」都没有**,链就在那一轴断掉 ⇒ **整笔不成奖**(不是"按 1×3=3 路算",是 **0**)。所以 `1、2、4` 有、`3` 没有 ⇒ 不成奖。
|
||
|
||
**同一笔落在不同波的结果**:
|
||
|
||
| 波 | 阶梯 | 计算 | 赢额 |
|
||
|---|---|---|---|
|
||
| 第 1 波 | x1 | 0.5 × 6 × 20 × 1 = 3.00 倍 | ¥60 |
|
||
| 第 2 波 | x2 | 0.5 × 6 × 20 × 2 = 6.00 倍 | ¥120 |
|
||
| 第 3 波 | x3 | 0.5 × 6 × 20 × 3 = 9.00 倍 | ¥180 |
|
||
| 第 4 波起 | x5 | 0.5 × 6 × 20 × 5 = 15.0 倍 | **¥300** |
|
||
|
||
**⚠️ 为什么不能把赔付表当成"倍投注总额"直接用**:原版那张表写的是 `10 / 60 / 100` 这种"点数",如果我们**原样照抄、又不除以 20**,这一笔就会变成 `10 × 6 × ¥20 = ¥1200`(**60 倍投注**)—— 基础旋 RTP 直接冲到几百个百分点,游戏发不出去。
|
||
**现在我们改成直接把赔付表写成倍率(0.5 / 3 / 5 …),这个问题就自然消失了** —— 见 §2.1 上方那段说明。
|
||
|
||
**赔付表**(单位:**倍投注总额**;原版截图上的"点数"已预先 ÷20 内化,见下方说明):
|
||
|
||
| 短码 | code | 中文名 | 3 连 | 4 连 | 5 连 |
|
||
|---|---|---|---|---|---|
|
||
| `A` | FAT_CHOI | 發 | **0.75** | **3** | **5** |
|
||
| `B` | RED_DRAGON | 中 | **0.5** | **2** | **4** |
|
||
| `C` | WHITE_DRAGON | 白板(紫框) | **0.4** | **1** | **3** |
|
||
| `D` | CHAR_8 | 八萬 | **0.3** | **0.75** | **2** |
|
||
| `E` | DOT_MULTI | 花筒(四色) | **0.2** | **0.5** | **1** |
|
||
| `F` | BAMBOO_MULTI | 索(红绿) | **0.2** | **0.5** | **1** |
|
||
| `G` | DOT_PURPLE | 筒(紫绿) | **0.1** | **0.25** | **0.5** |
|
||
| `H` | BAMBOO_GREEN | 索(绿) | **0.1** | **0.25** | **0.5** |
|
||
| `W` | WILD | 百搭(元宝) | — | — | — | **不由卷轴落下**,只能由金色符号转化产生(**每个金色格各留 1 个,无上限**),且只可能出现在第 2/3/4 轴 |
|
||
| `S` | SCATTER | 胡(夺宝符号) | — | — | — | 只触发免费旋转,不赔现金 |
|
||
|
||
> ✅ **赔付表直接用"倍投注总额"表达(Q0 定案;原版的"÷20"已内化进表里)**
|
||
>
|
||
> 原版规则页那张表的数字是 `15 / 60 / 100` 这类**"每条基础投注的点数"**——它规定「游戏共有 1,024 个中奖路(固定)**并规定了 20 条基础投注**」,所以要除以 20 才是倍率。
|
||
> **我们不改数学,只改写法**:把那张表**预先除以 20**,赔付表上直接就是倍率。于是公式里不再出现 ÷20。
|
||
>
|
||
> **排除法(确认"原版点数 ÷ 20 = 倍率"这个换算是对的)**:用真实运行截图(投注总额 ¥8.00、横幅「共赢得 **0.80**」、阶梯条高亮 **x2**)
|
||
>
|
||
> | 假设 | 推论 | 结论 |
|
||
> |---|---|---|
|
||
> | 这 ¥0.80 **已含** x2 倍率 | 基准价 = 0.80 ÷ 2 = ¥0.40 = **1 个赔付单位** | ❌ 赔付表里**没有值为 1 的档**(最小是 2),不可能 |
|
||
> | 这 ¥0.80 **未含**倍率(x1 基准价) | 最低档 3 连(原版点数 2)× ways 1 × (8.00 ÷ 20) = **¥0.80** ✅ | ✅ 成立,且 `8.00 ÷ 20 = 0.40` **正好** |
|
||
>
|
||
> → 原版点数 **2** 对应倍率 `2 ÷ 20 = **0.1 倍**`,与 `¥0.80 ÷ ¥8.00 = 0.1` 完全吻合 ✅
|
||
> 同时确认「阶梯条高亮 x2」表示的是**下一波(第 2 波)即将生效的倍率**,不是本波已生效的。
|
||
>
|
||
> **所以本游戏的完整赔付公式是(没有 ÷20)**:
|
||
> ```
|
||
> 本笔赢额 = 赔付表[X][连轴数] × ways × 投注总额 × 本波阶梯倍率
|
||
> ```
|
||
> 赔付表见上方表格(**已经是倍率**:發 3 连 0.75、中 3 连 0.5、筒 3 连 0.1 ……)。
|
||
>
|
||
> **换算对照表**(原版点数 → 我们的倍率,一个固定系数 20):
|
||
>
|
||
> | 符号 | 原版点数(3连 / 4连 / 5连) | 我们的倍率(3连 / 4连 / 5连) |
|
||
> |---|---|---|
|
||
> | 發 | 15 / 60 / 100 | **0.75 / 3 / 5** |
|
||
> | 中 | 10 / 40 / 80 | **0.5 / 2 / 4** |
|
||
> | 白板 | 8 / 20 / 60 | **0.4 / 1 / 3** |
|
||
> | 八萬 | 6 / 15 / 40 | **0.3 / 0.75 / 2** |
|
||
> | 花筒 / 索红绿 | 4 / 10 / 20 | **0.2 / 0.5 / 1** |
|
||
> | 筒 / 索绿 | 2 / 5 / 10 | **0.1 / 0.25 / 0.5** |
|
||
>
|
||
> **好处(为什么这么改更好)**:
|
||
> ① 公式少一步,实现、对账、仿真都不用再记那个 20;
|
||
> ② **赔付表与饰品桶表用同一个单位(倍率)** —— 后台看到桶是「0.5 倍 / 1.5 倍」,赔付表也是「0.5 倍 / 3 倍」,一脉相承,和玫瑰女郎的口径也一致;
|
||
> ③ 规则页不用再标「每条基础投注」,玩家看到 0.75 就知道是 0.75 倍,不会误解;
|
||
> ④ 生成端算出来的 `raw_mul` **直接就是倍率**,落桶不用再换算。
|
||
>
|
||
> **唯一的取舍**:赔付表上的数字会**比原版"看着小"**(原版 `100` vs 我们 `5`)。这是原版把数字放大 20 倍展示的结果,数学完全一样。
|
||
> 如果运营坚持"要看着慷慨",只需在**客户端展示层**把倍率 ×20 显示成原版数字(并且**必须标注"每条基础投注"**)——**内部数学一行都不用改**。
|
||
>
|
||
> **⚠️ 千万不能只做一半**:如果照抄原版的 `15/60/100`、又去掉 ÷20,这一笔就会变成 `10 × 6 × ¥20 = ¥1200`(**60 倍投注**),基础旋 RTP 冲到几百个百分点,游戏发不出去。
|
||
>
|
||
> **真机复核(30 秒,确认换算没搞错)**:原版投注总额 **¥8.00** 时,所有中奖金额都必须是 **¥0.40 的整数倍**(0.80 / 1.20 / 1.60 / 2.00 …);最低档符号 3 连 1 路应赔 **¥0.80**(= 0.1 倍)。
|
||
|
||
> 出现权重 `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 个百搭** |
|
||
|
||
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且**不限张数**——每个金色格被消除时各就地留一个(见 §2.2 铁律)。
|
||
> 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。
|
||
- **胡(夺宝/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. 金色的中奖格 → 该符号被消耗;**每个金色格各自就地写入 W**(无数量上限)
|
||
(这些格子**不是空洞**、不补新符号;同波每个金色格都按这一步处理)
|
||
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` 就算"涉及中奖"(于是该格被消除、就地转成 `W`)。⚠️ **金色格只当它自己的自然符号用,不做别的符号的替代**——金色「中」只算「中」;"顶替别人"这件事**只有元宝 `W` 会做**(`W` 既能顶替金色格的符号,也能顶替任何普通符号)。这是 §2.2 铁律 + §15 第 1 波算例共同确定的口径:**若金色格同时还能当百搭,§15 第 1 波白板链的 `ways` 就会从 2 变成 4,全盘数学都会变。**
|
||
> 注意:盘面上可以有**多个**百搭(每个金色格各留一个),且**同一个 `W` 可以被多个符号同时借用**(每条链各算各的,见 §15 第 4 波:一个 `W` 同时支撑筒与索红绿两条 5 连)——判定时逐个百搭独立参与每条链,既不限量、也不去重。
|
||
|
||
**其余约定**:
|
||
|
||
- 每旋开局(基础旋与每个 FS 旋)对**第 2/3/4 轴**上的普通符号按概率 `GOLDEN_CELL_PROB`(建议 0.10,逐格独立)打金色标记,不动 `W`/`S`;新补入的符号也可以带金色标记。
|
||
- 转化后的 `W` **不再回填金色标记**(每格只转化一次)。
|
||
- 金色符号在**波次判定时用的是自然符号**,所以"金色「中」链"就是「中」的链;转化后下一波起它才变成通用百搭。
|
||
- 金色格也受"百搭同样会被消除"约束(§2.2 边界二):本波转成 W 之后,若在后面的波里参与成奖,就随那一波被消除。
|
||
|
||
### 2.4 乘数阶梯(奖金翻倍)—— 「本波阶梯倍率」是什么
|
||
|
||
**先把三个词定死,后面全篇都按这个来**:
|
||
|
||
| 词 | 含义 | 例子 |
|
||
|---|---|---|
|
||
| **一波**(rotation) | 级联里的**一次消除结算**。消除→掉落→补位之后,就是**下一波** | 第 1 波、第 2 波… |
|
||
| **符号倍率** | 从**赔付表**查出来的那个数(不含任何阶梯) | 中 3 连 = **0.5** |
|
||
| **本波阶梯倍率** | **这一波整体适用的阶梯倍率**——本波所有中奖都要乘它 | 第 1 波 x1、第 2 波 x2… |
|
||
|
||
**它们怎么串起来**:
|
||
|
||
```
|
||
本笔基础倍率 = 符号倍率 × ways ← 一笔奖(一个符号的一条链)
|
||
本波基础倍率 = Σ 本波所有「本笔基础倍率」 ← 本波可能有多个符号同时中奖
|
||
本波赢额 = 本波基础倍率 × 本波阶梯倍率 × 投注总额
|
||
```
|
||
|
||
**本波阶梯倍率取多少**:
|
||
|
||
- **基础旋转**:一次消除 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),**不跨旋累计**
|
||
- HUD 就是截图里那条「1024 路中奖组合」横条下方的 `x1 x2 x3 x5`(当前档位金色高亮)
|
||
|
||
**完整算式串起来(一张表看懂)**:
|
||
|
||
| 环节 | 值 |
|
||
|---|---|
|
||
| 符号倍率 | `赔付表[X][连轴数]`(如 發 3 连 = 0.75、中 3 连 = 0.5) |
|
||
| × 路数 | `ways`(各轴个数的乘积) |
|
||
| = 本笔基础倍率 | 如 `0.5 × 6 = 3.00` |
|
||
| × 投注总额 | 直接乘(赔付表已经是倍率) |
|
||
| × 本波阶梯倍率 | 基础旋 `1/2/3/5`;FS 旋 `2/4/6/10` |
|
||
| = 本波赢额 | `本笔基础倍率 × 投注总额 × rotationMul` |
|
||
|
||
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.3)。
|
||
|
||
> 实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(`accumulator_before/after`,FS 内跨旋传递)机制**原封不动复用**——麻将胡了虽然乘数不跨旋累计,但"本波阶梯倍率 / 本波序号"需要每帧下发,字段名可直接沿用 `accumulator`(语义改为"本波阶梯倍率")以免客户端大改。**建议改名为 `rotationMul`**,避免误解。
|
||
|
||
### 2.5 级联(tumble)
|
||
|
||
每一波:结算 → 中奖符号(含已转化的百搭)爆炸消除 → 该轴上方符号整体下落 → 该轴顶部补入新符号 → 进入下一波。直到本波无任何成奖为止。下一旋开始时盘面是完全新的一屏。
|
||
|
||
### 2.6 免费旋转
|
||
|
||
- 触发:**普通旋转**里落 **3+ 个胡**(卷轴任意位置)→ 进入免费旋转。
|
||
- 首赠:**12 旋 + 每个额外胡 +2 旋**。
|
||
- 🎯 **一屏最多只出现 5 个胡**(用户 2026-09-21 定案)——**不会出现 6 个及以上**。
|
||
于是单次赠送被锁死在 **`{12, 14, 16}`**:
|
||
|
||
| 一屏胡数 | 赠送旋数 |
|
||
|---|---|
|
||
| 3 个 | **12** |
|
||
| 4 个 | **14** |
|
||
| 5 个(上限) | **16** |
|
||
| ~~6 个及以上~~ | 不出现 |
|
||
- 免费旋转内乘数阶梯为 `[x2, x4, x6, x10]`。
|
||
- 免费旋转内投注档与触发时一致(中途不换注)。
|
||
|
||
#### 重触发与上限(Q3 + Q11 定案)
|
||
|
||
- **重触发 = 同首触发公式**(规则页只有一句「免费旋转可以被重复触发」→ 那套规则再来一遍):
|
||
FS 内再落 3 / 4 / 5 个胡 → **再送 12 / 14 / 16 旋**。
|
||
- **总旋数上限 `MAX_FREE_SPINS = 36`**。
|
||
- 🎯 **硬规则:发不满就不发**(用户 2026-09-21 定案)——
|
||
生成端在**落 3+ 胡之前先检查**:
|
||
```
|
||
if 已发放总旋数 + 本次应赠旋数 ≤ 36 → 正常落 3+ 胡并发放
|
||
else → 本旋【不落】3+ 胡(绝不出现"落了胡但只补几旋")
|
||
```
|
||
用户原话:「**可以 36 旋,但是如果出现 32 旋了,后面的免费旋转肯定是不会再出 3 个以上的 fs 了。**」
|
||
- 🎯 **触发边界(用户追问确认)**:因为单次最少赠 12 旋,所以
|
||
|
||
```
|
||
已发放 ≤ 24 → 才可能再触发(24 + 12 = 36 ≤ 36)
|
||
已发放 ≥ 25 → 后面所有旋一律不再落 3+ 胡
|
||
```
|
||
|
||
⇒ **整局 FS 永远 ≤ 36 旋,37 旋以上百分之百不会出现**。
|
||
- **可达的旋数只有这 9 个值**(起点 12~16,每次追加 12~16,追加后不超 36):
|
||
|
||
```
|
||
12 / 14 / 16 ← 没重触发
|
||
24 / 26 / 28 / 30 / 32 ← 有重触发
|
||
36 ← 只有 12+12+12 这一种走法能到
|
||
```
|
||
|
||
其中 **26 就是"最后一次能触发的边界情况之一"**:`12 + 14 = 26` 之后 `26 + 12 = 38 > 36`,所以**从 26 旋往后,盘面再也不会出现 3 个及以上的胡**(见下方示例)。
|
||
- 举例:
|
||
|
||
| 时刻 | 检查 | 结果 |
|
||
|---|---|---|
|
||
| 基础旋落 3 个胡 | 0 + 12 = 12 ≤ 36 ✓ | 发 12 旋 |
|
||
| FS 第 3 旋又落 3 个胡 | 12 + 12 = 24 ≤ 36 ✓ | 发到 24 旋 |
|
||
| FS 第 10 旋又落 3 个胡 | 24 + 12 = **36** ≤ 36 ✓ | 发到 36 旋(顶到上限) |
|
||
| 已发放 **24** 旋时落 4 个胡 | 24 + 14 = 38 > 36 ✗ | **不落胡**(这一旋只能落"恰好 3 个胡") |
|
||
| 已发放 **26** 旋时落 3 个胡 | 26 + 12 = 38 > 36 ✗ | **不落胡,且后面所有旋都不再出胡** |
|
||
|
||
> 用户原话「再出现 fs,还会送 2 次」对应的是规则页**另一句**:「**每个额外的夺宝符号都将触发多 2 次的免费旋转**」——指同一屏里第 4、5 个胡各 +2 旋,**不是**重触发的额度。
|
||
> 三句合起来才是完整规则:① 一屏 3 个胡 = **12 旋**;② 每多 1 个胡 **+2 旋**(最多 5 个胡 → 16 旋);③ 这套规则**在 FS 内可以再来一遍**(受 36 旋上限 +"发不满就不发"约束)。
|
||
>
|
||
> 实现上保留常量以便口径反复时一行切换:`FS_RETRIGGER_AWARD = null`(`null` = 走首触发同式,当前口径;填整数 = 固定追加)。
|
||
|
||
### 2.7 封顶
|
||
|
||
- 单局总倍率封顶 **500x**(Q4 定案)。到达封顶即截断:剩余旋数作废、`maxWinReached=true`、`spinsRemaining=0`,保证"动画总赢额 = 实际到账"。
|
||
- 生成端在造回合时就按封顶截断并写 `win_capped` 标记(对齐 olympus `step.win_capped`)。
|
||
|
||
### 2.8 投注模型与下注档设计(推荐方案 A)
|
||
|
||
原版:投注大小 0.02~50.00 × 投注倍数 1~10,另有现金钱包与自动旋转。
|
||
|
||
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.2):
|
||
- `price(实际扣费) = box.bet`(本游戏 costMultiplier ≡ 1)
|
||
- **回合池里存的全是倍率**(`actual_mul`),与投注额无关 → **改档位不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
|
||
- 奖池水位按 `(boxId, audience)` 聚合,投注额只影响单局金额,不影响池结构。
|
||
|
||
**曾经的备选(已否)**:给"档位 × 倍数"每个组合建一个箱子 → 池子碎片化、水位口径混乱。
|
||
|
||
#### 2.8.1 该设计多少个下注额、哪几个、能多大?(先把成本分清)
|
||
|
||
**先分清两件事,因为成本差一个数量级:**
|
||
|
||
| 加什么 | 池子要不要动 | 单局理论最大赔付 | 成本 |
|
||
|---|---|---|---|
|
||
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 48 条配比〔16 桶 × 3 身份〕+ 一套水位) |
|
||
| **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **同箱内直接放大 10 倍** | 几乎为零 |
|
||
|
||
> ⚠️ **关键认识**:`betMul` 不是"免费做大",它**把同一箱内的金额跨度拉到 10 倍,等价于偷偷多开了 9 个档位**,却只用一个池子——风控口径和水位观察口径都会变粗。
|
||
> 而且在 500x 封顶下,`betMul` 会把**理论单局最大赔付**直接乘以 10。
|
||
> 所以本次**不再推荐"档位 × 倍数"双控件**,改为**只保留投注档**(与玫瑰女郎完全一致)。
|
||
|
||
#### 2.8.2 推荐方案(11 档,只保留投注档)
|
||
|
||
**结论:`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`,共 11 档;不设投注倍数。**
|
||
|
||
| boxId | 投注额(= 扣费) | boxId | 投注额 |
|
||
|---|---|---|---|
|
||
| 1 | ¥1 | 7 | ¥50 |
|
||
| 2 | ¥3 | 8 | ¥100 |
|
||
| 3 | ¥5 | 9 | ¥200 |
|
||
| 4 | ¥10 | 10 | ¥300 |
|
||
| 5 | ¥20 | **11** | **¥500** |
|
||
| 6 | ¥30 | | |
|
||
|
||
**为什么是这 11 档**
|
||
|
||
| 维度 | 测算 | 结论 |
|
||
|---|---|---|
|
||
| **下限(不丢分)** | 最小中奖 = 投注 ÷ 10(最低档符号 3 连 × 1 路 × x1);¥1 → **¥0.10** | ✅ 远高于 0.01 |
|
||
| **上限(风控)** | 单局理论最大赔付 = **500x × 500 = ¥250,000**;玫瑰女郎现状 = 1000x × 300 = **¥300,000** | ✅ **压在玫瑰女郎之下** |
|
||
| **档位取整** | 档位都是整数 → 任何中奖金额都是整分 | ✅ 不会出现除不尽的数 |
|
||
| **前 10 档** | 与玫瑰女郎**一字不差** | ✅ 玩家熟悉、报表口径统一 |
|
||
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 15~25 分钟 / 528 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
|
||
|
||
**为什么不设投注倍数**(与玫瑰女郎保持一致的做法)
|
||
|
||
1. **它会把风控上限放大 10 倍**:带 1~10 倍时,顶档只能压到 ¥50(50×10 = 500),实际覆盖反而更差;想保留大额档位就得放弃倍数。
|
||
2. **同一箱内金额跨度 10 倍**,奖池水位的观察口径变粗(水位是按箱 × 身份记的)。
|
||
3. **玩家要多调一个控件**,玫瑰女郎只有一个"选箱",本平台玩家已经习惯。
|
||
4. **想做大的正路是"加档"**:加一个箱不影响任何已有逻辑,**客户端不用改**(箱子列表是接口下发的),而加倍数做不到。
|
||
|
||
**如果以后要更大的数额**(升级路径,按需再开)
|
||
|
||
| 目标 | 做法 | 单局理论最大赔付 |
|
||
|---|---|---|
|
||
| 到 ¥1000 | 加第 12 档 ¥1000 | 500x × 1000 = **¥500,000** ⚠️ 超玫瑰女郎 67%(要同时降封顶,见下一行)|
|
||
| 到 ¥1000 **且守住风控** | 加 ¥1000 档的同时把封顶降到 **300x** | 300x × 1000 = **¥300,000** ✅ 正好对齐玫瑰女郎 |
|
||
| 到 ¥2000 | 加 ¥2000 档 + 封顶 150x | ¥300,000 ✅(但 150x 封顶会让桶表整体缩一档,要重配池子) |
|
||
|
||
> 每加一档的成本:+4500 回合 / +8 万帧 / +48 条配比行(16 桶 × 3 身份)/ 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
|
||
|
||
**已否掉的方案**
|
||
|
||
- ❌ 原版双控件(档位 1~300 × 倍数 1~10):顶档 ¥3000 一注、单局理论最大赔付 **¥1,500,000**(玫瑰女郎的 5 倍),且同箱金额跨度 10 倍。
|
||
- ❌ 12 档到 ¥1000 + 倍数 1~10(最高 ¥10,000 一注):单局理论最大赔付 **¥5,000,000**,是玫瑰女郎的 16 倍,风控口径直接破。
|
||
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.3),当成总额抄过来会让最低中奖显示成 +0.00。
|
||
- **冷门高档位会水位饥饿**:¥300 / ¥500 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放前 9 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
|
||
|
||
**为什么不是更多档**
|
||
|
||
1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束):
|
||
- 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
|
||
- 多 48 条**配比行**(16 桶 × 3 身份)要配、要重算、要维护模板;
|
||
- 多一套**奖池水位**(4 个身份各一条)要运营盯。
|
||
11 档 → 5 万回合 / 90 万帧 / 生成 15~25 分钟 / 528 条配比行,正好是玫瑰女郎已经在跑的量级。
|
||
2. **档位越多,冷门档越容易"饿死"**:奖池水位是**按箱独立**的,没人玩的档水位长期为 0 → `payoutCap = max(0, 水位+成本) × rtp` 被压到接近 0 → **那个档几乎出不了奖**,只能靠运营注水救。所以档位不是越多越好。
|
||
3. **玩家侧足够**:1~500 共 11 档,比原版 0.02~50 的覆盖面更宽;相邻档位间距 2~3 倍,选注手感是行业常规。
|
||
4. **对齐玫瑰女郎**:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。
|
||
|
||
**不推荐的做法**
|
||
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.3 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
|
||
- ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
|
||
- ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。
|
||
|
||
**两个附带决策**
|
||
- **档位表里 3 / 30 保留**(它们是整数,只是不是 10 的整数倍;玫瑰女郎就这么定的,玩家点"+"往上加时体验更顺),不改成 2 / 25 之类。
|
||
- **不设投注倍数**;接口里的 `betMul` 作为**预留字段**保留(缺省 1、当前服务端只接受 1),万一以后要开也不用动池子。
|
||
|
||
> ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。
|
||
|
||
#### 2.8.3 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
|
||
|
||
用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。
|
||
|
||
**约束一:来自赔付表 —— 最低档下注的最低中奖不能小于 ¥0.01**
|
||
|
||
赔付公式里最小的那一笔是:最低档符号(`G`/`H`,**3 连赔 0.1 倍**)× ways **1** × 第 1 波阶梯 **x1**:
|
||
|
||
```
|
||
最小中奖 = 0.1 × 1 × 投注总额 = 投注总额 × 0.1
|
||
```
|
||
|
||
| 投注总额 | 最小中奖 | 结论 |
|
||
|---|---|---|
|
||
| ¥0.10 | ¥0.01 | ⚠️ 理论下限,刚好不丢分 |
|
||
| **¥1(建议最低档)** | **¥0.10** | ✅ 留 10 倍余量 |
|
||
|
||
→ **`box.bet ≥ 0.10` 是硬下限**(我们的模型里 `box.bet` = **投注总额**)。
|
||
|
||
> ⚠️ **关于原版"投注大小 0.02~50.00"的理解**:规则页有一条「一条中奖路所得的奖励等同于赔付表上的赔付值**乘以投注大小和投注倍数**」。
|
||
> **已确认(用户口径)**:原版的「投注大小」**就是投注总额**——设成 1.00 就显示 1.00,不 ×20。所以那条规则里的"点数"必须按**"游戏规定了 20 条基础投注"折算**,实际算式是 `点数 × ways × (投注总额 ÷ 20) × 倍数`。
|
||
> 由此得到正确结论:原版最低档 `0.02` 的**最小中奖 = 2 × (0.02 ÷ 20) = ¥0.002** —— 那正是"社娱场面额"下会显示成 `+0.00` 的原因,也再次说明**原版的档位不能直接抄成本平台的总额**。
|
||
> 我们的口径不变:`box.bet` 是**投注总额**、赔付表已把 `÷20` 内化成倍率、11 档从 ¥1 起(最小中奖 ¥0.10)。
|
||
|
||
**附带的一个好性质**:**赔付表里的倍率都是 0.05 的整数倍**(0.1 / 0.2 / 0.25 / 0.3 / 0.4 / 0.5 / 0.75 / 1 / 2 / 3 / 4 / 5),而投注档又都是整数金额,所以**任何一笔中奖金额都是整分**,不会出现 0.0333 这种除不尽的数。建议投注档表**只放整数**,把这条性质保住。
|
||
|
||
**约束二:来自饰品区间表 —— 它只决定"发哪件饰品",不决定赔多少钱**
|
||
|
||
⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低档饰品的价格决定每局保底返奖",**是错前提**。真实模型:
|
||
|
||
- olympus 写背包用的是 `.ornamentsPrice(win)` —— **饰品的"价格"记的就是本局赢额**;
|
||
- 平台分解入账是 `Σ TtBoxRecords.ornamentsPrice`(`service-user/.../ApiUserPackSackServiceImpl.settleDecomposeBySource`)——**玩家最终拿到的钱就是这个数**;
|
||
- 玫瑰女郎线上 dump 里 1 号饰品(安慰奖)的注释也写着「兜底饰品 1是(**仅展示不覆盖金额**)」。
|
||
|
||
⇒ **每局实付 = `record.settled_win = win`**(空转局 = `max(totalWin, 0.01)` = **¥0.01**),**饰品只是载体**。所以"必得饰品"的真实保底成本:
|
||
|
||
| 兜底金额 | 空转率 | 投注 ¥1 时的保底 RTP |
|
||
|---|---|---|
|
||
| **¥0.01(当前实际值)** | 65% | **0.65%** ✅ 可忽略 |
|
||
| (假设哪天把兜底抬到 ¥0.50) | 65% | 32.5% ⚠️ 那才需要担心 |
|
||
|
||
→ **`prize_range` 不是 RTP 旋钮**:区间有空洞或没覆盖,只会让那一档走 `findPrizeWithFallback` **换一件饰品发**,**金额不变**(`settled_win` 照旧)。
|
||
→ 对账口径:水位里的"派彩"用 **`record.settled_win`**(= 本局 win = 背包记录里的 `ornaments_price`)。
|
||
|
||
**结论**:下注档表要满足
|
||
1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起)
|
||
2. 档位取整数(保证 unit 是整分)
|
||
3. ~~最低档饰品价 ≤ ¥0.05 量级~~ **(作废:饰品价格 = 赢额,不是成本项)** —— 真正要盯的是**空转兜底金额** `max(totalWin, 0.01)` 这个常量,目前 ¥0.01、可忽略
|
||
|
||
模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。
|
||
|
||
| boxType | 名称 | costMultiplier | 语义 |
|
||
|---|---|---|---|
|
||
| `normal` | 普通旋转 | **1x** | 唯一的玩法:自然触发免费旋转(3+ 胡) |
|
||
|
||
> 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套):
|
||
> - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet`;
|
||
> - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶;
|
||
> - 箱数从 30 降到 **11**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
|
||
> - 高倍桶(100x 以上)**只能靠自然触发 FS 的回合供给**,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动**构造**高倍局,不能靠碰运气(见 §5.4 风险)。
|
||
|
||
---
|
||
|
||
### 2.9 赢奖计算公式(最终版 · 一页速查)
|
||
|
||
> 这就是**唯一一套**公式。生成端用它造回合;运行端只用最后一行的结论。
|
||
|
||
**常量(配置,写死在 `config.py`)**
|
||
|
||
| 常量 | 值 | 说明 |
|
||
|---|---|---|
|
||
| `PAYTABLE[X][n]` | 0.75 / 0.5 / 0.4 / 0.3 / 0.2 / 0.1 … | **符号倍率**,单位 = 倍投注总额(表见 §2.1) |
|
||
| `LADDER_BASE` | `(1, 2, 3, 5)` | 基础旋的**本波阶梯倍率** |
|
||
| `LADDER_FS` | `(2, 4, 6, 10)` | 免费旋的**本波阶梯倍率** |
|
||
| `MAX_WIN_CAP_MUL` | **500** | 单局封顶倍率 |
|
||
| 投注总额 | `box.bet` | 玩家选的投注档(11 档,无倍数) |
|
||
|
||
**层级公式(自下而上四层)**
|
||
|
||
```
|
||
【第 1 层】一笔奖(某符号的一条前缀链)
|
||
n = 链长(从第 1 轴起连续有「该符号或百搭」的轴数,n ∈ {3,4,5})
|
||
ways = 链内各轴「该符号 + 百搭」个数的乘积
|
||
本笔基础倍率 = PAYTABLE[X][n] × ways
|
||
|
||
【第 2 层】一波(rotation = 一次消除结算)
|
||
本波阶梯倍率 = ladder[min(波序, 4)] # 基础旋 1/2/3/5;FS 旋 2/4/6/10
|
||
本波基础倍率 = Σ 本波所有符号的所有「本笔基础倍率」
|
||
本波赢额(倍率) = 本波基础倍率 × 本波阶梯倍率
|
||
|
||
【第 3 层】一旋(spin)
|
||
本旋倍率 = Σ 本旋所有波的「本波赢额(倍率)」
|
||
(注意:本波阶梯倍率**每旋重置**,不跨旋累计)
|
||
|
||
【第 4 层】一局(round)
|
||
本局原始倍率 = Σ 所有旋的「本旋倍率」
|
||
本局总倍率 = min(本局原始倍率, 500) # ← 封顶在这里截断
|
||
本局赢额 = 本局总倍率 × 投注总额
|
||
```
|
||
|
||
**合并成一行(实现速查)**
|
||
|
||
```
|
||
本局赢额 = min( Σ旋 Σ波 [ (Σ符号 PAYTABLE[X][n_X] × ways_X) × ladder[min(波序,4)] ] , 500 ) × 投注总额
|
||
```
|
||
|
||
**运行端只有一行**
|
||
|
||
因为回合池里**存的就是倍率**(`round.actual_mul`,见 §6.7),运行时不需要重算上面那套:
|
||
|
||
```
|
||
本局赢额 = round.actual_mul × 投注总额 # 与玫瑰女郎同一个公式
|
||
```
|
||
|
||
→ 再走「落 `prize_range` 区间 → 匹配饰品 → 写背包」发奖(§6.3)。
|
||
|
||
**示例核对**(数据来自 §15 那个完整盘面,投注总额 ¥100)
|
||
|
||
| 波 | 一笔奖 | 符号倍率 | ways | 本笔基础倍率 | 本波阶梯倍率 | 本波基础倍率(合计) | 本波赢额(倍率) | 本波赢额(钱) |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| 1 | 中 3 连 | 0.5 | 4 | 2.00 | x1 | **2.80** | 2.80 | ¥280 |
|
||
| 1 | 白板 3 连 | 0.4 | 2 | 0.80 | x1 | ↑ 并入上行 | ↑ | ↑ |
|
||
| 2 | 八萬 4 连 | 0.75 | 4 | 3.00 | x2 | 3.00 | 6.00 | ¥600 |
|
||
| 3 | 花筒 5 连 | 1 | 1 | 1.00 | x3 | 1.00 | 3.00 | ¥300 |
|
||
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.40** | 12.00 | ¥1200 |
|
||
| 4 | 索红绿 5 连 | 1 | 1 | 1.00 | x5 | ↑ 并入上行 | ↑ | ↑ |
|
||
| 4 | 白板 3 连 | 0.4 | 1 | 0.40 | x5 | ↑ 并入上行 | ↑ | ↑ |
|
||
|
||
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 12.00 = 23.80 倍**(未触 500x 封顶)
|
||
- **本局赢额 = 23.80 × ¥100 = ¥2380** ✓(与 §15 总账一致)
|
||
- 全程只有 **1 个胡** → 若满 3 个胡就会先走 FS(FS 内阶梯换成 `2/4/6/10`,其余公式不变)
|
||
|
||
---
|
||
|
||
### 2.9.1 完整消除示例:跟着公式走一遍,看钱怎么来
|
||
|
||
> **投注档 ¥20**(= 投注总额,无倍数)。3 波结束。为了看清公式,这个盘面**故意不含百搭/金色/胡**。
|
||
> 图例:`A`發 `B`中 `C`白板 `D`八萬 `E`花筒 `F`索红绿 `G`筒 `H`索绿。
|
||
|
||
#### 第 1 波(基础旋第 1 波 → 本波阶梯倍率 = **x1**)
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | **B** | **C** | **B** | E | H |
|
||
| 行2 | D | **B** | A | G | F |
|
||
| 行3 | E | D | **C** | A | G |
|
||
| 行4 | **C** | E | H | F | D |
|
||
|
||
**① 逐符号找链 → 算「本笔基础倍率」**
|
||
|
||
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 链长 n | ways | 符号倍率 | 本笔基础倍率 |
|
||
|---|---|---|---|---|---|---|---|---|
|
||
| 中 `B` | 1(行1) | 1(行2) | 1(行1) | 0(E/G/A/F) | **3** | 1×1×1 = **1** | 0.5 | **0.50** |
|
||
| 白板 `C` | 1(行4) | 1(行1) | 1(行3) | 0 | **3** | 1×1×1 = **1** | 0.4 | **0.40** |
|
||
| 八萬 `D` | 1(行2) | 1(行3) | 0 | — | — | — | — | 轴3 断,不成奖 |
|
||
| 花筒 `E` | 1(行3) | 1(行4) | 0 | — | — | — | — | 轴3 断 |
|
||
| 其余 | 轴1 里没有 | — | — | — | — | — | — | 不成奖 |
|
||
|
||
**② Σ 得「本波基础倍率」**:`0.50 + 0.40 = **0.90**`
|
||
**③ × 本波阶梯倍率**:`0.90 × x1 = **0.90 倍**`
|
||
**④ × 投注总额**:`0.90 × ¥20 = **¥18**`
|
||
|
||
> 注意这一波**赢了但不到 1 倍**——只中了 3 连、ways 又只有 1,这是麻将基础旋的常态。想赚钱要靠**更多的 ways** 和**更高的波次**。
|
||
|
||
**消除 6 格**:(行1,轴1)B、(行2,轴2)B、(行1,轴3)B、(行4,轴1)C、(行1,轴2)C、(行3,轴3)C → 全部变空洞(本波无金色 → 不产生百搭)
|
||
|
||
**落定后**(补位取值见下):
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | **E** | **G** | **F** | E | H |
|
||
| 行2 | **A** | **A** | **G** | G | F |
|
||
| 行3 | D | D | A | A | G |
|
||
| 行4 | E | E | H | F | D |
|
||
|
||
#### 第 2 波(第 2 波 → 本波阶梯倍率 = **x2**)
|
||
|
||
**① 找链**
|
||
|
||
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 链长 n | ways | 符号倍率 | 本笔基础倍率 |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| 發 `A` | 1(行2) | 1(行2) | 1(行3) | 1(行3) | 0 | **4** | 1×1×1×1 = **1** | 3 | **3.00** |
|
||
| 八萬 `D` | 1(行3) | 1(行3) | 0 | — | — | — | — | — | 轴3 断 |
|
||
| 花筒 `E` | 2(行1、行4) | 1(行4) | 0 | — | — | — | — | — | 轴3 断 |
|
||
| 其余 | 轴1 里没有 | — | — | — | — | — | — | — | 不成奖 |
|
||
|
||
**② 本波基础倍率** = `3.00`
|
||
**③ × x2** = `6.00 倍` **④ × ¥20** = **¥120**
|
||
|
||
**消除 4 格**:(行2,轴1)A、(行2,轴2)A、(行3,轴3)A、(行3,轴4)A(无金色 → 无百搭)
|
||
|
||
**落定后**(轴1 行1 补 `G`、轴2 行1 补 `G`、轴3 行1 补 `D`、轴4 行1 补 `D`):
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | **G** | **G** | **D** | **D** | H |
|
||
| 行2 | E | G | F | E | F |
|
||
| 行3 | D | D | G | G | G |
|
||
| 行4 | E | E | H | F | D |
|
||
|
||
#### 第 3 波(第 3 波 → 本波阶梯倍率 = **x3**)
|
||
|
||
**① 找链**
|
||
|
||
| 符号 | 轴1 | 轴2 | 轴3 | 轴4 | 轴5 | 链长 n | ways | 符号倍率 | 本笔基础倍率 |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| **八萬 `D`** | 1(行3) | 1(行3) | 1(行1) | 1(行1) | 1(行4) | **5** | 1×1×1×1×1 = **1** | **2** | **2.00** |
|
||
| **筒 `G`** | 1(行1) | **2**(行1、行2) | 1(行3) | 1(行3) | 1(行3) | **5** | 1×**2**×1×1×1 = **2** | **0.5** | **1.00** |
|
||
| 花筒 `E` | 2(行2、行4) | 1(行4) | 0 | — | — | — | — | — | 轴3 断 |
|
||
| 其余 | 轴1 里没有 | — | — | — | — | — | — | — | 不成奖 |
|
||
|
||
**② 本波基础倍率** = `2.00 + 1.00 = 3.00`(**两个符号同时中奖,相加**)
|
||
**③ × x3** = `9.00 倍` **④ × ¥20** = **¥180**
|
||
|
||
→ 补位取"不会再形成新链"的组合 → **本旋结束(共 3 波)**
|
||
|
||
#### 第 4 层:算出整局金额
|
||
|
||
| 波 | 本波基础倍率 | 本波阶梯倍率 | 本波赢额(倍率) | 本波赢额(钱) |
|
||
|---|---|---|---|---|
|
||
| 1 | 0.90 | x1 | **0.90** | ¥18 |
|
||
| 2 | 3.00 | x2 | **6.00** | ¥120 |
|
||
| 3 | 3.00 | x3 | **9.00** | ¥180 |
|
||
| | | | **本局总倍率 15.90** | **¥318** |
|
||
|
||
**套最终公式**:
|
||
|
||
```
|
||
本局总倍率 = min(0.90 + 6.00 + 9.00, 500) = min(15.90, 500) = 15.90 ← 未触顶,原样保留
|
||
本局赢额 = 15.90 × ¥20 = ¥318
|
||
```
|
||
|
||
**发奖**:`¥318` 落 `prize_range` 的对应区间(例如 250~400)→ 匹配到那件饰品 → 写背包(`MAHJONG_BOX(15)`)→ 末帧玩家选「放入背包 / 立即分解」。
|
||
`record.total_win = 15.90`、`record.settled_win = 318`、`round.actual_mul = 15.90`。
|
||
|
||
> **如果这是免费旋转里的旋**:只需把「本波阶梯倍率」换成 `LADDER_FS = (2,4,6,10)`,其余每一层**完全一样**。例如本示例第 1 波落在 FS 里就是 `0.90 × x2 = 1.80 倍 = ¥36`。
|
||
|
||
---
|
||
|
||
## 3. RTP 与桶设计(数学核心)
|
||
|
||
### 3.1 饰品桶表(16 档,0x ~ 500x)—— **沿用玫瑰女郎的桶表结构**
|
||
|
||
**直接沿用玫瑰女郎那张 17 档桶表,只去掉最高那档**(它到 1000x,我们封顶 500x,1000x 那档永远不会被命中)。
|
||
这样后台页面、配比算法、收敛逻辑、运营经验**全部零改动**;与玫瑰女郎的差异只有顶桶一行。
|
||
|
||
物理表名 `game_mahjong_bucket`。**后台命名一律沿用现网玫瑰女郎,不自造新词**:
|
||
- 菜单名 = **「饰品列表」**(与 rose 的 `game/olympus/ornament/index` 同款;别叫"倍率桶")
|
||
- 箱子页里的动作按钮 = **「饰品配置」**(别叫"桶配比")、**「模板管理」**
|
||
- 桶的 `name` 值也照母本:`0x / 0.5x / 1.5x / 2.5x / 4x / 6.5x / 10x / 16x / 25x / 40x / 60x / 100x / 150x / 200x / 300x / 500x`
|
||
- 箱子 `name` = **下注额本身**(`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`):本游戏**只有一个模式**,所以**不加** `{模式名}-bet` 前缀(母本有前缀是因为它有 normal/ante/buy_fs 三种模式)——2026-09-22 用户定;别叫"¥1 档"
|
||
|
||
| 桶ID | 名称 | 期望倍率 | 落桶下限 | 落桶上限 |
|
||
|---|---|---|---|---|
|
||
| 1 | 0x | 0 | 0 | 0 |
|
||
| 2 | 0.5x | 0.5 | 0.01 | 1 |
|
||
| 3 | 1.5x | 1.5 | 1 | 2 |
|
||
| 4 | 2.5x | 2.5 | 2 | 3 |
|
||
| 5 | 4x | 4 | 3 | 5 |
|
||
| 6 | 6.5x | 6.5 | 5 | 8 |
|
||
| 7 | 10x | 10 | 8 | 12 |
|
||
| 8 | 16x | 16 | 12 | 20 |
|
||
| 9 | 25x | 25 | 20 | 30 |
|
||
| 10 | 40x | 40 | 30 | 50 |
|
||
| 11 | 60x | 60 | 50 | 70 |
|
||
| 12 | 100x | 100 | 70 | 130 |
|
||
| 13 | 150x | 150 | 130 | 170 |
|
||
| 14 | 200x | 200 | 170 | 230 |
|
||
| 15 | 300x | 300 | 230 | 370 |
|
||
| **16** | **500x(封顶)** | **500** | 370 | **700** |
|
||
|
||
约定(与玫瑰女郎一致):
|
||
- **期望倍率 = (落桶下限 + 落桶上限) / 2**;区间**连续无缝、覆盖 0~封顶**。
|
||
- **桶 1 = 空转**(本局没有任何 1024 路中奖)。注意:**空转局照样发最低档饰品**("必得饰品",见 §6.3),所以桶 1 也是要配库存的。
|
||
- **桶 16 = 封顶档**:生成端**先按 500x 截断得 `actual_mul`、再落桶**(顺序不能反——先落桶再截断会让 500x 以上的局无桶可落);区间上界仍留 **700** 作缓冲,**实际发放一律 = 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
|
||
- 因为 500x 封顶,桶 16 的池内实测均值会明显低于名义 500(被截断的局都堆在这里),配比计算按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是名义 1000、实用 835)。
|
||
|
||
**这张表**只管两件事:① 一局落在哪个倍率档(配比与库存的单位);② RTP 配平。
|
||
**实际发哪一件饰品**是另一回事——由 `prize_range` **按金额(¥)匹配**,见 §3.8。
|
||
|
||
**配置量与规模**
|
||
- `16 桶 × 11 箱 × 3 身份 = **528 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
|
||
- 注册轴范围 / 生成器/后台页面清单里对应的桶数见 §4、§5、§8。
|
||
|
||
**可选优化(先不做,看实测再决定)**
|
||
|
||
麻将的赢额**集中在 1x 以下**(最小非零倍率只有 **0.1x** = 最低档符号 3 连赔 0.1 倍),而玫瑰女郎桶 2 一口气吃掉 `0.01~1` 整个区间。
|
||
如果上线后实测发现"小奖过于集中、桶 2 占了大头、调不动形状",再在低端插两档即可(变成 18 档,**不影响任何已有逻辑**,只多 88 条配比行):
|
||
|
||
| 插入 | 名称 | 期望倍率 | 落桶下限 | 落桶上限 |
|
||
|---|---|---|---|---|
|
||
| 2a | 0.2倍饰品 | 0.2 | 0.01 | 0.4 |
|
||
| 2b | 0.7倍饰品 | 0.7 | 0.4 | 1 |
|
||
|
||
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为赔付表本身就是 0.05 的整数倍(0.1 / 0.25 / 0.5 / 0.75 …),`赔付表 × ways × ladder` 的结果自然落在 0.05 的网格上。所以任何桶边界都会自然对齐,不需要额外处理。
|
||
|
||
### 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.settled_win) + adjust
|
||
payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null
|
||
```
|
||
|
||
- bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。
|
||
- **`record.audience` 是"开局时的身份快照",要落库**(`MahjongGameService` 在建 record 时写入):水位聚合用它、也可以像玫瑰女郎那样 join `tt_user` 现推 —— 身份没变时两者等价,**身份被改过(例如白号开关被关)时以快照为准**,所以快照必写。**派彩一律取 `record.settled_win`(= `max(total_win, 0.01)`,也是背包记录 `tt_box_records.ornaments_price` 存的那个数)**,而不是只认 `total_win` —— 玫瑰女郎 `OlympusRecordMapper.sumPool` 用的就是 `SUM(r.settled_win)`,取错会让水位单向漂移(见 §3.8)。
|
||
- 收敛/固定两阶段都用 `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 | 其余全部(**含 `user_type='03'` 机器人**) | 0.80 | |
|
||
| anchor | `user_type='01'` | 0.80 | **三身份互斥并集 = 全部** |
|
||
| white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 |
|
||
|
||
> ⚠️ **2026-09-22 用户定:身份维度从 4 个降为 3 个 —— 去掉 robot。** 机器人不再单独成池,
|
||
> `user_type='03'` 现在归入 `common`(会消耗 common 库存、计入 common 水位)。这条同时改了:
|
||
> 配比行 `16 桶 × 11 箱 × 3 身份 = 528 条`(原 704)、模板 `11 × 3 = 33 条`(原 44)、
|
||
> 后台奖池水位页 3 个 Tab(原 4 个)。Java 侧的 `MahjongAudience`(已删 `ROBOT`)、
|
||
> `MahjongAdminController.AUDIENCES`、`audienceUserSubQuery`、`MahjongRecordMapper.sumPool`
|
||
> 与前端 3 个页面的身份数组都已同步。
|
||
> **连带影响(要知道)**:机器人的下注/派彩现在会进入 common 池,不再是"隔离池"。
|
||
|
||
### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
|
||
|
||
- 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。
|
||
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 10~13 号桶。**生成器要保证 10~13 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
|
||
- **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**:
|
||
- 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋);
|
||
- 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`);
|
||
- 少了这条,客户端那段"最后一列放大"的动画**永远不会被触发**(纯自然概率太低),爽点白做。
|
||
|
||
### 3.8 「饰品倍率」其实是**两层**,别混(用户 2026-09-21 问)
|
||
|
||
麻将胡了这里的"饰品/奖励"分两层,**它们各自有不同的"倍率"含义**:
|
||
|
||
| 层 | 表 | 倍率含义 | 谁定 |
|
||
|---|---|---|---|
|
||
| **① 饰品桶表**(池子层) | `game_mahjong_bucket`(16 档,见 §3.1) | **倍率区间** —— 决定回合池组成与 RTP 配平 | **沿用玫瑰女郎的 17 档结构,去掉 1000x 档 → 16 档**(表见 §3.1) |
|
||
| **② 饰品区间表**(发放层) | `game_mahjong_prize_ornament` + `game_mahjong_prize_range` | **没有固定倍率**:区间是按**金额(¥)**划的 | **直接复用玫瑰女郎/糖果那套**(按金额匹配,与游戏无关) |
|
||
|
||
> ⚠️ **关键点:饰品的"倍率"不是一个固定数,它随投注档变化**——因为②是**按金额**匹配的:
|
||
>
|
||
> | 玩家 | 投注档 | 赢额 | = 倍率 | 实际拿到的饰品 |
|
||
> |---|---|---|---|---|
|
||
> | 甲 | ¥1 | ¥50 | **50x** | 饰品 A(价格落在 ¥50 区间) |
|
||
> | 乙 | ¥500 | ¥50 | **0.1x** | **同一个**饰品 A |
|
||
>
|
||
> 所以"每个饰品对应多少倍"这句话,只有"该饰品价格 ÷ 当前投注档"才有意义。
|
||
> 想要"饰品 ↔ 固定倍率"的对应关系,那是**①倍率桶**在做的事(池子按倍率分档),不是②。
|
||
|
||
**② 饰品区间表怎么来(不要重新设计)**
|
||
|
||
**已落地:直接沿用玫瑰女郎线上那套(用户 2026-09-22 提供的 `game_olympus_prize_ornament` dump)**,12 件饰品 + 12 段区间,`id` 原样保留(因为 `prize_range.prize_id` 引用它)、`game_type` 改成 `MAHJONG`,SQL 见 `mahjong-py/schema.sql` 末尾两段(写法是 `INSERT ... ON DUPLICATE KEY UPDATE`,幂等,**没有**用 dump 里的 `DROP TABLE`)。
|
||
|
||
若以后要换源(比如改成糖果那套),照下面这段整套导:
|
||
|
||
```sql
|
||
DELETE FROM game_mahjong_prize_range;
|
||
DELETE FROM game_mahjong_prize_ornament;
|
||
INSERT INTO game_mahjong_prize_ornament (id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback, create_time)
|
||
SELECT id, 'MAHJONG', name, image_url, grade, exterior_name, sort, status, is_fallback, NOW() FROM game_sweet_prize_ornament;
|
||
INSERT INTO game_mahjong_prize_range (prize_id, win_low, win_high)
|
||
SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|
||
```
|
||
|
||
**导入后核的 3 条(**已按玫瑰女郎线上 dump 导入并逐条核过:12 件饰品 / 12 段区间**)**
|
||
|
||
> ⚠️ **先纠一个前提**:这三条**都不影响 RTP**(饰品价格 = 本局赢额,见 §2.8.3)。它们只决定**"赢了这笔钱发哪件饰品"**(展示层)—— 没覆盖或有空洞时走 `findPrizeWithFallback` 换一件,**金额不变**。
|
||
|
||
| # | 检查项 | 为什么 | 判据 | 实测结果 |
|
||
|---|---|---|---|---|
|
||
| 1 | **最低区间要覆盖空转兜底 ¥0.01** | 空转局的 `win = max(totalWin, 0.01) = 0.01` 得能落进区间 | 存在一段覆盖 0.01 | ✅ 第 1 段 `0.01~0.14` → 安慰奖 |
|
||
| 2 | **区间上界要覆盖 500x × 最高档 = ¥250,000** | 覆盖不到的话,最大的那几档全走 fallback | 最高区间上限 ≥ ¥250,000 | ✅ 上界 99,999,999;¥250,000 落第 9 段 → 猎杀者匕首 \| 渐变之色 |
|
||
| 3 | **区间连续无缝、无空洞** | 有空洞就走 fallback,发奖与赢额不匹配 | 按 `win_low` 排序逐条核 `high_i == low_{i+1}` | ✅ 断点 0(12 段首尾相接) |
|
||
|
||
> **实测(导入后按 mapper 同口径 SQL 验证)**:¥0.10 / ¥0.14 → 安慰奖;¥10 → SG 553 浪花喷漆;¥50 → 双持贝瑞塔 黑榄仁木;¥20,000 → AK-47 燃料喷射器;**¥250,000 → 猎杀者匕首 渐变之色**;¥2,500,000 → AWP 巨龙传说。
|
||
> **两个实现细节(来自 `MahjongPrizeRangeMapper`,别按"双闭区间"想当然)**:
|
||
> ① mapper 用**半开区间** `win_low < win <= win_high` + `ORDER BY win_low DESC LIMIT 1` ⇒ 段与段共享的那个端点值归**上一段**(¥10.00 发 SG 553,不发双持贝瑞塔),也**不会** `TooManyResults`;
|
||
> ② `win = 0.01` 按半开语义其实**匹配不到任何区间**(`0.01 > 0.01` 为假)→ 走 `findFallbackPrize()`;而 1 号「安慰奖」正好就是 `is_fallback=1`,所以**结果一样**。以后在后台改兜底饰品时要记得:**空转局发的是它**。
|
||
|
||
---
|
||
|
||
### 3.9 空转率(桶 1 占比)定多少:**65%**(已用仿真校准)
|
||
|
||
**它是什么**:空转率 = **桶 1 的配额占比**(本局一圈 1024 路都没中),由 `calcRounds` 算出的 `count` 分布决定,不是硬编码概率 —— 所以"空转率定多少"就是"桶 1 的 count 占多少",后台「一键重算」可调。
|
||
|
||
**为什么它是个(小)指标**:空转局照样发兜底饰品,这笔钱无法用桶配比压掉:
|
||
|
||
```
|
||
保底 RTP = 空转率 × 兜底金额 ÷ 投注总额 (兜底金额 = max(totalWin, 0.01) = ¥0.01)
|
||
```
|
||
|
||
**¥0.01 的兜底很便宜**:空转 65% → 保底 **0.65%**;空转 80% → 0.80% —— 都在 ±1pt 容差内,不构成压力。
|
||
|
||
> ⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低饰品价 v 才是大闸门:v=¥0.05→3.25%、v=¥0.50→32.5%、v=¥2→130%(必亏)"——**那是把饰品的市场价当成了成本**。实际是 `ornamentsPrice = win`(olympus 写背包 `.ornamentsPrice(win)`,分解按 `Σ TtBoxRecords.ornamentsPrice` 入账),**饰品只是载体、每局实付 = `settled_win = win`**,所以兜底成本就是 ¥0.01。两个连带结论随之更正:
|
||
> ① **11 个箱可以共用同一套桶配比期望**:保底只占 ≤0.65pt(¥1 档 0.65% / ¥10 档 0.065% / ¥500 档 0.0013%),落在容差内;要较真只给 ¥1、¥3 两档微调,不必逐箱重配。
|
||
> ② **水位没有"虚高"问题**:空转局 `settled_win = 0.01` 就是实际派彩(饰品价格 = 赢额),`水位 = Σcost − Σsettled_win` 口径正确,**不需要**按饰品市场价记账。
|
||
|
||
**目标 RTP 0.80 的预算反解**(取 FS 触发率 0.5%、FS 局整局平均 45x、兜底金额 ¥0.01):
|
||
|
||
| 空转率 q | 命中率 | 保底 RTP | 其余中奖局需要的平均倍率 | 需要"抹掉"的自然中奖占比 |
|
||
|---|---|---|---|---|
|
||
| 50% | 50% | 0.50% | 1.15x | 2.5%(几乎不动自然形态) |
|
||
| 60% | 40% | 0.60% | 1.44x | 22% |
|
||
| **65%(建议)** | **35%** | **0.65%** | **1.65x** | **32%** |
|
||
| 70% | 30% | 0.70% | 1.93x | 42% |
|
||
| 80% | 20% | 0.80% | 2.91x | **61%** ⚠️ |
|
||
|
||
**结论:空转率取 65%(命中率 35%),可接受区间 60~70%。** 三条理由:
|
||
|
||
1. **≤50% 不建议**:中奖局平均只有 ~1.15x,而自然中奖的中位数只有 0.2x —— 池子里会塞满"中 0.1~0.5 倍"的小奖,观感是"一直在中小钱",运营侧每局都要走一次饰品区间匹配,账也更碎;
|
||
2. **≥80% 不建议**:要抹掉 61% 的自然中奖、并把 RTP 压在 14/15/16 号高倍桶上,而这三档**没有"买 FS"兜底**(§5.4 风险),最容易饿死;
|
||
3. **65% 只抹掉 32% 的自然中奖**(自然命中 51.3% → 35%),构造量适中,中奖局平均 1.65x "每次中奖都有感"。
|
||
|
||
**配套(同样要定死)**:**FS 局的整局倍率中心值取 45x、区间 25x~60x**,不要沿用"30x~150x"。因为按 0.5% 触发率算,FS 局平均 150x 一项就要吃掉 `0.5% × 150 = 75%` 的 RTP 预算,剩下的局几乎没有钱可赔;45x 时它占 **22.5%**,其余局还有 54% 的预算。
|
||
|
||
**自然形态实测(`mahjong-py` 20 万局、完全不加偏置)** —— 这些就是生成端要"买回来"的差距:
|
||
|
||
| 指标 | 自然值 | 设计目标 |
|
||
|---|---|---|
|
||
| 自然 RTP(平均 `actual_mul`) | **5.25**(525%) | 0.80 |
|
||
| 基础旋命中率 / 空转率 | 51.3% / 48.7% | 35% / **65%** |
|
||
| FS 触发率(3+ 胡落定) | **1.33%** | 0.5% |
|
||
| 悬念盘(2 胡且第 5 轴无胡) | **4.89%** | 1~2% |
|
||
| 触 500x 封顶 | 0.015% | — |
|
||
| 波数分布(0 / 1 / 2 / ≥3 波) | 48.7% / 26.0% / 13.3% / 12.0% | — |
|
||
|
||
> 两条硬结论:① **自然 RTP 5.25 说明"RTP 完全靠池子构造买回来"**(与玫瑰女郎同构:规则本身不收敛,配额才收敛)⇒ 生成端**不能"自然抽样 + 重新分桶"**,必须按桶目标主动摆盘面(`_force_win` / bias 反解);
|
||
> ② **胡要人为压到约 1/2.7**(1.33% → 0.5%)、**悬念盘压到约 1/3**(4.89% → 1.5%)—— 做法是**按配额投放胡**(生成端决定本旋落几个胡),而不是"改权重再撞运气"。
|
||
|
||
---
|
||
|
||
## 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-16), expect_mul, name, range_low, range_high | **16 档饰品桶** |
|
||
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 3 身份 = 528 条** |
|
||
| 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":0.75,"rotationMul":2,"win":3.00}],
|
||
// payBase 已是倍投注总额(發 3 连 = 0.75,无 ÷20);win = 0.75 × 2 × bet × 2(本例 bet=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 + 16 档桶种子(改前缀与种子)
|
||
├── 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 # 11 箱 × 3 身份 = 33 条生效模板
|
||
├── _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 张表 + 16 档桶种子(幂等)
|
||
python generate_rounds.py --per-bucket 20 # 干跑,不写库
|
||
python generate_rounds.py --all --alloc config --write --workers 4 # 11 箱写库(只有 normal)
|
||
python _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
|
||
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 池自己凑**,所以单箱池子要比玫瑰女郎更大 |
|
||
| 总规模 | **11 箱** × ~4500 = **5 万回合**,每回合 8~40 帧 → **~90 万 step**(与 olympus 的 106 万同一量级) |
|
||
| 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) |
|
||
| 风险点 | **高倍桶(14/15/16 号桶)没有买 FS 兜底**:500x 的局只能靠"基础旋自然触发 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):
|
||
校验箱子/维护/充值(703)/余额(702)
|
||
audience = resolve(player); isBlank = audience.isWhite()
|
||
bet = box.bet // 无模式倍率、无投注倍数
|
||
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 平台共享文件的改动(10 处,漏一处就少一块统计)
|
||
|
||
| # | 文件 | 改动 |
|
||
|---|---|---|
|
||
| 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` | **该台账已按需求清空,不再当建表台账/模板用**(建表只以 `mahjong-py/schema.sql` 为权威、直接对库执行);**返奖饰品种子**仍照 §3.8 那段从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
|
||
| 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 |
|
||
|
||
> **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。
|
||
|
||
### 6.6 封顶与"匹配/发放分离"
|
||
|
||
- 回合池里桶 16 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
|
||
- 运行期 `payoutCap` 过滤 + 收敛阶段 `capMul = payoutCap / bet` 双保险。
|
||
- FINISH 时 `totalMul ≥ 500` → `maxWinReached=true`、`spinsRemaining=0`。
|
||
|
||
### 6.7 赢奖金额口径**与玫瑰女郎完全一致**(用户 2026-09-21 要求)
|
||
|
||
麻将胡了**沿用玫瑰女郎那一整套"倍率 × 基础下注"的金额框架**,只在"倍率怎么算出来"这一步不同(玫瑰女郎靠 5×6 消除聚簇,麻将靠 1024 路前缀链 + 级联 + 阶梯)。
|
||
|
||
| 环节 | 玫瑰女郎(Olympus) | 麻将胡了 | 是否一致 |
|
||
|---|---|---|---|
|
||
| 扣费 | `cost = box.bet × modeCM`(CM = 1/2/50) | `cost = box.bet`(CM ≡ 1、无倍数) | 同结构 |
|
||
| 回合池里存什么 | **倍率** `round.actual_mul`(≤1000) | **倍率** `round.actual_mul`(≤500) | ✅ 一致(存倍率,不存金额) |
|
||
| 单笔/单波金额 | `base_score × bet`(base_score 由生成端算好) | `倍率 × ways × 投注额 × rotationMul` | 同结构(都是"乘数 × bet") |
|
||
| **本局赢额** | `totalWin = actual_mul × bet` | `totalWin = actual_mul × bet` | ✅ **完全一致** |
|
||
| 本局倍率 | `totalMul = actual_mul` | `totalMul = actual_mul` | ✅ 一致 |
|
||
| 发奖 | 赢额 → 落 `prize_range` 区间 → 选定饰品 → 写背包(`ornaments_price` = 该赢额)→ `settled_win` = 该赢额 | 同 | ✅ 一致 |
|
||
| 分解 | `OLYMPUS_BOX(13) → OLYMPUS_REWARD(61)` | `MAHJONG_BOX(15) → MAHJONG_REWARD(69)` | 同结构 |
|
||
| 空转局 | `win = max(totalWin, 0.01)` → 最低档饰品(必得饰品) | 同 | ✅ 一致 |
|
||
| 奖池水位记账 | `水位 = Σcost − Σsettled_win + adjust` | 同 | ✅ 一致 |
|
||
| 单局封顶 | **1000x** | **500x**(Q4 定案) | ⚠️ **唯一有意差异** |
|
||
|
||
**关键恒等式(两边通用)**:
|
||
|
||
```
|
||
round.actual_mul = 本局总赢额 ÷ bet
|
||
本局赢额 = round.actual_mul × bet ← 与玫瑰女郎同一个公式
|
||
```
|
||
|
||
麻将胡了这边,`actual_mul` 是由生成端按 §2.1 的公式**反推出来的**:
|
||
|
||
```
|
||
actual_mul = Σ(每一波、每一个符号: 赔付表[X][连轴数] × ways × rotationMul)
|
||
└──────── 赔付表已是倍率,所以加起来就是本局倍率 ────────┘
|
||
```
|
||
|
||
所以:
|
||
- **玩家看到的金额、`totalWin/totalMul`、`settled_win`、饰品区间匹配、水位记账——全部与玫瑰女郎同口径**;
|
||
- 麻将独有的只有"倍率怎么生成"(前缀链/级联/金色转百搭/阶梯/FS),以及**封顶 1000x→500x** 这一个有意差异。
|
||
|
||
> 若日后要与玫瑰女郎**完全对齐封顶**,只需把 `MAX_WIN_CAP_MUL` 与顶部桶区间一起改回 1000,不影响其他任何环节。
|
||
|
||
---
|
||
|
||
## 7. 接口协议(草案,最终产物是 `docs/麻将胡了接口文档.md`)
|
||
|
||
Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||
|
||
| # | 接口 | 用途 |
|
||
|---|---|---|
|
||
| 1 | `POST /boxes` | 箱子列表(含 `bet`、`price`;**没有 `modeName`**,只有一个普通旋转模式) |
|
||
| 2 | `POST /dospin` | 开局(`{boxId}`)/ 续播(空 body `{}`) |
|
||
| 3 | `POST /records` | `scope=1` 我的 / `scope=0` 全服,分页,只含已结束局 |
|
||
| 4 | `POST /hasActive` | 断线重连恢复 |
|
||
| 5 | `POST /api/userPackSack/decompose` | **复用玫瑰女郎那个通用背包接口**,末帧「立即分解」(含前端自动分解) |
|
||
|
||
> `dospin` 开局请求:`{"boxId": 1001}`。协议里保留可选字段 `betMul`(缺省 1、当前服务端只接受 1),给将来留口子,不影响当前逻辑。
|
||
|
||
### 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 × rotationMul`(`payBase` 已是倍投注总额,**无 ÷20**,见 §2.1) |
|
||
| `dropData` / `fillData` | 每帧 | 与玫瑰女郎同构(`from/to/symbol`、`pos/symbol`) |
|
||
| `rotationMul` | 每帧 | **本波阶梯倍率**(基础旋 `1/2/3/5`、FS `2/4/6/10`);重连时用它接回档位 |
|
||
| `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}` 开局,之后不断调 `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 的写法是 `SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单;**仓内已无模板可抄**(`docs/updata_sql.md` 已清空、`sql/` 目录是空的),改成对现库 `sys_menu` 直接操作(现成的 olympus 目录行可当样板)。**注意仓库现状**:
|
||
- 全量 DDL 只剩生成器的 `schema.sql` 这一处权威,别去 `sql/` 或 `docs/updata_sql.md` 找;
|
||
- 后台页面**没有任何 `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` |
|
||
| 横幅下方三格 | 余额 ¥ / 投注 ¥ / 赢额 ¥ | 余额接口 / 本局投注额 / 本局赢额 |
|
||
| 底部按钮 | 闪电(加速)/ − / 大 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 万旋仿真:基础旋命中率 **35%(空转率 65%,口径见 §3.9)**、**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 | ① **11 箱**池子生成成功,无饥饿桶(尤其 14/15/16 号高倍桶);② `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 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `W` 只可能出现在第 2/3/4 轴、且盘上百搭数 = 本波中奖金色格数(无上限)**;
|
||
- **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `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` 落在区间内、无"饥饿桶"(**重点查 14/15/16 号高倍桶**——没有买 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.ornaments_price`(source=15)—— 两边都是"本局赢额",逐条对得上(饰品由 `prize_range` 按这个金额选定)。
|
||
- 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。
|
||
- 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。
|
||
- 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。
|
||
|
||
---
|
||
|
||
## 12. 风险与对策
|
||
|
||
| # | 风险 | 对策 |
|
||
|---|---|---|
|
||
| 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 |
|
||
| 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 |
|
||
| 3 | **step 表体量**(~90 万帧,11 箱) | 无中奖桶(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` 饥饿重校准;上线前专门查 14/15/16 号桶有没有条数 |
|
||
|
||
---
|
||
|
||
## 13. 全部定案(**无待拍板项**)
|
||
|
||
> 2026-09-21 全部澄清完毕。下表保留完整决策链,方便回溯"这条为什么这么定"。
|
||
|
||
| # | 问题 | 结论 |
|
||
|---|---|---|
|
||
| ~~Q0~~ | ~~赔付值乘的基数是哪个?~~ | ✅ **赔付表直接用倍率表达**(原版点数 ÷ 20 已内化进表里),公式里不再有 ÷20;换算关系与排除法见 §2.1 |
|
||
| ~~Q1~~ | ~~纯百搭链(第1/2/3轴全百搭)算什么?~~ | ✅ **由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解**——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,**纯百搭链数学上不可能出现**,不需要单独规则、也不需要 `WILD_PAYS` 开关(见 §2.2) |
|
||
| ~~Q2~~ | ~~金色→百搭的那个百搭是否"钉住不动"?~~ | ✅ **不钉住**("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") |
|
||
| ~~Q3~~ | ~~免费旋转总旋数上限~~ | ✅ **36 旋**(用户 2026-09-21 定案),并配"**发不满就不发**"硬规则:`已发放 + 本次应赠 > 36` 时后续旋不再落 3+ 胡,杜绝"落胡只补几旋" |
|
||
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配最高档 ¥500(**无投注倍数**),单局理论最大赔付 **¥250,000**(见 Q7) |
|
||
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 11 箱、无 `need_fs`、`costMultiplier≡1` |
|
||
| ~~Q6~~ | ~~各身份目标 RTP~~ | ✅ **不是拍板项**:后台配置(箱子级 `rtp` + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
|
||
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.2 |
|
||
| 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 投注与结算
|
||
|
||
| 项 | 规则 |
|
||
|---|---|
|
||
| 投注构成 | 选一个**投注档**(11 档,见下),即为**投注总额**,开局即扣(**不设投注倍数**) |
|
||
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`(共 **11 档**,**无投注倍数**) |
|
||
| 游戏单位 | 金额以 ¥ 为单位 |
|
||
| 现金钱包 | 显示当下玩家可用的现金余额 |
|
||
| 自动旋转 | 可按玩家选择的回合数量自动进行游戏 |
|
||
| 中奖派发 | 按「**赔付表**」执行;不同中奖路上的奖金**叠加** |
|
||
| 结算形态 | 本局赢额**按区间转换为一个饰品发到背包**;末帧可选择「放入背包 / 立即分解」;开启「自动分解」则末帧自动分解为金币 |
|
||
|
||
> **开发参数**:`box.bet ∈ 上表 11 档`;`price = box.bet`(`costMultiplier ≡ 1`、无倍数);饰品按 `game_mahjong_prize_range` 的赢额区间匹配;开局即 `settleAtStart` 写背包(无损模型)。
|
||
|
||
### 14.3 中奖判定(1024 路 = 前缀链)
|
||
|
||
- 赔付表上的数值**就是倍投注总额**(原版"每条基础投注点数"已预先除以 20 内化,见 §2.1)。
|
||
- 一条中奖路必须**从最左(第 1 轴)开始,按 1 → 2 → 3 → 4 → 5 的顺序连续出现**该符号;
|
||
- 第 1 轴是**必需**的;
|
||
- **中间断了就整笔作废**——例如 1、2、4 有、3 没有,不算中奖;2、3、4 有、1 没有,也不算。
|
||
- 每一轴上该符号可以有**好几个**(最多 4 个),只要求「每轴至少 1 个」。
|
||
- **路数** = 链内各轴该符号个数的**乘积**。
|
||
- **本笔赢额 = 符号倍率 × 路数 × 投注总额 × 本波阶梯倍率**。
|
||
- 中奖路奖金**从最左至右卷轴的顺序派发**;不同符号各自独立成奖、**互相叠加**。
|
||
- 参考例子:`1 × 3 × 2 = 6` 路,**3 连赔 0.5 倍** → 本笔 `0.5 × 6 = 3.00 倍`。**逐步算例(含 ¥ 换算)见 §2.1.1**。
|
||
|
||
> **开发参数**:赔付表**直接以倍率存**(`PAYTABLE` 里就是 0.75/0.5/0.4…),**没有 `PAY_UNIT_DIVISOR` 这一步**;判定实现见 §5.2(`break` 就停,禁止写成"任意 3 轴凑齐")。
|
||
|
||
### 14.4 符号与赔付表
|
||
|
||
赔付倍率(**倍投注总额**):
|
||
|
||
| 符号 | 5 连 | 4 连 | 3 连 |
|
||
|---|---|---|---|
|
||
| 發 | **5** | **3** | **0.75** |
|
||
| 中 | **4** | **2** | **0.5** |
|
||
| 白板(紫框) | **3** | **1** | **0.4** |
|
||
| 八萬 | **2** | **0.75** | **0.3** |
|
||
| 花筒(四色) | **1** | **0.5** | **0.2** |
|
||
| 索(红绿) | **1** | **0.5** | **0.2** |
|
||
| 筒(紫绿) | **0.5** | **0.25** | **0.1** |
|
||
| 索(绿) | **0.5** | **0.25** | **0.1** |
|
||
| **百搭(元宝)** | — | — | — |
|
||
| **胡(夺宝符号)** | — | — | — |
|
||
|
||
### 14.5 百搭符号(元宝)
|
||
|
||
- **百搭符号可替代除「夺宝符号(胡)」以外的所有符号**。
|
||
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**每个金色格各留一个、无数量上限**(一个盘面可同时有多个)。
|
||
- 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。
|
||
- **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。
|
||
- 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。
|
||
|
||
### 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 次(**一屏最多只出现 5 个胡**,不存在 6 个及以上 → 单次最多 16 旋)。
|
||
- **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。
|
||
- **免费旋转可以被重复触发**(FS 内再落 3 / 4 / 5 个胡 → **再送 12 / 14 / 16 旋**,与首触发同一套公式,见 §2.6)。
|
||
- **免费旋转的总旋数上限为 36 旋**:`已发放 + 本次应赠 > 36` 时,后续旋**不再出现 3+ 个胡**(避免"落了胡却只补几旋")。因为单次最少赠 12 旋,所以**已发放 ≥ 25 旋之后一律不再出胡**,整局永远 ≤ 36 旋。
|
||
- 免费旋转期间**投注档与触发免费旋转时的参数保持一致**(中途不换注)。
|
||
|
||
> **开发参数**:`FS_AWARD = 12 + 2 × (scatterCount − 3)`;重触发同式;**总旋数上限 36**(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 内化) |
|
||
| 判定 | 前缀链(第 1 轴起连续,断链作废,`ways` 只乘链内轴) |
|
||
| 百搭 | **只能由金色符号转化产生**(原生盘面不落);**每个金色格各留一个(无上限)**,**只可能在第 2/3/4 轴**;可替代除「胡」外全部;满足消除链才被消除 |
|
||
| 金色符号 | 只在第 2/3/4 轴;按自然符号成奖;消除后就地留百搭 |
|
||
| 普通旋阶梯 | `1 / 2 / 3 / 5`(第 4 波及以后 x5) |
|
||
| FS 阶梯 | `2 / 4 / 6 / 10` |
|
||
| 免费旋转 | 3+ 胡 → 12 旋,每个额外胡 +2(**一屏最多 5 个胡 → 单次最多 16 旋**);FS 内重触发同式;**上限 36 旋**,且"发不满就不发" |
|
||
| 悬念盘 | 2 个胡 → 第 5 轴放大;生成端占比 1%~2% |
|
||
| 投注档 | **11 档:1/3/5/10/20/30/50/100/200/300/500** |
|
||
| 投注倍数 | **无**(只选投注档;协议预留 `betMul` 恒为 1) |
|
||
| 封顶 | **500x** |
|
||
| 发奖 | 赢额 → 区间匹配饰品 → 背包 → 可分解 |
|
||
|
||
---
|
||
|
||
## 15. 完整消除示例(逐波走一遍,用来校验规则理解)
|
||
|
||
> 场景:**普通旋转**,投注档 **¥100**(即投注总额,无倍数)。
|
||
> 本旋共 **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 连 | 0.5 | 4 | **2.00 倍** | x1 | **¥200** |
|
||
| 白板 3 连 | 0.4 | 2 | **0.80 倍** | 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 连** 赔 **0.75 倍** × ways **4** = **3.00 倍** → `3.00 × ¥100 × 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 连** 赔 **1 倍** × ways **1** = **1.00 倍** → `1.00 × ¥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) | **1(百搭当白板)** | 0(G/A/G/F) | — | **3 连,ways = 1×1×1 = 1** |
|
||
| 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 |
|
||
| 發 A | 轴1 没有 | — | — | — | — | 不成奖 |
|
||
|
||
> 同一个百搭这波**同时被筒、索红绿、白板三个符号借用**,三条链都成立 → **三笔并赔**。
|
||
> ⚠️ **勘误**:本节原来只算了筒与索红绿两笔,漏了"同一个 `W` 也能当白板"→ 白板 3 连(`loc` 含那个 `W`)。按 `mahjong-py` 引擎判定修正后,本波是三笔并赔,本旋总账从 ¥2180 变为 **¥2380**。
|
||
|
||
**赔付**:
|
||
|
||
| 中奖 | 符号倍率 | ways | 本笔基础倍率 | 本波阶梯倍率 | 赢额 |
|
||
|---|---|---|---|---|---|
|
||
| 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | **¥500** |
|
||
| 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | **¥500** |
|
||
| 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | **¥200** |
|
||
| | | | | 小计 | **¥1200** |
|
||
|
||
**本波共 12 个符号被消除**(筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,其中 `W` 被三条链共用 → 6 + 5 + 3 − 2 = 12):
|
||
轴1:G(行1)、C(行3)、F(行4) 轴2:C(行2)、F(行3)、G(行4) 轴3:**(行1) W** 轴4:G(行1)、G(行3)、F(行4) 轴5:F(行3)、G(行4)
|
||
`W` 参与了三条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
|
||
|
||
#### 盘面演变(第 4 波 → 本旋结束)
|
||
|
||
**① 消除瞬间**(12 格全爆:筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,`W` 被三条链共用):
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | ▢ | A | ▢ | ▢ | C |
|
||
| 行2 | H | ▢ | A | A | S |
|
||
| 行3 | ▢ | ▢ | B | ▢ | ▢ |
|
||
| 行4 | ▢ | ▢ | H | ▢ | ▢ |
|
||
|
||
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**(下表是引擎的一次真实结算,补位是新抽的牌):
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|
||
|---|---|---|---|---|---|---|
|
||
| 行1 | **H** | **D** | **F** | **F** | **S** | 轴1:3 格空洞(G/C/F 被消)→ 只剩 H 存活、下移三格到行4;顶部补 H、G、E |
|
||
| 行2 | **G** | **E** | A | **G** | **H** | 轴2:3 格空洞(C/F/G 被消)→ A 落到行4;顶部补 D、E、D |
|
||
| 行3 | **E** | **D** | B | **D** | C | 轴3:只有一个空洞(`W` 被消)→ A/B/H 各上一格;顶部补 F |
|
||
| 行4 | H | A | H | A | S | 轴4:3 格空洞 → A 落到底 + 补 F、G、D;轴5:2 格空洞 → C/S 下移两格 + 补 S、H |
|
||
|
||
→ 复核这个盘面:轴1 里有 H/G/E,其中 E 能在轴2 对上(行2),但**轴3 没有 E** → 轴3 断链;H、G 在轴2 就已经断了 ⇒ **无任何中奖组合,本旋结束(整局共 4 波)**。
|
||
|
||
### 本旋总账
|
||
|
||
| 波 | 中奖 | 符号倍率 | ways | 本笔基础倍率 | 本波阶梯倍率 | 本波赢额 |
|
||
|---|---|---|---|---|---|---|
|
||
| 1 | 中 3 连 | 0.5 | 4 | **2.00 倍** | x1 | ¥200 |
|
||
| 1 | 白板 3 连 | 0.4 | 2 | **0.80 倍** | x1 | ¥80 |
|
||
| 2 | 八萬 4 连 | 0.75 | 4 | **3.00 倍** | x2 | ¥600 |
|
||
| 3 | 花筒 5 连 | 1 | 1 | **1.00 倍** | x3 | ¥300 |
|
||
| 4 | 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | ¥500 |
|
||
| 4 | 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | ¥500 |
|
||
| 4 | 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | ¥200 |
|
||
| | | | | | 合计 | **¥2380** |
|
||
|
||
> 计算方法:**本笔基础倍率 = 符号倍率 × ways**;**本波赢额 = Σ本笔基础倍率 × 投注总额 × 本波阶梯倍率**(投注总额 ¥100)。
|
||
> 例:第 2 波 `0.75 × 4 = 3.00 倍` → `3.00 × 100 × 2 = ¥600` ✓
|
||
|
||
- 投注总额 ¥100 → **本局总倍率 = 2380 ÷ 100 = 23.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 因此永久关闭,不需要额外规则)。
|
||
|
||
> 每一波盘面上的百搭数 **= 本波中奖的金色格数**(0 / 1 / 2 …,无上限),且**永远只由金色转化产生** ✅。
|
||
> 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。
|
||
|
||
---
|
||
|
||
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 36 → FINISH)
|
||
|
||
> 场景:**普通旋转**,投注档 **¥100**。
|
||
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 36**、FINISH 帧。
|
||
> 图例同 §15:`A`發 `B`中 `C`白板 `D`八萬 `E`花筒 `F`索红绿 `G`筒 `H`索绿 `W`百搭 `S`胡 **`*`=金色**。
|
||
|
||
### 16.1 基础旋(`spinNum = 0`,阶梯 x1)——触发盘
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | B | C | **S** | E | H |
|
||
| 行2 | D | A | B | **S** | F |
|
||
| 行3 | E | F | G | D | **S** |
|
||
| 行4 | H | C | D | G | B |
|
||
|
||
- **判定**:轴1 只有 B/D/E/H,它们在第 2 轴全断 → **本旋没有任何 1024 路中奖组合**
|
||
- **胡**:3 个(轴3 行1、轴4 行2、轴5 行3)→ **触发免费旋转,赠 12 旋**
|
||
- **赔付**:`0`(胡本身不赔现金)
|
||
- 本步下发:`spinNum=0`、`scatterTrigger={scatterCount:3, freeSpins:12, extraSpins:0}`、`spinsRemaining=**12**`、`state=PLAYING`
|
||
|
||
### 16.2 FS 第 1 旋(`spinNum = 1`,**阶梯从 x2 起**)——两波 + 金色转百搭
|
||
|
||
**① 消除前盘面**(FS 每旋都会整盘重发)
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | B | D | B | C | F |
|
||
| 行2 | H | **B\*** | E | E | G |
|
||
| 行3 | C | E | H | A | D |
|
||
| 行4 | F | G | D | H | C |
|
||
|
||
**② 第 1 波(阶梯 x2)**:中 B:轴1 = 1(行1)/ 轴2 = 1(行2金)/ 轴3 = 1(行1)/ 轴4 = 0 → **3 连,ways = 1**
|
||
→ 赔付 **0.5 倍**(中 3 连)× ways 1 = 0.50 倍 → 本波赢额 = `0.50 × ¥100 × x2` = **¥100**
|
||
→ 金色格 (行2,轴2) 参与中奖 → **就地变 `W`**(本旋第一个百搭)
|
||
|
||
**③ 第 1 波落定后**:轴1 顶部补 1 张;轴3 顶部补 1 张;**轴2 没有空洞**(金色格变 W)→ 不动
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | **A** | D | **A** | C | F |
|
||
| 行2 | H | **W** | E | E | G |
|
||
| 行3 | C | E | H | A | D |
|
||
| 行4 | F | G | D | H | C |
|
||
|
||
**④ 第 2 波(阶梯 x3)**:索绿 H:轴1 = 1(行2)/ 轴2 = **1(百搭)** / 轴3 = 1(行3)/ 轴4 = 1(行4)/ 轴5 = 0 → **4 连,ways = 1**
|
||
→ 赔付 **0.25 倍**(索绿 4 连)× ways 1 = 0.25 倍 → 本波赢额 = `0.25 × ¥100 × x3` = **¥75**
|
||
→ `W` 参与成链 → **被消除**;本波无金色 → 不产生新百搭
|
||
|
||
**⑤ 本旋合计** ¥100 + ¥75 = **¥175**;补位符号取"不会形成新链"的组合 → **本旋结束**
|
||
→ `spinNum=1`、`spinsRemaining=**11**`(12 − 1)
|
||
|
||
### 16.3 FS 第 2 旋(`spinNum = 2`,阶梯重新从 x2 起)
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | C | E | C | A | D |
|
||
| 行2 | G | C | F | C | E |
|
||
| 行3 | H | D | G | H | F |
|
||
| 行4 | E | F | A | D | G |
|
||
|
||
- **白板 C**:轴1 = 1 / 轴2 = 1 / 轴3 = 1 / 轴4 = 1 / 轴5 = 0 → **4 连,ways = 1**
|
||
- 赔付 **1 倍**(白板 4 连)× ways 1 = 1.00 倍 → 本波赢额 = `1.00 × ¥100 × x2` = **¥200**
|
||
- 补位后无新链 → **本旋结束**;`spinsRemaining=**10**`
|
||
|
||
> **注意**:阶梯是**每旋重置**的——第 2 旋的起点又回到 **x2**,不是接着上一旋的 x3。
|
||
|
||
### 16.4 FS 第 3 旋(`spinNum = 3`)——**重触发 +12**
|
||
|
||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||
|---|---|---|---|---|---|
|
||
| 行1 | H | D | **S** | A | E |
|
||
| 行2 | E | F | C | **S** | G |
|
||
| 行3 | A | B | G | C | **S** |
|
||
| 行4 | G | H | D | E | F |
|
||
|
||
- 本旋**没有 1024 路中奖组合**(轴1 的 H/E/A/G 在第 2 轴全断)
|
||
- **但落了 3 个胡** → **重触发**:`retriggerInfo={scatterCount:3, extraSpins:12}`
|
||
- 旋数账本:已发 = 12 + 12 = **24**;已播 3 旋 → `spinsRemaining = 24 − 3 = **21**`
|
||
- 本旋赢额 `0`
|
||
|
||
### 16.5 旋数账本 + 整局金额(全程 36 旋)
|
||
|
||
**① 旋数账本**
|
||
|
||
| 时刻 | 事件 | 检查(已发放 + 应赠 ≤ 36?) | 已发放总旋数 | 已播 | `spinsRemaining` |
|
||
|---|---|---|---|---|---|
|
||
| 基础旋 | 3 胡 → 触发 +12 | 0 + 12 = 12 ✓ | 12 | 0 | **12** |
|
||
| FS 1 | 中 3 连(x2) + 索绿 4 连(x3) | — | 12 | 1 | **11** |
|
||
| FS 2 | 白板 4 连(x2) | — | 12 | 2 | **10** |
|
||
| FS 3 | 3 胡 → **重触发 +12** | 12 + 12 = 24 ✓ | **24** | 3 | **21** |
|
||
| FS 4 ~ 9 | 普通旋(各 1~3 波) | — | 24 | 9 | 15 |
|
||
| **FS 10** | 又落 3 胡 → **重触发 +12** | 24 + 12 = **36** ✓ | **36**(顶到上限) | 10 | **26** |
|
||
| FS 11 ~ 35 | 普通旋。**这一段里任何一个旋即便摇出 3 个胡,也会因为 `36 + 12 = 48 > 36` 而【不落胡】** | 48 > 36 ✗ | 36 | 35 | 1 |
|
||
| **FS 36** | 最后一旋播完 | — | 36 | 36 | **0** |
|
||
|
||
> **封顶规则(用户定案)**:**落胡前先检查** `已发放总旋数 + 本次应赠 ≤ 36`,不满足就**本旋不落 3+ 胡**。
|
||
> 所以最终旋数一定是"若干次完整赠送之和"(12 / 24 / 26 / 36 …),**永远不会出现"落了 3 个胡却只补几旋"**。
|
||
> **触发边界**:`已发放 ≤ 24` 才可能再触发;`≥ 25` 之后一律不再落 3+ 胡 ⇒ **整局 FS 永远 ≤ 36 旋,37 旋以上不可能出现**。
|
||
> 检查用的是**该旋实际摇出的胡数**(3 个 = +12、4 个 = +14…),所以会出现"同一个已发放旋数下,3 个胡能落、4 个胡不能落"——这是正确的。
|
||
|
||
**② 整局金额**
|
||
|
||
投注档 **¥100**。前三旋已在前文逐步算过;FS 4~36 是普通旋,本示例按**每旋平均 1.0 倍**计(真实值由生成器决定,这里只为把账算通):
|
||
|
||
| 段 | 旋数 | 每旋 | 倍率小计 | 金额 |
|
||
|---|---|---|---|---|
|
||
| 基础旋(触发盘) | 1 | 0 | 0 | ¥0 |
|
||
| FS 1 | 1 | — | 1.00 + 0.75 = **1.75** | ¥175 |
|
||
| FS 2 | 1 | — | **2.00** | ¥200 |
|
||
| FS 3(只重触发,本旋无中奖) | 1 | 0 | 0 | ¥0 |
|
||
| FS 4 ~ 9 | 6 | 1.0 | **6.00** | ¥600 |
|
||
| FS 10(只重触发,本旋无中奖) | 1 | 0 | 0 | ¥0 |
|
||
| FS 11 ~ 36 | 26 | 1.0 | **26.00** | ¥2600 |
|
||
| | **36 旋** | | **本局总倍率 35.75** | **¥3575** |
|
||
|
||
- 未触 500x 封顶 ⇒ `maxWinReached = false`
|
||
- `payoutCap` 仍会按水位兜一层(水位不够时这场大奖不会满额出)
|
||
|
||
### 16.6 FINISH 帧(整局最后一步)
|
||
|
||
```
|
||
state = "FINISH"
|
||
spinNum = 36 // 最后一旋
|
||
spinsRemaining = 0
|
||
totalMul = 35.75 // 整局总倍率
|
||
totalWin = 3575.00 // 整局总赢额(= 35.75 × ¥100)
|
||
maxWinReached = false // 35.75 < 500,未触顶
|
||
prizeInfo = { ¥3575 落 prize_range 区间匹配到的饰品 }
|
||
boxRecordId = 背包记录 id // 末帧「放入背包 / 立即分解」用它
|
||
```
|
||
|
||
### 16.7 FS 内必须注意的 5 条
|
||
|
||
1. **阶梯每旋重置**:FS 每一旋都从 **x2** 开始(`2 → 4 → 6 → 10`),不会跨旋累计。
|
||
2. **FS 内金色 / 百搭规则与基础旋完全一致**:金色只在第 2/3/4 轴;被消除后各自留一个百搭;百搭要满足消除链才被消除。
|
||
3. **旋数用 `spinsRemaining`**:它是服务端每帧算好的倒计值,前端**直接显示即可**,不要自己加减(重触发那一帧 `−1` 和 `+12` 是同时发生的)。
|
||
4. **触发盘本身可以有中奖**(本示例为了干净取 0 赔付);触发盘的中奖同样按 x1 阶梯结算。
|
||
5. **爆奖档位 `bigWinLevel`(小/中/大)只在 FS 的旋末帧判**,按**单旋**赢额 ÷ 基础下注判档,同档一局可多次;基础旋恒为 `null`。
|
||
|
||
---
|
||
|
||
## 附录 A:与玫瑰女郎的字段/表名映射速查
|
||
|
||
| 玫瑰女郎 | 麻将胡了 |
|
||
|---|---|
|
||
| `game_olympus_box.total_ornaments` | `game_mahjong_box.total_rounds` |
|
||
| `game_olympus_ornament`(17 档饰品桶) | `game_mahjong_bucket`(16 档饰品桶) |
|
||
| `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`~~(该台账已按需求清空,**不再可用**)→ 建表以 `mahjong-py/schema.sql` 为权威;菜单直接对现库 `sys_menu` 操作 |
|
||
| 接口契约模板 | ~~`docs/玫瑰女郎接口文档.md`~~(已按需求删除)→ 沿用本仓现存 `docs/疯狂转盘接口文档.md` / `docs/流水抽奖接口文档.md` 的写法 |
|
||
|
||
## 附录 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` → **给 11 箱 × 3 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥300/¥500 这两档必须单独注**)。
|
||
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode normal` → 清 `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` 抽样。
|