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

106 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(接口契约)。 状态:方案待评审,未动任何代码。末尾第 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 轴起算":

    # 对每个付费符号 X(含百搭替代):
    n = 0
    for c in range(5):                       # 第 1 轴 → 第 5 轴
        cnt = count(X or WILD in 轴 c)       # 该轴 X 的格数(百搭计入),0~4
        if cnt >= 1: n += 1
        else: break                          # 一旦断链,后面全部作废
    if n < 3: 不成奖                       # n 只能是 3 / 4 / 5 才有档位
    ways = Π(第 1..n 轴各自的格数)          # 只乘链内的轴,链外的轴不乘
    本笔 = 赔付表[X][n] × ways
    
  • 反例与正例(照这张表写单测):

    各轴该符号格数(轴1~轴5) 结果 原因
    2, 3, 1, 0, 0 ✅ 3 连,ways=6 前缀 3 轴连续
    2, 3, 1, 2, 0 ✅ 4 连,ways=12 第 5 轴才断,档位取 4
    2, 0, 3, 4, 4 ❌ 不成奖 第 2 轴断了(用户举的例子),后面再多也没用
    0, 4, 4, 4, 4 ❌ 不成奖 第 1 轴没有 → 第 1 轴必需
    1, 1, 0, 4, 4 ❌ 不成奖 3 连必须是 1-2-3,不能跳到 1-2-4
    1, 2, 3, 4, 4 ✅ 5 连,ways=96 满链
    1, 1, 1, 1, 1 ✅ 最低 ways=1 每轴 1 个,共 1 路
  • 单笔赢额(倍基础下注)= 赔付表[X][n] × ways × 本波乘数。

  • 多个符号各自独立成奖、累加(互不影响,各有各的前缀链);同一符号只按上述规则算一笔,不存在"同符号多个子路径重复叠加"。

  • 参考例子(截图):符号 ? 三连赔 10,ways=6(各轴 1/3/2)→ 本笔 60。

赔付表(数值抄自赔付表截图,含义见下方"计量单位"):

短码 code 中文名 3 连 4 连 5 连
A FAT_CHOI 發 15 60 100
B RED_DRAGON 中 10 40 80
C WHITE_DRAGON 白板(紫框) 8 20 60
D CHAR_8 八萬 6 15 40
E DOT_MULTI 花筒(四色) 4 10 20
F BAMBOO_MULTI 索(红绿) 4 10 20
G DOT_PURPLE 筒(紫绿) 2 5 10
H BAMBOO_GREEN 索(绿) 2 5 10
W WILD 百搭(元宝) — — —
S SCATTER 胡(夺宝符号) — — —

✅ 赔付计量单位 = 投注总额 ÷ 20(已用排除法锁死,Q0)

两个已知事实(均来自用户确认): ① 赔付表里的 15 / 60 / 100 是最基础的 1 倍价,不带任何阶梯倍率(用户 2026-09-21 原话:"这个是最基础的 1 倍的价格"); ② 规则页写着「游戏共有 1,024 个中奖路(固定)并规定了 20 条基础投注」——这句就是计量口径。

排除法(用真实运行截图:投注总额 ¥8.00、横幅「共赢得 0.80」、阶梯条高亮 x2):

假设 推论 结论
这 ¥0.80 已含 x2 倍率 基准价 = 0.80 ÷ 2 = ¥0.40 = 1 个赔付单位 ❌ 赔付表里没有值为 1 的档(最小是 2),不可能
这 ¥0.80 未含倍率(x1 基准价) 若 1 单位 = ¥0.40 → 最低档 3 连(赔付值 2)× ways 1 = ¥0.80 ✅ ✅ 成立,且 8.00 ÷ 20 = 0.40 正好

→ 赔付单位 = 投注总额 ÷ 20 是唯一自洽解;同时确认「阶梯条高亮 x2」表示的是下一波(第 2 波)即将生效的倍率,不是本波已生效的。

所以本游戏的完整赔付公式是:

本笔赢额 = 赔付表[X][连轴数] × ways × (投注总额 ÷ 20) × 本波阶梯倍率
         = 赔付表[X][n] × ways × (box.bet × betMul ÷ 20) × rotationMul

PAY_UNIT_DIVISOR = 20 写进 config.py,不要散落在各处。

真机复核(30 秒,做一次就彻底放心):投注总额 ¥8.00 时,若 K=20,则所有中奖金额都必须是 ¥0.40 的整数倍(0.80 / 1.20 / 1.60 / 2.00 …)。只要出现过 ¥0.20 / ¥0.60 / ¥1.00 这类不是 0.40 整数倍的金额,就说明 K=40,回来改一个常量即可。另外:¥8.00 时最低档符号 3 连 1 路应赔 ¥0.80。

为什么必须搞清:如果按"× 投注总额"实现,基础旋 RTP 会到 300%~400%(粗算:约 40% 的旋有 3+ 前缀链,均 1.7 路、均赔付 5.2 → 期望 3.5 倍/旋),游戏根本发不出去;除以 20 后基础旋自然 RTP ≈ 17%,剩下交给免费旋转与阶梯——这正是这类游戏该有的结构。 规则页展示的赔付表数值保持原样(15/60/100…),但必须在规则页同时写明"每条基础投注",否则玩家会按"倍总额"理解,产生对账投诉。

出现权重 SYMBOL_WEIGHTS 初值建议 {A:5,B:6,C:8,D:9,E:11,F:11,G:13,H:13,S:2},最终由 §5 的 RTP 校准反推,不在方案里定死。 ⚠️ 权重表里没有 W —— 百搭不由卷轴落下(见 §2.2),_fill / 新旋 / 补位都必须断言不产出 W。

2.2 百搭与夺宝符号(前缀链;百搭只在第 2/3/4 轴)

  • 🎯 百搭的两条铁律(用户 2026-09-21 确认):

    1. 原生盘面不会有百搭——卷轴永远不会落下元宝,补位也不会补出元宝;百搭的唯一来源是"金色普通符号被消除后的转化"。
    2. 几个金色格被消除,就留几个百搭(无数量上限)——所以一屏可以同时有多个百搭(示例见 §15 第 1 波:两张金色「中」→ 留下 2 个百搭)。
  • 🎯 从这两条推出的关键结论(决定了整套判定的正确性):

    金色符号只出现在第 2/3/4 轴(§2.3),而百搭的唯一来源是金色转化 ⇒ 百搭只可能出现在第 2/3/4 轴,第 1 轴永远没有百搭 ⇒ 前缀链的第 1 轴必然是自然符号 ⇒ 任何一笔中奖都至少含 1 个自然符号,"纯百搭链"在数学上不可能出现。

    这就是 Q1 永久关闭的原因(不需要 WILD_PAYS 开关、也不需要"每笔至少一个自然符号"的额外特判)——由"金色只在 2/3/4 轴"自动保证。

  • 百搭(元宝):唯一不能替代的是「胡/FS」(用户二次确认:"FS 不能作为百搭符号,其他的符号都可以作为百搭符号")。除此之外付费符号、金色符号统统能替代(金色本质就是普通符号,见 §2.3),在链里等同于该符号计数。

    • 盘面上只有一种百搭,就是元宝(短码 W),而且只有金色转化这一个来源(见 §2.3)。
    • 符号权重表里没有 W:生成器的卷轴权重只包含 8 个付费符号 + 胡,_fill / 新旋 / 补位不允许产出 W。
    • 百搭能补链中间的断点:發, 百搭, 發, _, _ → 3 连成立。
    • 百搭不能把断链处的胡"算成"付费符号,也不会因为某个胡的存在而变成胡——即 發, 胡, 發 不成链(第 2 轴该符号计数为 0,链在第 2 轴断掉)。
    • 不需要 WILD_REELS 配置(原生不下百搭)。
  • ✅ 落地规则(引擎必须实现):

    场景 处理
    本波有 N 个金色格中奖 N 个格子各自就地留下一个百搭(N 可以是 0、1、2…,无上限)
    卷轴补位 / 新旋开局 永远不产生百搭(原生不下百搭)
    盘面上的百搭参与中奖 随那一波被消除(和普通符号同权)
    盘面上的百搭没有参与任何成链 不消除,留在盘面上继续等下一波(见下方第 ⑤ 行)

    由此不再有"名额/上限"这类规则,WILD_SLOT_POLICY 开关取消——每个金色格各转各的,行为单一确定。

  • ✅ 百搭的完整生命周期(实现照这张表写,最容易错的是第 ⑤ 行):

    阶段 规则
    ① 出现 只能由"金色普通符号参与中奖被消除"时就地转化产生(原生盘面永不出现百搭)
    ② 数量 每个金色格各留 1 个百搭,无上限(同一个盘面可以有 2 个、3 个…)
    ③ 位置 只可能在第 2/3/4 轴(因为金色只在那三轴)——第 1 轴永远没有百搭
    ④ 作用 可以作为任何付费符号(除「胡」以外),在前缀链判定里等同于该符号计数——能补链、能搭桥;同一个百搭可以被多个符号同时"借用"(每条链各算各的)
    ⑤ 消除条件 必须满足消除链:只有它参与了某条成立的中奖链,才会随那一波被爆炸消除;不满足链就不消除,继续留在盘面上等下一波(用户原话:"是可以和其他符号是可以消除的(要满足消除链)")
    ⑥ 能和谁一起消 百搭和普通符号完全同权:它参与的那条前缀链里可以是普通符号,也可以是金色符号;金色符号本来就是普通符号镀金,所以不需要任何特殊逻辑
    ⑦ 不跨旋 下一旋整盘重发,百搭与金色标记都不带走;每个旋开局时盘面上 0 个百搭

    换句话说:百搭就是一张和普通符号完全同权的牌,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。 实现上不要给"百搭 + 金色"加任何特例——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。

  • 胡(夺宝/SCATTER):不参与 1024 路(既不计入链,也不被百搭替代);出现在任意轴任意位置;自身不赔现金(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 scatterTrigger.scatterWin 恒为 0。

  • 3 个及以上胡 → 触发免费旋转:12 旋 + 每多 1 个额外 +2 旋(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。

2.3 金色麻将符号(本游戏最独特、也最容易做错的一条)

截图原文:

  1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的某些符号(不包括百搭符号和夺宝符号)将可能显示为金色。」
  2. 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」

用户补充的落地口径(本条的权威解释,2026-09-21 多次确认):

百搭符号 就是"金色的普通符号"——它是普通符号(比如「中」)镀了金色,判定与赔付都按它的自然符号算。 这个符号被消除之后,该位置会留下一个真正的百搭符号(就是元宝 W)——这是百搭在整个游戏里唯一的来源(原生盘面不会有元宝); 之后它就按百搭的规则参与后续波的成奖与消除。每个金色格各留一个百搭、无上限(见 §2.2)。

所以转化时机不是"被消除后、下一波再补成百搭",而是本波消除的那一刻就地变成百搭。引擎按下面这条时序实现:

第 n 波结算(判定与赔付都用金色符号的"自然符号"身份,例如当「中」用):
  1. 算出本波所有中奖格(含金色格,金色格按它的自然符号计入 ways)
     → ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。
       "消除"不等于"清空":消除之后该格才分成两类——
  2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号
  3. 金色的中奖格  → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W**
     (这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空)
  4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题")
  5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画
第 n+1 波:
  W 按百搭规则替代任意付费符号(可搭桥、可补链,但**不能替代胡**),若它参与成奖则随该波被消除

一句话记:中奖格全部"被消除"(全爆),区别只在消除之后变成什么 —— 非金色格变成"空洞→补新牌",金色格变成"百搭"。这是和玫瑰女郎 _collapse 唯一的语义差别。 统计口径也要注意:说"本波消除了 4 个符号"包含金色格;说"本波有 3 个空洞要补牌"不含金色格。两个数不一样,别混。

必须定死的三个实现细节:

  • 🎯 百搭的两条铁律(用户 2026-09-21 补充,见 §2.2)。这两条直接改写了第 3 步:
    • 原生盘面永远没有百搭:卷轴不落元宝、补位也不补元宝,百搭的唯一来源就是金色转化;
    • 每个金色格各留一个百搭:同波有几个金色格中奖,就留下几个百搭(无上限)。卷轴永不落百搭。
    • 副作用(好副作用):"纯百搭链"在数学上不可能出现,原本要拍板的 Q1 自动消失,不需要 WILD_PAYS 开关。
  • ✅ 重力下落(Q2 已定案:不钉住)。用户 2026-09-21 确认:"符号是可以下落的,下面符号消除之后,上面符号会往下掉落"——百搭也是盘面符号,一视同仁跟着落,不锚定。 列内重力按标准写法:中奖格清空 → 该列幸存符号(含刚转化的百搭)保持相对顺序整体压到底 → 顶部补入新符号。
    • 百搭下方的格被清空 → 百搭跟着掉下去;
    • 百搭上方的格被清空 → 百搭留在原地不动,上方的牌落到它头上。
    • 如果把它做成"钉在空中",它下方被清空的格就落不进任何符号(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以不能钉。
    • 客户端要注意:百搭会移动位置,下落动画必须用帧里的 dropData(from → to)驱动,不能假设它原地不动;"金色→百搭"的变形动画(wildConverted)播完之后,紧接着就可能是它的下落动画。
  • ⚠ "涉及中奖"的判定:金色格被计入某笔中奖的 ways(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。金色格只做自己那一种符号的替代——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。

    注意:由于最多只有 1 个百搭,且金色格本身是自然符号,"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生,不会成群出现。

其余约定:

  • 每旋开局(基础旋与每个 FS 旋)对第 2/3/4 轴上的普通符号按概率 GOLDEN_CELL_PROB(建议 0.10,逐格独立)打金色标记,不动 W/S;新补入的符号也可以带金色标记。
  • 转化后的 W 不再回填金色标记(每格只转化一次)。
  • 金色符号在波次判定时用的是自然符号,所以"金色「中」链"就是「中」的链;转化后下一波起它才变成通用百搭。
  • 金色格也受"百搭同样会被消除"约束(§2.2 边界二):本波转成 W 之后,若在后面的波里参与成奖,就随那一波被消除。

2.4 乘数阶梯(奖金翻倍)

  • 赔付表是"1 倍基准价"(用户 2026-09-21 确认:"这个是最基础的 1 倍的价格")——表里的 15/60/100 不含任何阶梯倍率,阶梯是在它之上再乘的。
  • 本旋内每一波(rotation,即一次消除结算)用一个乘数;该波所有 1024 路赔付 × 该乘数。
  • 基础旋转(用户原话):一次消除 x1、两次消除 x2、三次消除 x3、四次消除 x5(第 4 波及以后都是 x5)→ LADDER_BASE = (1, 2, 3, 5)。
  • 免费旋转:LADDER_FS = (2, 4, 6, 10)(截图里 FS 模式下卷轴上方的 x2 x4 x6 x10)——FS 的第 1 波就是 x2。
  • 第 n 波取 ladder[min(n, 4)]。由于"第 n 波存在"本身就等价于"第 n−1 波有中奖",无需额外判断递进条件。
  • 每旋重新从第一档开始(基础旋从 x1、FS 旋从 x2),不跨旋累计。

完整算式串起来(一张表看懂):

环节 值
赔付表基准值 赔付表[X][连轴数](如 發 3 连 = 15)
× 路数 ways(各轴个数的乘积)
× 每单位金额 投注总额 ÷ 20(Q0)
× 本波阶梯 基础旋 1/2/3/5;FS 旋 2/4/6/10
= 本笔赢额 赔付值 × ways × (bet×betMul÷20) × rotationMul

由此可推出本局最小中奖(基础旋第 1 波、最低档符号、1 路):2 × 1 × (bet÷20) × 1 = bet ÷ 10 —— 这条直接约束了下注档下限(见 §2.8.2)。

  • 截图里那条「1024 路中奖组合」横条下方的 x1 x2 x3 x5(当前档位金色高亮)就是这条阶梯的 HUD。

实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(accumulator_before/after,FS 内跨旋传递)机制原封不动复用——麻将胡了虽然乘数不跨旋累计,但"本步乘数 / 本波序号"需要每帧下发,字段名可直接沿用 accumulator(语义改为"本波乘数")以免客户端大改。建议改名为 rotationMul,避免误解。

2.5 级联(tumble)

每一波:结算 → 中奖符号(含已转化的百搭)爆炸消除 → 该轴上方符号整体下落 → 该轴顶部补入新符号 → 进入下一波。直到本波无任何成奖为止。下一旋开始时盘面是完全新的一屏。

2.6 免费旋转

  • 触发:3+ 胡(任意位置)。
  • 赠送:12 + 2×(胡数−3);重触发同式。
  • 免费旋转内乘数阶梯为 [x2, x4, x6, x10]。
  • 可重复触发;总旋数上限 = 30 旋(Q3 定案)。到顶后不再落 3+ 胡(生成端保证:一旦已发旋数到 30,后续 FS 旋不再摇出胡)。
  • 免费旋转内投注大小/倍数与触发时一致。

2.7 封顶

  • 单局总倍率封顶 500x(Q4 定案)。到达封顶即截断:剩余旋数作废、maxWinReached=true、spinsRemaining=0,保证"动画总赢额 = 实际到账"。
  • 生成端在造回合时就按封顶截断并写 win_capped 标记(对齐 olympus step.win_capped)。

2.8 投注模型与下注档设计(推荐方案 A)

原版:投注大小 0.0250.00 × 投注倍数 110,另有现金钱包与自动旋转。

方案 A(推荐):箱子 = 投注档,请求可带 betMul(1~10):

  • price(实际扣费) = box.bet × betMul × costMultiplier(本游戏 costMultiplier ≡ 1)
  • 回合池里存的全是倍率(actual_mul),与 bet/betMul 无关 → 调档/调倍数不需要重生成池子(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
  • 奖池水位按 (boxId, audience) 聚合,betMul 只影响单局金额,不影响池结构。

方案 B(不推荐):给每个 bet × betMul 组合建一个箱子 → 100 个箱子、池子碎片化、水位口径混乱。

2.8.1 设计多少个下注额、哪几个、能多大?

先分清两件事,因为成本差一个数量级:

加什么 池子要不要动 成本
加档位(多一个箱子) 要:每档一个独立回合池 ~4500 回合 / 8 万帧 贵(生成时间 + 40 条配比 + 一套水位)
加 betMul 上限 不用:池子只存倍率(actual_mul),与下注额无关 几乎为零

→ 所以"想把数额做大"有两条路:改档位表(贵)或抬高倍数上限(免费)。

推荐:12 档,覆盖到 ¥1000(在玫瑰女郎 10 档基础上加 500 / 1000 两档),betMul 保持 1~10:

boxId box.bet × betMul(1~10) 实际扣费
1 1 1 ~ 10
2 3 3 ~ 30
3 5 5 ~ 50
4 10 10 ~ 100
5 20 20 ~ 200
6 30 30 ~ 300
7 50 50 ~ 500
8 100 100 ~ 1000
9 200 200 ~ 2000
10 300 300 ~ 3000
11 500 500 ~ 5000
12 1000 1000 ~ 10000
  • 成本增量很小:12 箱 → 5.4 万回合 / 96 万帧 / 生成 2030 分钟 / 480 条配比行(比 10 档只多 20%)。
  • 单注上限 ¥10,000 仍在平台既有范围内:玫瑰女郎 buy_fs 最高一注就是 300 × 50 = ¥15,000,所以这不是新量级的敞口。
  • 备选(想零成本扩大):保持 10 档,把 betMul 放到 1~20 → 实际覆盖 1~6000,池子一行不动。

⚠️ 做大之前必须先定的两件事

  1. 单局最大赔付口径(风控) —— 已定案:封顶 500x:
组合 单局最大赔付
玫瑰女郎现状(1000x × bet 300) ¥300,000
麻将胡了(500x × bet 10000) ¥5,000,000
麻将胡了(1000x × bet 10000) ¥10,000,000(已否)

运行期还有奖池水位 payoutCap 兜底(水不够就出不了大奖),但 500x 这条线是玩家预期与风控口径,代码里写死为 MAX_WIN_CAP_MUL = 500。 2. 冷门高档位会水位饥饿:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → payoutCap 把它们压到出不了奖。上线时必须给这两档单独注一笔初始水位,或干脆先只放 10 档、等有需求再开高档(加档不需要改客户端,只需加箱 + 生成池子)。

为什么不是更多档

  1. 每多一档 = 多一整套成本(这才是决定档位数量的真正约束):
    • 多一个回合池:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
    • 多 40 条配比行(10 桶 × 4 身份)要配、要重算、要维护模板;
    • 多一套奖池水位(4 个身份各一条)要运营盯。 10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
  2. 档位越多,冷门档越容易"饿死":奖池水位是按箱独立的,没人玩的档水位长期为 0 → payoutCap = max(0, 水位+成本) × rtp 被压到接近 0 → 那个档几乎出不了奖,只能靠运营注水救。所以档位不是越多越好。
  3. 玩家侧足够:11000 的 12 档 + 110 倍 = 实际覆盖 110000,比原版 0.0250 宽两个数量级,选注习惯也够密。
  4. 对齐玫瑰女郎:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。

不推荐的做法

  • ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场,玫瑰女郎最低档是 ¥1;而且 §2.8.2 会证明 0.02 档下最小中奖会丢分(显示成 +0.00)。
  • ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
  • ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。

两个附带决策

  • 档位表里 3 / 30 保留(它们是整数,只是不是 10 的整数倍;玫瑰女郎就这么定的,玩家点"+"往上加时体验更顺),不改成 2 / 25 之类。
  • 投注倍数默认 1,只作为"快速加倍/加注"的细调;若实测玩家几乎不用,可后续在需求里砍掉(表结构与接口预留,砍掉不用改池子)。

⚠️ 上线前必须做的一件事:新游戏的奖池水位从 0 开始,而 payoutCap = max(0, 水位 + 本局成本) × rtp —— 意味着开局头几局玩家最多只能赢 0.8 倍下注,大奖要等水位被玩起来才出得来。所以上线前要按箱 × 身份人工注水(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。

2.8.2 下注档不是随便定的:它被赔付表和饰品区间表两头夹住

用户问"是不是要根据赔付表来设计下注额"——是,而且还有一个来自饰品表的约束。这两条是硬约束,不是审美偏好。

约束一:来自赔付表 —— 最低档下注的最低中奖不能小于 ¥0.01

赔付公式里最小的那一笔是:最低档符号(G/H,3 连赔付值 2)× ways 1 × 第 1 波阶梯 x1:

最小中奖 = 2 × (投注总额 ÷ 20) = 投注总额 ÷ 10
投注总额 最小中奖 结论
¥0.02(原版最低档) ¥0.002 ❌ 金额字段是 DECIMAL(10,2),显示成 +0.00,玩家会认为"中了不给钱"
¥0.10 ¥0.01 ⚠️ 理论下限,刚好不丢分
¥1(建议最低档) ¥0.10 ✅ 留 10 倍余量

→ box.bet ≥ 0.10 是硬下限;这是"不能照抄原版 0.02"的可证明理由(不是我拍脑袋说社娱场面额)。

附带的一个好性质:档位全部取整数时,unit = bet ÷ 20 必是 0.05 的整数倍(1→0.05、3→0.15、5→0.25、20→1.00、300→15.00),所以任何"赔付值 × ways"都是整分,不会出现 0.0333 这种除不尽的数。betMul 取整也一样(3×3=9 → 0.45)。建议档位表只放整数,把这条性质保住。

约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"

"必得饰品"意味着空转局也会发一个最低档饰品(win = max(totalWin, 0.01) 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估:

每局保底返奖 ≈ 空转率 × 最低档饰品价格
低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额
最低档饰品价 空转率 投注 ¥1 时的保底 RTP
¥0.01 40% 0.4% ✅
¥0.05 40% 2% ✅
¥0.50 40% 20% ⚠️ 光保底就吃掉 1/4 的目标 RTP

→ 最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级;饰品区间表(prize_range)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。 → 对账口径也要注意:水位里的"派彩"应当用 record.settled_win(饰品价),而不是 total_win(游戏内赢额)——否则这两者对不上时,水位会漂。

结论:下注档表要同时满足

  1. bet ≥ 0.10(赔付表约束,实际取 ¥1 起)
  2. 档位取整数(保证 unit 是整分)
  3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束)

模式:只有一个「普通旋转」(用户 2026-09-21 明确:底注、购买免费旋转都不做)。

boxType 名称 costMultiplier 语义
normal 普通旋转 1x 唯一的玩法:自然触发免费旋转(3+ 胡)

砍掉 ante / buy_fs 带来的连锁简化(都要落地,别留半套):

  • MahjongModeType 只留 normal 一个枚举值,costMultiplier 恒为 1,price = box.bet × betMul;
  • 没有 need_fs 过滤了(那是 buy_fs 专用),挑回合只按桶;
  • 箱数从 30 降到 10(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
  • 高倍桶(100x 以上)只能靠自然触发 FS 的回合供给,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动构造高倍局,不能靠碰运气(见 §5.4 风险)。

3. RTP 与桶设计(数学核心)

3.1 倍率桶(10 档)

替代玫瑰女郎的"饰品桶"。约定:区间连续无缝、expect_mul = (low+high)/2、顶桶受封顶影响"匹配区间 ≠ 发放上限"。

id 名称 range_low range_high expect_mul 说明
1 空 0 0 0.00 完全未中
2 微 0.01 0.50 0.25
3 小 0.50 2.00 1.25
4 中 2.00 5.00 3.50
5 中大 5.00 15.00 10.00
6 大 15.00 40.00 27.50 自然触发的 FS 多落这里
7 很大 40.00 100.00 70.00
8 巨 100.00 250.00 175.00
9 超级 250.00 400.00 325.00
10 封顶 400.00 700.00 500.00 区间上界故意开到 1.4× 封顶(对齐玫瑰女郎"匹配区间 ≠ 发放上限"),实际发放截断 500

因为封顶是 500x(Q4),顶桶的"名义期望 500"在池内实测均值会明显低于 500(被截断的局都堆在这里),配比计算时要按池内 AVG(actual_mul) 走,必要时用 PAYOUT_EXPECT_OVERRIDE(玫瑰女郎顶桶就是这么用 835 顶 1000 的)。

3.2 RTP 恒等式

Σ (share_i × expect_mul_i) = rtp × costMultiplier          # 本游戏 costMultiplier ≡ 1
模式 costMultiplier 目标 RTP 需要的期望倍率
normal(唯一) 1 0.80 0.80

因为只有 1x 模式,所有箱、所有身份的目标期望倍率都是同一个数(只由 rtp 决定),收敛阶段 targetMul = rtp × cm = rtp——比玫瑰女郎三模式简单得多。

3.3 配比从哪来(calcRounds 算法)

两份实现必须一致:mahjong-py/_recalc_boxes.py::calc_rounds 与 MahjongDistributionService::calcRounds。

  1. 基础权重 30 × 0.6^k(k = 桶序号,低倍桶权重高);
  2. 二分搜索 power 使加权均值命中 targetRtp × cm(误差 <1%);
  3. 按权重把固定池分配到各桶,并施加单调约束 count[i] ≥ count[i+1](低倍桶条目多于高倍桶);
  4. 400 次"任意两桶搬 1 个"微调,逼到 |delta| < 0.01;
  5. 期望值取该箱池内实测 AVG(actual_mul)(样本 ≥1),无样本才回退名义 expect_mul/封顶修正值(玫瑰女郎顶桶用 835 而非 1000 就是这个原因)。

3.4 一轮(cycle)与收敛比例

fixedSum   = Σ count                      # 固定池
N          = round(fixedSum / (1 - cr))   # 一轮总抽数(写进 box.total_rounds)
fixedRounds= round(N × (1 - cr))
收敛阶段:  desiredNextMul = max(0, (N × targetMul − 已派彩) / max(1, N − drawn))
  • cr(rtp_converge_ratio)默认 0.15,线上玫瑰女郎用 0.20,夹在 [0, 0.9]。
  • 固定阶段消耗 used,收敛阶段不消耗库存——这是"RTP 自愈"的来源。
  • 抽满 drawn ≥ N → resetCycle + 按模板 refillFromConfig。

3.5 奖池水位(与玫瑰女郎同口径)

水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust
payoutCap           = max(0, 水位 + 本局成本) × box.rtp     # 白号 = null
  • bet/win 以 DB 为真相(game_mahjong_record 按身份聚合),Redis 只做 5 秒短缓存;adjust 是纯 Redis 的人工注水/抽水值。
  • 收敛/固定两阶段都用 payoutCap 过滤"期望赔付超上限"的桶,捞不到就退最小桶。
  • 白号(is_blank_account=1)payoutCap=null:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。

3.6 身份与目标 RTP(后台可配,不是拍板项)

各身份的 RTP 不在代码里写死,和玫瑰女郎一样由后台配置,两层:

  1. 箱子级:game_mahjong_box.rtp(目标返奖率)+ rtp_converge_ratio(收敛比例)—— 在后台「箱子配置」页改;
  2. 身份级:game_mahjong_box_bucket 里每个身份一套桶配比(count),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。

所以"各身份 RTP 定多少"不是一个需要在方案阶段拍板的数,而是运营参数:先全部按 0.80 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。

audience 判定 建库默认 RTP 说明
common 其余全部 0.80
anchor user_type='01' 0.80 四身份互斥并集 = 全部
robot user_type='03' 0.80
white is_blank_account=1(最高优先) 0.80 白号不进奖池:payoutCap=null、不注水不抽水,配比即爆率,给主播用

3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)

  • 唯一模式 normal:目标自然触发率 ≈0.5%(每 200 局 1 次),由 3+ 胡的落点概率控制。
  • 触发局整局倍率自然落在 30x150x 区间 → 天然归入 68 号桶。生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%,否则"自然触发免费旋转"会变成空话。
  • 悬念盘(near-miss):用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是为了钓出第 3 个胡的期待感。虽然表现是客户端动画(见 §9),但盘面得先存在:
    • 生成端要保证一定比例的基础旋恰好落 2 个胡且第 5 轴无胡(NEARMISS_2SCATTER_RATE,建议 1%~2% 的基础旋);
    • 玫瑰女郎有现成的同类配置可参照(olympus-py/olympus/config.py:181 的 BASE_NEARMISS_SCATTER);
    • 少了这条,客户端那段"最后一列放大"的动画永远不会被触发(纯自然概率太低),爽点白做。

4. 回合池数据模型(11 张表)

前缀 game_mahjong_,DDL 权威文件 mahjong-py/schema.sql(照抄 olympus-py/schema.sql 骨架)。

# 表 对应 olympus 表 关键列 差异
1 game_mahjong_box game_olympus_box box_id, box_type, bet, price, total_rounds, rtp, rtp_converge_ratio, status total_ornaments→total_rounds
2 game_mahjong_bucket game_olympus_ornament id(1-10), expect_mul, name, range_low, range_high 10 档倍率桶
3 game_mahjong_box_bucket game_olympus_box_ornament box_id, bucket_id, audience, count, used 同构,唯一键 (box_id,bucket_id,audience)
4 game_mahjong_round game_olympus_round round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss 去 total_cascades 语义改为 total_rotations(波数);need_fs 不需要了(没有买 FS 模式);加 near_miss 标记 2 胡悬念盘
5 game_mahjong_step game_olympus_step 见 §4.1 字段大改(ways/golden/阶梯)
6 game_mahjong_record game_olympus_record user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type 保留全部 prize_ 列*(发饰品要走区间匹配),只多一个 bet_mul
7 game_mahjong_user game_olympus_user user_id, total_spins, total_cost, total_win, max_win_mul 去 total_free_spins 或保留
8 game_mahjong_box_template game_olympus_box_template box_id, audience, name, total_rounds, items_json, is_active 同构
9 game_mahjong_reward_log game_olympus_reward_log box_id, audience, type, amount, balance_before/after, pool_balance, operator, remark 同构(原因必填由前端保证)
10 game_mahjong_prize_ornament game_olympus_prize_ornament id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback 必须有:赢额 → 饰品
11 game_mahjong_prize_range game_olympus_prize_range prize_id, win_low, win_high 必须有:赢额落区间

可选:game_mahjong_win(日报表)。若后台"每日统计"要独立日表则加回,否则按 record 聚合(Q8)。

4.1 game_mahjong_step 字段(每帧一行)

{
  "id": 1, "round_id": 1001,
  "reel_spin_id": 0,        // 0=基础旋/触发盘, >0=第N次免费旋转
  "rotation_seq": 1,        // 本旋内第几波(= 乘数阶梯序号)
  "grid_state": [["A","W","S","D","H"], ...],   // 4 行 × 5 列 = 20 格短码
  "golden_detail": [{"pos":7,"symbol":"B"}],     // 本帧盘面上仍是"金色普通符号"的格(1 基格位)
  "wild_converted": [{"pos":7,"fromSymbol":"B"}],// 本波"金色→百搭"就地转化的格(客户端播变形动画)
  "ways_detail": [{"symbol":"A","reelCount":3,"counts":[1,2,1,0,0],
                   "loc":[1,6,11,7],           // 本笔中奖的全部格位(1起),含金色格 —— 客户端按它播爆裂动画
                   "ways":2,"payBase":15,"rotationMul":2,"win":3.00}],
                   // win = 15 × 2 × (bet÷K) × 2,K 见 §2.1 计量单位
  "drop_data":  [{"from":12,"to":17,"symbol":"H"}],
  "fill_data":  [{"pos":2,"symbol":"G"}],
  "scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0},  // 或 null
  "retrigger_info":  null,
  "rotation_mul": 2,        // 本波乘数(阶梯档)
  "win_amount": 120.00,     // 本旋累计到本步(末帧 = 本旋总额),不是增量
  "spin_win": 340.00,       // 本局累计到本步
  "win_capped": 0,
  "bigwin_level": "small",  // 本旋末帧才有
  "is_final": 1
}

只落库不下发:pay_base、counts(留档/对账用)。接口下发字段见 §7。


5. Python 生成器(mahjong-py/)

5.1 目录结构(对拷 olympus-py)

mahjong-py/
├── schema.sql                 # 11 张表 DDL + 10 档桶种子(改前缀与种子)
├── mahjong_db.py              # 建表 + 批量写(executemany,死锁重试 5 次)
├── mahjong/
│   ├── config.py              # ★ 唯一事实来源:符号/赔付表/桶/模式/身份/RTP/FS 参数
│   ├── models.py              # frozen dataclass(Bomb → RotationMul)
│   └── engine.py              # 结算状态机骨架
├── mahjong_simulator.py       # ★ 核心:单回合模拟 + bias 反解 + FS 保底配额
├── generate_rounds.py         # 批量落库(箱编号/池子分配/id 段切分/并行)
├── simulate_cycle.py          # 整轮 RTP 仿真验收(几乎原样拷贝)
├── _recalc_boxes.py           # 按目标 RTP 重算配比(calc_rounds 一行不改)
├── _make_templates.py         # 12 箱 × 4 身份 = 48 条生效模板
├── _regen.py                  # check/snapshot/clear/restore/verify
├── _reset_boxes.py
├── README.md                  # 结构与 runbook(照抄并改写游戏规则段)
└── tests/
    ├── test_engine.py         # ways 判定 / 阶梯 / 金色转化 / 封顶
    └── test_simulator.py      # 级联几何复刻 / FS 触发 / 池内 RTP

5.2 必须重写的四处引擎逻辑

  1. _winners → 1024 路前缀链判定(_MIN_WIN_COUNT=8 语义作废)

    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 张表 + 10 档桶种子(幂等)
python generate_rounds.py --per-bucket 20               # 干跑,不写库
python generate_rounds.py --all --alloc config --write --workers 4   # 12 箱写库(只有 normal)
python _make_templates.py                               # 12 箱 × 4 身份 = 48 条生效模板
python _recalc_boxes.py --mode normal                   # 重算配比 + 回写 total_rounds
python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026   # RTP 验收
python _regen.py check|snapshot|clear|restore|verify    # 体检/快照/清池
python -m pytest tests -q                               # 回归

顺序不可颠倒:重建池子后必须 _recalc_boxes → 清 Redis ruoyi:mahjong:cycle:* → _make_templates,否则 Java 端拿的是过期配比与旧账本(这是玫瑰女郎踩过的坑)。

5.4 参数与工作量预估

参数 值 依据
ALLOC_K 1.5 池子 = 配比 × 1.5,留抽样多样性
ALLOC_FLOOR 60 每桶保底条数
BOX_STEP_BAND 200000 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估)
每箱池子 4000~5000 条回合 因为没有"买 FS"那批高倍回合兜底,高倍桶要靠 normal 池自己凑,所以单箱池子要比玫瑰女郎更大
总规模 12 箱 × 4500 = 5.4 万回合,每回合 840 帧 → ~96 万 step(与 olympus 的 106 万同一量级)
生成耗时 4 进程约 15~25 分钟(本机 i3-10105F 只 4 物理核,workers 必须 ≤4,再高反而更慢)
风险点 高倍桶(8/9/10 号桶)没有买 FS 兜底:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 _force_win 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 _calibrate_bias 饥饿重校准可复用)

若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 copyFromSameBucket 复制(olympus 已有此兜底,:179-231)。


6. Java 服务(skins-service/game-mahjong-service)

6.1 模块接线(3 处,易漏)

  1. skins-service/game-mahjong-service/pom.xml(拷 olympus,改 artifactId,parent = skins-service);
  2. skins-service/pom.xml 的 <modules> 加一行;
  3. ruoyi-admin/pom.xml 加依赖。

    无需 @ComponentScan/@MapperScan:包路径 com.ruoyi.* + mapperLocations: classpath*:mapper/**/*Mapper.xml 自动生效(ruoyi-admin/src/main/resources/application.yml:70-72)。

6.2 包与类清单(对照 olympus 35 个类)

com.ruoyi.game.mahjong
├── controller/MahjongClientController        # /api/mahjong
├── controller/MahjongAdminController         # /admin/mahjong
├── service/MahjongGameService                # ★ dospin 主链路(开局事务/结算/续播/重连)
├── service/MahjongRoundPicker                # ★ 两阶段挑桶
├── service/MahjongDistributionService        # 配比计算/补池/RTP 仿真
├── repository/MahjongCacheRepository         # ruoyi:mahjong:{userId}  TTL 30min
├── repository/MahjongCycleRepository         # ruoyi:mahjong:cycle:{boxId}:{audience}
├── repository/MahjongRewardPoolCacheRepository  # ruoyi:mahjong:reward:pool
├── model/PickedRound
├── model/cache/MahjongGameCache(内嵌 CachedStep)/ MahjongRewardPoolCache
├── domain/(11 张表实体)
├── mapper/(9 个接口)+ resources/mapper/mahjong/*.xml
├── enums/MahjongModeType
└── contract/request/{SpinRequest,RecordPageRequest} + response/{SpinResponse,BoxResponse,RecordItemResponse}

MahjongAudience 建议直接复用 OlympusAudience 并上移到共享模块(skins-model),避免第 4 份身份判定逻辑漂移(TtRechargeRecordController 里已有一份按 audience 聚合的 SQL)。

6.3 开局事务(与玫瑰女郎的唯一实质差异)

startNewGame(userId, boxId, betMul = 1..10):
  校验箱子/维护/充值(703)/余额(702)
  audience = resolve(player);  isBlank = audience.isWhite()
  bet  = box.bet × betMul                      // 没有模式倍率(只有 normal)
  cost = bet                                   // 实际扣费 = 下注总额
  payoutCap = isBlank ? null : scale2(max(0, 水位(box,aud) + cost) × box.rtp)
  picked = roundPicker.pick(boxId, audience, bet, payoutCap)
  totalMul = picked.round.actualMul ;  totalWin = totalMul × bet
  ── 事务 ────────────────────────────────────────────────
    updateUserAccount(userId, -cost, MAHJONG_SPIN(68))
    insert game_mahjong_record(settled=0, settle_source=null, bet_mul, audience)
    if (!picked.converge) boxBucketMapper.incrUsed(boxId, bucketId, audience)
    settleAtStart():                                    // 幂等守卫 + 发饰品(与玫瑰女郎逐行同构)
        win = scale2(max(totalWin, 0.01))               // ★ 空转局也要给最低档饰品,兜底 0.01 照抄
        prize = findPrizeWithFallback(win)              // 精确区间 → is_fallback=1 → 最低区间 → 最高区间
        guard = UPDATE record SET settled=1, total_win=?, settled_win=win, reward_type='ornament',
                    prize_id/name/image_url/level_img=?, settle_source=(单步?finish:start)
                WHERE id=? AND settled=0
        if guard == 0 → 已结算,清缓存返回
        insert tt_box_records(source = MAHJONG_BOX(15), status=0, is_show=1, box_name='MAHJONG')  // 写背包
                 → 回填 cache.boxRecordId
        upsert game_mahjong_user 累计
  ── 提交后(全部 try/catch 吞异常,绝不外抛 500)────────
    cycleRepository.recordDraw(...)
    if drawn >= cycleN → resetCycle + refillFromConfig
    firstIsFinal ? clearCache : setCache(30min)
    // 单步局开局即播完 → 此刻发站内信;多步局等末帧(continueGame)

硬约束(照抄玫瑰女郎):

  • 先扣费、后结算、同一事务;提交后的 Redis/账本/补池失败必须吞掉,否则玩家看到 500 会重开一局、再扣一次钱。
  • 开局就写背包:麻将胡了允许空转(没中奖),但空转局照样发最低档饰品(win = max(totalWin, 0.01) + findPrizeWithFallback 的最低区间兜底)——这就是玫瑰女郎的"必得饰品",也是"无损模型"(中途退出/断线饰品已在背包)成立的前提。
  • 奖励流水 MAHJONG_REWARD(69) 不在开局写:和玫瑰女郎一样,只在玩家分解饰品时才由通用背包接口写入(tt_box_records.source = MAHJONG_BOX(15) → MAHJONG_REWARD(69))。
  • settle_source 语义不变:开局 start(动画还在播,战绩列表不显示)→ 末帧 finish。

6.4 续播 / 重连 / 战绩可见性(照抄)

  • dospin 带 boxId = 开新局;不带 = 下一步;越界 = "游戏已结束"并清缓存。
  • COMMON_LOCK + userId(复用平台通用用户锁,与其它游戏互斥),失败 → "请求频繁,请稍后再试"。
  • 末帧:settle_source=finish + 返回 FINISH(totalWin/totalMul/maxWinReached/prizeInfo/boxRecordId)+ 清缓存;站内信在末帧才发(sendWinNotice,防剧透)。
  • 末帧客户端给玩家两个选择「放入背包 / 立即分解」,分解走通用接口 POST /api/userPackSack/decompose {packSackIds:[boxRecordId], isAll:false, decomposeType:"game"};"自动分解"是前端自己的开关,后端不参与、不回传。
  • hasActive:返回 stepIndex/totalSteps/spinsRemaining/rotationMul/lastStep,客户端用 lastStep.rotationMul 接回当前波次(否则重连后阶梯会从第一档重算)。
  • 维护开关:只拦开新局(带 boxId 才拦),进行中的局永远放行播完。
  • 战绩列表(records):settled=1 AND (settle_source != 'start' OR create_time < cutoff),cutoff = now − 30min。cutoff 必须在 Java 侧算,不能用 SQL NOW() —— DB 会话时区是 UTC,而 create_time 存的是本地时间,用 NOW() 会差 8 小时直接失效(玫瑰女郎踩过的坑)。
  • scope=0 全服口径排除机器人(user_type='03')、封禁(status='1')、已删除(del_flag!='0'),白号保留上榜。
  • 响应体用 @JsonInclude(NON_NULL):未出现的 key 就是 null,客户端无需判空。

6.5 平台共享文件的改动(9 处,漏一处就少一块统计)

# 文件 改动
1 skins-model/.../TtAccountRecordSource.java 新增 MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费") / MAHJONG_REWARD(69, INPUT, "麻将胡了奖励");并加进 getWelfarePrizeCodes():193、getGameConsumeCodes():203、getGameBetCodes():212、getGameRewardCodes():224、isNewGameConsumeForCommission():250(新游戏返佣口径)、getTurnoverLotteryCodes():271 —— 共 6 处聚合
2 skins-model/.../TtboxRecordSource.java 新增 MAHJONG_BOX(15, "麻将")(现有 13=玫瑰女郎、14=流水抽奖)—— 背包来源,漏了就写不进背包
3 skins-service/service-user/.../ApiUserPackSackServiceImpl.java:612-645 mapDecomposeSource 加 MAHJONG_BOX(15) → MAHJONG_REWARD(69);漏了就会走 DECOMPOSE_ORNAMENT(10) 默认口径,报表全错
4 skins-service/service-admin/.../TtRechargeRecordController.java:155-163,253-281 报表 sources 列表 + appendPoint 单列该游戏(可选)
5 skins-service/service-admin/.../config/RedisConstants.java 加 3 个键:ruoyi:mahjong: / ruoyi:mahjong:cycle: / ruoyi:mahjong:reward:pool
6 skins-model/.../MessageCategoryEnum.java 加 GAME_MAHJONG("GAME_MAHJONG","麻将胡了")(前缀 GAME_ 才进"游戏"Tab)—— 本游戏要发结算站内信,必加
7 sys_config mahjongMaintenance 维护开关(0 正常 1 维护)
8 sys_menu 7 个后台菜单(见 §8)
9 docs/updata_sql.md 新增一节「麻将胡了模块建表 / 服务接入」,格式照 1144-1225 行;返奖饰品种子照 1187-1200 行从糖果整套导入(DELETE + INSERT ... SELECT,保留原 id 以维持区间映射)
10 亏损返利 / 消费排行榜 / VIP 流水返利 这些模块按 source 码聚合,登记进第 1 项后自动生效;必须实测一次别只看代码

总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。

6.6 封顶与"匹配/发放分离"

  • 回合池里桶 10 的 actual_mul 一律 = 1000(发放值),但配比计算时期望用 835(实测池内均值,可配 PAYOUT_EXPECT_OVERRIDE),否则 RTP 会系统性偏低。
  • 运行期 payoutCap 过滤 + 收敛阶段 capMul = payoutCap / bet 双保险。
  • FINISH 时 totalMul ≥ 1000 → maxWinReached=true、spinsRemaining=0。

7. 接口协议(草案,最终产物是 docs/麻将胡了接口文档.md)

Base:{host}/api/mahjong,全部 POST + JSON + token 鉴权。

# 接口 用途
1 POST /boxes 箱子列表(含 bet、price;没有 modeName,只有一个普通旋转模式)
2 POST /dospin 开局({boxId, betMul})/ 续播(空 body {})
3 POST /records scope=1 我的 / scope=0 全服,分页,只含已结束局
4 POST /hasActive 断线重连恢复
5 POST /api/userPackSack/decompose 复用玫瑰女郎那个通用背包接口,末帧「立即分解」(含前端自动分解)

dospin 开局请求:{"boxId": 1001, "betMul": 1};betMul 缺省按 1 处理,范围 1~10,服务端夹取。

7.1 帧字段(SpinResponse)

字段 出现 说明
state 每帧 PLAYING / FINISH
reelSpinNum 每帧 0=基础旋,>0=第 N 次免费旋转
rotationNum 每帧 本旋内第几波(级联序号)
globalStep / globalTotalSteps 每帧 整局第几步 / 总步数
gridState 每帧 4 行 × 5 列短码矩阵,20 格全满(权威渲染数据)
goldenDetail 每帧 [{"pos":1起,"symbol":"B"}] 本帧仍为"金色普通符号"的格(它按 symbol 的自然符号成奖)
wildConverted 每帧 [{"pos":1起,"fromSymbol":"B"}] 本波就地转成百搭的格:该格 gridState 已是 W、不清空不补新符,客户端播"金色→百搭"变形动画
waysDetail 每帧 [{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]:loc = 本笔中奖的全部格位(1 起,含金色格),客户端按它播爆裂动画;win = payBase × ways × (bet÷K) × rotationMul(K 见 §2.1 计量单位)
dropData / fillData 每帧 与玫瑰女郎同构(from/to/symbol、pos/symbol)
rotationMul 每帧 本波乘数(阶梯档位),重连时用它接回档位
winAmount 每帧 本旋累计到本帧(末帧 = 本旋总额),不是增量
spinWin 每帧 本局累计到本帧;末帧 = 整局实付
scatterTrigger 有则 {"scatterCount":3,"freeSpins":12,"extraSpins":0}
retriggerInfo 有则 免费旋转内重触发
spinsRemaining 每帧 剩余免费旋转次数(服务端算好,前端直接倒计)
bigWinLevel FS 旋末帧 small / middle / big
maxWinReached FINISH 是否触顶
totalWin / totalMul FINISH 总赢额(显示用现金数字)/ 总倍率
prizeInfo FINISH {id,name,imageUrl,grade,exteriorName,isFallback} —— 本局命中的饰品(赢额落区间匹配所得)
boxRecordId FINISH 背包记录 ID:末帧「放入背包 / 立即分解」都靠它(分解时作为 packSackIds 传入)
recordId FINISH 本局记录 ID(战绩用)

位置约定:pos = 行序号(0 起) × 5 + 列序号(0 起) + 1,1~20。 短码:A~H 付费符号 / W 百搭 / S 胡。

前端只需保证:首次用 {boxId, betMul} 开局,之后不断调 dospin({}) 逐步揭晓,直到 state === "FINISH",然后在末帧用 boxRecordId 让玩家选「放入背包 / 立即分解」。

7.2 错误码(照抄)

702 余额不足 / 703 未充值 / 500 维护中、请求频繁、箱子不可用、该箱子暂无可用回合、游戏已结束、boxId 不能为空。本接口不返回 701。


8. 后台管理(7 页)

菜单 路径 component perms
麻将胡了(目录) mahjong —
箱子配置 box game/mahjong/box/index game:mahjong:box
倍率桶 bucket game/mahjong/bucket/index game:mahjong:bucket
回合管理 round game/mahjong/round/index game:mahjong:round
奖池水位 rewardPool game/mahjong/rewardPool/index game:mahjong:pool
玩家记录 record game/mahjong/record/index game:mahjong:record
每日统计 dailywin game/mahjong/dailywin/index game:mahjong:win
奖品管理 prize game/mahjong/prize/index game:mahjong:prize

菜单 SQL 照抄 docs/updata_sql.md:1165-1186 的写法(SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1) 动态挂到"游戏管理"下,再 LAST_INSERT_ID() 取目录 id 插 7 个子菜单)。注意两个仓库现状:

  • sql/ 目录是空的,全量 DDL 历史只在 docs/updata_sql.md(追加式)和生成器的 schema.sql 里,别去 sql/ 找;
  • 后台页面没有任何 v-hasPermi 指令,路由也不在 router/index.js 里静态注册——能否访问完全由 sys_menu 行决定(component='game/mahjong/<module>/index')。菜单 SQL 漏一条,页面就是白屏而不是报错。

箱子配置页:箱列表(bet/目标 RTP/固定池/收敛比例)+ 桶配比弹窗(改 count、看 remaining/convergeCount/计算 RTP)+ 模板管理 + 一键重算/批量重算。 奖池水位页:四身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + 调整原因必填,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。 奖品管理页:返奖饰品列表(名称/品质/图片/品质图/磨损/兜底/返奖区间)+ 编辑(prize/save 同步写 game_mahjong_prize_range)——照抄 olympus/prize/index.vue(121 行)。 玩家记录页:列里带饰品名+图与「播放状态 start/finish」标签(和玫瑰女郎一致)。 必做校验:calcRounds 回写时同时更新 total_rounds,否则 cycleN 与实际池子对不上。


9. 客户端(Cocos,D:\changzhou-cocos\changzhou_cocos\changzhou_cocos)

文件 作用 与玫瑰女郎面板的关系
assets/Scripts/Pages/MahjongPanel/MahjongPanel.ts 主面板(5×4 盘面、级联动画、阶梯条、FS 模式、自动旋转) 结构照 CandySloterPanel.ts(186KB),盘面改为 4 行 5 列、去掉炸弹、加金色转化动画
MahjongBigWin.ts 爆奖三档 拷贝
MahjongHistory.ts / MahjongRules.ts / MahjongSettings.ts 战绩 / 规则 / 设置(含投注大小与倍数) 拷贝改文案
MahjongSfx.ts / MahjongSpineUtil.ts 音效 / 骨骼工具 拷贝

主界面 HUD 布局(按用户给的真实运行截图逐块对齐):

位置 元素 数据来源
顶部横条 「1024 路中奖组合」标题条(两侧金环装饰) 静态
标题条下方 乘数阶梯行 x1 x2 x3 x5(FS 内 x2 x4 x6 x10):当前档金色高亮+放大,未到的档压暗 rotationMul
中部 5 列 × 4 行牌面;金色符号 = 普通牌 + 金色底/描边;本波中奖牌 = 红色边框 gridState / goldenDetail / waysDetail
牌面下方 「共赢得 X.XX」金边横幅(本局累计,跳字) spinWin
横幅下方三格 余额 ¥ / 投注 ¥ / 赢额 ¥ 余额接口 / bet×betMul / 本局赢额
底部按钮 闪电(加速)/ − / 大 Spin / + / 自动旋转,右上角 ✕ 退出 静态 + 投注档联动

截图实测细节:投注 ¥8.00、阶梯高亮在 x2(本旋第 2 波)、横幅「共赢得 0.80」→ 说明"每波结算后 HUD 立刻按当前波次刷新阶梯、并按本局累计刷新横幅",不是等整旋播完才更新。这两处要按帧驱动,别做成一旋只刷一次。

动画要点:

  1. 每波消除后"金色格 → 百搭"要有明确视觉反馈(用 wildConverted 驱动,不要靠 diff 盘面猜);
  2. 阶梯条随波次金色高亮递进(见上表),用 rotationMul 驱动;
  3. 悬念(near-miss)表现:基础旋逐列落地时,若前 4 列已出现恰好 2 个胡,第 5 列(最后一列)要播放放大/发光/慢转效果吊期待——这是用户点名要的"很刺激"的爽点。判据完全在客户端(数 gridState 里的 S),服务端只负责让这种盘面真的存在(§3.7 的 NEARMISS_2SCATTER_RATE)。
  4. FS 触发/重触发弹窗;剩余旋数直接显示 spinsRemaining,前端不要自己加减;
  5. 末帧结算交互(和玫瑰女郎一致):先播 totalWin 落袋动画 → 弹「放入背包 / 立即分解」(用 prizeInfo 展示饰品名与图)→ 分解走 POST /api/userPackSack/decompose;设置里的「自动分解」开关只是前端开关,开启后末帧自动替玩家调一次分解接口,调不动就留在背包(不会丢奖)。

10. 分期实施计划

阶段 交付物 验收标准 人日
P0 数学与仿真 mahjong-py 的 config.py / engine.py / mahjong_simulator.py / tests/ ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 2030%、FS 触发率 ≈0.5%、悬念盘占比 12%、总 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%) 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 轴、百搭不能替代胡(發,胡,發 不成链)、断言 count_wild(grid) ≤ 1;
    • 金色符号就地转百搭:① 金色「中」参与中奖后,该格 gridState 变 W 且 wildConverted 有它;② 该格没有被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 發,發,_,... 变成 3 连);④ 该 W 在更后面的波参与成奖时被正常消除;⑤ 未被中奖涉及的金色格不转化、保持原样;
    • 赔付表 8×3 全档、阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、百搭不能替代胡(發,胡,發 不成链)、悬念盘(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
  • test_simulator.py:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且金色格以 W 留在盘上,玫瑰女郎有 TestTumbleGeometry 可照抄)、池内每桶 actual_mul 落在区间内、无"饥饿桶"(重点查 8/9/10 号高倍桶——没有买 FS 兜底,最容易饿死)。
  • simulate_cycle.py:整轮 RTP 与设计的偏差;白号池独立。

Java

  • RoundPicker:固定阶段只消耗 used、收敛阶段不消耗;payoutCap 过滤生效;白号走纯概率分支。
  • 幂等:同一 recordId 重复 settleAtStart 只入账一次、只写一条背包记录。
  • 封顶局:maxWinReached=true、spinsRemaining=0、totalWin = 500×bet(500x 封顶,Q4 定案)。
  • 空转局(totalWin=0):仍按 max(totalWin,0.01) 命中最低区间饰品、照样写背包、settle_source 照常 finish;开局不写任何 MAHJONG_REWARD 流水。
  • 奖励流水时机:settleAtStart 不写 69;只有调 decompose 分解后才写 69(source 15 → 69 的映射生效),未分解就永远没有 69。
  • 站内信:单步局开局发、多步局末帧发、中途弃局不发(对齐玫瑰女郎)。
  • 并发:同一用户并发 dospin 只有一个能开局(COMMON_LOCK+userId)。

对账 / 回归

  • Σ record.settled_win(已结算局) == Σ 对应饰品价 × 条数 == 背包里 tt_box_records.source=15 的条数口径。
  • 分解后:MAHJONG_REWARD(69) 流水之和 == 已分解饰品价之和。
  • 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 TtAccountRecordSource 后必须实测,不能只看代码)。
  • 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;饰品此时已在背包(无损模型)。

12. 风险与对策

# 风险 对策
1 1024 路天然高方差(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) 桶配额 + 收敛阶段 + 500x 封顶(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 simulate_cycle 跑 5 轮以上看尾部
2 金色符号"就地转百搭"改变了级联密度(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证
3 step 表体量(~96 万帧,12 箱) 无中奖桶(1/2)合并成模板回合 + Java copyFromSameBucket 复制;必要时分表
4 py / Java 两份 calcRounds 不同步(玫瑰女郎已因此返工) 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比)
5 免费旋转"演不动"(FS 是白送的 12 旋,预算铺不开时动画会空转) 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分
6 现金直付与平台既有"游戏必给饰品"体验差异 已定案:走饰品(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期
7 客户端工作量大(玫瑰女郎面板是 186KB 单文件) 直接复制改造,不做公共 slot 基类抽象(两个游戏的盘面/动画差异大于共性)
8 生成耗时(本机 i3-10105F,4 物理核) --workers 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意
9 母本代码有文档漂移(olympus-py/README.md:459 声称 RoundPicker 有 ruoyi:olympus:seen:* 不重发去重,实际代码里不存在) 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认
10 高倍桶没有"买 FS"兜底(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) 生成端用 _force_win 主动构造而不是等运气;接受高倍桶 count 低、并保留 _calibrate_bias 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数

13. 全部定案(无待拍板项)

2026-09-21 全部澄清完毕。下表保留完整决策链,方便回溯"这条为什么这么定"。

# 问题 结论
Q0 赔付值乘的基数是哪个? ✅ 投注总额 ÷ 20(赔付表是 1 倍基准价 + 规则页"20 条基础投注" + 截图排除法,见 §2.1)
Q1 纯百搭链(第1/2/3轴全百搭)算什么? ✅ 由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,纯百搭链数学上不可能出现,不需要单独规则、也不需要 WILD_PAYS 开关(见 §2.2)
Q2 金色→百搭的那个百搭是否"钉住不动"? ✅ 不钉住("符号是可以下落的,下面符号消除之后,上面符号会往下掉落")
Q3 免费旋转总旋数上限 ✅ 30 旋
Q4 单局封顶倍率 ✅ 500x → MAX_WIN_CAP_MUL = 500;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000
Q5 是否要做 ante / buy_fs ✅ 都不做,只保留普通旋转 → 12 箱、无 need_fs、costMultiplier≡1
Q6 各身份目标 RTP ✅ 不是拍板项:后台配置(箱子级 rtp + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6
Q7 投注档数量与档位表 🔧 技术默认:12 档 1/3/5/10/20/30/50/100/200/300/500/1000 × betMul 110(实际 110000)。零成本备选:10 档 + betMul 1~20,见 §2.8.1
Q8 每日统计要不要独立日表 🔧 技术默认:不要,后台按 record 聚合
Q9 奖励形态:现金直付 vs 饰品入背包 ✅ 赢额变成一个饰品发到背包 → 区间匹配 → tt_box_records(source=15) → 末帧放入/分解 → 分解时写 MAHJONG_REWARD(69)
Q10 后台"回合管理"要不要暴露金色/阶梯明细 🔧 技术默认:只读展示 actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘,不展示内部帧
新 百搭:来源 / 数量 / 位置 / 消除条件 ✅ 已确认:① 原生盘面不会出现百搭(卷轴不落、补位不补),唯一来源是金色转化;② 几个金色格被消除就留几个百搭,无上限(一屏可同时有多个);③ 因此百搭只可能在第 2/3/4 轴,第 1 轴永远没有 ⇒ 纯百搭链不可能(Q1 永久关闭);④ 百搭可作为任何付费符号,被消除也要满足消除链;⑤ 可与普通符号、也可与金色符号连着消除(同权,无特例)
新 转化边界的策略开关 ❌ 已取消:既然"每个金色格各留一个、无上限",就不存在"已有百搭时该不该转化"的边界,WILD_SLOT_POLICY 不需要了

14. 完整规则汇总(规则页文案版 —— 可直接交客户端 / 策划 / QA)

本节把 §2 的游戏规格按玩家视角重排,既是客户端「规则」「赔付表」两个面板的文案来源,也是 QA 的验收清单。 文案口径:玩家能看懂;参数口径:见每节末尾的「开发参数」。

14.1 一句话

麻将胡了是一款 5 轴 × 4 行的视频老虎机游戏,共 1024 个中奖路,编入了百搭符号、金色麻将符号以及奖金乘数递增的免费旋转。

14.2 投注与结算

项 规则
投注构成 「投注大小」(12 档)× 「投注倍数」(1~10)= 投注总额,开局即扣
投注大小档位 1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000
游戏单位 金额以 ¥ 为单位
现金钱包 显示当下玩家可用的现金余额
自动旋转 可按玩家选择的回合数量自动进行游戏
中奖派发 按「赔付表」执行;不同中奖路上的奖金叠加
结算形态 本局赢额按区间转换为一个饰品发到背包;末帧可选择「放入背包 / 立即分解」;开启「自动分解」则末帧自动分解为金币

开发参数:box.bet ∈ 上表 12 档;price = box.bet × betMul(costMultiplier ≡ 1);饰品按 game_mahjong_prize_range 的赢额区间匹配;开局即 settleAtStart 写背包(无损模型)。

14.3 中奖判定(1024 路 = 前缀链)

  • 赔付表上的数值是「每条基础投注」的单价:1 条基础投注 = 投注总额 ÷ 20(游戏固定 20 条基础投注)。
  • 一条中奖路必须从最左(第 1 轴)开始,按 1 → 2 → 3 → 4 → 5 的顺序连续出现该符号;
    • 第 1 轴是必需的;
    • 中间断了就整笔作废——例如 1、2、4 有、3 没有,不算中奖;2、3、4 有、1 没有,也不算。
  • 每一轴上该符号可以有好几个(最多 4 个),只要求「每轴至少 1 个」。
  • 路数 = 链内各轴该符号个数的乘积。
  • 本笔赢额 = 赔付值 × 路数 × (投注总额 ÷ 20) × 本波乘数。
  • 中奖路奖金从最左至右卷轴的顺序派发;不同符号各自独立成奖、互相叠加。
  • 参考例子:1 × 3 × 2 = 6 路;赔付值 10 × 6 = 60(基础注),说明见游戏内「赔付表」。

开发参数:PAY_UNIT_DIVISOR = 20;判定实现见 §5.2(break 就停,禁止写成"任意 3 轴凑齐")。

14.4 符号与赔付表

赔付值(每条基础投注,1 倍基准价):

符号 5 连 4 连 3 连
發 100 60 15
中 80 40 10
白板(紫框) 60 20 8
八萬 40 15 6
花筒(四色) 20 10 4
索(红绿) 20 10 4
筒(紫绿) 10 5 2
索(绿) 10 5 2
百搭(元宝) — — —
胡(夺宝符号) — — —

14.5 百搭符号(元宝)

  • 百搭符号可替代除「夺宝符号(胡)」以外的所有符号。
  • 百搭不会从卷轴上出现:它只能由金色普通符号被消除后转化而来(见 14.6),而且一个盘面上最多只会出现 1 个。
  • 百搭能补链中间的断点(發, 百搭, 發 成立),但它不能替代胡——發, 胡, 發 不成链。
  • 百搭被消除也要满足消除链:它和普通符号完全同权——只有它参与了某条成立的中奖链,才会随那一波一起被消除;不满足链就留在盘面上继续用,不会凭空消失。
  • 百搭被消除之后,才可能再由金色符号转化出新的百搭;下一旋整盘重发,百搭不带走。

14.6 金色麻将符号

官方规则原文(游戏内「金色麻将符号」页):

  1. 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的**某些符号(不包括百搭符号和夺宝符号)**将可能显示为金色。」
  2. 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」

人话版(4 条,这是要写进规则页的文案):

# 规则
1 谁能变金:只有第 2、第 3、第 4 轴上的普通符号会显示为金色(百搭和胡不会变金)
2 金色是什么:就是那个普通符号镀了一层金,判定与赔付仍然按它的自然符号算(金色「中」照「中」的赔付表赔)
3 中奖后怎么变:这个金色符号参与中奖、被消除时,它原来的位置不会空掉、也不会补新牌,而是留下一个真正的百搭符号(元宝)——每个金色格各留一个(同波几个金色格中奖就留几个,无上限)
4 之后怎么用:留下的百搭和盘面上直接落下的百搭完全一样——可以替代除「胡」以外的所有符号、可以补链搭桥、参与中奖时同样被消除

两个最容易搞错的点:

  • 官方原文说"在新符号跌落后的每一轮旋转中……被转换为百搭"——这是动画时序的描述(视觉上要等新牌落下来才看到它变百搭);实现上是"该格被消除的那一刻就地写入百搭",两者不冲突。
  • 金色符号本身就是普通符号,所以它照样随重力下落(下面被消除就往下掉),不会钉在空中。

一个连贯的例子:

步骤 盘面 发生了什么
第 1 波 金色「中」参与「中」的 3 连 按「中」赔付;结算后该格留下一个百搭
第 2 波 掉落后形成 發, 百搭, 發 … 百搭搭桥,「發」3 连成立
第 3 波 这个百搭又参与了一笔中奖 它随这一波被消除(百搭也是一张普通牌)

开发参数:每旋开局对第 2/3/4 轴的普通符号按 GOLDEN_CELL_PROB(建议 0.10,逐格独立)打金色标记,新补入的符号也可能带金色;本波中奖涉及的金色格在消除时不补新符号、就地写 W,并随重力下落(不锚定);每格只转化一次,转化后的 W 不再回填金色。判定细节见 §2.3。

14.7 乘数阶梯(奖金翻倍)

  • 在任何旋转期间,所有赢奖都将乘以卷轴上方所显示的乘数值。
  • 普通旋转:第 1 次消除 x1 → 第 2 次 x2 → 第 3 次 x3 → 第 4 次及以后 x5。
  • 免费旋转:乘数分别增加至 x2、x4、x6、x10。
  • 每旋重新从第一档开始,不跨旋累计。

开发参数:LADDER_BASE = (1,2,3,5)、LADDER_FS = (2,4,6,10);第 n 波取 ladder[min(n,4)]。

14.8 级联(翻滚)

  • 每轮游戏结束并发放奖金后,所有赢奖符号都将爆炸消除,让其上方的符号向下跌落,以进行新一轮的旋转。
  • 顶部补入新符号后进入下一波,直到没有其他中奖组合为止。
  • 额外的中奖组合都将计入每次旋转的赢奖中。

14.9 免费旋转模式

  • 当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转。
  • 每个额外的夺宝符号都将触发多 2 次的免费旋转:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。
  • 免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10。
  • 免费旋转可以被重复触发(免费旋转内再出现 3+ 个胡,同样按"12 + 每个额外胡 +2"追加)。
  • 免费旋转的总旋数上限为 30 旋(到顶后不再出现 3+ 个胡)。
  • 免费旋转期间投注大小与投注倍数、以及触发免费旋转模式时的参数保持一致。

开发参数:FS_AWARD = 12 + 2 × (scatterCount − 3);重触发同式;总旋数上限 30(Q3 定案);到顶后不再落 3+ 胡。

14.10 悬念表现(2 个胡)

  • 当卷轴上已出现 2 个夺宝符号时,最后一列会播放大/发光效果,吊起"再来一个胡"的期待感。

开发参数:客户端按 gridState 里的 S 计数驱动;生成端用 NEARMISS_2SCATTER_RATE(1%~2%)保证这种盘面真实存在。

14.11 爆奖与封顶

  • 单局封顶:总倍率到达封顶即截断,剩余旋数作废,保证"动画总赢额 = 实际到账"。
  • 爆奖档位:小 / 中 / 大(按单旋赢额判档,只在免费旋转内播)。
  • 所有赢奖以现金显示(游戏内显示金额),最终以饰品形式发放到背包。

开发参数:封顶 500x(Q4 定案)——totalMul ≥ 500 即 maxWinReached=true、spinsRemaining=0、按 500 倍截断发放;爆奖阈值见 §12 对应条目。

14.12 参数速查(给开发 / QA)

参数 值
盘面 5 轴 × 4 行 = 20 格
路数 4⁵ = 1024
赔付单位 投注总额 ÷ 20(PAY_UNIT_DIVISOR = 20)
判定 前缀链(第 1 轴起连续,断链作废,ways 只乘链内轴)
百搭 只能由金色符号转化产生(原生盘面不落);每个金色格各留一个(无上限),只可能在第 2/3/4 轴;可替代除「胡」外全部;满足消除链才被消除
金色符号 只在第 2/3/4 轴;按自然符号成奖;消除后就地留百搭
普通旋阶梯 1 / 2 / 3 / 5(第 4 波及以后 x5)
FS 阶梯 2 / 4 / 6 / 10
免费旋转 3+ 胡 → 12 旋,每个额外胡 +2,可重触发,上限 30 旋
悬念盘 2 个胡 → 第 5 轴放大;生成端占比 1%~2%
投注档 12 档:1/3/5/10/20/30/50/100/200/300/500/1000
投注倍数 1~10
封顶 500x
发奖 赢额 → 区间匹配饰品 → 背包 → 可分解

15. 完整消除示例(逐波走一遍,用来校验规则理解)

场景:普通旋转,投注大小 ¥100 × 投注倍数 1 → 投注总额 ¥100,故 1 条基础投注 = 100 ÷ 20 = ¥5.00。 本旋共 4 波消除,覆盖:两个符号同时中奖(叠加)、两个金色格各留一个百搭、百搭随重力下落、百搭参与成链后自己也被消除、3/4/5 连三种档位、x1→x2→x3→x5 全阶梯。 图例:A=發 B=中 C=白板 D=八萬 E=花筒 F=索(红绿) G=筒(紫绿) H=索(绿) W=百搭(元宝) S=胡 加粗带 * = 金色。 ⚠️ 金色格只能出现在第 2/3/4 轴(本示例的两张金色都在轴2、轴3)。

第 1 波(基础旋 · 阶梯 x1)

轴 → 1 2 3 4 5
行1 B C B E S
行2 B B* B* D F
行3 D C C G E
行4 C E H F G

判定(前缀链;两张金色格都按自然符号「中」算):

符号 轴1 轴2 轴3 轴4 轴5 结果
中 B 2(行1、行2) 1(行2金) 2(行1、行2金) 0(E/D/G/F) — 3 连,ways = 2×1×2 = 4
白板 C 1(行4) 2(行1、行3) 1(行3) 0 — 3 连,ways = 1×2×1 = 2
其余 6 个 轴2 里都没有它们 — — — — 轴 2 断,不成奖

本波两个符号同时中奖,奖金叠加。

赔付:

中奖 赔付值 ways 基础注 阶梯 赢额
中 3 连 10 4 40 x1 ¥200
白板 3 连 8 2 16 x1 ¥80
小计 ¥280

本波共 9 个符号被消除(9 格全播爆裂动画):

格子 消除后
中链:(行1,轴1)、(行2,轴1)、(行1,轴3) 空洞 → 下落 + 补新牌
中链:(行2,轴2)金、(行2,轴3)金 每张金色格各留一个百搭 → 两格都就地变 W(本波留下 2 个百搭)
白板链:(行4,轴1)、(行1,轴2)、(行3,轴2)、(行3,轴3) 空洞 → 下落 + 补新牌

⚠️ 两条口径一起记: ① "消除 ≠ 清空"——9 格全部"被消除"(全爆),区别只在消除之后:金色格写 W,其余格置空待补; ② "几个金色格中奖,就留几个百搭"——不是每波只留 1 个。本波留 2 个。

重力 + 补位(列内幸存符号保持顺序压到底,顶部补新牌):

轴 本波消除 该列变化 补位后(上→下)
1 行1、行2、行4 只剩 D(行3) → 落到 行4 顶部补 D、D、D → D / D / D / D
2 行1、行3 幸存 W(行2)、E(行4) → W 落到行3、E 到行4 顶部补 F、G → F / G / W / E
3 行1、行3 幸存 W(行2)、H(行4) → W 落到行3、H 到行4 顶部补 A、B → A / B / W / H
4、5 无 — 原样不动

第 1 波 → 第 2 波的盘面演变(两步)

⚠️ 说明:消除之后符号立刻落下来(存活符号下移 + 新牌从列顶落下),“整列空洞”只是一个瞬时中间帧、不是一个静止状态,所以下面只展示两个关键画面:消除瞬间 和 落定后。

步骤 ① 消除瞬间(9 格全爆;两张金色格就地变 W,其余中奖格变空洞):

轴 → 1 2 3 4 5
行1 ▢ ▢ ▢ E S
行2 ▢ W W D F
行3 D ▢ ▢ G E
行4 ▢ E H F G

▢ = 空洞(待补)。两张金色格各自变成了 W,盘面上现在同时有 2 个百搭。

步骤 ② 符号落定(存活符号下移 + 新牌从列顶落下)→ 这就是「第 2 波消除之前的盘面」:

轴 → 1 2 3 4 5 这一列发生了什么
行1 D F A E S 轴1 整列被清空 → 落进来 4 张新牌
行2 D G B D F 轴2:W 与 E 下移一格 → 顶部落进 2 张新牌
行3 D W W G E 轴3:W 与 H 下移一格 → 顶部落进 2 张新牌
行4 D E H F G 轴4、轴5 没被消除 → 不动

粗体 = 本步落进来的牌。两个 W 都从行2 落到了行3;轴1 因为四格全爆、列内没有存活符号,所以落进来的全是新牌(4 张)——这就是"消除完符号掉落下来"的那一步。

第 2 波(阶梯 x2)——两个百搭一起帮忙成链,然后一起被消除

轴 → 1 2 3 4 5
行1 D F A E S
行2 D G B D F
行3 D W W G E
行4 D E H F G
符号 轴1 轴2 轴3 轴4 轴5 结果
八萬 D 4(行1~行4) 1(百搭当八萬) 1(百搭当八萬) 1(行2) 0(S/F/E/G) 4 连,ways = 4×1×1×1 = 4
F / G / A / B 轴1 没有 — — — — 轴1 断,不成奖

本波盘面只有轴 1 是纯八萬(补位恰好都补成 D),所以只有八萬成链。这也顺带说明了:有百搭的那几轴对所有符号都成立,链能不能成完全取决于"没有百搭的那些轴"。

赔付:八萬 4 连赔付值 15 × ways 4 = 60 基础注 = ¥300 × 阶梯 x2 = ¥600

本波共 7 个符号被消除:轴1 行1~行4 的 4 张 D、(行3,轴2) W、(行3,轴3) W、(行2,轴4) D。

  • 两个百搭都参与了成立的中奖链 → 按"满足消除链才消除" → 两个 W 一起被消除(盘面回到 0 个百搭)
  • 本波中奖格没有金色 → 不产生新百搭

重力 + 补位:

轴 该列变化 补位后(上→下)
1 4 格全消除 → 无幸存 顶部补 4 张 → E / H / C / F
2 消除行3 → 幸存 F,G,E 落到行2~4 顶部补 C → C / F / G / E
3 消除行3 → 幸存 A,B,H 落到行2~4 顶部补 E*(这张是金色) → E* / A / B / H
4 消除行2 → 幸存 E,G,F 落到行2~4 顶部补 A → A / E / G / F
5 无 原样 S / F / E / G

盘面演变(第 2 波 → 第 3 波)

① 消除瞬间(7 格全爆,两个 W 都被消除;本波无金色格,不产生新百搭):

轴 → 1 2 3 4 5
行1 ▢ F A E S
行2 ▢ G B ▢ F
行3 ▢ ▢ ▢ G E
行4 ▢ E H F G

② 符号落定(存活符号下移 + 新牌从列顶落下)→ 「第 3 波消除之前的盘面」:

轴 → 1 2 3 4 5 这一列发生了什么
行1 E C E* A S 轴1 整列清空 → 落进 4 张新牌
行2 H F A E F 轴2:F/G/E 下移一格 → 顶部落进 1 张
行3 C G B G E 轴3:A/B/H 下移一格 → 顶部落进 1 张(金色)
行4 F E H F G 轴4:E/G/F 下移一格 → 顶部落进 1 张;轴5 没被消除

轴1 四格全爆 → 没有存活符号 → 落进来的全是新牌;两个 W 被消除,本波不产生新百搭。

第 3 波(阶梯 x3)——一张金色格中奖,留 1 个百搭

轴 → 1 2 3 4 5
行1 E C E* A S
行2 H F A E F
行3 C G B G E
行4 F E H F G
符号 轴1 轴2 轴3 轴4 轴5 结果
花筒 E 1(行1) 1(行4) 1(行1金) 1(行2) 1(行3) 5 连,ways = 1×1×1×1×1 = 1
索绿 H 1(行2) 0(C/F/G/E) — — — 轴2 断
白板 C 1(行3) 1(行1) 0(E/A/B/H) — — 轴3 断
索红绿 F 1(行4) 1(行2) 0 — — 轴3 断
其余 轴1 没有 — — — — 不成奖

赔付:花筒 5 连赔付值 20 × ways 1 = 20 基础注 = ¥100 × 阶梯 x3 = ¥300

本波共 5 个符号被消除:(行1,轴1)E、(行4,轴2)E、(行1,轴3) 金、(行2,轴4)E、(行3,轴5)E——5 格全爆。 其中 4 格变空洞;金色格 (行1,轴3) 原地留百搭 W(本波开始时盘面 0 个百搭 ✅,本波只有 1 张金色格中奖 → 留 1 个)。

重力 + 补位:

轴 该列变化 补位后(上→下)
1 消除行1 → 幸存 H,C,F 落到行2~4 顶部补 G → G / H / C / F
2 消除行4 → 幸存 C,F,G 落到行2~4 顶部补 A → A / C / F / G
3 该列无空洞(金色格变 W) 原样 → W / A / B / H
4 消除行2 → 幸存 A,G,F 落到行2~4 顶部补 G → G / A / G / F
5 消除行3 → 幸存 S,F,G 落到行2~4 顶部补 C → C / S / F / G

盘面演变(第 3 波 → 第 4 波)

① 消除瞬间(5 格全爆;金色格 (行1,轴3) 就地变 W):

轴 → 1 2 3 4 5
行1 ▢ C W A S
行2 H F A ▢ F
行3 C G B G ▢
行4 F ▢ H F G

② 符号落定(存活符号下移 + 新牌从列顶落下)→ 「第 4 波消除之前的盘面」:

轴 → 1 2 3 4 5 这一列发生了什么
行1 G A W G C 轴1:H/C/F 下移一格 → 顶部落进 1 张
行2 H C A A S 轴2:C/F/G 下移一格 → 顶部落进 1 张
行3 C F B G F 轴3 没有空洞(金色格变 W)→ 整列不动
行4 F G H F G 轴4:A/G/F 下移一格 → 顶部落进 1 张;轴5:S/F/G 下移 → 落进 1 张

W 停在原处(轴3 那列没被清空),其余列各自下移一格并补新牌。

第 4 波(阶梯 x5,第 4 波及以后都是 x5)——一个百搭被两个符号同时借用,本旋结束

轴 → 1 2 3 4 5
行1 G A W G C
行2 H C A A S
行3 C F B G F
行4 F G H F G
符号 轴1 轴2 轴3 轴4 轴5 结果
筒 G 1(行1) 1(行4) 1(百搭当筒) 1(行1、行3 → 2) 1(行4) 5 连,ways = 1×1×1×2×1 = 2
索红绿 F 1(行4) 1(行3) 1(百搭当索红绿) 1(行4) 1(行3) 5 连,ways = 1×1×1×1×1 = 1
白板 C 1(行3) 1(行2) 0(W/A/B/H) — — 轴3 断
索绿 H 1(行2) 0(A/C/F/G) — — — 轴2 断
發 A 轴1 没有 — — — — 不成奖

同一个百搭这波同时被筒和索红绿两个符号借用,两条链都成立 → 两笔并赔。

赔付:

中奖 赔付值 ways 基础注 阶梯 赢额
筒 5 连 10 2 20 x5 ¥500
索红绿 5 连 20 1 20 x5 ¥500
小计 ¥1000

本波共 9 个符号被消除(筒链 5 格 ∪ 索红绿链 5 格,重叠在 W 与部分格): (行1,轴1)G、(行4,轴1)F、(行4,轴2)G、(行3,轴2)F、(行1,轴3) W、(行1,轴4)G、(行3,轴4)G、(行4,轴4)F、(行3,轴5)F、(行4,轴5)G—— W 参与了两条链 → 被消除;本波中奖格无金色 → 不产生新百搭。

盘面演变(第 4 波 → 本旋结束)

① 消除瞬间(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 W):

轴 → 1 2 3 4 5
行1 ▢ A ▢ ▢ C
行2 H C A A S
行3 C ▢ B ▢ ▢
行4 ▢ ▢ H ▢ ▢

② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面:

轴 → 1 2 3 4 5 这一列发生了什么
行1 E G E C A 轴1:H/C 下移两格 → 落进 2 张
行2 F D A D B 轴2:A/C 下移两格 → 落进 2 张
行3 H A B E C 轴3:A/B/H 下移一格 → 落进 1 张
行4 C C H A S 轴4:A 落到底 + 落进 3 张;轴5:C/S 下移 + 落进 2 张

→ 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而轴3 没有 C → 无任何中奖组合,本旋结束(整局共 4 波)。

本旋总账

波 中奖 赔付值 ways 基础注 阶梯 本波赢额
1 中 3 连 10 4 40 x1 ¥200
1 白板 3 连 8 2 16 x1 ¥80
2 八萬 4 连 15 4 60 x2 ¥600
3 花筒 5 连 20 1 20 x3 ¥300
4 筒 5 连 10 2 20 x5 ¥500
4 索红绿 5 连 20 1 20 x5 ¥500
合计 ¥2180
  • 投注总额 ¥100 → 本局总倍率 = 2180 ÷ 100 = 21.80x(未触及 500x 封顶)
  • 本旋只有 1 个胡(一直挂在轴5)→ 不触发免费旋转(需 3+)

百搭轨迹(这张表是重点:几个金色格中奖,就留几个百搭)

时刻 盘上百搭数 发生了什么
旋开局 0 原生盘面不落百搭
第 1 波结束 0 → 2 同波有两张金色「中」中奖 → 各留一个百搭(轴2、轴3 各一个)。两个 W 随后都随重力从行2 掉到行3
第 2 波结束 2 → 0 两个 W 都参与了八萬链(满足消除条件)→ 一起被消除;本波无金色中奖,不补
第 3 波结束 0 → 1 本波只有 1 张金色「花筒」中奖 → 留 1 个 W;该列没有空洞,所以它原地不动
第 4 波结束 1 → 0 W 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补
旋结束 0 无后续中奖;下一旋整盘重发

百搭数量没有上限(第 1 波就同时有 2 个),但只可能出现在第 2/3/4 轴——因为金色格只在那三轴,而百搭的唯一来源是金色转化。 这条推论很关键:第 1 轴永远不会有百搭,所以前缀链的第 1 轴必然是自然符号 ⇒ 任何一笔中奖都至少含 1 个自然符号 ⇒ "纯百搭链"在数学上不可能出现(Q1 因此永久关闭,不需要额外规则)。

全程每一波盘面上的百搭数都 ≤ 1 ✅,且永远只由金色转化产生 ✅。 本示例注意:第 1 波两张金色格各留一个百搭 → 盘面同时有 2 个百搭,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。


附录 A:与玫瑰女郎的字段/表名映射速查

玫瑰女郎 麻将胡了
game_olympus_box.total_ornaments game_mahjong_box.total_rounds
game_olympus_ornament(17 档饰品桶) game_mahjong_bucket(10 档倍率桶)
game_olympus_box_ornament.ornament_id game_mahjong_box_bucket.bucket_id
game_olympus_round.ornament_id game_mahjong_round.bucket_id
game_olympus_step.symbol_links game_mahjong_step.ways_detail
game_olympus_step.multiplier_detail(炸弹) 删除,改为 golden_detail + rotation_mul
game_olympus_step.accumulator_* game_mahjong_step.rotation_mul(语义:本波档位,不跨旋)
game_olympus_step.free_spin_id / cascade_seq reel_spin_id / rotation_seq
game_olympus_prize_ornament / _prize_range game_mahjong_prize_ornament / _prize_range(要,赢额→饰品)
TtboxRecordSource.OLYMPUS_BOX(13) TtboxRecordSource.MAHJONG_BOX(15)(写背包)
OLYMPUS_SPIN(60) / OLYMPUS_REWARD(61) MAHJONG_SPIN(68) / MAHJONG_REWARD(69)
ruoyi:olympus:* ruoyi:mahjong:*
olympusMaintenance mahjongMaintenance

附录 B:关键代码坐标(新游戏要对齐的现有实现)

主题 文件:行
两阶段挑桶 game-olympus-service/.../service/OlympusRoundPicker.java:44-100
水位仓储 .../repository/OlympusRewardPoolCacheRepository.java:80-84
开局事务 + 提交后吞异常 .../service/OlympusGameService.java:173-220
开局结算 + 幂等守卫 .../service/OlympusGameService.java:279-306
续播 / 末帧 / 战绩可见性 .../service/OlympusGameService.java:230-254
封顶判定 .../service/OlympusGameService.java:426-429(MAX_WIN_CAP_MUL)
配比算法(py) olympus-py/_recalc_boxes.py:52-129
配比算法(Java) .../service/OlympusDistributionService.java:100-207
生成端补桶/饥饿重校准 olympus-py/olympus_simulator.py:740-805
级联几何复刻测试 olympus-py/tests/test_simulator.py::TestTumbleGeometry
平台流水 source 聚合 skins-model/.../TtAccountRecordSource.java:193-279
建表 + 菜单 SQL 写法 docs/updata_sql.md:1144-1225
接口契约模板 docs/玫瑰女郎接口文档.md(706 行)

附录 C:待新增的运维 runbook(对齐 olympus-py/README.md)

  1. 首次上线:mahjong_db.py → generate_rounds.py --all --alloc config --write → _make_templates.py → _recalc_boxes.py --mode normal → 给 12 箱 × 4 身份注水(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;¥500/¥1000 这两档必须单独注)。
  2. 改配比(不重建池子):后台"一键重算"或 _recalc_boxes.py --mode X → 清 ruoyi:mahjong:cycle:* → _make_templates.py。
  3. 重建池子:_regen.py snapshot → generate_rounds.py --clear --write → 重算 → 清 Redis → 刷模板 → _regen.py verify。
  4. 调水位:后台奖池水位页按身份逐箱注水/抽水(原因必填)或批量按"一轮花费基数 × 比例%"。
  5. 日常体检:_regen.py check(每桶有条目 / 无封顶缺失 / 池内 RTP)+ simulate_cycle.py 抽样。