Files
henan-backend/docs/麻将胡了设计方案.md
T
2026-09-22 15:39:53 +08:00

152 KiB
Raw Blame History

麻将胡了(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 轴起算":

    # 对每个付费符号 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 百搭(元宝) — — —
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 个值(起点 1216,每次追加 1216,追加后不超 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.0250.00 × 投注倍数 110,另有现金钱包与自动旋转。

方案(推荐):箱子 = 投注档,不设投注倍数(见 §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 万帧 / 生成 1525 分钟 / 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 档,看实际流水再决定要不要开高档。

已否掉的方案

  • ❌ 原版双控件(档位 1300 × 倍数 110):顶档 ¥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. 玩家侧足够:1500 共 11 档,比原版 0.0250 的覆盖面更宽;相邻档位间距 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+ 胡的落点概率控制。
  • 触发局整局倍率自然落在 30x150x 区间 → 天然归入 1013 号桶。生成器要保证 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)。

若以后要换源(比如改成糖果那套),照下面这段整套导:

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.10.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 字段(每帧一行)

{
  "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 语义作废)

    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 的纯消除):

    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 生成流水线(顺序敏感,照抄)

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 万回合,每回合 840 帧 → ~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%) 45(比三模式省 11.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

总计:后端 2026 人日、客户端 1520 人日、联调 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 抽样。