麻将胡了

This commit is contained in:
2026-09-22 15:39:53 +08:00
parent 8f8d2e042c
commit 470d1471e5
70 changed files with 6497 additions and 150 deletions
+217 -148
View File
@@ -2,7 +2,11 @@
> 目标:把「麻将胡了」(5 轴 × 4 行、1024 路、级联消除、金色符号、乘数阶梯、免费旋转)做成**玫瑰女郎(Olympus)同一套设计模式**的兄弟游戏。
> 参考实现:`olympus-py/`(回合池生成器)+ `skins-service/game-olympus-service/`(Java 服务)+ `ruoyi-ui/src/views/game/olympus/`(后台)+ `docs/玫瑰女郎接口文档.md`(接口契约)。
> 状态:**方案待评审**,未动任何代码。末尾第 13 节列出必须由你拍板的 10 个问题。
> **文档来源**:本方案是随同源项目从 **`changzhou_backend`** 搬到本仓的(本仓 `docs/修改记录.md` 已把它归为"旧项目文档"),**通篇是 changzhou 语境**。所以文内的 `olympus-py/`、`skins-service/game-olympus-service/`、`docs/玫瑰女郎接口文档.md`、`docs/updata_sql.md`、`D:\changzhou-cocos\...` 都是**那个仓库**的资产:
> - 在 `D:\changzhou_app\changzhou_backend` 里它们**完整存在**(含上面两个 docs 模板);
> - 在本仓(henan-backend)里**没有**——`olympus-py/` 与 `game-olympus-service/` 未随拷贝带过来,`docs/updata_sql.md` 已清空、`docs/玫瑰女郎接口文档.md` 已删(均为有意为之)。
> - **落地仓库已定:本仓 henan-backend**。因此:① 路径类引用一律按**本仓现状**读(§3.8 / §6.5 / §8 / 附录 B 已就地标注);② 开工前先把母本从 `D:\changzhou_app\changzhou_backend` 拷过来当参照(`olympus-py/` → 本方案 §5 的照抄对象;`skins-service/game-olympus-service/` → §6 的照抄对象);③ 注意本仓 `skins-service/pom.xml:22` 仍声明 `game-olympus-service` 而该目录不存在,**Maven 从根构建会以 "Child module does not exist" 失败**(`ruoyi-admin/pom.xml:100` 也依赖它),要么先拷母本、要么先摘掉这行。数学与规则部分(§2 全篇)与仓库无关,两边通用。
> 状态:**方案待评审**,未动任何代码。第 13 节是全部定案与决策链(原列的 10 个待拍板项已全部澄清)。
---
@@ -11,12 +15,12 @@
| 项 | 结论 |
|---|---|
| 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 |
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **12 个箱子**(只按投注档分,档位见 §2.8.1)、无模式倍率、无 `need_fs` 过滤 |
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **11 个箱子**(只按投注档分,档位见 §2.8.2)、无模式倍率、无 `need_fs` 过滤 |
| 必须重写什么 | ① 中奖判定(1024 路**前缀链**:必须从第 1 轴起 1→2→3→4→5 连续,中间断链即整笔作废,替代 8+ 聚簇)② 盘面几何(5×4=20 格替代 5×6=30 格)③ 乘数介质(阶梯倍率替代炸弹球)④ **级联的消除规则**(金色中奖格**不清空**,就地留一个百搭占位) |
| 发奖形态 | **与玫瑰女郎完全一致**:游戏内**显示现金**(「共赢得 X.XX」),但**最终赢额转成一个饰品发到背包**(`settleAtStart` 按赢额落区间查饰品 → 写 `tt_box_records`),玩家在末帧选择「放入背包 / 立即分解」。→ 所以**不做**"开户即加余额"那条近路,`prize_ornament` / `prize_range` 两张表都要 |
| 新增资产 | `mahjong-py/`(生成器)+ `game-mahjong-service`(Java 模块)+ **11 张** `game_mahjong_*` 表 + 7 个后台页(含奖品管理)+ `docs/麻将胡了接口文档.md` + Cocos 面板 |
| 工作量 | 后端(py + Java + 后台 + 文档)≈ **20~26** 人日;客户端 ≈ 15~20 人日;联调/RTP 验收 ≈ 5 人日 |
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ 赔付计量单位(=总额÷20)若在规则页没说清,玩家会按"倍总额"理解而产生对账投诉 |
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ **`prize_range` 只管"发哪件饰品"、不是 RTP 旋钮**(12 件/12 段已导入且连续无缝、上界 99,999,999,见 §3.8;饰品价格 = 本局赢额,空转兜底只有 ¥0.01)—— RTP 全部在桶配额里,别再往饰品价格上找旋钮 |
---
@@ -28,12 +32,12 @@
|---|---|---|---|
| 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`,同构 |
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 16 档(见 §3.1) |
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构(**3 身份**,见 §3.6) |
| 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` |
| 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) |
| 7 | **身份分桶** common/anchor/robot/white,白号独立库存不进真实经济 | `OlympusAudience` | 直接复用(建议上移到共享模块) |
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致,但结算动作从"写背包"改为"加余额" |
| 7 | **身份分桶** common/anchor/white(**3 个**;原 robot 维度已按用户要求去掉,`user_type='03'` 归 common),白号独立库存不进真实经济 | `OlympusAudience`(它那份是 4 个) | 本游戏自带 3 个身份的判定,见 §3.6 |
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致(结算动作就是"写背包":赢额落 `prize_range` 区间匹配饰品,见 §6.3) |
| 9 | **客户端只续播**:`dospin` 带 `boxId` = 开新局;不带 = 下一步;`state=FINISH` 才展示结算 | `OlympusGameService.continueGame():230-254` | 一致 |
| 10 | **Redis 单局缓存 30 分钟 + `hasActive` 重连**,重连要能接回累计量 | `ruoyi:olympus:{userId}` TTLL 30min | `ruoyi:mahjong:{userId}`;接回量 = 本旋波次/本局累计 |
| 11 | **一轮 cycle**:`N = 固定池/(1−收敛比)`,抽满 `resetCycle + refillFromConfig`(模板) | `OlympusCycleRepository` + `box_template` | 一致 |
@@ -41,7 +45,7 @@
**结论:12 条全部照抄,一条都不改。**(用户 2026-09-21 定案:麻将胡了的赢额也**要变成饰品发到背包**,所以"赢额 → 落区间 → 查饰品 → 写背包 → 末帧选择放入/分解"这条链路与玫瑰女郎一模一样,连 `settleAtStart` 的 `max(totalWin, 0.01)` 兜底都能照抄。)
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值 = 饰品价格",前端同样显示金额)。这句话不是"直付余额"的意思。
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值",而背包记录里的价格字段 `ornaments_price` 存的就是这个赢额——前端同样显示金额)。这句话不是"直付余额"的意思。
---
@@ -255,7 +259,7 @@
| ⑥ 能和谁一起消 | 百搭**和普通符号完全同权**:它参与的那条前缀链里可以是普通符号,**也可以是金色符号**;金色符号本来就是普通符号镀金,所以**不需要任何特殊逻辑** |
| ⑦ **不跨旋** | 下一旋**整盘重发**,百搭与金色标记都不带走;**每个旋开局时盘面上 0 个百搭** |
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且**不限张数**——每个金色格被消除时各就地留一个(见 §2.2 铁律)。
> 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。
- **胡(夺宝/SCATTER)**:不参与 1024 路(**既不计入链,也不被百搭替代**);出现在任意轴任意位置;**自身不赔现金**(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 `scatterTrigger.scatterWin` 恒为 0。
- 3 个及以上胡 → 触发免费旋转:**12 旋 + 每多 1 个额外 +2 旋**(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。
@@ -280,8 +284,8 @@
→ ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。
"消除"不等于"清空":消除之后该格才分成两类——
2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号
3. 金色的中奖格 → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W**
(这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空)
3. 金色的中奖格 → 该符号被消耗;**每个金色格各自就地写入 W**(无数量上限)
(这些格子**不是空洞**、不补新符号;同波每个金色格都按这一步处理)
4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题")
5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画
第 n+1 波:
@@ -303,8 +307,8 @@
- 百搭**上方**的格被清空 → 百搭留在原地不动,上方的牌落到它头上。
- 如果把它做成"钉在空中",**它下方被清空的格就落不进任何符号**(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以**不能钉**。
- 客户端要注意:**百搭会移动位置**,下落动画必须用帧里的 `dropData`(`from → to`)驱动,**不能假设它原地不动**;"金色→百搭"的变形动画(`wildConverted`)播完之后,紧接着就可能是它的下落动画。
- ⚠ **"涉及中奖"的判定**:金色格被计入某笔中奖的 `ways`(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。**金色格只做自己那一种符号的替代**——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。
> 注意:由于最多只有 1 个百搭,且金色格本身是自然符号,**"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生**,不会成群出现。
- ⚠ **"涉及中奖"的判定**:金色格只要被计入某笔中奖的 `ways` 就算"涉及中奖"(于是该格被消除、就地转成 `W`)。⚠️ **金色格只当它自己的自然符号用,不做别的符号的替代**——金色「中」只算「中」;"顶替别人"这件事**只有元宝 `W` 会做**(`W` 既能顶替金色格的符号,也能顶替任何普通符号)。这是 §2.2 铁律 + §15 第 1 波算例共同确定的口径:**若金色格同时还能当百搭,§15 第 1 波白板链的 `ways` 就会从 2 变成 4,全盘数学都会变。**
> 注意:盘面上可以有**多个**百搭(每个金色格各留一个),且**同一个 `W` 可以被多个符号同时借用**(每条链各算各的,见 §15 第 4 波:一个 `W` 同时支撑筒与索红绿两条 5 连)——判定时逐个百搭独立参与每条链,既不限量、也不去重。
**其余约定**:
@@ -350,7 +354,7 @@
| × 本波阶梯倍率 | 基础旋 `1/2/3/5`;FS 旋 `2/4/6/10` |
| = 本波赢额 | `本笔基础倍率 × 投注总额 × rotationMul` |
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.2)。
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.3)。
> 实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(`accumulator_before/after`,FS 内跨旋传递)机制**原封不动复用**——麻将胡了虽然乘数不跨旋累计,但"本波阶梯倍率 / 本波序号"需要每帧下发,字段名可直接沿用 `accumulator`(语义改为"本波阶梯倍率")以免客户端大改。**建议改名为 `rotationMul`**,避免误解。
@@ -427,27 +431,27 @@
原版:投注大小 0.02~50.00 × 投注倍数 1~10,另有现金钱包与自动旋转。
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.1):
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.2):
- `price(实际扣费) = box.bet`(本游戏 costMultiplier ≡ 1)
- **回合池里存的全是倍率**(`actual_mul`),与投注额无关 → **改档位不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
- 奖池水位按 `(boxId, audience)` 聚合,投注额只影响单局金额,不影响池结构。
**曾经的备选(已否)**:给"档位 × 倍数"每个组合建一个箱子 → 池子碎片化、水位口径混乱。
#### 2.8.1 设计多少个下注额、哪几个、能多大?
#### 2.8.1 该设计多少个下注额、哪几个、能多大?(先把成本分清)
**先分清两件事,因为成本差一个数量级:**
| 加什么 | 池子要不要动 | 单局理论最大赔付 | 成本 |
|---|---|---|---|
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 40 条配比 + 一套水位) |
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 48 条配比〔16 桶 × 3 身份〕+ 一套水位) |
| **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **同箱内直接放大 10 倍** | 几乎为零 |
> ⚠️ **关键认识**:`betMul` 不是"免费做大",它**把同一箱内的金额跨度拉到 10 倍,等价于偷偷多开了 9 个档位**,却只用一个池子——风控口径和水位观察口径都会变粗。
> 而且在 500x 封顶下,`betMul` 会把**理论单局最大赔付**直接乘以 10。
> 所以本次**不再推荐"档位 × 倍数"双控件**,改为**只保留投注档**(与玫瑰女郎完全一致)。
#### 2.8.1 推荐方案(11 档,只保留投注档)
#### 2.8.2 推荐方案(11 档,只保留投注档)
**结论:`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`,共 11 档;不设投注倍数。**
@@ -468,7 +472,7 @@
| **上限(风控)** | 单局理论最大赔付 = **500x × 500 = ¥250,000**;玫瑰女郎现状 = 1000x × 300 = **¥300,000** | ✅ **压在玫瑰女郎之下** |
| **档位取整** | 档位都是整数 → 任何中奖金额都是整分 | ✅ 不会出现除不尽的数 |
| **前 10 档** | 与玫瑰女郎**一字不差** | ✅ 玩家熟悉、报表口径统一 |
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 20~30 分钟 / 704 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 15~25 分钟 / 528 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
**为什么不设投注倍数**(与玫瑰女郎保持一致的做法)
@@ -485,28 +489,28 @@
| 到 ¥1000 **且守住风控** | 加 ¥1000 档的同时把封顶降到 **300x** | 300x × 1000 = **¥300,000** ✅ 正好对齐玫瑰女郎 |
| 到 ¥2000 | 加 ¥2000 档 + 封顶 150x | ¥300,000 ✅(但 150x 封顶会让桶表整体缩一档,要重配池子) |
> 每加一档的成本:+4500 回合 / +8 万帧 / +52 条配比行 / 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
> 每加一档的成本:+4500 回合 / +8 万帧 / +48 条配比行(16 桶 × 3 身份)/ 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
**已否掉的方案**
- ❌ 原版双控件(档位 1~300 × 倍数 1~10):顶档 ¥3000 一注、单局理论最大赔付 **¥1,500,000**(玫瑰女郎的 5 倍),且同箱金额跨度 10 倍。
- ❌ 12 档到 ¥1000 + 倍数 1~10(最高 ¥10,000 一注):单局理论最大赔付 **¥5,000,000**,是玫瑰女郎的 16 倍,风控口径直接破。
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.2),当成总额抄过来会让最低中奖显示成 +0.00。
2. **冷门高档位会水位饥饿**:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放 10 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.3),当成总额抄过来会让最低中奖显示成 +0.00。
- **冷门高档位会水位饥饿**:¥300 / ¥500 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放前 9 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
**为什么不是更多档**
1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束):
- 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
- 多 64 条**配比行**(16 桶 × 4 身份)要配、要重算、要维护模板;
- 多 48 条**配比行**(16 桶 × 3 身份)要配、要重算、要维护模板;
- 多一套**奖池水位**(4 个身份各一条)要运营盯。
10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
11 档 → 5 万回合 / 90 万帧 / 生成 15~25 分钟 / 528 条配比行,正好是玫瑰女郎已经在跑的量级。
2. **档位越多,冷门档越容易"饿死"**:奖池水位是**按箱独立**的,没人玩的档水位长期为 0 → `payoutCap = max(0, 水位+成本) × rtp` 被压到接近 0 → **那个档几乎出不了奖**,只能靠运营注水救。所以档位不是越多越好。
3. **玩家侧足够**:1~500 共 11 档,比原版 0.02~50 的覆盖面更宽;相邻档位间距 2~3 倍,选注手感是行业常规。
4. **对齐玫瑰女郎**:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。
**不推荐的做法**
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.2 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.3 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
- ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
- ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。
@@ -516,7 +520,7 @@
> ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。
#### 2.8.2 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
#### 2.8.3 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。
@@ -536,36 +540,34 @@
→ **`box.bet ≥ 0.10` 是硬下限**(我们的模型里 `box.bet` = **投注总额**)。
> ⚠️ **关于原版"投注大小 0.02~50.00"的理解**:规则页有一条「一条中奖路所得的奖励等同于赔付表上的赔付值**乘以投注大小和投注倍数**」。
> 这条要成立,只有一种读法:**原版的「投注大小」= 每条基础投注的金额**(即 `投注总额 = 投注大小 × 20 × 投注倍数`)。
> 按这个读法,原版最低档 0.02 的**最小中奖 = 2 × 0.02 = ¥0.04**,**不会丢分**。
> 而我们的模型里 `box.bet` 是**投注总额**,所以下限是 `0.10`;单纯把原版的 `0.02` 抄过来当**总额**才会丢分(0.002 显示成 +0.00)。
> **待确认(一个 30 秒的真机测试)**:把原版「投注大小」设成 **1.00**、「投注倍数」设成 **1**,看「投注总额」显示 **20.00** 还是 **1.00**——
> 显示 **20.00** ⇒ 投注大小 = 每条基础投注(上面那种读法,规则页第 1 条成立);显示 **1.00** ⇒ 投注大小 = 总额(那第 1 条就该改写成"乘以投注总额后除以 20")。
> **已确认(用户口径)**:原版的「投注大小」**就是投注总额**——设成 1.00 就显示 1.00,不 ×20。所以那条规则里的"点数"必须按**"游戏规定了 20 条基础投注"折算**,实际算式是 `点数 × ways × (投注总额 ÷ 20) × 倍数`。
> 由此得到正确结论:原版最低档 `0.02` 的**最小中奖 = 2 × (0.02 ÷ 20) = ¥0.002** —— 那正是"社娱场面额"下会显示成 `+0.00` 的原因,也再次说明**原版的档位不能直接抄成本平台的总额**。
> 我们的口径不变:`box.bet` 是**投注总额**、赔付表已把 `÷20` 内化成倍率、11 档从 ¥1 起(最小中奖 ¥0.10)。
**附带的一个好性质**:**赔付表里的倍率都是 0.05 的整数倍**(0.1 / 0.2 / 0.25 / 0.3 / 0.4 / 0.5 / 0.75 / 1 / 2 / 3 / 4 / 5),而投注档又都是整数金额,所以**任何一笔中奖金额都是整分**,不会出现 0.0333 这种除不尽的数。建议投注档表**只放整数**,把这条性质保住。
**约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"**
**约束二:来自饰品区间表 —— 它只决定"发哪件饰品",不决定赔多少钱**
"必得饰品"意味着**空转局也会发一个最低档饰品**(`win = max(totalWin, 0.01)` 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估:
⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低档饰品的价格决定每局保底返奖",**是错前提**。真实模型:
```
每局保底返奖 ≈ 空转率 × 最低档饰品价格
低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额
```
- olympus 写背包用的是 `.ornamentsPrice(win)` —— **饰品的"价格"记的就是本局赢额**;
- 平台分解入账是 `Σ TtBoxRecords.ornamentsPrice`(`service-user/.../ApiUserPackSackServiceImpl.settleDecomposeBySource`)——**玩家最终拿到的钱就是这个数**;
- 玫瑰女郎线上 dump 里 1 号饰品(安慰奖)的注释也写着「兜底饰品 1是(**仅展示不覆盖金额**)」。
| 最低档饰品价 | 空转率 | 投注 ¥1 时的保底 RTP |
⇒ **每局实付 = `record.settled_win = win`**(空转局 = `max(totalWin, 0.01)` = **¥0.01**),**饰品只是载体**。所以"必得饰品"的真实保底成本:
| 兜底金额 | 空转率 | 投注 ¥1 时的保底 RTP |
|---|---|---|
| ¥0.01 | 40% | 0.4% ✅ |
| ¥0.05 | 40% | 2% ✅ |
| ¥0.50 | 40% | **20%** ⚠️ 光保底就吃掉 1/4 的目标 RTP |
| **¥0.01(当前实际值)** | 65% | **0.65%** ✅ 可忽略 |
| (假设哪天把兜底抬到 ¥0.50) | 65% | 32.5% ⚠️ 那才需要担心 |
→ **最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级**;饰品区间表(`prize_range`)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。
→ 对账口径也要注意:水位里的"派彩"应当用 **`record.settled_win`(饰品价)**,而不是 `total_win`(游戏内赢额)——否则这两者对不上时,水位会漂。
→ **`prize_range` 不是 RTP 旋钮**:区间有空洞或没覆盖,只会让那一档走 `findPrizeWithFallback` **换一件饰品发**,**金额不变**(`settled_win` 照旧)。
→ 对账口径:水位里的"派彩"用 **`record.settled_win`**(= 本局 win = 背包记录里的 `ornaments_price`)。
**结论**:下注档表要同时满足
**结论**:下注档表要满足
1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起)
2. 档位取整数(保证 unit 是整分)
3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束)
3. ~~最低档饰品价 ≤ ¥0.05 量级~~ **(作废:饰品价格 = 赢额,不是成本项)** —— 真正要盯的是**空转兜底金额** `max(totalWin, 0.01)` 这个常量,目前 ¥0.01、可忽略
模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。
@@ -576,7 +578,7 @@
> 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套):
> - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet`;
> - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶;
> - 箱数从 30 降到 **10**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
> - 箱数从 30 降到 **11**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
> - 高倍桶(100x 以上)**只能靠自然触发 FS 的回合供给**,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动**构造**高倍局,不能靠碰运气(见 §5.4 风险)。
---
@@ -642,11 +644,12 @@
| 1 | 白板 3 连 | 0.4 | 2 | 0.80 | x1 | ↑ 并入上行 | ↑ | ↑ |
| 2 | 八萬 4 连 | 0.75 | 4 | 3.00 | x2 | 3.00 | 6.00 | ¥600 |
| 3 | 花筒 5 连 | 1 | 1 | 1.00 | x3 | 1.00 | 3.00 | ¥300 |
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.00** | 10.00 | ¥1000 |
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.40** | 12.00 | ¥1200 |
| 4 | 索红绿 5 连 | 1 | 1 | 1.00 | x5 | ↑ 并入上行 | ↑ | ↑ |
| 4 | 白板 3 连 | 0.4 | 1 | 0.40 | x5 | ↑ 并入上行 | ↑ | ↑ |
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 10.00 = 21.80 倍**(未触 500x 封顶)
- **本局赢额 = 21.80 × ¥100 = ¥2180** ✓(与 §15 总账一致)
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 12.00 = 23.80 倍**(未触 500x 封顶)
- **本局赢额 = 23.80 × ¥100 = ¥2380** ✓(与 §15 总账一致)
- 全程只有 **1 个胡** → 若满 3 个胡就会先走 FS(FS 内阶梯换成 `2/4/6/10`,其余公式不变)
---
@@ -763,38 +766,42 @@
**直接沿用玫瑰女郎那张 17 档桶表,只去掉最高那档**(它到 1000x,我们封顶 500x,1000x 那档永远不会被命中)。
这样后台页面、配比算法、收敛逻辑、运营经验**全部零改动**;与玫瑰女郎的差异只有顶桶一行。
物理表名 `game_mahjong_bucket`;**后台页显示名「饰品桶」**(对应玫瑰女郎的 `game_olympus_ornament`,见附录 A 映射)。
物理表名 `game_mahjong_bucket`。**后台命名一律沿用现网玫瑰女郎,不自造新词**:
- 菜单名 = **「饰品列表」**(与 rose 的 `game/olympus/ornament/index` 同款;别叫"倍率桶")
- 箱子页里的动作按钮 = **「饰品配置」**(别叫"桶配比")、**「模板管理」**
- 桶的 `name` 值也照母本:`0x / 0.5x / 1.5x / 2.5x / 4x / 6.5x / 10x / 16x / 25x / 40x / 60x / 100x / 150x / 200x / 300x / 500x`
- 箱子 `name` = **下注额本身**(`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`):本游戏**只有一个模式**,所以**不加** `{模式名}-bet` 前缀(母本有前缀是因为它有 normal/ante/buy_fs 三种模式)——2026-09-22 用户定;别叫"¥1 档"
| 桶ID | 名称 | 期望倍率 | 落桶下限 | 落桶上限 |
|---|---|---|---|---|
| 1 | 0倍饰品 | 0 | 0 | 0 |
| 2 | 0.5倍饰品 | 0.5 | 0.01 | 1 |
| 3 | 1.5倍饰品 | 1.5 | 1 | 2 |
| 4 | 2.5倍饰品 | 2.5 | 2 | 3 |
| 5 | 4倍饰品 | 4 | 3 | 5 |
| 6 | 6.5倍饰品 | 6.5 | 5 | 8 |
| 7 | 10倍饰品 | 10 | 8 | 12 |
| 8 | 16倍饰品 | 16 | 12 | 20 |
| 9 | 25倍饰品 | 25 | 20 | 30 |
| 10 | 40倍饰品 | 40 | 30 | 50 |
| 11 | 60倍饰品 | 60 | 50 | 70 |
| 12 | 100倍饰品 | 100 | 70 | 130 |
| 13 | 150倍饰品 | 150 | 130 | 170 |
| 14 | 200倍饰品 | 200 | 170 | 230 |
| 15 | 300倍饰品 | 300 | 230 | 370 |
| **16** | **500倍饰品(封顶)** | **500** | 370 | **700** |
| 1 | 0x | 0 | 0 | 0 |
| 2 | 0.5x | 0.5 | 0.01 | 1 |
| 3 | 1.5x | 1.5 | 1 | 2 |
| 4 | 2.5x | 2.5 | 2 | 3 |
| 5 | 4x | 4 | 3 | 5 |
| 6 | 6.5x | 6.5 | 5 | 8 |
| 7 | 10x | 10 | 8 | 12 |
| 8 | 16x | 16 | 12 | 20 |
| 9 | 25x | 25 | 20 | 30 |
| 10 | 40x | 40 | 30 | 50 |
| 11 | 60x | 60 | 50 | 70 |
| 12 | 100x | 100 | 70 | 130 |
| 13 | 150x | 150 | 130 | 170 |
| 14 | 200x | 200 | 170 | 230 |
| 15 | 300x | 300 | 230 | 370 |
| **16** | **500x(封顶)** | **500** | 370 | **700** |
约定(与玫瑰女郎一致):
- **期望倍率 = (落桶下限 + 落桶上限) / 2**;区间**连续无缝、覆盖 0~封顶**。
- **桶 1 = 空转**(本局没有任何 1024 路中奖)。注意:**空转局照样发最低档饰品**("必得饰品",见 §6.3),所以桶 1 也是要配库存的。
- **桶 16 = 封顶档**:区间上界**故意开到 700(1.4× 封顶)**,这样"本来该赔 600x 的局"也落进这一档;**实际发放一律截断到 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
- **桶 16 = 封顶档**:生成端**先按 500x 截断得 `actual_mul`、再落桶**(顺序不能反——先落桶再截断会让 500x 以上的局无桶可落);区间上界仍留 **700** 作缓冲,**实际发放一律 = 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
- 因为 500x 封顶,桶 16 的池内实测均值会明显低于名义 500(被截断的局都堆在这里),配比计算按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是名义 1000、实用 835)。
**这张表**只管两件事:① 一局落在哪个倍率档(配比与库存的单位);② RTP 配平。
**实际发哪一件饰品**是另一回事——由 `prize_range` **按金额(¥)匹配**,见 §3.8。
**配置量与规模**
- `16 桶 × 11 箱 × 4 身份 = **704 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
- `16 桶 × 11 箱 × 3 身份 = **528 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
- 注册轴范围 / 生成器/后台页面清单里对应的桶数见 §4、§5、§8。
**可选优化(先不做,看实测再决定)**
@@ -807,7 +814,7 @@
| 2a | 0.2倍饰品 | 0.2 | 0.01 | 0.4 |
| 2b | 0.7倍饰品 | 0.7 | 0.4 | 1 |
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为 `赔付值 × ways × ladder ÷ 20` 里赔付值都是整数。所以任何桶边界都会自然对齐,不需要额外处理。
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为赔付表本身就是 0.05 的整数倍(0.1 / 0.25 / 0.5 / 0.75 …),`赔付表 × ways × ladder` 的结果自然落在 0.05 的网格上。所以任何桶边界都会自然对齐,不需要额外处理。
### 3.2 RTP 恒等式
```
@@ -846,11 +853,12 @@ fixedRounds= round(N × (1 - cr))
### 3.5 奖池水位(与玫瑰女郎同口径)
```
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.settled_win) + adjust
payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null
```
- bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。
- **`record.audience` 是"开局时的身份快照",要落库**(`MahjongGameService` 在建 record 时写入):水位聚合用它、也可以像玫瑰女郎那样 join `tt_user` 现推 —— 身份没变时两者等价,**身份被改过(例如白号开关被关)时以快照为准**,所以快照必写。**派彩一律取 `record.settled_win`(= `max(total_win, 0.01)`,也是背包记录 `tt_box_records.ornaments_price` 存的那个数)**,而不是只认 `total_win` —— 玫瑰女郎 `OlympusRecordMapper.sumPool` 用的就是 `SUM(r.settled_win)`,取错会让水位单向漂移(见 §3.8)。
- 收敛/固定两阶段都用 `payoutCap` 过滤"期望赔付超上限"的桶,捞不到就退最小桶。
- 白号(`is_blank_account=1`)`payoutCap=null`:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。
@@ -859,21 +867,28 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
各身份的 RTP **不在代码里写死**,和玫瑰女郎一样由后台配置,两层:
1. **箱子级**:`game_mahjong_box.rtp`(目标返奖率)+ `rtp_converge_ratio`(收敛比例)—— 在后台「箱子配置」页改;
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。
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 | |
| common | 其余全部(**含 `user_type='03'` 机器人**) | 0.80 | |
| anchor | `user_type='01'` | 0.80 | **三身份互斥并集 = 全部** |
| white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 |
> ⚠️ **2026-09-22 用户定:身份维度从 4 个降为 3 个 —— 去掉 robot。** 机器人不再单独成池,
> `user_type='03'` 现在归入 `common`(会消耗 common 库存、计入 common 水位)。这条同时改了:
> 配比行 `16 桶 × 11 箱 × 3 身份 = 528 条`(原 704)、模板 `11 × 3 = 33 条`(原 44)、
> 后台奖池水位页 3 个 Tab(原 4 个)。Java 侧的 `MahjongAudience`(已删 `ROBOT`)、
> `MahjongAdminController.AUDIENCES`、`audienceUserSubQuery`、`MahjongRecordMapper.sumPool`
> 与前端 3 个页面的身份数组都已同步。
> **连带影响(要知道)**:机器人的下注/派彩现在会进入 common 池,不再是"隔离池"。
### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
- 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 6~8 号桶。**生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 10~13 号桶。**生成器要保证 10~13 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
- **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**:
- 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋);
- 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`);
@@ -900,7 +915,9 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
**② 饰品区间表怎么来(不要重新设计)**
照玫瑰女郎的做法(`docs/updata_sql.md:1187-1200`)**整套导入**:
**已落地:直接沿用玫瑰女郎线上那套(用户 2026-09-22 提供的 `game_olympus_prize_ornament` dump)**,12 件饰品 + 12 段区间,`id` 原样保留(因为 `prize_range.prize_id` 引用它)、`game_type` 改成 `MAHJONG`,SQL 见 `mahjong-py/schema.sql` 末尾两段(写法是 `INSERT ... ON DUPLICATE KEY UPDATE`,幂等,**没有**用 dump 里的 `DROP TABLE`)。
若以后要换源(比如改成糖果那套),照下面这段整套导:
```sql
DELETE FROM game_mahjong_prize_range;
@@ -911,21 +928,70 @@ INSERT INTO game_mahjong_prize_range (prize_id, win_low, win_high)
SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
```
**导入后必须核的 3 条(都直接决定 RTP,不能跳过)**
**导入后核的 3 条(**已按玫瑰女郎线上 dump 导入并逐条核过:12 件饰品 / 12 段区间**)**
| # | 检查项 | 为什么 | 判据 |
|---|---|---|---|
| 1 | **最低档饰品的价格** | "必得饰品"意味着**空转局也发最低档饰品**,它的价格就是每局的**保底返奖**:`保底 RTP ≈ 空转率 × 最低饰品价 ÷ 投注额`。空转率按 **65%** 估,¥1 档下若最低饰品价 **¥0.50** → **保底 RTP 32.5%**,直接把目标 80% 吃掉一半 | **最低饰品价 ≤ ¥0.05**(→ 保底 ≈3%) |
| 2 | **区间上界要覆盖到 500x × 最高档 = ¥250,000** | 覆盖不到的话,高倍局全都走 `findPrizeWithFallback` 落到兜底饰品,出现"赢了 20 万给个 50 块的皮肤" | 最高区间下限 ≥ ¥250,000(玫瑰女郎是 1000x × 300 = ¥300,000,量级相同,大概率够) |
| 3 | **区间连续无缝、无空洞** | 有空洞就会走 fallback(落到 `is_fallback=1` 或最低/最高区间),发奖与赢额不匹配 | 按 `win_low` 排序后逐条核 `high_i + 0.01 == low_{i+1}` |
> ⚠️ **先纠一个前提**:这三条**都不影响 RTP**(饰品价格 = 本局赢额,见 §2.8.3)。它们只决定**"赢了这笔钱发哪件饰品"**(展示层)—— 没覆盖或有空洞时走 `findPrizeWithFallback` 换一件,**金额不变**。
> 本地 MySQL 没启动,我没法直连查现成的区间数据;**上线前请在测试库跑一次**:
> ```sql
> SELECT MIN(win_low) AS 最低区间下限, MAX(win_high) AS 最高区间上限,
> COUNT(*) AS 条数 FROM game_olympus_prize_range; -- 玫瑰女郎现成的
> SELECT o.name, o.grade, r.win_low, r.win_high FROM game_olympus_prize_range r
> JOIN game_olympus_prize_ornament o ON o.id = r.prize_id ORDER BY r.win_low LIMIT 5;
> ```
| # | 检查项 | 为什么 | 判据 | 实测结果 |
|---|---|---|---|---|
| 1 | **最低区间要覆盖空转兜底 ¥0.01** | 空转局的 `win = max(totalWin, 0.01) = 0.01` 得能落进区间 | 存在一段覆盖 0.01 | ✅ 第 1 段 `0.01~0.14` → 安慰奖 |
| 2 | **区间上界要覆盖 500x × 最高档 = ¥250,000** | 覆盖不到的话,最大的那几档全走 fallback | 最高区间上限 ≥ ¥250,000 | ✅ 上界 99,999,999;¥250,000 落第 9 段 → 猎杀者匕首 \| 渐变之色 |
| 3 | **区间连续无缝、无空洞** | 有空洞就走 fallback,发奖与赢额不匹配 | 按 `win_low` 排序逐条核 `high_i == low_{i+1}` | ✅ 断点 0(12 段首尾相接) |
> **实测(导入后按 mapper 同口径 SQL 验证)**:¥0.10 / ¥0.14 → 安慰奖;¥10 → SG 553 浪花喷漆;¥50 → 双持贝瑞塔 黑榄仁木;¥20,000 → AK-47 燃料喷射器;**¥250,000 → 猎杀者匕首 渐变之色**;¥2,500,000 → AWP 巨龙传说。
> **两个实现细节(来自 `MahjongPrizeRangeMapper`,别按"双闭区间"想当然)**:
> ① mapper 用**半开区间** `win_low < win <= win_high` + `ORDER BY win_low DESC LIMIT 1` ⇒ 段与段共享的那个端点值归**上一段**(¥10.00 发 SG 553,不发双持贝瑞塔),也**不会** `TooManyResults`;
> ② `win = 0.01` 按半开语义其实**匹配不到任何区间**(`0.01 > 0.01` 为假)→ 走 `findFallbackPrize()`;而 1 号「安慰奖」正好就是 `is_fallback=1`,所以**结果一样**。以后在后台改兜底饰品时要记得:**空转局发的是它**。
---
### 3.9 空转率(桶 1 占比)定多少:**65%**(已用仿真校准)
**它是什么**:空转率 = **桶 1 的配额占比**(本局一圈 1024 路都没中),由 `calcRounds` 算出的 `count` 分布决定,不是硬编码概率 —— 所以"空转率定多少"就是"桶 1 的 count 占多少",后台「一键重算」可调。
**为什么它是个(小)指标**:空转局照样发兜底饰品,这笔钱无法用桶配比压掉:
```
保底 RTP = 空转率 × 兜底金额 ÷ 投注总额 (兜底金额 = max(totalWin, 0.01) = ¥0.01)
```
**¥0.01 的兜底很便宜**:空转 65% → 保底 **0.65%**;空转 80% → 0.80% —— 都在 ±1pt 容差内,不构成压力。
> ⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低饰品价 v 才是大闸门:v=¥0.05→3.25%、v=¥0.50→32.5%、v=¥2→130%(必亏)"——**那是把饰品的市场价当成了成本**。实际是 `ornamentsPrice = win`(olympus 写背包 `.ornamentsPrice(win)`,分解按 `Σ TtBoxRecords.ornamentsPrice` 入账),**饰品只是载体、每局实付 = `settled_win = win`**,所以兜底成本就是 ¥0.01。两个连带结论随之更正:
> ① **11 个箱可以共用同一套桶配比期望**:保底只占 ≤0.65pt(¥1 档 0.65% / ¥10 档 0.065% / ¥500 档 0.0013%),落在容差内;要较真只给 ¥1、¥3 两档微调,不必逐箱重配。
> ② **水位没有"虚高"问题**:空转局 `settled_win = 0.01` 就是实际派彩(饰品价格 = 赢额),`水位 = Σcost − Σsettled_win` 口径正确,**不需要**按饰品市场价记账。
**目标 RTP 0.80 的预算反解**(取 FS 触发率 0.5%、FS 局整局平均 45x、兜底金额 ¥0.01):
| 空转率 q | 命中率 | 保底 RTP | 其余中奖局需要的平均倍率 | 需要"抹掉"的自然中奖占比 |
|---|---|---|---|---|
| 50% | 50% | 0.50% | 1.15x | 2.5%(几乎不动自然形态) |
| 60% | 40% | 0.60% | 1.44x | 22% |
| **65%(建议)** | **35%** | **0.65%** | **1.65x** | **32%** |
| 70% | 30% | 0.70% | 1.93x | 42% |
| 80% | 20% | 0.80% | 2.91x | **61%** ⚠️ |
**结论:空转率取 65%(命中率 35%),可接受区间 60~70%。** 三条理由:
1. **≤50% 不建议**:中奖局平均只有 ~1.15x,而自然中奖的中位数只有 0.2x —— 池子里会塞满"中 0.1~0.5 倍"的小奖,观感是"一直在中小钱",运营侧每局都要走一次饰品区间匹配,账也更碎;
2. **≥80% 不建议**:要抹掉 61% 的自然中奖、并把 RTP 压在 14/15/16 号高倍桶上,而这三档**没有"买 FS"兜底**(§5.4 风险),最容易饿死;
3. **65% 只抹掉 32% 的自然中奖**(自然命中 51.3% → 35%),构造量适中,中奖局平均 1.65x "每次中奖都有感"。
**配套(同样要定死)**:**FS 局的整局倍率中心值取 45x、区间 25x~60x**,不要沿用"30x~150x"。因为按 0.5% 触发率算,FS 局平均 150x 一项就要吃掉 `0.5% × 150 = 75%` 的 RTP 预算,剩下的局几乎没有钱可赔;45x 时它占 **22.5%**,其余局还有 54% 的预算。
**自然形态实测(`mahjong-py` 20 万局、完全不加偏置)** —— 这些就是生成端要"买回来"的差距:
| 指标 | 自然值 | 设计目标 |
|---|---|---|
| 自然 RTP(平均 `actual_mul`) | **5.25**(525%) | 0.80 |
| 基础旋命中率 / 空转率 | 51.3% / 48.7% | 35% / **65%** |
| FS 触发率(3+ 胡落定) | **1.33%** | 0.5% |
| 悬念盘(2 胡且第 5 轴无胡) | **4.89%** | 1~2% |
| 触 500x 封顶 | 0.015% | — |
| 波数分布(0 / 1 / 2 / ≥3 波) | 48.7% / 26.0% / 13.3% / 12.0% | — |
> 两条硬结论:① **自然 RTP 5.25 说明"RTP 完全靠池子构造买回来"**(与玫瑰女郎同构:规则本身不收敛,配额才收敛)⇒ 生成端**不能"自然抽样 + 重新分桶"**,必须按桶目标主动摆盘面(`_force_win` / bias 反解);
> ② **胡要人为压到约 1/2.7**(1.33% → 0.5%)、**悬念盘压到约 1/3**(4.89% → 1.5%)—— 做法是**按配额投放胡**(生成端决定本旋落几个胡),而不是"改权重再撞运气"。
---
@@ -937,7 +1003,7 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|---|---|---|---|---|
| 1 | `game_mahjong_box` | `game_olympus_box` | box_id, box_type, bet, price, total_rounds, rtp, rtp_converge_ratio, status | `total_ornaments`→`total_rounds` |
| 2 | `game_mahjong_bucket` | `game_olympus_ornament` | id(1-16), expect_mul, name, range_low, range_high | **16 档饰品桶** |
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 4 身份 = 704 条** |
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 3 身份 = 528 条** |
| 4 | `game_mahjong_round` | `game_olympus_round` | round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss | 去 `total_cascades` 语义改为 `total_rotations`(波数);`need_fs` 不需要了(没有买 FS 模式);加 `near_miss` 标记 2 胡悬念盘 |
| 5 | `game_mahjong_step` | `game_olympus_step` | 见 §4.1 | 字段大改(ways/golden/阶梯) |
| 6 | `game_mahjong_record` | `game_olympus_record` | user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, **prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type** | **保留全部 prize_* 列**(发饰品要走区间匹配),只多一个 `bet_mul` |
@@ -961,8 +1027,8 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
"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 计量单位
"ways":2,"payBase":0.75,"rotationMul":2,"win":3.00}],
// payBase 已是倍投注总额(發 3 连 = 0.75,无 ÷20);win = 0.75 × 2 × bet × 2(本例 bet=1)
"drop_data": [{"from":12,"to":17,"symbol":"H"}],
"fill_data": [{"pos":2,"symbol":"G"}],
"scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0}, // 或 null
@@ -996,7 +1062,7 @@ mahjong-py/
├── generate_rounds.py # 批量落库(箱编号/池子分配/id 段切分/并行)
├── simulate_cycle.py # 整轮 RTP 仿真验收(几乎原样拷贝)
├── _recalc_boxes.py # 按目标 RTP 重算配比(calc_rounds 一行不改)
├── _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
├── _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
├── _regen.py # check/snapshot/clear/restore/verify
├── _reset_boxes.py
├── README.md # 结构与 runbook(照抄并改写游戏规则段)
@@ -1056,8 +1122,8 @@ mahjong-py/
cd mahjong-py
python mahjong_db.py # 建 11 张表 + 16 档桶种子(幂等)
python generate_rounds.py --per-bucket 20 # 干跑,不写库
python generate_rounds.py --all --alloc config --write --workers 4 # 12 箱写库(只有 normal)
python _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
python generate_rounds.py --all --alloc config --write --workers 4 # 11 箱写库(只有 normal)
python _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
python _recalc_boxes.py --mode normal # 重算配比 + 回写 total_rounds
python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026 # RTP 验收
python _regen.py check|snapshot|clear|restore|verify # 体检/快照/清池
@@ -1074,9 +1140,9 @@ python -m pytest tests -q # 回归
| `ALLOC_FLOOR` | 60 | 每桶保底条数 |
| `BOX_STEP_BAND` | 200000 | 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估) |
| 每箱池子 | 4000~5000 条回合 | 因为没有"买 FS"那批高倍回合兜底,**高倍桶要靠 normal 池自己凑**,所以单箱池子要比玫瑰女郎更大 |
| 总规模 | **12 箱** × ~4500 = **5.4 万回合**,每回合 8~40 帧 → **~96 万 step**(与 olympus 的 106 万同一量级) |
| 总规模 | **11 箱** × ~4500 = **5 万回合**,每回合 8~40 帧 → **~90 万 step**(与 olympus 的 106 万同一量级) |
| 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) |
| 风险点 | **高倍桶(8/9/10 号桶)没有买 FS 兜底**:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
| 风险点 | **高倍桶(14/15/16 号桶)没有买 FS 兜底**:500x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
> 若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 `copyFromSameBucket` 复制(olympus 已有此兜底,`:179-231`)。
@@ -1163,7 +1229,7 @@ startNewGame(userId, boxId):
- `scope=0` 全服口径排除机器人(`user_type='03'`)、封禁(`status='1'`)、已删除(`del_flag!='0'`),**白号保留上榜**。
- 响应体用 `@JsonInclude(NON_NULL)`:未出现的 key 就是 null,客户端无需判空。
### 6.5 平台共享文件的改动(9 处,漏一处就少一块统计)
### 6.5 平台共享文件的改动(10 处,漏一处就少一块统计)
| # | 文件 | 改动 |
|---|---|---|
@@ -1175,14 +1241,14 @@ startNewGame(userId, boxId):
| 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 以维持区间映射) |
| 9 | `docs/updata_sql.md` | **该台账已按需求清空,不再当建表台账/模板用**(建表只以 `mahjong-py/schema.sql` 为权威、直接对库执行);**返奖饰品种子**仍照 §3.8 那段从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
| 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 |
> **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。
### 6.6 封顶与"匹配/发放分离"
- 回合池里桶 10 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
- 回合池里桶 16 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
- 运行期 `payoutCap` 过滤 + 收敛阶段 `capMul = payoutCap / bet` 双保险。
- FINISH 时 `totalMul ≥ 500` → `maxWinReached=true`、`spinsRemaining=0`。
@@ -1197,7 +1263,7 @@ startNewGame(userId, boxId):
| 单笔/单波金额 | `base_score × bet`(base_score 由生成端算好) | `倍率 × ways × 投注额 × rotationMul` | 同结构(都是"乘数 × bet") |
| **本局赢额** | `totalWin = actual_mul × bet` | `totalWin = actual_mul × bet` | ✅ **完全一致** |
| 本局倍率 | `totalMul = actual_mul` | `totalMul = actual_mul` | ✅ 一致 |
| 发奖 | 赢额 → 落 `prize_range` 区间 → 饰品 → 写背包 → `settled_win` = 饰品价 | 同 | ✅ 一致 |
| 发奖 | 赢额 → 落 `prize_range` 区间 → 选定饰品 → 写背包(`ornaments_price` = 该赢额)→ `settled_win` = 该赢额 | 同 | ✅ 一致 |
| 分解 | `OLYMPUS_BOX(13) → OLYMPUS_REWARD(61)` | `MAHJONG_BOX(15) → MAHJONG_REWARD(69)` | 同结构 |
| 空转局 | `win = max(totalWin, 0.01)` → 最低档饰品(必得饰品) | 同 | ✅ 一致 |
| 奖池水位记账 | `水位 = Σcost − Σsettled_win + adjust` | 同 | ✅ 一致 |
@@ -1250,7 +1316,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| `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 计量单位**) |
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × bet × rotationMul`(`payBase` 已是倍投注总额,**无 ÷20**,见 §2.1) |
| `dropData` / `fillData` | 每帧 | 与玫瑰女郎同构(`from/to/symbol`、`pos/symbol`) |
| `rotationMul` | 每帧 | **本波阶梯倍率**(基础旋 `1/2/3/5`、FS `2/4/6/10`);重连时用它接回档位 |
| `winAmount` | 每帧 | **本旋累计到本帧**(末帧 = 本旋总额),不是增量 |
@@ -1282,19 +1348,19 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|
| 麻将胡了(目录) | `mahjong` | — | |
| 箱子配置 | `box` | `game/mahjong/box/index` | `game:mahjong:box` |
| 倍率桶 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
| 饰品列表 | `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/` 找;
菜单 SQL 的写法是 `SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单;**仓内已无模板可抄**(`docs/updata_sql.md` 已清空、`sql/` 目录是空的),改成对现库 `sys_menu` 直接操作(现成的 olympus 目录行可当样板)。**注意仓库现状**:
- 全量 DDL 只剩生成器的 `schema.sql` 这一处权威,别去 `sql/` 或 `docs/updata_sql.md` 找;
- 后台页面**没有任何 `v-hasPermi` 指令**,路由也**不在 `router/index.js` 里静态注册**——能否访问完全由 `sys_menu` 行决定(`component='game/mahjong/<module>/index'`)。菜单 SQL 漏一条,页面就是白屏而不是报错。
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 桶配比弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
**奖池水位页**:四身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
**箱子配置页**:箱列表(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` 与实际池子对不上。
@@ -1307,7 +1373,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|
| `assets/Scripts/Pages/MahjongPanel/MahjongPanel.ts` | 主面板(5×4 盘面、级联动画、阶梯条、FS 模式、自动旋转) | 结构照 `CandySloterPanel.ts`(186KB),**盘面改为 4 行 5 列、去掉炸弹、加金色转化动画** |
| `MahjongBigWin.ts` | 爆奖三档 | 拷贝 |
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注大小与倍数) | 拷贝改文案 |
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注档选择,**无投注倍数**) | 拷贝改文案 |
| `MahjongSfx.ts` / `MahjongSpineUtil.ts` | 音效 / 骨骼工具 | 拷贝 |
**主界面 HUD 布局(按用户给的真实运行截图逐块对齐)**:
@@ -1336,8 +1402,8 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 阶段 | 交付物 | 验收标准 | 人日 |
|---|---|---|---|
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 20~30%、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **12 箱**池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 **35%(空转率 65%,口径见 §3.9)**、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **11 箱**池子生成成功,无饥饿桶(尤其 14/15/16 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
| **P2 Java 服务** | `game-mahjong-service` 全套 + 10 处共享文件改动 | ① `mvn -pl skins-service/game-mahjong-service -am compile` 通过;② 开局/续播/重连/封顶/空转局五条链路离线单测;③ 扣费与发奖同一事务、`settled` 幂等可重放、饰品开局即入背包、分解流水口径正确 | 6~8(含饰品/背包/站内信链路) |
| **P3 后台 + SQL 文档** | 7 个 Vue 页面(含奖品管理)+ `docs/updata_sql.md` 新节 + `docs/麻将胡了接口文档.md` | ① 菜单挂载正确、权限生效;② 一键重算与注水/抽水(原因必填)可用;③ 返奖饰品与区间可编辑;④ 接口文档与真实响应逐字段对齐 | 4~5 |
| **P4 客户端** | Mahjong 面板全套 | ① 逐帧复刻服务端盘面(含金色转化、阶梯高亮、**2 胡悬念放大**);② 断线重连回到正确波次;③ 末帧「放入背包/立即分解」+ 自动分解开关 | 15~20 |
@@ -1352,10 +1418,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
**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`**;
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `W` 只可能出现在第 2/3/4 轴、且盘上百搭数 = 本波中奖金色格数(无上限)**;
- **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `gridState` 变 `W` 且 `wildConverted` 有它;② 该格**没有**被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 `發,發,_,...` 变成 3 连);④ 该 W 在更后面的波参与成奖时**被正常消除**;⑤ 未被中奖涉及的金色格**不转化**、保持原样;
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**百搭不能替代胡**(`發,胡,發` 不成链)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 8/9/10 号高倍桶**——没有买 FS 兜底,最容易饿死)。
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 14/15/16 号高倍桶**——没有买 FS 兜底,最容易饿死)。
- `simulate_cycle.py`:整轮 RTP 与设计的偏差;白号池独立。
**Java**
@@ -1368,7 +1434,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
- 并发:同一用户并发 `dospin` 只有一个能开局(`COMMON_LOCK+userId`)。
**对账 / 回归**
- `Σ record.settled_win`(已结算局) == `Σ` 对应饰品价 × 条数 == 背包里 `tt_box_records.source=15` 的条数口径。
- `Σ record.settled_win`(已结算局) == `Σ tt_box_records.ornaments_price`(source=15)—— 两边都是"本局赢额",逐条对得上(饰品由 `prize_range` 按这个金额选定)。
- 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。
- 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。
- 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。
@@ -1381,14 +1447,14 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|
| 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 |
| 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 |
| 3 | **step 表体量**(~96 万帧,12 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
| 3 | **step 表体量**(~90 万帧,11 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
| 4 | **py / Java 两份 `calcRounds` 不同步**(玫瑰女郎已因此返工) | 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比) |
| 5 | **免费旋转"演不动"**(FS 是白送的 12 旋,预算铺不开时动画会空转) | 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分 |
| 6 | ~~现金直付与平台既有"游戏必给饰品"体验差异~~ | **已定案:走饰品**(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期 |
| 7 | **客户端工作量大**(玫瑰女郎面板是 186KB 单文件) | 直接复制改造,**不做公共 slot 基类抽象**(两个游戏的盘面/动画差异大于共性) |
| 8 | **生成耗时**(本机 i3-10105F,4 物理核) | `--workers` 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意 |
| 9 | **母本代码有文档漂移**(`olympus-py/README.md:459` 声称 RoundPicker 有 `ruoyi:olympus:seen:*` 不重发去重,**实际代码里不存在**) | 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认 |
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数 |
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 14/15/16 号桶有没有条数 |
---
@@ -1402,10 +1468,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| ~~Q1~~ | ~~纯百搭链(第1/2/3轴全百搭)算什么?~~ | ✅ **由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解**——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,**纯百搭链数学上不可能出现**,不需要单独规则、也不需要 `WILD_PAYS` 开关(见 §2.2) |
| ~~Q2~~ | ~~金色→百搭的那个百搭是否"钉住不动"?~~ | ✅ **不钉住**("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") |
| ~~Q3~~ | ~~免费旋转总旋数上限~~ | ✅ **36 旋**(用户 2026-09-21 定案),并配"**发不满就不发**"硬规则:`已发放 + 本次应赠 > 36` 时后续旋不再落 3+ 胡,杜绝"落胡只补几旋" |
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 |
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 12 箱、无 `need_fs`、`costMultiplier≡1` |
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配最高档 ¥500(**无投注倍数**),单局理论最大赔付 **¥250,000**(见 Q7) |
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 11 箱、无 `need_fs`、`costMultiplier≡1` |
| ~~Q6~~ | ~~各身份目标 RTP~~ | ✅ **不是拍板项**:后台配置(箱子级 `rtp` + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.1 |
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.2 |
| Q8 | 每日统计要不要独立日表 | 🔧 **技术默认:不要**,后台按 `record` 聚合 |
| ~~Q9~~ | ~~奖励形态:现金直付 vs 饰品入背包~~ | ✅ **赢额变成一个饰品发到背包** → 区间匹配 → `tt_box_records`(source=15) → 末帧放入/分解 → 分解时写 `MAHJONG_REWARD(69)` |
| Q10 | 后台"回合管理"要不要暴露金色/阶梯明细 | 🔧 **技术默认**:只读展示 `actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘`,不展示内部帧 |
@@ -1428,7 +1494,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 项 | 规则 |
|---|---|
| 投注构成 | 选一个**投注档**(11 档,见下),即为**投注总额**,开局即扣(**不设投注倍数**) |
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000` |
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`(共 **11 档**,**无投注倍数**) |
| 游戏单位 | 金额以 ¥ 为单位 |
| 现金钱包 | 显示当下玩家可用的现金余额 |
| 自动旋转 | 可按玩家选择的回合数量自动进行游戏 |
@@ -1471,7 +1537,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
### 14.5 百搭符号(元宝)
- **百搭符号可替代除「夺宝符号(胡)」以外的所有符号**。
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**一个盘面上最多只会出现 1 个**。
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**每个金色格各留一个、无数量上限**(一个盘面可同时有多个)。
- 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。
- **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。
- 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。
@@ -1525,7 +1591,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
### 14.9 免费旋转模式
- **当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转**。
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次(**一屏最多只出现 5 个胡**,不存在 6 个及以上 → 单次最多 16 旋)。
- **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。
- **免费旋转可以被重复触发**(FS 内再落 3 / 4 / 5 个胡 → **再送 12 / 14 / 16 旋**,与首触发同一套公式,见 §2.6)。
- **免费旋转的总旋数上限为 36 旋**:`已发放 + 本次应赠 > 36` 时,后续旋**不再出现 3+ 个胡**(避免"落了胡却只补几旋")。因为单次最少赠 12 旋,所以**已发放 ≥ 25 旋之后一律不再出胡**,整局永远 ≤ 36 旋。
@@ -1757,7 +1823,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
> `W` 停在原处(轴3 那列没被清空),其余列各自下移一格并补新牌。
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被两个符号同时借用,本旋结束**
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被三个符号同时借用,本旋结束**
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
@@ -1770,11 +1836,12 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|---|---|---|
| **筒 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 断 |
| **白板 C** | 1(行3) | 1(行2) | **1(百搭当白板)** | 0(G/A/G/F) | — | **3 连,ways = 1×1×1 = 1** |
| 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 |
| 發 A | 轴1 没有 | — | — | — | — | 不成奖 |
> 同一个百搭这波**同时被筒和索红绿两个符号借用**,两条链都成立 → **两笔并赔**。
> 同一个百搭这波**同时被筒、索红绿、白板三个符号借用**,三条链都成立 → **三笔并赔**。
> ⚠️ **勘误**:本节原来只算了筒与索红绿两笔,漏了"同一个 `W` 也能当白板"→ 白板 3 连(`loc` 含那个 `W`)。按 `mahjong-py` 引擎判定修正后,本波是三笔并赔,本旋总账从 ¥2180 变为 **¥2380**。
**赔付**:
@@ -1782,33 +1849,34 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|---|---|---|---|---|---|
| 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | **¥500** |
| 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | **¥500** |
| | | | | 小计 | **¥1000** |
| 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | **¥200** |
| | | | | 小计 | **¥1200** |
**本波共 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` 参与了两条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
**本波共 12 个符号被消除**(筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,其中 `W` 被三条链共用 → 6 + 5 + 3 − 2 = 12):
轴1:G(行1)、C(行3)、F(行4) 轴2:C(行2)、F(行3)、G(行4) 轴3:**(行1) W** 轴4:G(行1)、G(行3)、F(行4) 轴5:F(行3)、G(行4)
`W` 参与了三条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
#### 盘面演变(第 4 波 → 本旋结束)
**① 消除瞬间**(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 `W`):
**① 消除瞬间**(12 格全爆:筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,`W` 被三条链共用):
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 行1 | ▢ | A | ▢ | ▢ | C |
| 行2 | H | C | A | A | S |
| 行3 | C | ▢ | B | ▢ | ▢ |
| 行2 | H | ▢ | A | A | S |
| 行3 | ▢ | ▢ | 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 | **H** | **D** | **F** | **F** | **S** | 轴1:3 格空洞(G/C/F 被消)→ 只剩 H 存活、下移三格到行4;顶部补 H、G、E |
| 行2 | **G** | **E** | A | **G** | **H** | 轴2:3 格空洞(C/F/G 被消)→ A 落到行4;顶部补 D、E、D |
| 行3 | **E** | **D** | B | **D** | C | 轴3:只有一个空洞(`W` 被消)→ A/B/H 各上一格;顶部补 F |
| 行4 | H | A | H | A | S | 轴4:3 格空洞 → A 落到底 + 补 F、G、D;轴5:2 格空洞 → C/S 下移两格 + 补 S、H |
→ 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而**轴3 没有 C** → **无任何中奖组合,本旋结束(整局共 4 波)**。
→ 复核这个盘面:轴1 里有 H/G/E,其中 E 能在轴2 对上(行2),但**轴3 没有 E** → 轴3 断链;H、G 在轴2 就已经断了 ⇒ **无任何中奖组合,本旋结束(整局共 4 波)**。
### 本旋总账
@@ -1820,12 +1888,13 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 3 | 花筒 5 连 | 1 | 1 | **1.00 倍** | x3 | ¥300 |
| 4 | 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | ¥500 |
| 4 | 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | ¥500 |
| | | | | | 合计 | **¥2180** |
| 4 | 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | ¥200 |
| | | | | | 合计 | **¥2380** |
> 计算方法:**本笔基础倍率 = 符号倍率 × ways**;**本波赢额 = Σ本笔基础倍率 × 投注总额 × 本波阶梯倍率**(投注总额 ¥100)。
> 例:第 2 波 `0.75 × 4 = 3.00 倍` → `3.00 × 100 × 2 = ¥600` ✓
- 投注总额 ¥100 → **本局总倍率 = 2180 ÷ 100 = 21.80x**(未触及 500x 封顶)
- 投注总额 ¥100 → **本局总倍率 = 2380 ÷ 100 = 23.80x**(未触及 500x 封顶)
- 本旋只有 **1 个胡**(一直挂在轴5)→ **不触发免费旋转**(需 3+)
### 百搭轨迹(这张表是重点:**几个金色格中奖,就留几个百搭**)
@@ -1836,21 +1905,21 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
| 第 1 波结束 | 0 → **2** | 同波有**两张**金色「中」中奖 → **各留一个百搭**(轴2、轴3 各一个)。两个 `W` 随后**都随重力从行2 掉到行3** |
| 第 2 波结束 | 2 → **0** | **两个 `W` 都参与了八萬链**(满足消除条件)→ 一起被消除;本波无金色中奖,不补 |
| 第 3 波结束 | 0 → **1** | 本波只有 **1 张**金色「花筒」中奖 → 留 **1 个** `W`;该列没有空洞,所以它**原地不动** |
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补 |
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链、索红绿链、白板链 → 被消除;本波无金色中奖,不补 |
| 旋结束 | 0 | 无后续中奖;下一旋整盘重发 |
> **百搭数量没有上限**(第 1 波就同时有 2 个),但**只可能出现在第 2/3/4 轴**——因为金色格只在那三轴,而百搭的唯一来源是金色转化。
> 这条推论很关键:**第 1 轴永远不会有百搭**,所以前缀链的第 1 轴必然是自然符号 ⇒ **任何一笔中奖都至少含 1 个自然符号** ⇒ **"纯百搭链"在数学上不可能出现**(Q1 因此永久关闭,不需要额外规则)。
> 全程每一波盘面上的百搭数都 **≤ 1** ✅,且**永远只由金色转化产生** ✅。
> 每一波盘面上的百搭数 **= 本波中奖的金色格数**(0 / 1 / 2 …,无上限),且**永远只由金色转化产生** ✅。
> 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。
---
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 30 → FINISH)
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 36 → FINISH)
> 场景:**普通旋转**,投注档 **¥100**。
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 30**、FINISH 帧。
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 36**、FINISH 帧。
> 图例同 §15:`A`發 `B`中 `C`白板 `D`八萬 `E`花筒 `F`索红绿 `G`筒 `H`索绿 `W`百搭 `S`胡 **`*`=金色**。
### 16.1 基础旋(`spinNum = 0`,阶梯 x1)——触发盘
@@ -2020,13 +2089,13 @@ boxRecordId = 背包记录 id // 末帧「放入背包 / 立即
| 生成端补桶/饥饿重校准 | `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 行) |
| 建表 + 菜单 SQL 写法 | ~~`docs/updata_sql.md:1144-1225`~~(该台账已按需求清空,**不再可用**)→ 建表以 `mahjong-py/schema.sql` 为权威;菜单直接对现库 `sys_menu` 操作 |
| 接口契约模板 | ~~`docs/玫瑰女郎接口文档.md`~~(已按需求删除)→ 沿用本仓现存 `docs/疯狂转盘接口文档.md` / `docs/流水抽奖接口文档.md` 的写法 |
## 附录 C:待新增的运维 runbook(对齐 `olympus-py/README.md`)
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 12 箱 × 4 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥500/¥1000 这两档必须单独注**)。
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode X` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 11 箱 × 3 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥300/¥500 这两档必须单独注**)。
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode normal` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
3. **重建池子**:`_regen.py snapshot` → `generate_rounds.py --clear --write` → 重算 → 清 Redis → 刷模板 → `_regen.py verify`。
4. **调水位**:后台奖池水位页按身份逐箱注水/抽水(**原因必填**)或批量按"一轮花费基数 × 比例%"。
5. **日常体检**:`_regen.py check`(每桶有条目 / 无封顶缺失 / 池内 RTP)+ `simulate_cycle.py` 抽样。