# 麻将胡了(Mahjong)电子游戏 · 设计方案 v1.0 > 目标:把「麻将胡了」(5 轴 × 4 行、1024 路、级联消除、金色符号、乘数阶梯、免费旋转)做成**玫瑰女郎(Olympus)同一套设计模式**的兄弟游戏。 > 参考实现:`olympus-py/`(回合池生成器)+ `skins-service/game-olympus-service/`(Java 服务)+ `ruoyi-ui/src/views/game/olympus/`(后台)+ `docs/玫瑰女郎接口文档.md`(接口契约)。 > 状态:**方案待评审**,未动任何代码。末尾第 13 节列出必须由你拍板的 10 个问题。 --- ## 0. TL;DR | 项 | 结论 | |---|---| | 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 | | 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **12 个箱子**(只按投注档分,档位见 §2.8.1)、无模式倍率、无 `need_fs` 过滤 | | 必须重写什么 | ① 中奖判定(1024 路**前缀链**:必须从第 1 轴起 1→2→3→4→5 连续,中间断链即整笔作废,替代 8+ 聚簇)② 盘面几何(5×4=20 格替代 5×6=30 格)③ 乘数介质(阶梯倍率替代炸弹球)④ **级联的消除规则**(金色中奖格**不清空**,就地留一个百搭占位) | | 发奖形态 | **与玫瑰女郎完全一致**:游戏内**显示现金**(「共赢得 X.XX」),但**最终赢额转成一个饰品发到背包**(`settleAtStart` 按赢额落区间查饰品 → 写 `tt_box_records`),玩家在末帧选择「放入背包 / 立即分解」。→ 所以**不做**"开户即加余额"那条近路,`prize_ornament` / `prize_range` 两张表都要 | | 新增资产 | `mahjong-py/`(生成器)+ `game-mahjong-service`(Java 模块)+ **11 张** `game_mahjong_*` 表 + 7 个后台页(含奖品管理)+ `docs/麻将胡了接口文档.md` + Cocos 面板 | | 工作量 | 后端(py + Java + 后台 + 文档)≈ **20~26** 人日;客户端 ≈ 15~20 人日;联调/RTP 验收 ≈ 5 人日 | | 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ 赔付计量单位(=总额÷20)若在规则页没说清,玩家会按"倍总额"理解而产生对账投诉 | --- ## 1. 先把"玫瑰女郎设计模式"拆成 12 条可照抄的规则 这一节是整份方案的骨架。麻将胡了必须**逐条对齐**,任何一条偷懒都会在后期以 bug 的形式回来。 | # | 规则 | 玫瑰女郎现状(证据) | 麻将胡了落法 | |---|---|---|---| | 1 | **服务端权威**:客户端不能决定任何结果 | 全部结果由后端挑出的回合决定 | 一致 | | 2 | **离线预生成回合池**:Python 造 `round`+`step` 落库,Java 只挑不算 | `olympus-py/generate_rounds.py` 写 11.7 万回合 / 106 万步 | `mahjong-py/generate_rounds.py`,同结构 | | 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 10 档(见 §3.1) | | 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构 | | 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` | | 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) | | 7 | **身份分桶** common/anchor/robot/white,白号独立库存不进真实经济 | `OlympusAudience` | 直接复用(建议上移到共享模块) | | 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致,但结算动作从"写背包"改为"加余额" | | 9 | **客户端只续播**:`dospin` 带 `boxId` = 开新局;不带 = 下一步;`state=FINISH` 才展示结算 | `OlympusGameService.continueGame():230-254` | 一致 | | 10 | **Redis 单局缓存 30 分钟 + `hasActive` 重连**,重连要能接回累计量 | `ruoyi:olympus:{userId}` TTLL 30min | `ruoyi:mahjong:{userId}`;接回量 = 本旋波次/本局累计 | | 11 | **一轮 cycle**:`N = 固定池/(1−收敛比)`,抽满 `resetCycle + refillFromConfig`(模板) | `OlympusCycleRepository` + `box_template` | 一致 | | 12 | **运维面**:维护开关 + 后台 7 页 + 注水/抽水/重置(原因必填)+ 流水 source 登记 6 处聚合 | `sys_config.olympusMaintenance`、`TtAccountRecordSource:193-279` | 同名同构(见 §6.5) | **结论:12 条全部照抄,一条都不改。**(用户 2026-09-21 定案:麻将胡了的赢额也**要变成饰品发到背包**,所以"赢额 → 落区间 → 查饰品 → 写背包 → 末帧选择放入/分解"这条链路与玫瑰女郎一模一样,连 `settleAtStart` 的 `max(totalWin, 0.01)` 兜底都能照抄。) **唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值 = 饰品价格",前端同样显示金额)。这句话不是"直付余额"的意思。 --- ## 2. 游戏规格(截图是唯一事实来源) ### 2.1 盘面与 1024 路判定 - 5 轴(reel)× 4 行 = 20 格;ways = 4⁵ = **1024**。 - **判定口径(用户 2026-09-21 明确澄清,必须逐字实现)**: > 中奖符号必须**从第 1 轴开始、按 1→2→3→4→5 的顺序连续出现**,中间不许断。 > 第 1 轴是**必需**的;第 2 轴断了,后面 3/4/5 轴有再多也不能中奖。 > 每一轴上该符号可以有好几个(最多 4 个),只要求「每轴至少 1 个」。 即**前缀链(prefix run)**,不是"任意 3 轴凑齐",也不是"从第 2 轴起算": ```python # 对每个付费符号 X(含百搭替代): n = 0 for c in range(5): # 第 1 轴 → 第 5 轴 cnt = count(X or WILD in 轴 c) # 该轴 X 的格数(百搭计入),0~4 if cnt >= 1: n += 1 else: break # 一旦断链,后面全部作废 if n < 3: 不成奖 # n 只能是 3 / 4 / 5 才有档位 ways = Π(第 1..n 轴各自的格数) # 只乘链内的轴,链外的轴不乘 本笔 = 赔付表[X][n] × ways ``` - **反例与正例(照这张表写单测)**: | 各轴该符号格数(轴1~轴5) | 结果 | 原因 | |---|---|---| | `2, 3, 1, 0, 0` | ✅ 3 连,ways=6 | 前缀 3 轴连续 | | `2, 3, 1, 2, 0` | ✅ **4 连**,ways=12 | 第 5 轴才断,档位取 4 | | `2, 0, 3, 4, 4` | ❌ 不成奖 | **第 2 轴断了**(用户举的例子),后面再多也没用 | | `0, 4, 4, 4, 4` | ❌ 不成奖 | 第 1 轴没有 → 第 1 轴必需 | | `1, 1, 0, 4, 4` | ❌ 不成奖 | 3 连必须是 1-2-3,不能跳到 1-2-4 | | `1, 2, 3, 4, 4` | ✅ 5 连,ways=96 | 满链 | | `1, 1, 1, 1, 1` | ✅ 最低 ways=1 | 每轴 1 个,共 1 路 | - 单笔赢额 = `符号倍率(赔付表) × 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 个百搭** | > 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。 > 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。 - **胡(夺宝/SCATTER)**:不参与 1024 路(**既不计入链,也不被百搭替代**);出现在任意轴任意位置;**自身不赔现金**(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 `scatterTrigger.scatterWin` 恒为 0。 - 3 个及以上胡 → 触发免费旋转:**12 旋 + 每多 1 个额外 +2 旋**(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。 ### 2.3 金色麻将符号(本游戏最独特、也最容易做错的一条) 截图原文: 1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的某些符号(不包括百搭符号和夺宝符号)将可能显示为金色。」 2. 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」 **用户补充的落地口径(本条的权威解释,2026-09-21 多次确认)**: > 百搭符号 **就是"金色的普通符号"**——它是普通符号(比如「中」)镀了金色,**判定与赔付都按它的自然符号算**。 > 这个符号**被消除之后,该位置会留下一个真正的百搭符号**(就是元宝 `W`)——这是**百搭在整个游戏里唯一的来源**(原生盘面不会有元宝); > 之后它就按百搭的规则参与后续波的成奖与消除。**每个金色格各留一个百搭、无上限**(见 §2.2)。 所以转化时机不是"被消除后、下一波再补成百搭",而是**本波消除的那一刻就地变成百搭**。引擎按下面这条时序实现: ``` 第 n 波结算(判定与赔付都用金色符号的"自然符号"身份,例如当「中」用): 1. 算出本波所有中奖格(含金色格,金色格按它的自然符号计入 ways) → ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。 "消除"不等于"清空":消除之后该格才分成两类—— 2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号 3. 金色的中奖格 → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W** (这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空) 4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题") 5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画 第 n+1 波: W 按百搭规则替代任意付费符号(可搭桥、可补链,但**不能替代胡**),若它参与成奖则随该波被消除 ``` > 一句话记:**中奖格全部"被消除"(全爆),区别只在消除之后变成什么** —— 非金色格变成"空洞→补新牌",金色格变成"百搭"。这是和玫瑰女郎 `_collapse` 唯一的语义差别。 > **统计口径也要注意**:说"本波消除了 4 个符号"包含金色格;说"本波有 3 个空洞要补牌"不含金色格。两个数不一样,别混。 **必须定死的三个实现细节**: - 🎯 **百搭的两条铁律**(用户 2026-09-21 补充,见 §2.2)。这两条**直接改写了第 3 步**: - **原生盘面永远没有百搭**:卷轴不落元宝、补位也不补元宝,**百搭的唯一来源就是金色转化**; - **每个金色格各留一个百搭**:同波有几个金色格中奖,就留下几个百搭(无上限)。卷轴永不落百搭。 - 副作用(好副作用):**"纯百搭链"在数学上不可能出现**,原本要拍板的 Q1 自动消失,不需要 `WILD_PAYS` 开关。 - ✅ **重力下落(Q2 已定案:不钉住)**。用户 2026-09-21 确认:"**符号是可以下落的,下面符号消除之后,上面符号会往下掉落**"——百搭也是盘面符号,**一视同仁跟着落**,不锚定。 列内重力按标准写法:**中奖格清空 → 该列幸存符号(含刚转化的百搭)保持相对顺序整体压到底 → 顶部补入新符号**。 - 百搭**下方**的格被清空 → 百搭跟着掉下去; - 百搭**上方**的格被清空 → 百搭留在原地不动,上方的牌落到它头上。 - 如果把它做成"钉在空中",**它下方被清空的格就落不进任何符号**(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以**不能钉**。 - 客户端要注意:**百搭会移动位置**,下落动画必须用帧里的 `dropData`(`from → to`)驱动,**不能假设它原地不动**;"金色→百搭"的变形动画(`wildConverted`)播完之后,紧接着就可能是它的下落动画。 - ⚠ **"涉及中奖"的判定**:金色格被计入某笔中奖的 `ways`(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。**金色格只做自己那一种符号的替代**——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。 > 注意:由于最多只有 1 个百搭,且金色格本身是自然符号,**"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生**,不会成群出现。 **其余约定**: - 每旋开局(基础旋与每个 FS 旋)对**第 2/3/4 轴**上的普通符号按概率 `GOLDEN_CELL_PROB`(建议 0.10,逐格独立)打金色标记,不动 `W`/`S`;新补入的符号也可以带金色标记。 - 转化后的 `W` **不再回填金色标记**(每格只转化一次)。 - 金色符号在**波次判定时用的是自然符号**,所以"金色「中」链"就是「中」的链;转化后下一波起它才变成通用百搭。 - 金色格也受"百搭同样会被消除"约束(§2.2 边界二):本波转成 W 之后,若在后面的波里参与成奖,就随那一波被消除。 ### 2.4 乘数阶梯(奖金翻倍)—— 「本波阶梯倍率」是什么 **先把三个词定死,后面全篇都按这个来**: | 词 | 含义 | 例子 | |---|---|---| | **一波**(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.2)。 > 实现提示(来自 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.1): - `price(实际扣费) = box.bet`(本游戏 costMultiplier ≡ 1) - **回合池里存的全是倍率**(`actual_mul`),与投注额无关 → **改档位不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。 - 奖池水位按 `(boxId, audience)` 聚合,投注额只影响单局金额,不影响池结构。 **曾经的备选(已否)**:给"档位 × 倍数"每个组合建一个箱子 → 池子碎片化、水位口径混乱。 #### 2.8.1 设计多少个下注额、哪几个、能多大? **先分清两件事,因为成本差一个数量级:** | 加什么 | 池子要不要动 | 单局理论最大赔付 | 成本 | |---|---|---|---| | **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 40 条配比 + 一套水位) | | **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **同箱内直接放大 10 倍** | 几乎为零 | > ⚠️ **关键认识**:`betMul` 不是"免费做大",它**把同一箱内的金额跨度拉到 10 倍,等价于偷偷多开了 9 个档位**,却只用一个池子——风控口径和水位观察口径都会变粗。 > 而且在 500x 封顶下,`betMul` 会把**理论单局最大赔付**直接乘以 10。 > 所以本次**不再推荐"档位 × 倍数"双控件**,改为**只保留投注档**(与玫瑰女郎完全一致)。 #### 2.8.1 推荐方案(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 万帧** / 生成 20~30 分钟 / 704 条配比行 | ✅ 与玫瑰女郎同量级(它 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 万帧 / +52 条配比行 / 多一套水位要运营盯。**建议先上 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.2),当成总额抄过来会让最低中奖显示成 +0.00。 2. **冷门高档位会水位饥饿**:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放 10 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。 **为什么不是更多档** 1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束): - 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成; - 多 64 条**配比行**(16 桶 × 4 身份)要配、要重算、要维护模板; - 多一套**奖池水位**(4 个身份各一条)要运营盯。 10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。 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.2 的说明),当成总额抄过来会让最低中奖显示成 +0.00。 - ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。 - ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。 **两个附带决策** - **档位表里 3 / 30 保留**(它们是整数,只是不是 10 的整数倍;玫瑰女郎就这么定的,玩家点"+"往上加时体验更顺),不改成 2 / 25 之类。 - **不设投注倍数**;接口里的 `betMul` 作为**预留字段**保留(缺省 1、当前服务端只接受 1),万一以后要开也不用动池子。 > ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。 #### 2.8.2 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住 用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。 **约束一:来自赔付表 —— 最低档下注的最低中奖不能小于 ¥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"的理解**:规则页有一条「一条中奖路所得的奖励等同于赔付表上的赔付值**乘以投注大小和投注倍数**」。 > 这条要成立,只有一种读法:**原版的「投注大小」= 每条基础投注的金额**(即 `投注总额 = 投注大小 × 20 × 投注倍数`)。 > 按这个读法,原版最低档 0.02 的**最小中奖 = 2 × 0.02 = ¥0.04**,**不会丢分**。 > 而我们的模型里 `box.bet` 是**投注总额**,所以下限是 `0.10`;单纯把原版的 `0.02` 抄过来当**总额**才会丢分(0.002 显示成 +0.00)。 > **待确认(一个 30 秒的真机测试)**:把原版「投注大小」设成 **1.00**、「投注倍数」设成 **1**,看「投注总额」显示 **20.00** 还是 **1.00**—— > 显示 **20.00** ⇒ 投注大小 = 每条基础投注(上面那种读法,规则页第 1 条成立);显示 **1.00** ⇒ 投注大小 = 总额(那第 1 条就该改写成"乘以投注总额后除以 20")。 **附带的一个好性质**:**赔付表里的倍率都是 0.05 的整数倍**(0.1 / 0.2 / 0.25 / 0.3 / 0.4 / 0.5 / 0.75 / 1 / 2 / 3 / 4 / 5),而投注档又都是整数金额,所以**任何一笔中奖金额都是整分**,不会出现 0.0333 这种除不尽的数。建议投注档表**只放整数**,把这条性质保住。 **约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"** "必得饰品"意味着**空转局也会发一个最低档饰品**(`win = max(totalWin, 0.01)` 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估: ``` 每局保底返奖 ≈ 空转率 × 最低档饰品价格 低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额 ``` | 最低档饰品价 | 空转率 | 投注 ¥1 时的保底 RTP | |---|---|---| | ¥0.01 | 40% | 0.4% ✅ | | ¥0.05 | 40% | 2% ✅ | | ¥0.50 | 40% | **20%** ⚠️ 光保底就吃掉 1/4 的目标 RTP | → **最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级**;饰品区间表(`prize_range`)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。 → 对账口径也要注意:水位里的"派彩"应当用 **`record.settled_win`(饰品价)**,而不是 `total_win`(游戏内赢额)——否则这两者对不上时,水位会漂。 **结论**:下注档表要同时满足 1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起) 2. 档位取整数(保证 unit 是整分) 3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束) 模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。 | boxType | 名称 | costMultiplier | 语义 | |---|---|---|---| | `normal` | 普通旋转 | **1x** | 唯一的玩法:自然触发免费旋转(3+ 胡) | > 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套): > - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet`; > - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶; > - 箱数从 30 降到 **10**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 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.00** | 10.00 | ¥1000 | | 4 | 索红绿 5 连 | 1 | 1 | 1.00 | x5 | ↑ 并入上行 | ↑ | ↑ | - **本局总倍率 = 2.80 + 6.00 + 3.00 + 10.00 = 21.80 倍**(未触 500x 封顶) - **本局赢额 = 21.80 × ¥100 = ¥2180** ✓(与 §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`;**后台页显示名「饰品桶」**(对应玫瑰女郎的 `game_olympus_ornament`,见附录 A 映射)。 | 桶ID | 名称 | 期望倍率 | 落桶下限 | 落桶上限 | |---|---|---|---|---| | 1 | 0倍饰品 | 0 | 0 | 0 | | 2 | 0.5倍饰品 | 0.5 | 0.01 | 1 | | 3 | 1.5倍饰品 | 1.5 | 1 | 2 | | 4 | 2.5倍饰品 | 2.5 | 2 | 3 | | 5 | 4倍饰品 | 4 | 3 | 5 | | 6 | 6.5倍饰品 | 6.5 | 5 | 8 | | 7 | 10倍饰品 | 10 | 8 | 12 | | 8 | 16倍饰品 | 16 | 12 | 20 | | 9 | 25倍饰品 | 25 | 20 | 30 | | 10 | 40倍饰品 | 40 | 30 | 50 | | 11 | 60倍饰品 | 60 | 50 | 70 | | 12 | 100倍饰品 | 100 | 70 | 130 | | 13 | 150倍饰品 | 150 | 130 | 170 | | 14 | 200倍饰品 | 200 | 170 | 230 | | 15 | 300倍饰品 | 300 | 230 | 370 | | **16** | **500倍饰品(封顶)** | **500** | 370 | **700** | 约定(与玫瑰女郎一致): - **期望倍率 = (落桶下限 + 落桶上限) / 2**;区间**连续无缝、覆盖 0~封顶**。 - **桶 1 = 空转**(本局没有任何 1024 路中奖)。注意:**空转局照样发最低档饰品**("必得饰品",见 §6.3),所以桶 1 也是要配库存的。 - **桶 16 = 封顶档**:区间上界**故意开到 700(1.4× 封顶)**,这样"本来该赔 600x 的局"也落进这一档;**实际发放一律截断到 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。 - 因为 500x 封顶,桶 16 的池内实测均值会明显低于名义 500(被截断的局都堆在这里),配比计算按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是名义 1000、实用 835)。 **这张表**只管两件事:① 一局落在哪个倍率档(配比与库存的单位);② RTP 配平。 **实际发哪一件饰品**是另一回事——由 `prize_range` **按金额(¥)匹配**,见 §3.8。 **配置量与规模** - `16 桶 × 11 箱 × 4 身份 = **704 条**` 配比行(玫瑰女郎是 `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…)——因为 `赔付值 × ways × ladder ÷ 20` 里赔付值都是整数。所以任何桶边界都会自然对齐,不需要额外处理。 ### 3.2 RTP 恒等式 ``` Σ (share_i × expect_mul_i) = rtp × costMultiplier # 本游戏 costMultiplier ≡ 1 ``` | 模式 | costMultiplier | 目标 RTP | 需要的期望倍率 | |---|---|---|---| | normal(唯一) | 1 | 0.80 | **0.80** | 因为只有 1x 模式,**所有箱、所有身份的目标期望倍率都是同一个数**(只由 rtp 决定),收敛阶段 `targetMul = rtp × cm = rtp`——比玫瑰女郎三模式简单得多。 ### 3.3 配比从哪来(`calcRounds` 算法) 两份实现必须一致:`mahjong-py/_recalc_boxes.py::calc_rounds` 与 `MahjongDistributionService::calcRounds`。 1. 基础权重 `30 × 0.6^k`(k = 桶序号,低倍桶权重高); 2. 二分搜索 `power` 使加权均值命中 `targetRtp × cm`(误差 <1%); 3. 按权重把固定池分配到各桶,并施加单调约束 `count[i] ≥ count[i+1]`(低倍桶条目多于高倍桶); 4. 400 次"任意两桶搬 1 个"微调,逼到 `|delta| < 0.01`; 5. **期望值取该箱池内实测 `AVG(actual_mul)`**(样本 ≥1),无样本才回退名义 `expect_mul`/封顶修正值(玫瑰女郎顶桶用 835 而非 1000 就是这个原因)。 ### 3.4 一轮(cycle)与收敛比例 ``` fixedSum = Σ count # 固定池 N = round(fixedSum / (1 - cr)) # 一轮总抽数(写进 box.total_rounds) fixedRounds= round(N × (1 - cr)) 收敛阶段: desiredNextMul = max(0, (N × targetMul − 已派彩) / max(1, N − drawn)) ``` - `cr`(`rtp_converge_ratio`)默认 0.15,线上玫瑰女郎用 0.20,夹在 `[0, 0.9]`。 - 固定阶段消耗 `used`,收敛阶段**不消耗库存**——这是"RTP 自愈"的来源。 - 抽满 `drawn ≥ N` → `resetCycle` + 按模板 `refillFromConfig`。 ### 3.5 奖池水位(与玫瑰女郎同口径) ``` 水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null ``` - bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。 - 收敛/固定两阶段都用 `payoutCap` 过滤"期望赔付超上限"的桶,捞不到就退最小桶。 - 白号(`is_blank_account=1`)`payoutCap=null`:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。 ### 3.6 身份与目标 RTP(**后台可配**,不是拍板项) 各身份的 RTP **不在代码里写死**,和玫瑰女郎一样由后台配置,两层: 1. **箱子级**:`game_mahjong_box.rtp`(目标返奖率)+ `rtp_converge_ratio`(收敛比例)—— 在后台「箱子配置」页改; 2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。 所以"各身份 RTP 定多少"**不是一个需要在方案阶段拍板的数**,而是运营参数:先全部按 **0.80** 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。 | audience | 判定 | 建库默认 RTP | 说明 | |---|---|---|---| | common | 其余全部 | 0.80 | | | anchor | `user_type='01'` | 0.80 | 四身份互斥并集 = 全部 | | robot | `user_type='03'` | 0.80 | | | white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 | ### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束) - 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。 - 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 6~8 号桶。**生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。 - **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**: - 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋); - 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`); - 少了这条,客户端那段"最后一列放大"的动画**永远不会被触发**(纯自然概率太低),爽点白做。 ### 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 | > > 所以"每个饰品对应多少倍"这句话,只有"该饰品价格 ÷ 当前投注档"才有意义。 > 想要"饰品 ↔ 固定倍率"的对应关系,那是**①倍率桶**在做的事(池子按倍率分档),不是②。 **② 饰品区间表怎么来(不要重新设计)** 照玫瑰女郎的做法(`docs/updata_sql.md:1187-1200`)**整套导入**: ```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 条(都直接决定 RTP,不能跳过)** | # | 检查项 | 为什么 | 判据 | |---|---|---|---| | 1 | **最低档饰品的价格** | "必得饰品"意味着**空转局也发最低档饰品**,它的价格就是每局的**保底返奖**:`保底 RTP ≈ 空转率 × 最低饰品价 ÷ 投注额`。空转率按 **65%** 估,¥1 档下若最低饰品价 **¥0.50** → **保底 RTP 32.5%**,直接把目标 80% 吃掉一半 | **最低饰品价 ≤ ¥0.05**(→ 保底 ≈3%) | | 2 | **区间上界要覆盖到 500x × 最高档 = ¥250,000** | 覆盖不到的话,高倍局全都走 `findPrizeWithFallback` 落到兜底饰品,出现"赢了 20 万给个 50 块的皮肤" | 最高区间下限 ≥ ¥250,000(玫瑰女郎是 1000x × 300 = ¥300,000,量级相同,大概率够) | | 3 | **区间连续无缝、无空洞** | 有空洞就会走 fallback(落到 `is_fallback=1` 或最低/最高区间),发奖与赢额不匹配 | 按 `win_low` 排序后逐条核 `high_i + 0.01 == low_{i+1}` | > 本地 MySQL 没启动,我没法直连查现成的区间数据;**上线前请在测试库跑一次**: > ```sql > SELECT MIN(win_low) AS 最低区间下限, MAX(win_high) AS 最高区间上限, > COUNT(*) AS 条数 FROM game_olympus_prize_range; -- 玫瑰女郎现成的 > SELECT o.name, o.grade, r.win_low, r.win_high FROM game_olympus_prize_range r > JOIN game_olympus_prize_ornament o ON o.id = r.prize_id ORDER BY r.win_low LIMIT 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 箱 × 4 身份 = 704 条** | | 4 | `game_mahjong_round` | `game_olympus_round` | round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss | 去 `total_cascades` 语义改为 `total_rotations`(波数);`need_fs` 不需要了(没有买 FS 模式);加 `near_miss` 标记 2 胡悬念盘 | | 5 | `game_mahjong_step` | `game_olympus_step` | 见 §4.1 | 字段大改(ways/golden/阶梯) | | 6 | `game_mahjong_record` | `game_olympus_record` | user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, **prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type** | **保留全部 prize_* 列**(发饰品要走区间匹配),只多一个 `bet_mul` | | 7 | `game_mahjong_user` | `game_olympus_user` | user_id, total_spins, total_cost, total_win, max_win_mul | 去 `total_free_spins` 或保留 | | 8 | `game_mahjong_box_template` | `game_olympus_box_template` | box_id, audience, name, total_rounds, items_json, is_active | 同构 | | 9 | `game_mahjong_reward_log` | `game_olympus_reward_log` | box_id, audience, type, amount, balance_before/after, pool_balance, operator, remark | 同构(原因必填由前端保证) | | 10 | **`game_mahjong_prize_ornament`** | `game_olympus_prize_ornament` | id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback | **必须有**:赢额 → 饰品 | | 11 | **`game_mahjong_prize_range`** | `game_olympus_prize_range` | prize_id, win_low, win_high | **必须有**:赢额落区间 | **可选**:`game_mahjong_win`(日报表)。若后台"每日统计"要独立日表则加回,否则按 `record` 聚合(Q8)。 ### 4.1 `game_mahjong_step` 字段(每帧一行) ```jsonc { "id": 1, "round_id": 1001, "reel_spin_id": 0, // 0=基础旋/触发盘, >0=第N次免费旋转 "rotation_seq": 1, // 本旋内第几波(= 乘数阶梯序号) "grid_state": [["A","W","S","D","H"], ...], // 4 行 × 5 列 = 20 格短码 "golden_detail": [{"pos":7,"symbol":"B"}], // 本帧盘面上仍是"金色普通符号"的格(1 基格位) "wild_converted": [{"pos":7,"fromSymbol":"B"}],// 本波"金色→百搭"就地转化的格(客户端播变形动画) "ways_detail": [{"symbol":"A","reelCount":3,"counts":[1,2,1,0,0], "loc":[1,6,11,7], // 本笔中奖的全部格位(1起),含金色格 —— 客户端按它播爆裂动画 "ways":2,"payBase":15,"rotationMul":2,"win":3.00}], // win = 15 × 2 × (bet÷K) × 2,K 见 §2.1 计量单位 "drop_data": [{"from":12,"to":17,"symbol":"H"}], "fill_data": [{"pos":2,"symbol":"G"}], "scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0}, // 或 null "retrigger_info": null, "rotation_mul": 2, // 本波阶梯倍率(本波适用的档位) "win_amount": 120.00, // 本旋累计到本步(末帧 = 本旋总额),不是增量 "spin_win": 340.00, // 本局累计到本步 "win_capped": 0, "bigwin_level": "small", // 本旋末帧才有 "is_final": 1 } ``` **只落库不下发**:`pay_base`、`counts`(留档/对账用)。接口下发字段见 §7。 --- ## 5. Python 生成器(`mahjong-py/`) ### 5.1 目录结构(对拷 `olympus-py`) ``` mahjong-py/ ├── schema.sql # 11 张表 DDL + 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 # 12 箱 × 4 身份 = 48 条生效模板 ├── _regen.py # check/snapshot/clear/restore/verify ├── _reset_boxes.py ├── README.md # 结构与 runbook(照抄并改写游戏规则段) └── tests/ ├── test_engine.py # ways 判定 / 阶梯 / 金色转化 / 封顶 └── test_simulator.py # 级联几何复刻 / FS 触发 / 池内 RTP ``` ### 5.2 必须重写的四处引擎逻辑 1. **`_winners` → 1024 路前缀链判定**(`_MIN_WIN_COUNT=8` 语义作废) ```python def ways_win(grid): # grid: 4行 × 5列 for sym in PAYING: run, ways = 0, 1 for c in range(5): # 第1轴 → 第5轴,断了就停 cnt = count_sym_or_wild(grid, c, sym) if cnt == 0: break run += 1; ways *= cnt if run < 3: continue # 1-2-4 / 2-3-4 / 第1轴为空 一律不成奖 yield Win(sym, reelCount=run, ways=ways, pay=PAYTABLE[sym][run] * ways) ``` **必须写成前缀链(`break` 就停),不是"5 轴里有 3 轴有就算 3 连"** —— 后者会把用户举的 `1,0,3,4,4` 判成中奖,方向性错误,且会系统性抬高 RTP。 注意:`连轴数 = run`(3/4/5),档位从第 1 轴起算;`ways` **只乘链内的轴**,链外轴的个数不参与。 **不需要**再写"纯百搭链不成奖"的特判 —— **百搭只可能出现在第 2/3/4 轴**(金色只在那三轴),第 1 轴必然是自然符号,所以纯百搭链不可能出现(§2.2)。生成端与校验端加一条断言:**`W` 只出现在第 2/3/4 轴**。 2. **几何**:`GRID_ROWS=4 / GRID_COLS=5 / GRID_SIZE=20`;`_force_win` 的 `random.sample(free, cnt)`、`_force_small_win` 的可用格数、`_place_scatter` 的保护集都要跟着改。 3. **乘数阶梯替换炸弹**: - 删掉 `BOMB_CELL_PROBABILITY` / `BOMB_VALUES` / `BOMB_QUALITIES` / `FS_SCREEN_BOMB_PROB` 一整组; - 新增 `LADDER_BASE = (1,2,3,5)` / `LADDER_FS = (2,4,6,10)`,**第 n 波的阶梯倍率** = `ladder[min(n,4)]`; - `_force_win` 的目标反解改为"基础组合 × 本波阶梯乘数",即给定目标倍率 T 时,需要的基础分 = `T / ladder[n]`; - **累计器机制不删**:`accumulator_before/after` 改成 `rotation_mul`,语义从"跨旋累加"变为"本波档位"。 4. **级联的"消除"不再是"全部清空"**(金色符号就地转百搭,替换 olympus 的纯消除): ```python def settle_rotation(grid, golden): # golden: {pos: natural_symbol} wins = prefix_chain_wins(grid) # 判定时金色格按 natural_symbol 计数 if not wins: return None converted = [] for pos in win_cells(wins): if pos in golden: grid[pos] = WILD # ★ 就地改写,不清空、不放新符号 converted.append({"pos": pos, "fromSymbol": golden.pop(pos)}) for pos in win_cells(wins) - {c["pos"] for c in converted}: grid[pos] = None # 其余中奖格照常清空 drop_and_fill(grid) # W 作为幸存符号一起下落(§2.3 Q2) return converted ``` - 这一处**必须写对**:olympus 是"中奖格全清 + 补新符号",本游戏是"金色中奖格变成 W 留在盘上",会直接改变级联的密度与 RTP,也是本游戏最独特的爽点来源。 - `_collapse` 仍可复用(列式下落方向不变),只是入参从"清空后的盘面"变成"金色格已改写为 W 的盘面"。 - 落库/下发都要带 `wild_converted`(§4.1 / §7.1)。 其余可原样拷贝:`_collapse`(5 轴列式下落,方向不变)、`_fill`(去掉炸弹参数)、`fill_distribution`(阶段1 自由生成 + 阶段2 按缺口补桶 + `_calibrate_bias` 饥饿重校准)、FS 保底配额机制(`FS_SPIN_WIN_PROB` / `FS_CASCADE_CONTINUE_PROB` / `FS_GUARANTEE_COMBOS` 换成本游戏的"保底最少中奖形状")。 ### 5.3 生成流水线(顺序敏感,照抄) ```bash cd mahjong-py python mahjong_db.py # 建 11 张表 + 16 档桶种子(幂等) python generate_rounds.py --per-bucket 20 # 干跑,不写库 python generate_rounds.py --all --alloc config --write --workers 4 # 12 箱写库(只有 normal) python _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板 python _recalc_boxes.py --mode normal # 重算配比 + 回写 total_rounds python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026 # RTP 验收 python _regen.py check|snapshot|clear|restore|verify # 体检/快照/清池 python -m pytest tests -q # 回归 ``` **顺序不可颠倒**:重建池子后必须 `_recalc_boxes` → 清 Redis `ruoyi:mahjong:cycle:*` → `_make_templates`,否则 Java 端拿的是过期配比与旧账本(这是玫瑰女郎踩过的坑)。 ### 5.4 参数与工作量预估 | 参数 | 值 | 依据 | |---|---|---| | `ALLOC_K` | 1.5 | 池子 = 配比 × 1.5,留抽样多样性 | | `ALLOC_FLOOR` | 60 | 每桶保底条数 | | `BOX_STEP_BAND` | 200000 | 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估) | | 每箱池子 | 4000~5000 条回合 | 因为没有"买 FS"那批高倍回合兜底,**高倍桶要靠 normal 池自己凑**,所以单箱池子要比玫瑰女郎更大 | | 总规模 | **12 箱** × ~4500 = **5.4 万回合**,每回合 8~40 帧 → **~96 万 step**(与 olympus 的 106 万同一量级) | | 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) | | 风险点 | **高倍桶(8/9/10 号桶)没有买 FS 兜底**:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) | > 若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 `copyFromSameBucket` 复制(olympus 已有此兜底,`:179-231`)。 --- ## 6. Java 服务(`skins-service/game-mahjong-service`) ### 6.1 模块接线(3 处,易漏) 1. `skins-service/game-mahjong-service/pom.xml`(拷 olympus,改 artifactId,parent = `skins-service`); 2. `skins-service/pom.xml` 的 `` 加一行; 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 平台共享文件的改动(9 处,漏一处就少一块统计) | # | 文件 | 改动 | |---|---|---| | 1 | `skins-model/.../TtAccountRecordSource.java` | 新增 `MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费")` / `MAHJONG_REWARD(69, INPUT, "麻将胡了奖励")`;并加进 `getWelfarePrizeCodes():193`、`getGameConsumeCodes():203`、`getGameBetCodes():212`、`getGameRewardCodes():224`、`isNewGameConsumeForCommission():250`(新游戏返佣口径)、`getTurnoverLotteryCodes():271` —— **共 6 处聚合** | | 2 | **`skins-model/.../TtboxRecordSource.java`** | **新增 `MAHJONG_BOX(15, "麻将")`**(现有 13=玫瑰女郎、14=流水抽奖)—— 背包来源,漏了就写不进背包 | | 3 | **`skins-service/service-user/.../ApiUserPackSackServiceImpl.java:612-645`** | `mapDecomposeSource` 加 `MAHJONG_BOX(15) → MAHJONG_REWARD(69)`;**漏了就会走 `DECOMPOSE_ORNAMENT(10)` 默认口径,报表全错** | | 4 | `skins-service/service-admin/.../TtRechargeRecordController.java:155-163,253-281` | 报表 sources 列表 + `appendPoint` 单列该游戏(可选) | | 5 | `skins-service/service-admin/.../config/RedisConstants.java` | 加 3 个键:`ruoyi:mahjong:` / `ruoyi:mahjong:cycle:` / `ruoyi:mahjong:reward:pool` | | 6 | `skins-model/.../MessageCategoryEnum.java` | 加 `GAME_MAHJONG("GAME_MAHJONG","麻将胡了")`(前缀 `GAME_` 才进"游戏"Tab)—— **本游戏要发结算站内信,必加** | | 7 | `sys_config` | `mahjongMaintenance` 维护开关(0 正常 1 维护) | | 8 | `sys_menu` | 7 个后台菜单(见 §8) | | 9 | `docs/updata_sql.md` | 新增一节「麻将胡了模块建表 / 服务接入」,格式照 1144-1225 行;**返奖饰品种子**照 1187-1200 行从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) | | 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 | > **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。 ### 6.6 封顶与"匹配/发放分离" - 回合池里桶 10 的 `actual_mul` 一律 **= 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` 区间 → 饰品 → 写背包 → `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÷K) × rotationMul`(**K 见 §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 照抄 `docs/updata_sql.md:1165-1186` 的写法(`SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单)。**注意两个仓库现状**: - `sql/` 目录是空的,全量 DDL 历史只在 `docs/updata_sql.md`(追加式)和生成器的 `schema.sql` 里,别去 `sql/` 找; - 后台页面**没有任何 `v-hasPermi` 指令**,路由也**不在 `router/index.js` 里静态注册**——能否访问完全由 `sys_menu` 行决定(`component='game/mahjong//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 万旋仿真:基础旋命中率 20~30%、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 | | **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **12 箱**池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) | | **P2 Java 服务** | `game-mahjong-service` 全套 + 10 处共享文件改动 | ① `mvn -pl skins-service/game-mahjong-service -am compile` 通过;② 开局/续播/重连/封顶/空转局五条链路离线单测;③ 扣费与发奖同一事务、`settled` 幂等可重放、饰品开局即入背包、分解流水口径正确 | 6~8(含饰品/背包/站内信链路) | | **P3 后台 + SQL 文档** | 7 个 Vue 页面(含奖品管理)+ `docs/updata_sql.md` 新节 + `docs/麻将胡了接口文档.md` | ① 菜单挂载正确、权限生效;② 一键重算与注水/抽水(原因必填)可用;③ 返奖饰品与区间可编辑;④ 接口文档与真实响应逐字段对齐 | 4~5 | | **P4 客户端** | Mahjong 面板全套 | ① 逐帧复刻服务端盘面(含金色转化、阶梯高亮、**2 胡悬念放大**);② 断线重连回到正确波次;③ 末帧「放入背包/立即分解」+ 自动分解开关 | 15~20 | | **P5 联调与上线** | 测试服部署 + 对账脚本 + 上线清单 | ① 真实 30 分钟对账:`game_mahjong_record.settled_win` 之和 = 背包记录数 × 对应饰品价,分解后 = `MAHJONG_REWARD(69)` 流水;② RTP 抽样 ≥1000 局落 ±5pt;③ 流水抽奖 / 消费排行 / VIP 返利三项回归通过 | 4~5 | **总计**:后端 20~26 人日、客户端 15~20 人日、联调 4~5 人日。P0→P1 强串行,P4 可在 P2 接口冻结后并行。 --- ## 11. 测试与验收清单 **Python** - `test_engine.py`: - **前缀链判定逐条覆盖 §2.1 的 7 个正反例**(`2,3,1,0,0` ✅ / `2,3,1,2,0` ✅ 4 连 / `2,0,3,4,4` ❌ / `0,4,4,4,4` ❌ / `1,1,0,4,4` ❌ / `1,2,3,4,4` ✅ ways=96 / `1,1,1,1,1` ✅ ways=1)——**这是全游戏最容易写错的一处,必须单测钉死**; - 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `count_wild(grid) ≤ 1`**; - **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `gridState` 变 `W` 且 `wildConverted` 有它;② 该格**没有**被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 `發,發,_,...` 变成 3 连);④ 该 W 在更后面的波参与成奖时**被正常消除**;⑤ 未被中奖涉及的金色格**不转化**、保持原样; - 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**百搭不能替代胡**(`發,胡,發` 不成链)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。 - `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 8/9/10 号高倍桶**——没有买 FS 兜底,最容易饿死)。 - `simulate_cycle.py`:整轮 RTP 与设计的偏差;白号池独立。 **Java** - `RoundPicker`:固定阶段只消耗 `used`、收敛阶段不消耗;`payoutCap` 过滤生效;白号走纯概率分支。 - 幂等:同一 `recordId` 重复 `settleAtStart` 只入账一次、**只写一条背包记录**。 - 封顶局:`maxWinReached=true`、`spinsRemaining=0`、`totalWin = 500×bet`(**500x 封顶,Q4 定案**)。 - **空转局(`totalWin=0`)**:仍按 `max(totalWin,0.01)` 命中**最低区间饰品**、照样写背包、`settle_source` 照常 `finish`;**开局不写任何 `MAHJONG_REWARD` 流水**。 - **奖励流水时机**:`settleAtStart` **不写** 69;只有调 `decompose` 分解后才写 69(source 15 → 69 的映射生效),未分解就永远没有 69。 - 站内信:单步局开局发、多步局末帧发、**中途弃局不发**(对齐玫瑰女郎)。 - 并发:同一用户并发 `dospin` 只有一个能开局(`COMMON_LOCK+userId`)。 **对账 / 回归** - `Σ record.settled_win`(已结算局) == `Σ` 对应饰品价 × 条数 == 背包里 `tt_box_records.source=15` 的条数口径。 - 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。 - 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。 - 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。 --- ## 12. 风险与对策 | # | 风险 | 对策 | |---|---|---| | 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 | | 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 | | 3 | **step 表体量**(~96 万帧,12 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 | | 4 | **py / Java 两份 `calcRounds` 不同步**(玫瑰女郎已因此返工) | 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比) | | 5 | **免费旋转"演不动"**(FS 是白送的 12 旋,预算铺不开时动画会空转) | 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分 | | 6 | ~~现金直付与平台既有"游戏必给饰品"体验差异~~ | **已定案:走饰品**(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期 | | 7 | **客户端工作量大**(玫瑰女郎面板是 186KB 单文件) | 直接复制改造,**不做公共 slot 基类抽象**(两个游戏的盘面/动画差异大于共性) | | 8 | **生成耗时**(本机 i3-10105F,4 物理核) | `--workers` 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意 | | 9 | **母本代码有文档漂移**(`olympus-py/README.md:459` 声称 RoundPicker 有 `ruoyi:olympus:seen:*` 不重发去重,**实际代码里不存在**) | 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认 | | 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数 | --- ## 13. 全部定案(**无待拍板项**) > 2026-09-21 全部澄清完毕。下表保留完整决策链,方便回溯"这条为什么这么定"。 | # | 问题 | 结论 | |---|---|---| | ~~Q0~~ | ~~赔付值乘的基数是哪个?~~ | ✅ **赔付表直接用倍率表达**(原版点数 ÷ 20 已内化进表里),公式里不再有 ÷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`;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 | | ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 12 箱、无 `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.1 | | Q8 | 每日统计要不要独立日表 | 🔧 **技术默认:不要**,后台按 `record` 聚合 | | ~~Q9~~ | ~~奖励形态:现金直付 vs 饰品入背包~~ | ✅ **赢额变成一个饰品发到背包** → 区间匹配 → `tt_box_records`(source=15) → 末帧放入/分解 → 分解时写 `MAHJONG_REWARD(69)` | | Q10 | 后台"回合管理"要不要暴露金色/阶梯明细 | 🔧 **技术默认**:只读展示 `actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘`,不展示内部帧 | | **新** | **百搭:来源 / 数量 / 位置 / 消除条件** | ✅ **已确认**:① 原生盘面**不会**出现百搭(卷轴不落、补位不补),**唯一来源是金色转化**;② **几个金色格被消除就留几个百搭,无上限**(一屏可同时有多个);③ 因此**百搭只可能在第 2/3/4 轴**,第 1 轴永远没有 ⇒ **纯百搭链不可能**(Q1 永久关闭);④ 百搭**可作为任何付费符号**,**被消除也要满足消除链**;⑤ 可与普通符号、也可与金色符号连着消除(同权,无特例) | | ~~新~~ | ~~转化边界的策略开关~~ | ❌ **已取消**:既然"每个金色格各留一个、无上限",就不存在"已有百搭时该不该转化"的边界,`WILD_SLOT_POLICY` 不需要了 | --- ## 14. 完整规则汇总(规则页文案版 —— 可直接交客户端 / 策划 / QA) > 本节把 §2 的游戏规格**按玩家视角重排**,既是客户端「规则」「赔付表」两个面板的文案来源,也是 QA 的验收清单。 > 文案口径:**玩家能看懂**;参数口径:见每节末尾的「开发参数」。 ### 14.1 一句话 麻将胡了是一款 **5 轴 × 4 行**的视频老虎机游戏,共 **1024 个中奖路**,编入了**百搭符号**、**金色麻将符号**以及**奖金乘数递增的免费旋转**。 ### 14.2 投注与结算 | 项 | 规则 | |---|---| | 投注构成 | 选一个**投注档**(11 档,见下),即为**投注总额**,开局即扣(**不设投注倍数**) | | 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000` | | 游戏单位 | 金额以 ¥ 为单位 | | 现金钱包 | 显示当下玩家可用的现金余额 | | 自动旋转 | 可按玩家选择的回合数量自动进行游戏 | | 中奖派发 | 按「**赔付表**」执行;不同中奖路上的奖金**叠加** | | 结算形态 | 本局赢额**按区间转换为一个饰品发到背包**;末帧可选择「放入背包 / 立即分解」;开启「自动分解」则末帧自动分解为金币 | > **开发参数**:`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),而且**一个盘面上最多只会出现 1 个**。 - 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。 - **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。 - 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。 ### 14.6 金色麻将符号 **官方规则原文(游戏内「金色麻将符号」页)**: 1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的**某些符号(不包括百搭符号和夺宝符号)**将可能显示为金色。」 2. 「在**新符号跌落后**的每一轮旋转中,**上一轮赢奖中所涉及的**黄色麻将符号将被**转换为百搭符号**。」 **人话版(4 条,这是要写进规则页的文案)**: | # | 规则 | |---|---| | 1 | **谁能变金**:只有**第 2、第 3、第 4 轴**上的**普通符号**会显示为金色(**百搭和胡不会变金**) | | 2 | **金色是什么**:就是那个普通符号**镀了一层金**,判定与赔付**仍然按它的自然符号**算(金色「中」照「中」的赔付表赔) | | 3 | **中奖后怎么变**:这个金色符号**参与中奖、被消除时**,它原来的位置**不会空掉、也不会补新牌,而是留下一个真正的百搭符号**(元宝)——**每个金色格各留一个**(同波几个金色格中奖就留几个,无上限) | | 4 | **之后怎么用**:留下的百搭和盘面上直接落下的百搭**完全一样**——可以替代除「胡」以外的所有符号、可以补链搭桥、参与中奖时同样被消除 | **两个最容易搞错的点**: - 官方原文说"**在新符号跌落后**的每一轮旋转中……被转换为百搭"——这是**动画时序**的描述(视觉上要等新牌落下来才看到它变百搭);**实现上是"该格被消除的那一刻就地写入百搭"**,两者不冲突。 - 金色符号**本身就是普通符号**,所以它照样**随重力下落**(下面被消除就往下掉),**不会钉在空中**。 **一个连贯的例子**: | 步骤 | 盘面 | 发生了什么 | |---|---|---| | 第 1 波 | 金色「中」参与「中」的 3 连 | 按「中」赔付;结算后该格**留下一个百搭** | | 第 2 波 | 掉落后形成 `發, 百搭, 發 …` | 百搭**搭桥**,「發」3 连成立 | | 第 3 波 | 这个百搭又参与了一笔中奖 | 它**随这一波被消除**(百搭也是一张普通牌) | > **开发参数**:每旋开局对第 2/3/4 轴的普通符号按 `GOLDEN_CELL_PROB`(建议 0.10,逐格独立)打金色标记,新补入的符号也可能带金色;本波中奖涉及的金色格在消除时**不补新符号、就地写 `W`**,并随重力下落(不锚定);每格只转化一次,转化后的 `W` 不再回填金色。判定细节见 §2.3。 ### 14.7 乘数阶梯(奖金翻倍) - **在任何旋转期间,所有赢奖都将乘以卷轴上方所显示的乘数值**。 - **普通旋转**:第 1 次消除 **x1** → 第 2 次 **x2** → 第 3 次 **x3** → 第 4 次及以后 **x5**。 - **免费旋转**:乘数分别增加至 **x2、x4、x6、x10**。 - **每旋重新从第一档开始**,不跨旋累计。 > **开发参数**:`LADDER_BASE = (1,2,3,5)`、`LADDER_FS = (2,4,6,10)`;第 n 波取 `ladder[min(n,4)]`。 ### 14.8 级联(翻滚) - **每轮游戏结束并发放奖金后,所有赢奖符号都将爆炸消除,让其上方的符号向下跌落**,以进行新一轮的旋转。 - 顶部补入新符号后进入下一波,**直到没有其他中奖组合**为止。 - **额外的中奖组合都将计入每次旋转的赢奖中**。 ### 14.9 免费旋转模式 - **当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转**。 - **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。 - **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。 - **免费旋转可以被重复触发**(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) | 0(W/A/B/H) | — | — | 轴3 断 | | 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 | | 發 A | 轴1 没有 | — | — | — | — | 不成奖 | > 同一个百搭这波**同时被筒和索红绿两个符号借用**,两条链都成立 → **两笔并赔**。 **赔付**: | 中奖 | 符号倍率 | ways | 本笔基础倍率 | 本波阶梯倍率 | 赢额 | |---|---|---|---|---|---| | 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | **¥500** | | 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | **¥500** | | | | | | 小计 | **¥1000** | **本波共 9 个符号被消除**(筒链 5 格 ∪ 索红绿链 5 格,重叠在 `W` 与部分格): (行1,轴1)G、(行4,轴1)F、(行4,轴2)G、(行3,轴2)F、**(行1,轴3) W**、(行1,轴4)G、(行3,轴4)G、(行4,轴4)F、(行3,轴5)F、(行4,轴5)G—— `W` 参与了两条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。 #### 盘面演变(第 4 波 → 本旋结束) **① 消除瞬间**(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 `W`): | 轴 → | 1 | 2 | 3 | 4 | 5 | |---|---|---|---|---|---| | 行1 | ▢ | A | ▢ | ▢ | C | | 行2 | H | C | A | A | S | | 行3 | C | ▢ | B | ▢ | ▢ | | 行4 | ▢ | ▢ | H | ▢ | ▢ | **② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**: | 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 | |---|---|---|---|---|---|---| | 行1 | **E** | **G** | **E** | **C** | **A** | 轴1:H/C 下移两格 → 落进 2 张 | | 行2 | **F** | **D** | A | **D** | **B** | 轴2:A/C 下移两格 → 落进 2 张 | | 行3 | H | A | B | **E** | C | 轴3:A/B/H 下移一格 → 落进 1 张 | | 行4 | C | C | H | A | S | 轴4:A 落到底 + 落进 3 张;轴5:C/S 下移 + 落进 2 张 | → 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而**轴3 没有 C** → **无任何中奖组合,本旋结束(整局共 4 波)**。 ### 本旋总账 | 波 | 中奖 | 符号倍率 | ways | 本笔基础倍率 | 本波阶梯倍率 | 本波赢额 | |---|---|---|---|---|---|---| | 1 | 中 3 连 | 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 | | | | | | | 合计 | **¥2180** | > 计算方法:**本笔基础倍率 = 符号倍率 × ways**;**本波赢额 = Σ本笔基础倍率 × 投注总额 × 本波阶梯倍率**(投注总额 ¥100)。 > 例:第 2 波 `0.75 × 4 = 3.00 倍` → `3.00 × 100 × 2 = ¥600` ✓ - 投注总额 ¥100 → **本局总倍率 = 2180 ÷ 100 = 21.80x**(未触及 500x 封顶) - 本旋只有 **1 个胡**(一直挂在轴5)→ **不触发免费旋转**(需 3+) ### 百搭轨迹(这张表是重点:**几个金色格中奖,就留几个百搭**) | 时刻 | 盘上百搭数 | 发生了什么 | |---|---|---| | 旋开局 | **0** | 原生盘面不落百搭 | | 第 1 波结束 | 0 → **2** | 同波有**两张**金色「中」中奖 → **各留一个百搭**(轴2、轴3 各一个)。两个 `W` 随后**都随重力从行2 掉到行3** | | 第 2 波结束 | 2 → **0** | **两个 `W` 都参与了八萬链**(满足消除条件)→ 一起被消除;本波无金色中奖,不补 | | 第 3 波结束 | 0 → **1** | 本波只有 **1 张**金色「花筒」中奖 → 留 **1 个** `W`;该列没有空洞,所以它**原地不动** | | 第 4 波结束 | 1 → **0** | `W` 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补 | | 旋结束 | 0 | 无后续中奖;下一旋整盘重发 | > **百搭数量没有上限**(第 1 波就同时有 2 个),但**只可能出现在第 2/3/4 轴**——因为金色格只在那三轴,而百搭的唯一来源是金色转化。 > 这条推论很关键:**第 1 轴永远不会有百搭**,所以前缀链的第 1 轴必然是自然符号 ⇒ **任何一笔中奖都至少含 1 个自然符号** ⇒ **"纯百搭链"在数学上不可能出现**(Q1 因此永久关闭,不需要额外规则)。 > 全程每一波盘面上的百搭数都 **≤ 1** ✅,且**永远只由金色转化产生** ✅。 > 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。 --- ## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 30 → FINISH) > 场景:**普通旋转**,投注档 **¥100**。 > 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 30**、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` | | 接口契约模板 | `docs/玫瑰女郎接口文档.md`(706 行) | ## 附录 C:待新增的运维 runbook(对齐 `olympus-py/README.md`) 1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 12 箱 × 4 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥500/¥1000 这两档必须单独注**)。 2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode X` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。 3. **重建池子**:`_regen.py snapshot` → `generate_rounds.py --clear --write` → 重算 → 清 Redis → 刷模板 → `_regen.py verify`。 4. **调水位**:后台奖池水位页按身份逐箱注水/抽水(**原因必填**)或批量按"一轮花费基数 × 比例%"。 5. **日常体检**:`_regen.py check`(每桶有条目 / 无封顶缺失 / 池内 RTP)+ `simulate_cycle.py` 抽样。