106 KiB
麻将胡了(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 确认):
- 原生盘面不会有百搭——卷轴永远不会落下元宝,补位也不会补出元宝;百搭的唯一来源是"金色普通符号被消除后的转化"。
- 几个金色格被消除,就留几个百搭(无数量上限)——所以一屏可以同时有多个百搭(示例见 §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 金色麻将符号(本游戏最独特、也最容易做错的一条)
截图原文:
- 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的某些符号(不包括百搭符号和夺宝符号)将可能显示为金色。」
- 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」
用户补充的落地口径(本条的权威解释,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标记(对齐 olympusstep.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,池子一行不动。
⚠️ 做大之前必须先定的两件事
- 单局最大赔付口径(风控) —— 已定案:封顶 500x:
| 组合 | 单局最大赔付 |
|---|---|
| 玫瑰女郎现状(1000x × bet 300) | ¥300,000 |
| 麻将胡了(500x × bet 10000) | ¥5,000,000 |
运行期还有奖池水位 payoutCap 兜底(水不够就出不了大奖),但 500x 这条线是玩家预期与风控口径,代码里写死为 MAX_WIN_CAP_MUL = 500。
2. 冷门高档位会水位饥饿:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → payoutCap 把它们压到出不了奖。上线时必须给这两档单独注一笔初始水位,或干脆先只放 10 档、等有需求再开高档(加档不需要改客户端,只需加箱 + 生成池子)。
为什么不是更多档
- 每多一档 = 多一整套成本(这才是决定档位数量的真正约束):
- 多一个回合池:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
- 多 40 条配比行(10 桶 × 4 身份)要配、要重算、要维护模板;
- 多一套奖池水位(4 个身份各一条)要运营盯。 10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
- 档位越多,冷门档越容易"饿死":奖池水位是按箱独立的,没人玩的档水位长期为 0 →
payoutCap = max(0, 水位+成本) × rtp被压到接近 0 → 那个档几乎出不了奖,只能靠运营注水救。所以档位不是越多越好。 - 玩家侧足够:1
1000 的 12 档 + 110 倍 = 实际覆盖 110000,比原版 0.0250 宽两个数量级,选注习惯也够密。 - 对齐玫瑰女郎:前 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(游戏内赢额)——否则这两者对不上时,水位会漂。
结论:下注档表要同时满足
bet ≥ 0.10(赔付表约束,实际取 ¥1 起)- 档位取整数(保证 unit 是整分)
- 最低档饰品价 ≤ ¥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。
- 基础权重
30 × 0.6^k(k = 桶序号,低倍桶权重高); - 二分搜索
power使加权均值命中targetRtp × cm(误差 <1%); - 按权重把固定池分配到各桶,并施加单调约束
count[i] ≥ count[i+1](低倍桶条目多于高倍桶); - 400 次"任意两桶搬 1 个"微调,逼到
|delta| < 0.01; - 期望值取该箱池内实测
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 不在代码里写死,和玫瑰女郎一样由后台配置,两层:
- 箱子级:
game_mahjong_box.rtp(目标返奖率)+rtp_converge_ratio(收敛比例)—— 在后台「箱子配置」页改; - 身份级:
game_mahjong_box_bucket里每个身份一套桶配比(count),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。
所以"各身份 RTP 定多少"不是一个需要在方案阶段拍板的数,而是运营参数:先全部按 0.80 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。
| audience | 判定 | 建库默认 RTP | 说明 |
|---|---|---|---|
| common | 其余全部 | 0.80 | |
| anchor | user_type='01' |
0.80 | 四身份互斥并集 = 全部 |
| robot | user_type='03' |
0.80 | |
| white | is_blank_account=1(最高优先) |
0.80 | 白号不进奖池:payoutCap=null、不注水不抽水,配比即爆率,给主播用 |
3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
- 唯一模式
normal:目标自然触发率 ≈0.5%(每 200 局 1 次),由 3+ 胡的落点概率控制。 - 触发局整局倍率自然落在 30x
150x 区间 → 天然归入 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); - 少了这条,客户端那段"最后一列放大"的动画永远不会被触发(纯自然概率太低),爽点白做。
- 生成端要保证一定比例的基础旋恰好落 2 个胡且第 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-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 必须重写的四处引擎逻辑
-
_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 轴。 -
几何:
GRID_ROWS=4 / GRID_COLS=5 / GRID_SIZE=20;_force_win的random.sample(free, cnt)、_force_small_win的可用格数、_place_scatter的保护集都要跟着改。 -
乘数阶梯替换炸弹:
- 删掉
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,语义从"跨旋累加"变为"本波档位"。
- 删掉
-
级联的"消除"不再是"全部清空"(金色符号就地转百搭,替换 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 箱 × |
|
| 生成耗时 | 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 处,易漏)
skins-service/game-mahjong-service/pom.xml(拷 olympus,改 artifactId,parent =skins-service);skins-service/pom.xml的<modules>加一行;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 侧算,不能用 SQLNOW()—— 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 立刻按当前波次刷新阶梯、并按本局累计刷新横幅",不是等整旋播完才更新。这两处要按帧驱动,别做成一旋只刷一次。
动画要点:
- 每波消除后"金色格 → 百搭"要有明确视觉反馈(用
wildConverted驱动,不要靠 diff 盘面猜); - 阶梯条随波次金色高亮递进(见上表),用
rotationMul驱动; - 悬念(near-miss)表现:基础旋逐列落地时,若前 4 列已出现恰好 2 个胡,第 5 列(最后一列)要播放放大/发光/慢转效果吊期待——这是用户点名要的"很刺激"的爽点。判据完全在客户端(数
gridState里的S),服务端只负责让这种盘面真的存在(§3.7 的NEARMISS_2SCATTER_RATE)。 - FS 触发/重触发弹窗;剩余旋数直接显示
spinsRemaining,前端不要自己加减; - 末帧结算交互(和玫瑰女郎一致):先播
totalWin落袋动画 → 弹「放入背包 / 立即分解」(用prizeInfo展示饰品名与图)→ 分解走POST /api/userPackSack/decompose;设置里的「自动分解」开关只是前端开关,开启后末帧自动替玩家调一次分解接口,调不动就留在背包(不会丢奖)。
10. 分期实施计划
| 阶段 | 交付物 | 验收标准 | 人日 |
|---|---|---|---|
| P0 数学与仿真 | mahjong-py 的 config.py / engine.py / mahjong_simulator.py / tests/ |
① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 20 |
6~8 |
| P1 回合池 | schema.sql / mahjong_db.py / generate_rounds.py / _recalc_boxes.py / _make_templates.py / simulate_cycle.py / README |
① 12 箱池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② simulate_cycle 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) |
4 |
| 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 轴无胡的盘面占比落在设计值)。
- 前缀链判定逐条覆盖 §2.1 的 7 个正反例(
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 全部澄清完毕。下表保留完整决策链,方便回溯"这条为什么这么定"。
| # | 问题 | 结论 |
|---|---|---|
| ✅ 投注总额 ÷ 20(赔付表是 1 倍基准价 + 规则页"20 条基础投注" + 截图排除法,见 §2.1) | ||
✅ 由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,纯百搭链数学上不可能出现,不需要单独规则、也不需要 WILD_PAYS 开关(见 §2.2) |
||
| ✅ 不钉住("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") | ||
| ✅ 30 旋 | ||
✅ 500x → MAX_WIN_CAP_MUL = 500;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 |
||
ante / buy_fs |
✅ 都不做,只保留普通旋转 → 12 箱、无 need_fs、costMultiplier≡1 |
|
✅ 不是拍板项:后台配置(箱子级 rtp + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
||
| Q7 | 投注档数量与档位表 | 🔧 技术默认:12 档 1/3/5/10/20/30/50/100/200/300/500/1000 × betMul 1 |
| Q8 | 每日统计要不要独立日表 | 🔧 技术默认:不要,后台按 record 聚合 |
✅ 赢额变成一个饰品发到背包 → 区间匹配 → 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 金色麻将符号
官方规则原文(游戏内「金色麻将符号」页):
- 「在任何旋转期间,第 2、第 3 和/或第 4 轴上的**某些符号(不包括百搭符号和夺宝符号)**将可能显示为金色。」
- 「在新符号跌落后的每一轮旋转中,上一轮赢奖中所涉及的黄色麻将符号将被转换为百搭符号。」
人话版(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)
- 首次上线:
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 这两档必须单独注)。 - 改配比(不重建池子):后台"一键重算"或
_recalc_boxes.py --mode X→ 清ruoyi:mahjong:cycle:*→_make_templates.py。 - 重建池子:
_regen.py snapshot→generate_rounds.py --clear --write→ 重算 → 清 Redis → 刷模板 →_regen.py verify。 - 调水位:后台奖池水位页按身份逐箱注水/抽水(原因必填)或批量按"一轮花费基数 × 比例%"。
- 日常体检:
_regen.py check(每桶有条目 / 无封顶缺失 / 池内 RTP)+simulate_cycle.py抽样。