麻将胡了

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` 抽样。
+38
View File
@@ -0,0 +1,38 @@
# mahjong-py · 麻将胡了(回合池生成器)
规则唯一事实来源是 `docs/麻将胡了设计方案.md`;本目录是它的 Python 实现(母本风格照 `D:\changzhou_app\changzhou_backend\olympus-py`,但**引擎是重写的**:麻将从盘面自己判定,母本只记账)。
## 现在有什么 / 还没有什么
| 状态 | 内容 |
|---|---|
| ✅ 已实现 | `mahjong/config.py`(规则参数唯一来源)、`models.py`、`engine.py`(前缀链 / 金色就地转百搭 / 列内重力+补位 / 阶梯 / FS / 500x 封顶)、`tests/test_engine.py`(11 项)、`mahjong_simulator.py`(自然盘面仿真) |
| ⛔ 还没有 | `generate_rounds.py`(按桶配额主动摆盘面 + bias 反解 + 落库)、`_recalc_boxes.py`(配比)、`schema.sql`、`mahjong_db.py`、`_make_templates.py`、`simulate_cycle.py`、`_regen.py` |
## 跑起来
```bash
cd mahjong-py
set PYTHONIOENCODING=utf-8 # Windows:中文输出
python tests/test_engine.py # 11 项规则单测(无需 pytest)
python mahjong_simulator.py --rounds 200000 --seed 2026 # 自然盘面仿真(不含偏置构造)
```
## 引擎的 6 条铁律(写代码时别绕开)
1. **前缀链**:从第 1 轴起 1→2→3→4→5 连续,断链整笔作废;`ways` 只乘链内轴。
2. **金色格只当它自己的自然符号**(不做别的符号的替代);"顶替别人"只有 `W` 会做。
3. **金色中奖格就地变 `W`**:不清空、不补牌;几个金格中奖就留几个 `W`(无上限)。金色只镀在第 2/3/4 轴的普通符号上 ⇒ **`W` 永远不落在第 1/5 轴**。
4. **`W` 要满足消除链才被消除**;没参与成奖就留到下一波。同一个 `W` 可以同时被多个符号借用。
5. **金色标记跟着符号下落**(不是跟着格号)——否则会"传染"给新补的牌。
6. **阶梯每旋重置**:基础 `1/2/3/5`、FS `2/4/6/10`;**一屏最多 5 个胡**;FS 上限 36 旋且"发不满就不发"。
## 与设计文档的已知偏差(文档已同步勘误)
- §15 第 4 波原本只算两笔奖,漏了"同一个 `W` 也能当白板"→ 白板 3 连。修正后本波 ¥1200、本旋总账 **¥2380**(原 ¥2180)。
- §3.9 是新加的空转率结论(65%),依据就是本目录 20 万局的自然仿真数据。
## ponytail 备注
- 生成端(配额/偏置)**故意还没写**:它的形状取决于 §3.9 的空转率口径与桶配比,先把引擎钉死再往上堆,避免拿未验证的数学去凑池子。
- 仿真只做自然盘面,没有引入任何"目标倍率反解";`play_spin` 已经支持外部传盘面,构造器接上去即可。
+7
View File
@@ -0,0 +1,7 @@
"""麻将胡了 · 生成器包(规则参数 / 模型 / 引擎)。"""
from .config import * # noqa: F401,F403
from .engine import ( # noqa: F401
assert_no_native_wild, assert_wild_only_middle, count_scatters, new_grid,
play_round, play_spin, pos, settle_rotation, ways_wins,
)
from .models import Round, Rotation, Spin, Win # noqa: F401
+166
View File
@@ -0,0 +1,166 @@
"""麻将胡了 · 唯一事实来源(规则参数)。
数值全部取自 docs/麻将胡了设计方案.md(§2 规则 / §3 桶与 RTP / §2.8 投注档)。
改规则改这里;生成器、引擎、单测、仿真都从这里读。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Mapping, Tuple
# ============================================================
# 盘面:5 轴 × 4 行 = 20 格,ways = 4^5 = 1024
# ============================================================
GRID_COLS = 5
GRID_ROWS = 4
GRID_SIZE = GRID_ROWS * GRID_COLS
WAYS = GRID_ROWS ** GRID_COLS
WILD = "W" # 百搭(元宝):只能由金色符号转化产生,卷轴永不落
SCATTER = "S" # 胡(夺宝):只触发免费旋转,不赔现金
# 格位约定:接口/落库用 1 基、行优先(1~20);引擎内部用 0 基。
# 引擎内 index = row * GRID_COLS + col;pos() 负责换算(见 engine.py)。
@dataclass(frozen=True)
class SymbolRule:
code: str
name: str
payouts: Mapping[int, float] # 连轴数(3/4/5) → 倍投注总额
# 赔付表:原版"每条基础投注点数"已 ÷20 内化,表中数值**直接是倍率**
SYMBOL_RULES: Dict[str, SymbolRule] = {
"A": SymbolRule("A", "發", {3: 0.75, 4: 3.0, 5: 5.0}),
"B": SymbolRule("B", "中", {3: 0.5, 4: 2.0, 5: 4.0}),
"C": SymbolRule("C", "白板", {3: 0.4, 4: 1.0, 5: 3.0}),
"D": SymbolRule("D", "八萬", {3: 0.3, 4: 0.75, 5: 2.0}),
"E": SymbolRule("E", "花筒", {3: 0.2, 4: 0.5, 5: 1.0}),
"F": SymbolRule("F", "索红绿", {3: 0.2, 4: 0.5, 5: 1.0}),
"G": SymbolRule("G", "筒", {3: 0.1, 4: 0.25, 5: 0.5}),
"H": SymbolRule("H", "索绿", {3: 0.1, 4: 0.25, 5: 0.5}),
}
PAYING: Tuple[str, ...] = ("A", "B", "C", "D", "E", "F", "G", "H")
PAYTABLE: Dict[str, Mapping[int, float]] = {code: r.payouts for code, r in SYMBOL_RULES.items()}
MIN_CHAIN = 3 # 前缀链至少 3 连才成奖(只有 3/4/5 三档)
# 卷轴出现权重:只有 8 个付费符号 + 胡(**没有 W**,原生盘面永不落百搭)
SYMBOL_WEIGHTS: Dict[str, int] = {
"A": 5, "B": 6, "C": 8, "D": 9, "E": 11, "F": 11, "G": 13, "H": 13, "S": 2,
}
# 金色麻将符号:只可能出现在第 2/3/4 轴(0 基列 1..3)
GOLDEN_COLS: Tuple[int, ...] = (1, 2, 3)
GOLDEN_CELL_PROB = 0.10 # 逐格独立;新补入的符号也可以带金
# ============================================================
# 乘数阶梯(本波阶梯倍率):每旋从第一档重新开始,不跨旋累计
# ============================================================
LADDER_BASE: Tuple[int, ...] = (1, 2, 3, 5)
LADDER_FS: Tuple[int, ...] = (2, 4, 6, 10)
def ladder_mul(rotation_seq: int, is_fs: bool) -> int:
"""第 n 波(1 基)的阶梯倍率;第 4 波及以后取最后一档。"""
if rotation_seq < 1:
raise ValueError("波序从 1 开始")
ladder = LADDER_FS if is_fs else LADDER_BASE
return ladder[min(rotation_seq, len(ladder)) - 1]
# ============================================================
# 免费旋转:3+ 胡 → 12 旋 + 每多 1 个 +2;一屏最多 5 个胡;上限 36 旋
# "发不满就不发":已发放 + 本次应赠 > 36 时,本旋绝不落 3+ 胡
# ============================================================
SCATTER_TRIGGER = 3
FS_BASE_SPINS = 12
FS_EXTRA_PER_SCATTER = 2
MAX_SCATTER_PER_SCREEN = 5
MAX_FREE_SPINS = 36
def fs_award(scatter_count: int) -> int:
return FS_BASE_SPINS + FS_EXTRA_PER_SCATTER * max(0, scatter_count - SCATTER_TRIGGER)
# 单局封顶:总倍率到达即截断,剩余旋作废
MAX_WIN_CAP_MUL = 500
# 悬念盘:基础旋恰好 2 个胡且第 5 轴无胡(生成端配额,客户端播第 5 列放大)
NEARMISS_SCATTER_COUNT = 2
NEARMISS_2SCATTER_RATE = 0.015
# ============================================================
# 投注档:11 档纯投注档,无投注倍数(betMul 仅预留、服务端只接受 1)
# price = cost = box.bet;boxId = 序号(1~11)
# ============================================================
BET_LADDER: Tuple[int, ...] = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500)
BOX_IDS: Tuple[int, ...] = tuple(range(1, len(BET_LADDER) + 1))
def bet_of(box_id: int) -> int:
return BET_LADDER[box_id - 1]
# ============================================================
# 饰品桶(16 档,0x~500x):沿用玫瑰女郎 17 档结构、删掉 1000x 那档
# 期望倍率 = (区间下限 + 上限) / 2;区间连续无缝
# 桶 16 区间上界故意开到 700 作缓冲,但生成端"先按 500 截断再落桶"
# ============================================================
@dataclass(frozen=True)
class Bucket:
id: int
name: str
expect_mul: float
range_low: float
range_high: float
BUCKETS: Tuple[Bucket, ...] = (
Bucket(1, "0倍饰品", 0.0, 0.0, 0.0),
Bucket(2, "0.5倍饰品", 0.5, 0.01, 1.0),
Bucket(3, "1.5倍饰品", 1.5, 1.0, 2.0),
Bucket(4, "2.5倍饰品", 2.5, 2.0, 3.0),
Bucket(5, "4倍饰品", 4.0, 3.0, 5.0),
Bucket(6, "6.5倍饰品", 6.5, 5.0, 8.0),
Bucket(7, "10倍饰品", 10.0, 8.0, 12.0),
Bucket(8, "16倍饰品", 16.0, 12.0, 20.0),
Bucket(9, "25倍饰品", 25.0, 20.0, 30.0),
Bucket(10, "40倍饰品", 40.0, 30.0, 50.0),
Bucket(11, "60倍饰品", 60.0, 50.0, 70.0),
Bucket(12, "100倍饰品", 100.0, 70.0, 130.0),
Bucket(13, "150倍饰品", 150.0, 130.0, 170.0),
Bucket(14, "200倍饰品", 200.0, 170.0, 230.0),
Bucket(15, "300倍饰品", 300.0, 230.0, 370.0),
Bucket(16, "500倍饰品(封顶)", 500.0, 370.0, 700.0),
)
FIXED_CAP_BUCKET_ID = 16 # 封顶桶
def bucket_of(mul: float) -> int:
"""按**已截断**的倍率落桶(顺序:先 min(mul, 500) 再落桶)。<0.01 视为空转。"""
mul = min(float(mul), MAX_WIN_CAP_MUL)
if mul < 0.01:
return 1
for b in BUCKETS:
if b.range_low <= mul <= b.range_high:
return b.id
return FIXED_CAP_BUCKET_ID
# ============================================================
# RTP 与身份
# ============================================================
RTP = 0.80 # 目标返奖率(后台可按箱改)
CONVERGE_RATIO = 0.15 # 收敛阶段占比(一轮 N = fixedSum / (1 - cr))
AUDIENCE_COMMON = "common" # 其余全部(**含 user_type='03' 机器人**)
AUDIENCE_ANCHOR = "anchor" # user_type = '01'
AUDIENCE_WHITE = "white" # is_blank_account = 1(最高优先)
# 只有 3 个身份维度:robot 维度已按用户要求去掉(2026-09-22),机器人归 common
AUDIENCES: Tuple[str, ...] = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_WHITE)
# 生成器/池子规模(先小规模验证,再放大到线上)
ALLOC_K = 1.5 # 池子 = 配比 × 1.5
ALLOC_FLOOR = 60 # 每桶保底条数
SMOKE_ROUNDS_PER_BUCKET = 20 # `--per-bucket 20` 那种冒烟规模
+279
View File
@@ -0,0 +1,279 @@
"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
与母本 olympus/engine.py 的差别(母本只记账、消除由 simulator 决定):
本引擎**从盘面自己判定**——1024 路前缀链、金色就地转百搭、列内重力+补位、阶梯、FS、500x 封顶。
生成端(mahjong_simulator.py)只负责"把盘面摆成想要的形状",摆完之后一切由这里算。
"""
from __future__ import annotations
import random
from dataclasses import replace
from typing import Dict, List, Optional, Sequence, Set, Tuple
from .config import (
GOLDEN_CELL_PROB, GOLDEN_COLS, GRID_COLS, GRID_ROWS, MAX_SCATTER_PER_SCREEN,
MAX_WIN_CAP_MUL, MIN_CHAIN, NEARMISS_SCATTER_COUNT, PAYING, PAYTABLE, SCATTER,
SCATTER_TRIGGER, SYMBOL_WEIGHTS, WILD, fs_award, ladder_mul,
)
from .models import Round, Rotation, Spin, Win
Cell = Optional[str] # None = 空洞(结算中间态,必被补位填满)
Grid = List[Cell]
# ---------------------------------------------------------------- 格位换算
def pos(row: int, col: int) -> int:
"""0 基 (行,列) → 接口/落库用的 1 基格位 1~20(行优先)。"""
return row * GRID_COLS + col + 1
def index_of(p: int) -> int:
"""1 基格位 → 0 基内部下标。"""
return p - 1
def _rc(i: int) -> Tuple[int, int]:
return divmod(i, GRID_COLS)
# ---------------------------------------------------------------- 盘面构造
def _pick(rng: random.Random) -> str:
"""卷轴落一个符号:只从 8 付费符号 + 胡里出(永不落 W)。"""
codes = tuple(SYMBOL_WEIGHTS)
return rng.choices(codes, weights=[SYMBOL_WEIGHTS[c] for c in codes])[0]
def _mark_golden(rng: random.Random, col: int, sym: str) -> bool:
"""金色只在第 2/3/4 轴,且只打在普通符号上(百搭/胡不变金)。"""
return (col in GOLDEN_COLS and sym != SCATTER and sym != WILD
and rng.random() < GOLDEN_CELL_PROB)
def new_grid(rng: random.Random) -> Tuple[Grid, Set[int]]:
"""开一旋:整盘新发 + 第 2/3/4 轴的普通符号按概率打金色。返回 (盘面, 金色格下标集)。"""
grid: Grid = []
golden: Set[int] = set()
for i in range(GRID_ROWS * GRID_COLS):
row, col = _rc(i)
sym = _pick(rng)
grid.append(sym)
if _mark_golden(rng, col, sym):
golden.add(i)
return grid, golden
def assert_no_native_wild(grid: Sequence[Cell]) -> None:
"""铁律:原生盘面(未结算)+ 补位都不产出 W,百搭只能由金色转化产生。"""
for i, v in enumerate(grid):
if v == WILD:
raise AssertionError(f"格位 {pos(*_rc(i))} 出现了非转化来源的百搭")
def assert_wild_only_middle(grid: Sequence[Cell]) -> None:
"""铁律:金色只在第 2/3/4 轴 ⇒ 百搭只可能在第 2/3/4 轴(第 1 轴永远没有)。"""
for i, v in enumerate(grid):
if v == WILD and _rc(i)[1] not in GOLDEN_COLS:
raise AssertionError(f"格位 {pos(*_rc(i))} 的百搭落在第 1 或第 5 轴,不可能")
def count_scatters(grid: Sequence[Cell]) -> int:
return sum(1 for v in grid if v == SCATTER)
def scatters_in_col(grid: Sequence[Cell], col: int) -> int:
return sum(1 for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] == SCATTER)
# ---------------------------------------------------------------- 1024 路判定
def _count_in_col(grid: Sequence[Cell], col: int, sym: str) -> int:
"""该轴「符号 X 或百搭」的格数(金色格按自然符号计入)。"""
n = 0
for r in range(GRID_ROWS):
v = grid[r * GRID_COLS + col]
if v == sym or v == WILD:
n += 1
return n
def chain_cells(grid: Sequence[Cell], sym: str, run: int) -> Tuple[int, ...]:
cells = []
for col in range(run):
for r in range(GRID_ROWS):
if grid[r * GRID_COLS + col] in (sym, WILD):
cells.append(pos(r, col))
return tuple(cells)
def ways_wins(grid: Sequence[Cell]) -> Tuple[Win, ...]:
"""前缀链:第 1 轴起 1→2→3→4→5 连续,一旦断链整笔作废;ways 只乘链内轴。"""
wins: List[Win] = []
for sym in PAYING:
run, ways, counts = 0, 1, []
for col in range(GRID_COLS):
n = _count_in_col(grid, col, sym)
if n == 0:
break
run += 1
ways *= n
counts.append(n)
if run >= MIN_CHAIN:
wins.append(Win(
symbol=sym, run=run, counts=tuple(counts), ways=ways,
loc=chain_cells(grid, sym, run), pay_base=PAYTABLE[sym][run] * ways,
))
return tuple(wins)
# ---------------------------------------------------------------- 重力 + 补位
def collapse_fill(grid: Grid, golden: Set[int], rng: random.Random
) -> Tuple[Tuple[Tuple[int, int], ...], Tuple[Tuple[int, str], ...]]:
"""列内重力:幸存符号(含刚转化的百搭)保持顺序压到底,顶部补新符号(可带金色)。
金色是**镀在符号上**的:幸存符号下落时,金色标记要跟着它一起走,
否则会留在原格"传染"给新补进来的牌(也就不再是"那个金色符号"了)。
"""
drops: List[Tuple[int, int]] = []
fills: List[Tuple[int, str]] = []
for col in range(GRID_COLS):
survivors = [(r, grid[r * GRID_COLS + col], (r * GRID_COLS + col) in golden)
for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] is not None]
for r in range(GRID_ROWS):
golden.discard(r * GRID_COLS + col) # 清空本列标记,按落点重打
new_col: List[Cell] = [None] * GRID_ROWS
offset = GRID_ROWS - len(survivors)
for k, (r, v, was_golden) in enumerate(survivors):
dst_row = offset + k
new_col[dst_row] = v
if was_golden:
golden.add(dst_row * GRID_COLS + col)
if dst_row != r:
drops.append((pos(r, col), pos(dst_row, col)))
for r in range(offset):
sym = _pick(rng)
new_col[r] = sym
fills.append((pos(r, col), sym))
if _mark_golden(rng, col, sym):
golden.add(r * GRID_COLS + col)
for r in range(GRID_ROWS):
grid[r * GRID_COLS + col] = new_col[r]
return tuple(drops), tuple(fills)
# ---------------------------------------------------------------- 一波结算
def settle_rotation(grid: Grid, golden: Set[int], seq: int, is_fs: bool,
rng: random.Random) -> Optional[Rotation]:
"""结算一波;无成奖返回 None。会就地改写 grid / golden。"""
wins = ways_wins(grid)
if not wins:
return None
involved: Set[int] = {index_of(p) for w in wins for p in w.loc}
rotation_mul = ladder_mul(seq, is_fs)
base_mul = sum(w.pay_base for w in wins)
# ① 全部中奖格"被消除",但金色格是就地改写:不空洞、不补牌
converted: List[Tuple[int, str]] = []
for i in sorted(involved):
if i in golden:
golden.discard(i)
converted.append((i, grid[i] or ""))
grid[i] = WILD
converted_idx = {i for i, _ in converted}
for i in involved:
if i not in converted_idx:
grid[i] = None
# ② 重力下落 + 顶部补位(刚转化的 W 也是普通盘面符号,一起落)
drops, fills = collapse_fill(grid, golden, rng)
assert_wild_only_middle(grid)
return Rotation(
seq=seq, rotation_mul=rotation_mul, wins=wins, base_mul=base_mul,
win_mul=base_mul * rotation_mul,
converted=tuple((pos(*_rc(i)), s) for i, s in converted),
drops=drops, fills=fills, grid=tuple(grid),
)
# ---------------------------------------------------------------- 一旋 / 一局
def _strip_scatters(grid: Grid, golden: Set[int], rng: random.Random,
keep: int = SCATTER_TRIGGER - 1) -> None:
"""把落定的胡数压到 keep(用于"一屏最多 5 个胡"与"发不满就不发")。"""
hits = [i for i, v in enumerate(grid) if v == SCATTER]
for i in hits[keep:]:
row, col = _rc(i)
grid[i] = _pick(rng)
golden.discard(i)
if _mark_golden(rng, col, grid[i] or ""):
golden.add(i)
def play_spin(rng: random.Random, index: int, is_fs: bool,
grid: Optional[Grid] = None, golden: Optional[Set[int]] = None) -> Spin:
"""播一旋:落定(grid)→ 逐波结算 → 直到本波无成奖。"""
if grid is None or golden is None:
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
scatters = count_scatters(grid) # 触发只看**落定盘面**(补位落下的胡不算)
rotations: List[Rotation] = []
seq = 1
while True:
rot = settle_rotation(grid, golden, seq, is_fs, rng)
if rot is None:
break
rotations.append(rot)
seq += 1
return Spin(index=index, is_fs=is_fs, rotations=tuple(rotations),
mul=sum(r.win_mul for r in rotations), scatters=scatters)
def _is_near_miss(grid: Sequence[Cell]) -> bool:
"""恰好 2 个胡、且第 5 轴没有胡(客户端在第 5 列播放大)。"""
return (count_scatters(grid) == NEARMISS_SCATTER_COUNT
and scatters_in_col(grid, GRID_COLS - 1) == 0)
def play_round(rng: random.Random, box_id: int = 1) -> Round:
"""完整一局:基础旋 →(3+ 胡)免费旋转 → 重触发 → 封顶截断。"""
round_ = Round(box_id=box_id)
total = 0.0
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN) # 一屏最多 5 个胡
round_.near_miss = _is_near_miss(grid)
base = play_spin(rng, index=0, is_fs=False, grid=grid, golden=golden)
if base.scatters >= SCATTER_TRIGGER:
award = fs_award(base.scatters)
base = replace(base, fs_awarded=award)
round_.free_spins = award
round_.spins.append(base)
total += base.mul
played = 0
while played < round_.free_spins:
played += 1
grid, golden = new_grid(rng)
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
sc = count_scatters(grid)
retrigger = False
if sc >= SCATTER_TRIGGER:
extra = fs_award(sc)
if round_.free_spins + extra <= 36: # "发不满就不发"
round_.free_spins += extra
retrigger = True
else:
_strip_scatters(grid, golden, rng) # 本旋就是不落 3+ 胡
spin = play_spin(rng, index=played, is_fs=True, grid=grid, golden=golden)
if retrigger:
spin = replace(spin, retrigger=True)
round_.spins.append(spin)
total += spin.mul
if total >= MAX_WIN_CAP_MUL: # 触顶:剩余旋作废
round_.capped = True
break
round_.raw_mul = total
round_.actual_mul = min(total, float(MAX_WIN_CAP_MUL))
return round_
+58
View File
@@ -0,0 +1,58 @@
"""麻将胡了 · 数据模型(frozen dataclass,母本 olympus/models.py 同风格)。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
@dataclass(frozen=True)
class Win:
"""一笔奖 = 某符号的一条前缀链。"""
symbol: str
run: int # 链长 3/4/5(从第 1 轴起连续的轴数)
counts: Tuple[int, ...] # 链内各轴的格数(百搭计入)
ways: int # counts 的乘积
loc: Tuple[int, ...] # 本笔涉及的全部格位(1 基,含金色格与百搭)
pay_base: float # 赔付表[符号][链长] × ways,单位 = 倍投注总额
@dataclass(frozen=True)
class Rotation:
"""一波 = 一次消除结算。"""
seq: int # 本旋内第几波(= 阶梯序号)
rotation_mul: int # 本波阶梯倍率
wins: Tuple[Win, ...]
base_mul: float # Σ pay_base
win_mul: float # base_mul × rotation_mul
converted: Tuple[Tuple[int, str], ...] # (格位1基, 原自然符号):金色就地转百搭
drops: Tuple[Tuple[int, int], ...] # (from1基, to1基)
fills: Tuple[Tuple[int, str], ...] # (格位1基, 新符号)
grid: Tuple[Optional[str], ...] # 本波结算+补位后的盘面(20 格短码)
@dataclass(frozen=True)
class Spin:
"""一旋。"""
index: int # 0 = 基础旋(触发盘),>0 = 第 N 次免费旋转
is_fs: bool
rotations: Tuple[Rotation, ...]
mul: float # 本旋倍率
scatters: int # 本旋**落定盘面**上的胡数(补位产生的不算触发)
fs_awarded: int = 0 # 本旋触发的赠送旋数(0 = 没触发)
retrigger: bool = False
@dataclass
class Round:
"""一局 = 基础旋 + 免费旋转(可重触发),受 500x 封顶。"""
box_id: int
spins: List[Spin] = field(default_factory=list)
raw_mul: float = 0.0 # 封顶前的总倍率
actual_mul: float = 0.0 # min(raw_mul, 500) —— 落桶/发奖用这个
capped: bool = False
near_miss: bool = False # 基础旋恰好 2 个胡且第 5 轴无胡
free_spins: int = 0 # 本局发放的免费旋数
@property
def has_fs(self) -> bool:
return self.free_spins > 0
+99
View File
@@ -0,0 +1,99 @@
"""麻将胡了 · 自然盘面仿真(回答"空转率/命中率到底是多少、离目标差多远")。
用法:
python mahjong_simulator.py --rounds 200000 --seed 2026
它只跑**自然落牌 + 无偏构造**的局面 —— 也就是"完全不干预"时游戏长什么样:
自然 RTP(= 平均 actual_mul)、基础旋命中率、级联波数分布、FS 自然触发率、悬念盘占比。
生成端要做的事(把配比凑到目标 RTP、把高倍桶构造出来)都在这些数字之上做偏置。
"""
from __future__ import annotations
import argparse
import random
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import play_round # noqa: E402
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--rounds", type=int, default=100000)
ap.add_argument("--seed", type=int, default=2026)
ap.add_argument("--box", type=int, default=5, help="投注档 boxId,只影响金额不影响倍率")
args = ap.parse_args()
rng = random.Random(args.seed)
n = args.rounds
base_hit = 0
empty_rounds = 0
fs_rounds = 0
retrigger_rounds = 0
near_miss = 0
capped = 0
rotations = Counter() # 本旋波数(基础旋)
fs_award_dist = Counter()
bucket_dist = Counter()
scatter_dist = Counter()
muls = []
total = 0.0
for _ in range(n):
r = play_round(rng, box_id=args.box)
muls.append(r.actual_mul)
total += r.actual_mul
base = r.spins[0]
if base.mul > 0:
base_hit += 1
else:
empty_rounds += 1
rotations[len(base.rotations)] += 1
scatter_dist[base.scatters] += 1
if r.has_fs:
fs_rounds += 1
fs_award_dist[r.free_spins] += 1
if any(s.retrigger for s in r.spins):
retrigger_rounds += 1
if r.near_miss:
near_miss += 1
if r.capped:
capped += 1
bucket_dist[C.bucket_of(r.actual_mul)] += 1
muls.sort()
def pct(p: float) -> float:
return muls[min(n - 1, int(n * p))]
print(f"=== 自然仿真:{n} 局 / seed={args.seed} / 无偏构造 ===")
print(f"自然 RTP(平均 actual_mul) : {total / n:.4f}")
print(f"基础旋命中率(至少一波成奖) : {base_hit / n:.4%} → 空转率 {empty_rounds / n:.2%}")
print(f"基础旋空转局(= 桶 1 占比) : {empty_rounds / n:.4%}")
print(f"FS 自然触发率(3+ 胡) : {fs_rounds / n:.4%} (设计目标 ≈0.5%,生成端要压低)")
print(f"其中重触发过的局 : {retrigger_rounds / n:.4%}")
print(f"悬念盘(2 胡且第 5 轴无胡) : {near_miss / n:.4%} (设计目标 1~2%)")
print(f"触 500x 封顶 : {capped / n:.4%}")
print(f"actual_mul 中位 / p90 / p99 / max: {pct(0.5):.2f} / {pct(0.9):.2f} / {pct(0.99):.2f} / {muls[-1]:.2f}")
print("\n基础旋波数分布(0 = 一波都不成):")
for k in sorted(rotations):
print(f" {k} 波: {rotations[k] / n:7.3%}")
print("\n落定盘面的胡数分布:")
for k in sorted(scatter_dist):
print(f" {k} 个: {scatter_dist[k] / n:7.3%}")
print("\nFS 赠送旋数分布(触发局内):")
for k in sorted(fs_award_dist):
print(f" {k} 旋: {fs_award_dist[k] / max(1, fs_rounds):7.3%}")
print("\n落桶分布(桶: 占比 / 名义期望倍率):")
for b in C.BUCKETS:
share = bucket_dist[b.id] / n
if share:
print(f" {b.id:2d} {b.name:<16} {share:7.4%} expect={b.expect_mul}")
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
-- ============================================================================
-- 步骤 1:插入「麻将胡了」顶级目录(照 olympus menu_id=3130 的结构)
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
visible, status, perms, icon, create_by, create_time, remark)
VALUES
('麻将胡了', 0, 6, 'mahjong', '', NULL, 1, 0, 'M',
'0', '0', '', 'star', 'admin', NOW(), '麻将胡了游戏后台管理目录');
-- 步骤 2:取刚插入的目录 id(LAST_INSERT_ID() 是**会话级**的,
-- 所以目录 INSERT 和下面子菜单 INSERT 必须在同一个会话里连着执行,不能拆开跑)
SET @mj_dir := LAST_INSERT_ID();
-- 步骤 3:插入 7 个子菜单(order_num 1..7,与 olympus 同序)
INSERT INTO sys_menu
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
visible, status, perms, icon, create_by, create_time, remark)
VALUES
('箱子配置', @mj_dir, 1, 'box', 'game/mahjong/box/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:box', 'shopping', 'admin', NOW(), '麻将胡了·箱子配置(11 档投注,price ≡ bet)'),
('饰品列表', @mj_dir, 2, 'bucket', 'game/mahjong/bucket/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:bucket', 'star', 'admin', NOW(), '麻将胡了·16 档饰品桶定义(0x~500x,只读)'),
('回合管理', @mj_dir, 3, 'round', 'game/mahjong/round/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:round', 'documentation', 'admin', NOW(), '麻将胡了·预生成回合池(只读)'),
('奖池水位', @mj_dir, 4, 'rewardPool', 'game/mahjong/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:pool', 'money', 'admin', NOW(), '麻将胡了·奖池水位(3 身份:普通/主播/白号)'),
('玩家记录', @mj_dir, 5, 'record', 'game/mahjong/record/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:record', 'list', 'admin', NOW(), '麻将胡了·玩家游玩记录'),
('每日统计', @mj_dir, 6, 'dailywin', 'game/mahjong/dailywin/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:win', 'chart', 'admin', NOW(), '麻将胡了·每日投注/赢奖/RTP 统计'),
('奖品管理', @mj_dir, 7, 'prize', 'game/mahjong/prize/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:prize', 'guide', 'admin', NOW(), '麻将胡了·返奖饰品与返奖区间');
+298
View File
@@ -0,0 +1,298 @@
-- ============================================================
-- 麻将胡了(Mahjong)模块 全量建表 DDL
-- 表前缀: game_mahjong_ 游戏类型: MAHJONG
-- 字符集/排序规则: **utf8mb4 / utf8mb4_0900_ai_ci**(显式写出,不依赖服务器默认;与本库 henan 的默认一致,
-- 也与 game_wheel_* 一致。注意本库另有 111 张表是 utf8mb4_general_ci / utf8mb4_unicode_ci —— 只要**不要**拿
-- 字符串列去 join 它们(我们的 join 全在 user_id/box_id/round_id 等数值键上),就不会撞 Illegal mix of collations)
-- 权威来源: docs/麻将胡了设计方案.md §4(表清单)/ 附录 A(与玫瑰女郎的映射)
-- 与玫瑰女郎(game_olympus_*)的差异:
-- 1. 饰品桶 16 档(0x~500x,删掉它的 1000x 那档);单局封顶 500 倍
-- 2. 无模式倍率:只有 normal,cost = bet(去 mode_type 的 CM 语义)
-- 3. 无炸弹:step 的 multiplier_detail / accumulator_* → rotation_mul(本波阶梯,不跨旋)
-- 4. 盘面 4 行 × 5 列 = 20 格;step 自带 golden_detail / wild_converted / ways_detail
-- 5. record 自带 audience(按箱×身份记水位)与 bet_mul(预留、恒 1)
-- 6. box.total_ornaments → total_rounds
-- 执行:mysql --default-character-set=utf8mb4 -u<user> -p <db> < schema.sql (可重复执行)
-- ============================================================
-- 1. 箱子配置(= 投注档,11 档;price = bet)
CREATE TABLE IF NOT EXISTS game_mahjong_box (
box_id BIGINT NOT NULL COMMENT 'PK 1-11',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
box_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '只有 normal',
bet DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '投注档 = 投注总额',
name VARCHAR(50) DEFAULT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际扣费 = bet(costMultiplier ≡ 1)',
total_rounds INT NOT NULL DEFAULT '0' COMMENT '总回合数(配比重算时回写)',
rtp DECIMAL(5,4) NOT NULL DEFAULT '0.8000' COMMENT '目标返奖率',
rtp_converge_ratio DECIMAL(4,3) NULL COMMENT '一轮收敛回合占比,空=默认0.15',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1' COMMENT '1启用 0禁用',
del_flag VARCHAR(1) DEFAULT '0',
remark VARCHAR(200) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (box_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子配置(=投注档)';
-- 2. 饰品桶定义(0x ~ 500x,共 16 档)
CREATE TABLE IF NOT EXISTS game_mahjong_bucket (
id BIGINT NOT NULL COMMENT 'PK 1-16',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高 500)',
name VARCHAR(50) DEFAULT NULL,
range_low DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间下限(含)',
range_high DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间上限(含)',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了饰品桶定义';
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
-- audience 多身份爆率:每种身份一套独立分布;白号(white)独立库存、不进真实经济
CREATE TABLE IF NOT EXISTS game_mahjong_box_bucket (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL,
bucket_id INT NOT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common普通/anchor主播/white白号(无 robot 维度)',
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
PRIMARY KEY (id),
UNIQUE KEY uk_box_bucket_aud (box_id, bucket_id, audience)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子饰品桶分布(多身份)';
-- 4. 预生成回合(池里只存倍率,与投注额无关)
CREATE TABLE IF NOT EXISTS game_mahjong_round (
round_id BIGINT NOT NULL COMMENT 'PK',
box_id BIGINT NOT NULL,
bucket_id INT NOT NULL COMMENT '落入的饰品桶 1-16',
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(先按 500 截断再落桶)',
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(12/14/16 + 重触发)',
total_rotations INT NOT NULL DEFAULT '0' COMMENT '整局总波数',
has_fs TINYINT NOT NULL DEFAULT '0' COMMENT '是否含免费旋转 0否1是',
near_miss TINYINT NOT NULL DEFAULT '0' COMMENT '悬念盘:基础旋恰好2个胡且第5轴无胡',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (round_id),
INDEX idx_box_bucket (box_id, bucket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了预生成回合';
-- 5. 回合步骤(每帧一行,客户端逐帧续播)
CREATE TABLE IF NOT EXISTS game_mahjong_step (
id BIGINT NOT NULL COMMENT 'PK',
round_id BIGINT NOT NULL,
reel_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋/触发盘, >0=第N次免费旋转',
rotation_seq INT NOT NULL DEFAULT '0' COMMENT '本旋内第几波(= 阶梯序号)',
grid_state JSON DEFAULT NULL COMMENT '4行×5列=20格短码矩阵',
golden_detail JSON DEFAULT NULL COMMENT '本帧仍为"金色普通符号"的格 [{"pos","symbol"}]',
wild_converted JSON DEFAULT NULL COMMENT '本波"金色→百搭"就地转化的格 [{"pos","fromSymbol"}]',
ways_detail JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]',
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋累计消除符号 {"A":9,...}',
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加,新旋清空',
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...](含金色格)',
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from","to","symbol"}]',
fill_data JSON DEFAULT NULL COMMENT '补位数据 [{"pos","symbol"}]',
scatter_trigger JSON DEFAULT NULL COMMENT '触发免费旋转 {"scatterCount":3,"freeSpins":12,"extraSpins":0}',
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转内重触发',
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波基础倍率(Σ符号倍率×ways,未乘阶梯)',
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本旋累计到本步(末帧=本旋总额),不是增量',
rotation_mul DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波阶梯倍率(基础 1/2/3/5,FS 2/4/6/10;每旋重置)',
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额(倍率)到本步',
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步被 500x 封顶截断 0否1是',
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位 small/middle/big(只在 FS 旋末帧,按单旋赢额)',
is_final TINYINT DEFAULT '0' COMMENT '本旋末帧',
PRIMARY KEY (id),
INDEX idx_round (round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了回合步骤';
-- 6. 游戏记录(含结算/幂等/发奖字段,建表即带全)
CREATE TABLE IF NOT EXISTS game_mahjong_record (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
box_id BIGINT NOT NULL,
round_id BIGINT NOT NULL,
cost DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '扣费 = bet',
bet_mul DECIMAL(10,2) NOT NULL DEFAULT '1.00' COMMENT '投注倍数(预留字段,恒 1)',
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(倍数×投注额,≤bet×500)',
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
mode_type VARCHAR(20) DEFAULT 'normal' COMMENT '只有 normal',
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(=命中饰品价格;空转局按饰品实际价值记账)',
prize_id BIGINT DEFAULT NULL COMMENT 'game_mahjong_prize_ornament.id',
prize_name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
prize_image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
prize_level_img VARCHAR(500) DEFAULT NULL COMMENT '品质图片URL',
settle_source VARCHAR(20) DEFAULT NULL COMMENT 'start=开局已结算(动画还在播) / finish=末帧播完(进战绩)',
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份:水位按 箱×身份 聚合',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user_time (user_id, create_time),
INDEX idx_box_time (box_id, create_time),
INDEX idx_settled_time (settled, create_time),
INDEX idx_box_aud (box_id, audience)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了游戏记录';
-- 7. 用户统计
CREATE TABLE IF NOT EXISTS game_mahjong_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
user_id BIGINT NOT NULL,
total_spins BIGINT NOT NULL DEFAULT '0',
total_cost DECIMAL(12,2) NOT NULL DEFAULT '0.00',
total_win DECIMAL(12,2) NOT NULL DEFAULT '0.00',
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤500)',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user (user_id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了用户统计';
-- 8. 箱子桶分布模板(抽干补桶依据,按身份区分)
CREATE TABLE IF NOT EXISTS game_mahjong_box_template (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT NOT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common',
name VARCHAR(100) NOT NULL COMMENT '模板名称(同箱+身份内唯一)',
total_rounds INT NOT NULL DEFAULT 0 COMMENT '= items 各 count 之和',
items_json TEXT NOT NULL COMMENT '分布JSON: [{"bucketId":1,"count":100},...]',
is_active TINYINT NOT NULL DEFAULT 0 COMMENT '是否生效模板 1是 0否',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_box_aud_name (box_id, audience, name),
KEY idx_box_aud_active (box_id, audience, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子桶分布模板(多身份)';
-- 9. 奖池水位调整日志(注水/抽水/重置审计,原因必填由前端保证)
CREATE TABLE IF NOT EXISTS game_mahjong_reward_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
box_name VARCHAR(100) DEFAULT NULL,
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common/anchor/white',
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
operator VARCHAR(64) DEFAULT NULL,
remark VARCHAR(200) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_create (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了奖池水位调整日志';
-- 10. 返奖饰品定义(按赢额金额区间匹配,与游戏无关)
CREATE TABLE IF NOT EXISTS game_mahjong_prize_ornament (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
name VARCHAR(100) DEFAULT NULL,
image_url VARCHAR(500) DEFAULT NULL,
grade INT DEFAULT NULL COMMENT '品质等级 1-5',
exterior_name VARCHAR(100) DEFAULT NULL COMMENT '品质名称 崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累',
sort INT DEFAULT '0',
status VARCHAR(1) DEFAULT '1',
is_fallback TINYINT DEFAULT '0' COMMENT '兜底饰品 1是 0否(区间空洞时落它)',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖饰品';
-- 11. 返奖饰品区间(与 prize_ornament 一一对应,区间连续无缝覆盖 0~250000)
CREATE TABLE IF NOT EXISTS game_mahjong_prize_range (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
prize_id BIGINT NOT NULL COMMENT 'game_mahjong_prize_ornament.id',
win_low DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间下限(含)',
win_high DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间上限(含)',
PRIMARY KEY (id),
UNIQUE KEY uk_prize (prize_id),
KEY idx_win_low (win_low)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖区间';
-- ============================================================
-- 种子数据:饰品桶 1-16(0x ~ 500x)
-- ============================================================
INSERT INTO game_mahjong_bucket (id, game_type, expect_mul, name, range_low, range_high) VALUES
(1, 'MAHJONG', 0.00, '0x', 0.00, 0.00),
(2, 'MAHJONG', 0.50, '0.5x', 0.01, 1.00),
(3, 'MAHJONG', 1.50, '1.5x', 1.00, 2.00),
(4, 'MAHJONG', 2.50, '2.5x', 2.00, 3.00),
(5, 'MAHJONG', 4.00, '4x', 3.00, 5.00),
(6, 'MAHJONG', 6.50, '6.5x', 5.00, 8.00),
(7, 'MAHJONG', 10.00, '10x', 8.00, 12.00),
(8, 'MAHJONG', 16.00, '16x', 12.00, 20.00),
(9, 'MAHJONG', 25.00, '25x', 20.00, 30.00),
(10, 'MAHJONG', 40.00, '40x', 30.00, 50.00),
(11, 'MAHJONG', 60.00, '60x', 50.00, 70.00),
(12, 'MAHJONG', 100.00, '100x', 70.00, 130.00),
(13, 'MAHJONG', 150.00, '150x', 130.00, 170.00),
(14, 'MAHJONG', 200.00, '200x', 170.00, 230.00),
(15, 'MAHJONG', 300.00, '300x', 230.00, 370.00),
(16, 'MAHJONG', 500.00, '500x', 370.00, 700.00)
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
range_low=VALUES(range_low), range_high=VALUES(range_high);
-- ============================================================
-- 种子数据:11 个箱子(=11 个投注档,price = bet,无投注倍数)
-- ============================================================
INSERT INTO game_mahjong_box (box_id, game_type, box_type, bet, name, price, rtp, sort, status, remark) VALUES
(1, 'MAHJONG', 'normal', 1.00, '1', 1.00, 0.8000, 1, '1', NULL),
(2, 'MAHJONG', 'normal', 3.00, '3', 3.00, 0.8000, 2, '1', NULL),
(3, 'MAHJONG', 'normal', 5.00, '5', 5.00, 0.8000, 3, '1', NULL),
(4, 'MAHJONG', 'normal', 10.00, '10', 10.00, 0.8000, 4, '1', NULL),
(5, 'MAHJONG', 'normal', 20.00, '20', 20.00, 0.8000, 5, '1', NULL),
(6, 'MAHJONG', 'normal', 30.00, '30', 30.00, 0.8000, 6, '1', NULL),
(7, 'MAHJONG', 'normal', 50.00, '50', 50.00, 0.8000, 7, '1', NULL),
(8, 'MAHJONG', 'normal', 100.00, '100', 100.00, 0.8000, 8, '1', NULL),
(9, 'MAHJONG', 'normal', 200.00, '200', 200.00, 0.8000, 9, '1', NULL),
(10, 'MAHJONG', 'normal', 300.00, '300', 300.00, 0.8000, 10, '1', NULL),
(11, 'MAHJONG', 'normal', 500.00, '500', 500.00, 0.8000, 11, '1', NULL)
ON DUPLICATE KEY UPDATE bet=VALUES(bet), price=VALUES(price), name=VALUES(name),
rtp=VALUES(rtp), sort=VALUES(sort), status=VALUES(status);
-- ============================================================
-- 种子数据:返奖饰品 12 件(取自玫瑰女郎线上 dump,id 原样保留以维持区间映射)
-- 说明:zone 是按**赢额金额(¥)**匹配的展示层,与饰品桶(倍率)无关;
-- 保留原 id 是因为 prize_range.prize_id 直接引用它。
-- ============================================================
INSERT INTO game_mahjong_prize_ornament
(id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback, create_time, update_time) VALUES
(1, 'MAHJONG', '安慰奖', '/profile/upload/2026/09/15/VU1QLTQ1IHwgU2NvcmNoZWQgKEZhY3RvcnkgTmV3KQ==_20260915144142A001.png', 4, '战痕累累', 1, '1', 1, '2026-08-01 14:05:58', '2026-09-15 14:41:45'),
(2, 'MAHJONG', 'SG 553 | 浪花喷漆', '/profile/upload/2026/07/02/U0cgNTUzIHwgV2F2ZSBTcHJheSAoRmFjdG9yeSBOZXcp_20260702034148A002.png', 2, '略有磨损', 2, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(3, 'MAHJONG', '双持贝瑞塔 | 黑榄仁木', '/profile/upload/2026/07/02/RHVhbCBCZXJldHRhcyB8IEJsYWNrIExpbWJhIChGYWN0b3J5IE5ldyk=_20260702034157A003.png', 3, '久经沙场', 3, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(4, 'MAHJONG', 'M4A4 | 皇家圣骑士', '/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png', 4, '破损不堪', 4, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(5, 'MAHJONG', 'AK-47 | 深海复仇', '/profile/upload/2026/07/02/QUstNDcgfCBBcXVhbWFyaW5lIFJldmVuZ2UgKEZhY3RvcnkgTmV3KQ==_20260702034212A005.png', 4, '破损不堪', 5, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(6, 'MAHJONG', 'AK-47 | 火蛇', '/profile/upload/2026/07/02/QUstNDcgfCBGaXJlIFNlcnBlbnQgKEZhY3RvcnkgTmV3KQ==_20260702034220A006.png', 3, '久经沙场', 6, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(7, 'MAHJONG', 'AK-47 | 燃料喷射器', '/profile/upload/2026/07/02/QUstNDcgfCBGdWVsIEluamVjdG9yIChGYWN0b3J5IE5ldyk=_20260702034228A007.png', 3, '久经沙场', 7, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(8, 'MAHJONG', '加利尔 AR | 水蓝阶地', '/profile/upload/2026/07/02/R2FsaWwgQVIgfCBBcXVhIFRlcnJhY2UgKEZhY3RvcnkgTmV3KQ==_20260702034236A008.png', 2, '略有磨损', 8, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(9, 'MAHJONG', '猎杀者匕首 | 渐变之色', '/profile/upload/2026/07/02/4piFIEh1bnRzbWFuIEtuaWZlIHwgRmFkZSAoRmFjdG9yeSBOZXcp_20260702034302A012.png', 2, '略有磨损', 9, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(10, 'MAHJONG', '爪子刀 | 都市伪装', '/profile/upload/2026/07/02/4piFIEthcmFtYml0IHwgVXJiYW4gTWFza2VkIChGYWN0b3J5IE5ldyk=_20260702034248A010.png', 1, '崭新出厂', 10, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(11, 'MAHJONG', '熊刀 | 多普勒', '/profile/upload/2026/07/02/4piFIFVyc3VzIEtuaWZlIHwgRG9wcGxlciAoTWluaW1hbCBXZWFyKQ==_20260702034256A011.png', 1, '崭新出厂', 11, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
(12, 'MAHJONG', 'AWP | 巨龙传说', '/profile/upload/2026/07/02/QVdQIHwgRHJhZ29uIExvcmUgKEZhY3RvcnkgTmV3KQ==_20260702034308A013.png', 1, '崭新出厂', 12, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58')
ON DUPLICATE KEY UPDATE game_type=VALUES(game_type), name=VALUES(name), image_url=VALUES(image_url),
grade=VALUES(grade), exterior_name=VALUES(exterior_name), sort=VALUES(sort),
status=VALUES(status), is_fallback=VALUES(is_fallback), update_time=NOW();
-- ============================================================
-- 种子数据:返奖区间 12 段(连续无缝覆盖 0.01 ~ 99,999,999)
-- 已核:① 每段 win_high = 下一段 win_low(无空洞、无重叠)
-- ② 最大档上界 99,999,999 ≥ 麻将单局理论最大赔付 ¥250,000(500x × ¥500)
-- —— ¥250,000 落在第 9 段(100,005.82~499,988),发 9 号饰品
-- ③ 空转局兜底 win=0.01 落在第 1 段(0.01~0.14),发「安慰奖」(is_fallback=1)
-- ⚠️ 这 12 段只决定"发哪件饰品"(展示层);实际入账金额是 record.settled_win
-- = 本局 win,与区间无关(见 service-user 分解入账:Σ TtBoxRecords.ornamentsPrice)
-- ============================================================
INSERT INTO game_mahjong_prize_range (id, prize_id, win_low, win_high) VALUES
(1, 1, 0.01, 0.14),
(2, 2, 0.14, 10.00),
(3, 3, 10.00, 50.00),
(4, 4, 50.00, 200.00),
(5, 5, 200.00, 1000.00),
(6, 6, 1000.00, 5000.00),
(7, 7, 5000.00, 20000.00),
(8, 8, 20000.00, 100005.82),
(9, 9, 100005.82, 499988.00),
(10, 10, 499988.00, 1000013.00),
(11, 11, 1000013.00, 2000012.00),
(12, 12, 2000012.00, 99999999.00)
ON DUPLICATE KEY UPDATE prize_id=VALUES(prize_id), win_low=VALUES(win_low), win_high=VALUES(win_high);
-- ============================================================
-- 还没做(不在本文件内,见设计文档 §6.5):
-- 1. 528 条 game_mahjong_box_bucket 配比行(16 桶 × 11 箱 × 3 身份):由 `_recalc_boxes.py` / 后台「一键重算」生成
-- 2. sys_config mahjongMaintenance、sys_menu 7 个菜单:平台共享改动,单独执行
-- 3. 每箱×身份的初始水位:后台奖池水位页注水(¥300/¥500 两档必须单独注)
-- ============================================================
+203
View File
@@ -0,0 +1,203 @@
"""麻将胡了 · 引擎单测(不依赖 pytest:`python tests/test_engine.py` 直接跑)。
重点覆盖:§2.1 七个正反例、百搭补链/不能替代胡、金色就地转百搭、
一个百搭被两条链同时借用、阶梯取档、FS 旋数上限与"发不满就不发"、500x 封顶。
"""
from __future__ import annotations
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from mahjong import config as C # noqa: E402
from mahjong.engine import ( # noqa: E402
assert_wild_only_middle, count_scatters, new_grid, play_round,
settle_rotation, ways_wins,
)
def make_grid(rows, golden=()):
"""rows: 4 个长度 5 的短码字符串(上→下);golden: 0 基 (行,列) 集合。"""
grid = [c for row in rows for c in row]
assert len(grid) == C.GRID_SIZE, f"盘面必须是 20 格,收到 {len(grid)}"
return grid, {r * C.GRID_COLS + c for r, c in golden}
def cols_of(counts, fill="S", sym="B"):
"""按"每轴 sym 的个数"造盘面(其余填 fill,保证不形成别的链)。"""
rows = []
for r in range(C.GRID_ROWS):
row = []
for c in range(C.GRID_COLS):
# 从底部往上摆 sym
row.append(sym if r >= C.GRID_ROWS - counts[c] else fill)
rows.append("".join(row))
return make_grid(rows)
def mul(x):
return round(x, 4)
# ---------------------------------------------------------------- §2.1 前缀链
CASES = [
([2, 3, 1, 0, 0], 3, 6), # ✅ 3 连
([2, 3, 1, 2, 0], 4, 12), # ✅ 4 连(第 5 轴才断)
([2, 0, 3, 4, 4], None, None), # ❌ 第 2 轴断
([0, 4, 4, 4, 4], None, None), # ❌ 第 1 轴必需
([1, 1, 0, 4, 4], None, None), # ❌ 不能跳轴
([1, 2, 3, 4, 4], 5, 96), # ✅ 满链
([1, 1, 1, 1, 1], 5, 1), # ✅ 最低 ways
]
def test_prefix_chain():
for counts, run, ways in CASES:
grid, _ = cols_of(counts)
wins = [w for w in ways_wins(grid) if w.symbol == "B"]
if run is None:
assert not wins, f"{counts} 不该成奖,却得到 {wins}"
else:
assert len(wins) == 1, f"{counts} 应恰好一笔,得到 {wins}"
assert wins[0].run == run and wins[0].ways == ways, f"{counts} → {wins[0]}"
print("[ok] §2.1 前缀链 7 个正反例")
def test_scatter_breaks_chain_and_no_substitute():
grid, _ = make_grid(["ASASS", "SSSSS", "SSSSS", "SSSSS"])
assert ways_wins(grid) == (), "發,胡,發 不能成链(胡不进链、也挡链)"
grid, _ = make_grid(["AWASS", "SSSSS", "SSSSS", "SSSSS"])
wins = [w for w in ways_wins(grid) if w.symbol == "A"]
assert len(wins) == 1 and wins[0].run == 3 and wins[0].ways == 1, "百搭要能补链中间的断点"
print("[ok] 胡不成链 / 百搭补链")
def test_paytable_grid():
for code, rule in C.SYMBOL_RULES.items():
for run, pay in rule.payouts.items():
assert abs(pay / 0.05 - round(pay / 0.05)) < 1e-9, f"{code} {run} 连 {pay} 不是 0.05 的整数倍"
print("[ok] 赔付表全部落在 0.05 网格上")
# ---------------------------------------------------------------- 金色 → 百搭
def test_golden_converts_in_place():
"""§15 第 1 波:两张金色「中」各留一个百搭,且是就地改写的。"""
grid, golden = make_grid(
["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(1, 1), (1, 2)])
rng = random.Random(7)
rot = settle_rotation(grid, golden, 1, False, rng)
assert rot is not None
paid = {w.symbol: mul(w.pay_base) for w in rot.wins}
assert paid == {"B": 2.0, "C": 0.8}, f"本波两笔奖应为 2.00 / 0.80,得到 {paid}"
assert mul(rot.win_mul) == 2.8, "x1 阶梯下本波 2.80 倍"
assert len(rot.converted) == 2, f"两个金色格各留一个百搭,得到 {rot.converted}"
assert {s for _, s in rot.converted} == {"B"}, "转化前的自然符号应记进 wild_converted"
assert all(grid[i] != C.WILD for i in golden), "百搭永不回填金色标记(每格只转化一次)"
assert sum(1 for v in grid if v == C.WILD) == 2, "两个百搭仍在盘面上(跟着重力落)"
assert_wild_only_middle(grid)
print("[ok] 金色格就地转百搭(不空洞/不补牌,2 个金色格 → 2 个百搭)")
def test_one_wild_borrowed_by_three_chains():
"""§15 第 4 波:同一个百搭同时撑起「筒」5 连、「索红绿」5 连、「白板」3 连。
注:设计文档原来只算了前两笔(漏了白板链),本测试按规则把它补上;
文档已同步勘误(本波 ¥1200、本旋总账 ¥2380)。
"""
grid, golden = make_grid(["GAWGC", "HCAAS", "CFBGF", "FGHFG"])
rng = random.Random(11)
rot = settle_rotation(grid, golden, 4, False, rng)
assert rot is not None
got = {w.symbol: (w.run, w.ways, mul(w.pay_base)) for w in rot.wins}
assert got == {"G": (5, 2, 1.0), "F": (5, 1, 1.0), "C": (3, 1, 0.4)}, f"三笔并赔,得到 {got}"
shared = set(rot.wins[0].loc)
for w in rot.wins[1:]:
shared &= set(w.loc)
assert shared == {3}, f"三条链必须共用第 3 格那个百搭,实际 {shared}"
assert mul(rot.base_mul) == 2.4 and mul(rot.win_mul) == 12.0, "本波 2.40 倍 × x5 = 12.00 倍"
assert sum(1 for v in grid if v == C.WILD) == 0, "参与成链的百搭要随本波被消除"
print("[ok] 一个百搭被三条链同时借用,并随波被消除")
def test_involved_golden_only():
"""只有"参与成奖"的金色格才转化;没被涉及的金色格原地不动。"""
# B 链在轴1~轴3;轴4 的 F 带金色但没参与任何链 → 不转化
grid, golden = make_grid(["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(3, 3)])
rng = random.Random(9)
rot = settle_rotation(grid, golden, 1, False, rng)
assert rot is not None and rot.converted == (), f"未被涉及的金色格不该转化:{rot.converted}"
assert 3 * C.GRID_COLS + 3 in golden, "未涉及的金色标记要保留"
print("[ok] 只有参与成奖的金色格才就地转百搭")
def test_golden_travels_with_symbol():
"""金色是镀在符号上的:金色格没被消除但它下方的牌被消 → 它下落,金标记跟着走。"""
grid, golden = make_grid(["BDBSS", "BBBSS", "BBBSS", "SSSSS"], golden=[(0, 1)])
rot = settle_rotation(grid, golden, 1, False, random.Random(1))
assert rot is not None, "B 应为 3 连(col0=3 / col1=2 / col2=3)"
# D 从行1 落到行3(col1 有 2 格空洞),金标记必须跟着
assert grid[2 * C.GRID_COLS + 1] == "D", "金色 D 应该落到行3"
assert 2 * C.GRID_COLS + 1 in golden, "金标记要跟着符号落到新位置"
assert 0 * C.GRID_COLS + 1 not in golden, "不能留在原格(否则会传染给新补的牌)"
print("[ok] 金色标记跟着符号下落")
def test_no_wild_without_conversion():
"""原生盘面(卷轴+补位)永不落百搭;金色只在第 2/3/4 轴。"""
for seed in range(300):
g, gold = new_grid(random.Random(seed))
assert C.WILD not in g, "原生盘面永远不落百搭"
assert all(c % C.GRID_COLS in C.GOLDEN_COLS for c in gold), "金色只在第 2/3/4 轴"
print("[ok] 原生不落百搭 / 金色只在第 2/3/4 轴")
# ---------------------------------------------------------------- 阶梯 / 封顶 / FS
def test_ladder():
base = [C.ladder_mul(n, False) for n in range(1, 7)]
fs = [C.ladder_mul(n, True) for n in range(1, 7)]
assert base == [1, 2, 3, 5, 5, 5], base
assert fs == [2, 4, 6, 10, 10, 10], fs
print("[ok] 阶梯:基础 1/2/3/5,FS 2/4/6/10,第 4 波起封顶")
def test_example_2_9_1_wave1():
"""§2.9.1 第 1 波:中 3 连 + 白板 3 连 = 0.90 倍(x1)。"""
grid, golden = make_grid(["BCBEH", "DBAGF", "EDCAG", "CEHFD"])
rot = settle_rotation(grid, golden, 1, False, random.Random(5))
assert rot is not None and mul(rot.base_mul) == 0.9 and mul(rot.win_mul) == 0.9
print("[ok] §2.9.1 第 1 波 = 0.90 倍")
def test_fs_award_and_cap():
assert [C.fs_award(n) for n in (3, 4, 5)] == [12, 14, 16]
rng = random.Random(2026)
reachable = set()
for _ in range(40000):
r = play_round(rng, box_id=5)
assert r.actual_mul <= C.MAX_WIN_CAP_MUL, "单局封顶 500x"
assert mul(r.actual_mul) == mul(min(r.raw_mul, C.MAX_WIN_CAP_MUL))
if r.free_spins:
assert r.free_spins <= C.MAX_FREE_SPINS, f"FS 上限 36,得到 {r.free_spins}"
assert r.free_spins in (12, 14, 16, 24, 26, 28, 30, 32, 36), r.free_spins
reachable.add(r.free_spins)
if r.capped:
assert r.actual_mul == C.MAX_WIN_CAP_MUL
assert reachable, "4 万局都没触发过一次 FS,权重/参数明显不对"
print(f"[ok] 封顶与 FS 上限(4 万局内出现的赠送旋数:{sorted(reachable)})")
if __name__ == "__main__":
test_prefix_chain()
test_scatter_breaks_chain_and_no_substitute()
test_paytable_grid()
test_golden_converts_in_place()
test_one_wild_borrowed_by_three_chains()
test_involved_golden_only()
test_golden_travels_with_symbol()
test_no_wild_without_conversion()
test_ladder()
test_example_2_9_1_wave1()
test_fs_award_and_cap()
print("\n全部通过 ✅")
+1 -1
View File
@@ -97,7 +97,7 @@
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>game-olympus-service</artifactId>
<artifactId>game-mahjong-service</artifactId>
<version>${ruoyi.version}</version>
</dependency>
+62
View File
@@ -0,0 +1,62 @@
import request from '@/utils/request'
// 箱子列表(分页;boxType 只有一个 normal,留参数只为兼容后台筛选框):MahjongAdminController.boxList
// 返回 { rows:[{...各身份 targetRtpByAudience/actualRtpByAudience/totalRoundsByAudience}], total }
export function getBoxList(params) {
return request({ url: '/admin/mahjong/box/list', method: 'get', params })
}
// 新增/编辑箱子:MahjongAdminController.boxSave(price = bet,本游戏无投注倍数、costMultiplier ≡ 1)
export function saveBox(data) {
return request({ url: '/admin/mahjong/box/save', method: 'post', data })
}
// 某箱某身份的倍率桶分布:MahjongAdminController.boxBuckets
// 返回 { list:[{bucketId,count,used,remaining,convergeCount,...}], audience, fixedRounds, convergeRounds, poolExpect }
export function getBoxBuckets(boxId, audience) {
return request({ url: `/admin/mahjong/box/buckets/${boxId}`, method: 'get', params: { audience }})
}
// 保存某箱某身份倍率桶分布(items 为 [{bucketId,count}]):MahjongAdminController.boxBucketsSave
export function saveBoxBuckets(data) {
return request({ url: '/admin/mahjong/box/buckets/save', method: 'post', data })
}
// 按目标 RTP 重算某箱某身份分布:MahjongAdminController.calcDistribution
export function calcDistribution(data) {
return request({ url: '/admin/mahjong/box/buckets/calc', method: 'post', data })
}
// 模拟 payoutCap 约束下的预估实际 RTP:MahjongAdminController.simulateRtp
export function simulateRtp(data) {
return request({ url: '/admin/mahjong/box/buckets/simulate-rtp', method: 'post', data })
}
// ==================== 分布模板(按身份) ====================
// 模板列表:MahjongAdminController.templateList
export function getBoxTemplates(boxId, audience) {
return request({ url: `/admin/mahjong/box/templates/${boxId}`, method: 'get', params: { audience }})
}
// 保存为模板:MahjongAdminController.templateSave
export function saveBoxTemplate(data) {
return request({ url: '/admin/mahjong/box/template/save', method: 'post', data })
}
// 删除模板:MahjongAdminController.templateDelete
export function deleteBoxTemplate(id) {
return request({ url: `/admin/mahjong/box/template/${id}`, method: 'delete' })
}
// 应用选中模板:MahjongAdminController.templateApply
export function applyBoxTemplate(data) {
return request({ url: '/admin/mahjong/box/template/apply', method: 'post', data })
}
// 一键重算所有箱子 × 所有身份(按各箱现有配置):MahjongAdminController.recalcAll
export function recalcAll() {
return request({ url: '/admin/mahjong/box/recalc_all', method: 'post' })
}
// 重算分布:按参数批量重算(boxType 空=全部;audience 空=全部身份):MahjongAdminController.recalcBatch
export function recalcBatch(data) {
return request({ url: '/admin/mahjong/box/recalcBatch', method: 'post', data })
}
@@ -0,0 +1,6 @@
import request from '@/utils/request'
// 倍率桶定义列表(16 档 0x~500x):MahjongAdminController.bucketList
export function getBucketList() {
return request({ url: '/admin/mahjong/bucket/list', method: 'get' })
}
@@ -0,0 +1,11 @@
import request from '@/utils/request'
// 每日统计列表(分页 + 按日期区间):MahjongAdminController.winList
export function getWinList(params) {
return request({ url: '/admin/mahjong/win/list', method: 'get', params })
}
// 汇总(按日期区间):MahjongAdminController.winSummary
export function getWinSummary(params) {
return request({ url: '/admin/mahjong/win/summary', method: 'get', params })
}
@@ -0,0 +1,11 @@
import request from '@/utils/request'
// 返奖饰品列表(含返奖区间 winLow/winHigh、品质图 levelImg):MahjongAdminController.prizeList
export function prizeList() {
return request({ url: '/admin/mahjong/prize/list', method: 'get' })
}
// 新增/编辑返奖饰品(同步写 game_mahjong_prize_range):MahjongAdminController.prizeSave
export function prizeSave(data) {
return request({ url: '/admin/mahjong/prize/save', method: 'post', data })
}
@@ -0,0 +1,7 @@
import request from '@/utils/request'
// 玩家记录列表(分页 + 按玩家ID/身份筛选;audience=common/anchor/white,空=全部身份)
// MahjongAdminController.recordList(行内回填 audience)
export function getRecordList(params) {
return request({ url: '/admin/mahjong/record/list', method: 'get', params })
}
@@ -0,0 +1,29 @@
import request from '@/utils/request'
// 奖池配置快照(总池 + 各箱池),按身份 audience=common/anchor/white
// MahjongAdminController.rewardPoolConfig
export function getPoolConfig(params) {
return request({ url: '/admin/mahjong/rewardPool/config', method: 'get', params })
}
// 逐箱注水/抽水(type=1 注水 / 2 抽水,含 audience、remark):MahjongAdminController.rewardPoolAdjust
export function adjustPool(data) {
return request({ url: '/admin/mahjong/rewardPool/adjust', method: 'post', data })
}
// 批量按一轮花费比例注水/抽水:{ audience, type, ratio, remark }
// 本游戏无投注倍数,每箱基数 = bet × 总回合数;MahjongAdminController.rewardPoolAdjustBatch
export function adjustPoolBatch(data) {
return request({ url: '/admin/mahjong/rewardPool/adjustBatch', method: 'post', data })
}
// 重置人工调整:该身份所有箱子注水/抽水累计归零,只保留真实水位(下注−返奖):{ audience, remark }
// MahjongAdminController.rewardPoolAdjustReset
export function adjustPoolReset(data) {
return request({ url: '/admin/mahjong/rewardPool/adjustReset', method: 'post', data })
}
// 奖池调整历史(分页,params 含 audience):MahjongAdminController.rewardPoolAdjustLog
export function getPoolLogs(params) {
return request({ url: '/admin/mahjong/rewardPool/adjustLog', method: 'get', params })
}
@@ -0,0 +1,6 @@
import request from '@/utils/request'
// 预生成回合列表(分页 + 按回合ID/箱子筛选):MahjongAdminController.roundList
export function getRoundList(params) {
return request({ url: '/admin/mahjong/round/list', method: 'get', params })
}
@@ -0,0 +1,448 @@
<template>
<div class="app-container">
<el-row :gutter="10" style="margin-bottom:16px">
<el-input v-model="queryParams.name" placeholder="按赌注搜索" clearable style="width:200px;margin-right:10px" @clear="loadData" @keyup.enter.native="loadData">
<el-button slot="append" icon="el-icon-search" @click="loadData" />
</el-input>
<span style="margin-left:10px;margin-right:4px;color:#606266">身份:</span>
<el-radio-group v-model="audienceFilter" size="small" @change="loadData">
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
</el-radio-group>
<el-button icon="el-icon-refresh" style="margin-left:10px" @click="loadData">刷新</el-button>
<el-button type="danger" icon="el-icon-refresh" :loading="recalcLoading" style="float:right" @click="openRecalcDialog">一键重算分布</el-button>
</el-row>
<el-table v-loading="loading" :data="boxList" border>
<el-table-column prop="boxId" label="ID" min-width="50" align="center" />
<el-table-column prop="bet" label="赌注" min-width="70" align="center" />
<el-table-column label="总回合" min-width="80" align="center">
<template slot-scope="scope">
<span v-if="totalRoundsOf(scope.row) != null">{{ totalRoundsOf(scope.row) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="目标RTP" min-width="80" align="center">
<template slot-scope="scope">
<!-- 显示 1 位小数:配比是"整数条回合 × 桶内实际均值"分出来的,精确值留在 title 里,鼠标悬停可看 -->
<span v-if="targetRtp(scope.row) != null" :title="targetRtp(scope.row) + '%'">{{ Number(targetRtp(scope.row)).toFixed(1) + '%' }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="实时RTP" min-width="80" align="center">
<template slot-scope="scope">
<span v-if="actualRtp(scope.row) != null" :style="{color: actualRtp(scope.row) > (targetRtp(scope.row) != null ? targetRtp(scope.row) : 0) ? '#f56c6c' : '#67c23a'}">{{ actualRtp(scope.row) }}%</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="60" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '1' ? 'success' : 'danger'" size="small">{{ scope.row.status === '1' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="270" align="center">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="info" @click="handleBuckets(scope.row)">饰品配置</el-button>
<el-button size="mini" type="success" @click="openTemplateManager(scope.row)">模板管理</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50,100]"
@size-change="loadData"
@current-change="loadData"
/>
<!-- 编辑弹窗 -->
<el-dialog
:title="'编辑箱子 - 赌注 ' + (form.bet != null ? form.bet : '')"
:visible.sync="dialogVisible"
width="480px"
append-to-body
>
<el-form ref="form" :model="form" label-width="110px">
<el-form-item label="基本赌注"><el-input-number v-model="form.bet" disabled style="width:100%" /></el-form-item>
<el-form-item label="总回合数">
<el-input-number v-model="form.totalRounds" :min="0" :step="50" style="width:100%" />
<span style="color:#909399;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
</el-form-item>
<el-form-item label="目标RTP">
<el-input-number v-model="form.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
<span style="color:#909399;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
</el-form-item>
<el-form-item label="固定/收敛回合%">
<el-input-number v-model="form.fixedPct" :min="0" :max="100" :step="5" style="width:38%" @change="onFixedPctChange" />
<span style="margin-left:4px">%</span>
<span style="margin:0 4%">/</span>
<el-input-number v-model="form.convergePct" :min="0" :max="100" :step="5" style="width:38%" @change="onConvergePctChange" />
<span style="margin-left:4px">%</span>
<div style="color:#909399;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标,随保存作用于当前身份</div>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio label="1">启用</el-radio>
<el-radio label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitForm">确 定</el-button>
</div>
</el-dialog>
<!-- 重算分布弹窗 -->
<el-dialog
title="重算分布"
:visible.sync="recalcDialogVisible"
width="480px"
append-to-body
>
<el-form label-width="110px">
<el-form-item label="身份范围">
<el-radio-group v-model="recalcForm.onlyCurrent">
<el-radio :label="false">全部身份</el-radio>
<el-radio :label="true">仅当前身份【{{ audienceLabel(audienceFilter) }}】</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="目标RTP">
<el-input-number v-model="recalcForm.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
</el-form-item>
<el-form-item label="总回合">
<el-input-number v-model="recalcForm.totalRounds" :min="50" :step="50" style="width:100%" />
</el-form-item>
<el-form-item label="固定/收敛回合%">
<el-input-number v-model="recalcForm.fixedPct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcFixedPctChange" />
<span style="margin-left:4px">%</span>
<span style="margin:0 4%">/</span>
<el-input-number v-model="recalcForm.convergePct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcConvergePctChange" />
<span style="margin-left:4px">%</span>
<div style="color:#909399;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标</div>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="recalcDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="recalcLoading" @click="submitRecalc">确认重算</el-button>
</div>
</el-dialog>
<!-- 饰品配置弹窗(跟随列表页当前身份) -->
<el-dialog
:title="'饰品配置 - (ID:' + currentBox.boxId + ')【' + audienceLabel(audience) + '】'"
:visible.sync="bucketVisible"
width="760px"
append-to-body
>
<div style="display:flex;align-items:center;margin-bottom:10px">
<el-alert type="info" :closable="false" style="flex:1">
身份【{{ audienceLabel(audience) }}】 计算回合(固定): <b>{{ bucketTotal }}</b> &nbsp;|&nbsp;
收敛回合: <b>{{ convergeRoundsDerived }}</b> &nbsp;|&nbsp; 总回合: <b>{{ totalRoundsDerived }}</b> &nbsp;|&nbsp;
计算RTP(池内实际均值): <b>{{ bucketRTP }}%</b> &nbsp;|&nbsp; 目标RTP: <b>{{ currentBucketTargetRtp }}%</b>
</el-alert>
<el-button type="warning" size="small" icon="el-icon-refresh" style="margin-left:10px;flex-shrink:0" @click="handleBucketRecalc">重算分布</el-button>
</div>
<el-table v-loading="bucketLoading" :data="bucketList" border max-height="420">
<el-table-column prop="bucketId" label="桶ID" width="60" align="center" />
<el-table-column label="饰品" min-width="110" align="center">
<template slot-scope="scope">{{ (scope.row.bucketName || scope.row.name || '').replace('x', '倍桶') }}</template>
</el-table-column>
<el-table-column label="倍率范围" min-width="120" align="center">
<template slot-scope="scope">{{ scope.row.rangeLow }} - {{ scope.row.rangeHigh }}</template>
</el-table-column>
<el-table-column label="期望倍率" min-width="90" align="center">
<template slot-scope="scope">{{ scope.row.expectMul }}</template>
</el-table-column>
<el-table-column label="回合数" min-width="140" align="center">
<template slot-scope="scope">
<el-input-number v-model="scope.row.count" :min="0" :step="10" size="small" controls-position="right" />
</template>
</el-table-column>
<el-table-column label="剩余" min-width="70" align="center">
<template slot-scope="scope">
<span :style="{color: (scope.row.remaining != null ? scope.row.remaining : scope.row.count) <= 0 ? '#f56c6c' : '#67c23a'}">{{ scope.row.remaining != null ? scope.row.remaining : scope.row.count }}</span>
</template>
</el-table-column>
<el-table-column label="动态收敛回合" min-width="100" align="center">
<template slot-scope="scope"><span style="color:#e6a23c">{{ scope.row.convergeCount || 0 }}</span></template>
</el-table-column>
</el-table>
<div slot="footer">
<el-button @click="bucketVisible = false">取 消</el-button>
<el-button type="success" icon="el-icon-document-add" @click="handleSaveAsTemplate">保存为模板</el-button>
<el-button type="primary" @click="saveBuckets">保存</el-button>
</div>
</el-dialog>
<!-- 模板管理弹窗(跟随列表页当前身份) -->
<el-dialog
:title="'模板管理 - (ID:' + templateBox.boxId + ')【' + audienceLabel(templateAudience) + '】'"
:visible.sync="templateDialogVisible"
width="720px"
append-to-body
>
<el-alert type="info" :closable="false" style="margin-bottom:12px">
当前为身份【{{ audienceLabel(templateAudience) }}】的模板。模板保存了该箱子该身份的饰品桶回合分布。选择后点【应用】可将该身份桶数量恢复为模板值,并设为抽干补桶依据。
</el-alert>
<el-table v-if="boxTemplates.length > 0" :data="boxTemplates" border highlight-current-row @current-change="onTemplateRowChange">
<el-table-column label="模板名称" min-width="150">
<template slot-scope="scope">
{{ scope.row.name }}
<el-tag v-if="scope.row.isActive === 1" type="success" size="mini" style="margin-left:6px">生效中</el-tag>
</template>
</el-table-column>
<el-table-column label="饰品数" width="80" align="center">
<template slot-scope="scope">{{ scope.row.items ? scope.row.items.length : 0 }}</template>
</el-table-column>
<el-table-column label="固定回合" width="90" align="center">
<template slot-scope="scope">{{ scope.row.totalRounds }}</template>
</el-table-column>
<el-table-column label="保存时间" width="170" align="center">
<template slot-scope="scope">{{ scope.row.createTime }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template slot-scope="scope">
<el-button size="mini" type="danger" @click="handleDeleteTemplate(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="该身份暂无模板,请在饰品配置中修改数量后点【保存为模板】。" />
<div slot="footer">
<el-button @click="templateDialogVisible = false">关 闭</el-button>
<el-button type="primary" :disabled="!selectedTemplateRow" @click="handleApplyTemplate">应用选中模板</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getBoxList, saveBox, getBoxBuckets, saveBoxBuckets, calcDistribution, getBoxTemplates, saveBoxTemplate, deleteBoxTemplate, applyBoxTemplate, recalcBatch } from '@/api/game/mahjong/box/api'
import { getBucketList } from '@/api/game/mahjong/bucket/api'
export default {
name: 'MahjongBox',
data() {
return {
loading: false, recalcLoading: false, saveLoading: false, boxList: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, boxType: null },
audienceFilter: 'common',
dialogVisible: false, bucketVisible: false, currentBox: {},
form: { boxId: null, name: '', bet: 1, boxType: 'normal', rtp: 0.8, status: '1', price: 0, totalRounds: 0, fixedPct: 85, convergePct: 15 },
// 重算分布弹窗
recalcDialogVisible: false,
recalcForm: { onlyCurrent: false, boxType: '', rtp: 0.8, totalRounds: 200, fixedPct: 85, convergePct: 15 },
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
audience: 'common', bucketLoading: false, bucketList: [], bucketAll: [], bucketExpects: {},
// 模板
templateDialogVisible: false, templateBox: {}, templateAudience: 'common', boxTemplates: [], selectedTemplateRow: null
}
},
computed: {
bucketTotal() { return this.bucketList.reduce((s, o) => s + (o.count || 0), 0) },
bucketRTP() {
const total = this.bucketTotal
if (!total || !this.currentBox.bet) return '0.0'
let contrib = 0
this.bucketList.forEach(o => { const mul = this.bucketExpects[o.bucketId]; if (mul != null) contrib += (o.count || 0) * mul })
// 本游戏 costMultiplier ≡ 1(price ≡ bet),此处不再有模式倍率表
const cm = Number(this.currentBox.costMultiplier) || 1
return (contrib / total / cm * 100).toFixed(1)
},
convergeRatioVal() {
let cr = this.currentBox.rtpConvergeRatio != null ? Number(this.currentBox.rtpConvergeRatio) : 0.15
if (!(cr >= 0)) cr = 0.15
return Math.min(0.9, Math.max(0, cr))
},
totalRoundsDerived() { const fixed = this.bucketTotal; return fixed > 0 ? Math.round(fixed / (1 - this.convergeRatioVal)) : 0 },
convergeRoundsDerived() { return Math.max(0, this.totalRoundsDerived - this.bucketTotal) },
// 当前身份目标RTP(%):优先取该身份分布反推值,无则回退箱子统一值
currentBucketTargetRtp() {
const m = this.currentBox.targetRtpByAudience
const v = m && m[this.audience] != null ? m[this.audience] : null
if (v != null) return Number(v).toFixed(1)
return this.currentBox.rtp != null ? (this.currentBox.rtp * 100).toFixed(1) : '-'
}
},
created() { this.loadData() },
methods: {
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
getBoxList(params).then(res => {
this.boxList = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
openRecalcDialog() {
// 默认参数取当前列表第一行的当前身份反推值(无数据时用默认)
const row = this.boxList[0] || {}
let rtp = 0.8, totalRounds = 200, convergePct = 15
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
if (t != null) rtp = Number(t) / 100
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
if (tr != null) totalRounds = Number(tr)
if (row.rtpConvergeRatio != null) convergePct = Math.round(Number(row.rtpConvergeRatio) * 100)
this.recalcForm = { onlyCurrent: false, boxType: '', rtp, totalRounds, fixedPct: 100 - convergePct, convergePct }
this.recalcDialogVisible = true
},
onRecalcFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.fixedPct = val; this.recalcForm.convergePct = 100 - val },
onRecalcConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.convergePct = val; this.recalcForm.fixedPct = 100 - val },
submitRecalc() {
const f = this.recalcForm
const params = {
boxType: f.boxType || null,
audience: f.onlyCurrent ? this.audienceFilter : null,
rtp: f.rtp,
totalRounds: f.totalRounds,
convergeRatio: f.convergePct / 100
}
this.recalcLoading = true
recalcBatch(params).then(() => {
this.$message.success('重算完成')
this.recalcDialogVisible = false
this.loadData()
}).catch(e => this.$message.error('重算失败: ' + (e.message || e)))
.finally(() => { this.recalcLoading = false })
},
handleEdit(row) {
const convergePct = row.rtpConvergeRatio != null ? Math.round(row.rtpConvergeRatio * 100) : 15
// 目标RTP 初始值 = 当前身份分布反推的目标(百分点/100),避免显示成其他身份的值
let rtp = row.rtp != null ? row.rtp : 0.8
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
if (t != null) rtp = Number(t) / 100
// 总回合 初始值 = 当前身份分布反推的一轮总回合(身份级独立)
let totalRounds = row.totalRounds || 0
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
if (tr != null) totalRounds = Number(tr)
this.form = { ...row, totalRounds, rtp, status: row.status || '1', convergePct, fixedPct: 100 - convergePct }
this.dialogVisible = true
},
onFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.fixedPct = val; this.form.convergePct = 100 - val },
onConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.convergePct = val; this.form.fixedPct = 100 - val },
submitForm() {
const convergeRatio = (this.form.convergePct != null ? this.form.convergePct : 15) / 100
// totalRounds 会同时回写 total_rounds,保证 cycleN 与实际池子一致
const data = { boxId: this.form.boxId, name: this.form.name, bet: this.form.bet, boxType: this.form.boxType, rtp: this.form.rtp, status: this.form.status, rtpConvergeRatio: convergeRatio, totalRounds: this.form.totalRounds }
this.saveLoading = true
saveBox(data).then(res => {
const boxId = this.form.boxId || (res.data && res.data.boxId)
if (!boxId) { this.saveLoading = false; this.$message.success('保存成功'); this.dialogVisible = false; this.loadData(); return }
// 保存后只重算当前身份
calcDistribution({ boxId, audience: this.audienceFilter, totalRounds: this.form.totalRounds, rtp: this.form.rtp, convergeRatio })
.then(() => { this.$message.success('修改成功,已重算【' + this.audienceLabel(this.audienceFilter) + '】身份分布') })
.catch(() => { this.$message.success('修改成功(分布计算失败,可稍后用"重算分布"重试)') })
.finally(() => { this.saveLoading = false; this.dialogVisible = false; this.loadData() })
}).catch(() => { this.saveLoading = false })
},
handleBuckets(row) {
this.currentBox = row
this.audience = this.audienceFilter // 跟随列表页当前身份
getBucketList().then(bkRes => {
this.bucketAll = (bkRes.data && bkRes.data.list) ? bkRes.data.list : []
this.bucketExpects = {}
this.bucketAll.forEach(o => { this.bucketExpects[o.id] = Number(o.expectMul) })
this.bucketVisible = true
this._refreshBucketList()
})
},
_refreshBucketList() {
this.bucketLoading = true
getBoxBuckets(this.currentBox.boxId, this.audience).then(res => {
const items = (res.data && res.data.list) ? res.data.list : []
// 「计算RTP」必须与后台「目标RTP」同一口径 = 该桶**池内实际均值**(AVG(actual_mul)),
// 否则会拿桶定义的名义 expectMul(区间中点)来算,两个数必然对不上。
// 池子里还没有该桶的回合时,后端会回退名义值,这里保持同样回退。
const pe = (res.data && res.data.poolExpect) || {}
const expects = {}
this.bucketAll.forEach(o => {
expects[o.id] = pe[o.id] != null ? Number(pe[o.id]) : Number(o.expectMul)
})
this.bucketExpects = expects
const map = {}
items.forEach(it => { map[it.bucketId] = { count: it.count, remaining: it.remaining != null ? it.remaining : (it.count - (it.used || 0)), convergeCount: it.convergeCount } })
// 只有 normal 一种模式:桶范围恒为 1~16
this.bucketList = this.bucketAll.filter(o => o.id >= 1 && o.id <= 16)
.map(o => ({ ...o, count: (map[o.id] && map[o.id].count) || 0, remaining: map[o.id] ? map[o.id].remaining : 0, convergeCount: map[o.id] ? map[o.id].convergeCount : 0 }))
this.bucketLoading = false
}).catch(() => { this.bucketLoading = false })
},
handleBucketRecalc() {
const box = this.currentBox
const cr = box.rtpConvergeRatio != null ? box.rtpConvergeRatio : 0.15
// 目标RTP 优先用当前身份分布反推值,与"按身份编辑"语义一致
const t = box.targetRtpByAudience && box.targetRtpByAudience[this.audience] != null ? Number(box.targetRtpByAudience[this.audience]) / 100 : (box.rtp || 0.8)
// 总回合 用当前身份反推值(身份级独立)
const tr = box.totalRoundsByAudience && box.totalRoundsByAudience[this.audience] != null ? Number(box.totalRoundsByAudience[this.audience]) : (box.totalRounds || 200)
calcDistribution({ boxId: box.boxId, audience: this.audience, totalRounds: tr, rtp: t, convergeRatio: cr }).then(() => {
this.$message.success('分布已重算并保存')
this._refreshBucketList()
this.loadData()
}).catch(() => { this.$message.error('重算失败') })
},
saveBuckets() {
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
saveBoxBuckets({ boxId: this.currentBox.boxId, audience: this.audience, items }).then(() => {
this.$message.success('保存成功')
this.bucketVisible = false
this.loadData()
})
},
handleSaveAsTemplate() {
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
if (items.length === 0) return this.$message.warning('当前没有配置任何桶回合数,无法保存模板')
this.$prompt('请输入模板名称(同一箱子+身份内不可重复)', '保存为模板【' + this.audienceLabel(this.audience) + '】', { inputPattern: /\S+/, inputErrorMessage: '名称不能为空' })
.then(({ value }) => {
saveBoxTemplate({ boxId: this.currentBox.boxId, audience: this.audience, name: value.trim(), items })
.then(() => this.$message.success('模板【' + value.trim() + '】已保存'))
.catch(e => this.$message.error('保存失败: ' + (e.message || e)))
}).catch(() => {})
},
// 模板管理
openTemplateManager(row) { this.templateBox = row; this.templateAudience = this.audienceFilter; this.selectedTemplateRow = null; this.templateDialogVisible = true; this._loadTemplates() },
_loadTemplates() {
getBoxTemplates(this.templateBox.boxId, this.templateAudience).then(res => { this.boxTemplates = (res.data && res.data.list) || [] }).catch(() => { this.boxTemplates = [] })
},
onTemplateRowChange(row) { this.selectedTemplateRow = row },
handleDeleteTemplate(tpl) {
this.$confirm('确定删除模板【' + tpl.name + '】吗?', '提示', { type: 'warning' }).then(() => {
deleteBoxTemplate(tpl.id).then(() => { this.selectedTemplateRow = null; this.$message.success('已删除'); this._loadTemplates() })
}).catch(() => {})
},
handleApplyTemplate() {
if (!this.selectedTemplateRow) return
const tpl = this.selectedTemplateRow
this.$confirm('将模板【' + tpl.name + '】应用到身份【' + this.audienceLabel(this.templateAudience) + '】,并设为抽干补桶依据,确认?', '应用模板', { type: 'warning' }).then(() => {
applyBoxTemplate({ templateId: tpl.id }).then(() => {
this.$message.success('模板已应用,桶分布已更新')
this.templateDialogVisible = false
if (this.bucketVisible && this.currentBox.boxId === this.templateBox.boxId && this.audience === this.templateAudience) this._refreshBucketList()
this.loadData()
}).catch(e => this.$message.error('应用失败: ' + (e.message || e)))
}).catch(() => {})
},
audienceLabel(key) { const a = this.audiences.find(x => x.key === key); return a ? a.name : key },
// 按当前身份筛选取值(后端一次返回4身份)
targetRtp(row) {
const m = row.targetRtpByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
},
actualRtp(row) {
const m = row.actualRtpByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
},
// 当前身份一轮总回合(身份级独立)
totalRoundsOf(row) {
const m = row.totalRoundsByAudience
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : (row.totalRounds != null ? row.totalRounds : null)
}
}
}
</script>
@@ -0,0 +1,33 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:16px">
16 档倍率桶(0x~500x)的定义与落桶区间。单局封顶 500x,所以最高一档的上限就是 500。
分布由 Python 预生成落库,此处仅供参考查看。
</el-alert>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="桶ID" width="80" align="center" />
<el-table-column label="名称" min-width="140" align="center">
<template slot-scope="scope">{{ (scope.row.name || '').replace('x', '倍桶') }}</template>
</el-table-column>
<el-table-column prop="expectMul" label="期望倍率" min-width="120" align="center" />
<el-table-column prop="rangeLow" label="落桶下限" min-width="120" align="center" />
<el-table-column prop="rangeHigh" label="落桶上限" min-width="120" align="center" />
</el-table>
</div>
</template>
<script>
import { getBucketList } from '@/api/game/mahjong/bucket/api'
export default {
name: 'MahjongBucket',
data() { return { loading: false, list: [] } },
created() {
this.loading = true
getBucketList().then(res => {
this.list = (res.data && res.data.list) || []
this.loading = false
}).catch(() => { this.loading = false })
}
}
</script>
@@ -0,0 +1,92 @@
<template>
<div class="app-container">
<el-row :gutter="16" style="margin-bottom:16px">
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计投注</small><h3>{{ summary.totalCost || 0 }}</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计赢奖</small><h3>{{ summary.totalWin || 0 }}</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>整体RTP</small><h3 :style="{color: rtp > 100 ? 'red' : 'green'}">{{ rtp }}%</h3></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>总局数</small><h3>{{ summary.totalSpins || 0 }}</h3></div></el-card></el-col>
</el-row>
<div style="margin-bottom:16px">
<el-date-picker v-model="queryParams.dateStart" type="date" placeholder="开始日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
<span style="margin:0 4px">至</span>
<el-date-picker v-model="queryParams.dateEnd" type="date" placeholder="结束日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
<el-button type="primary" icon="el-icon-search" @click="loadAll">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
</div>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="date" label="日期" min-width="120" align="center" />
<el-table-column prop="totalCost" label="总投注" min-width="120" align="center" />
<el-table-column prop="totalWin" label="总赢奖" min-width="120" align="center" />
<el-table-column prop="totalSpins" label="总局数" min-width="90" align="center" />
<el-table-column prop="playerCount" label="玩家数" min-width="90" align="center" />
<el-table-column label="RTP" min-width="100" align="center">
<template slot-scope="scope">
<span :style="{color: scope.row.totalCost > 0 && scope.row.totalWin / scope.row.totalCost > 1 ? 'red' : 'green'}">
{{ scope.row.totalCost > 0 ? (Math.round(scope.row.totalWin / scope.row.totalCost * 10000) / 100).toFixed(2) + '%' : '-' }}
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getWinList, getWinSummary } from '@/api/game/mahjong/dailywin/api'
export default {
name: 'MahjongDailyWin',
data() {
return {
loading: false, list: [], total: 0, summary: {},
queryParams: { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
}
},
computed: {
rtp() {
const c = Number(this.summary.totalCost) || 0
const w = Number(this.summary.totalWin) || 0
return c > 0 ? (Math.round(w / c * 10000) / 100).toFixed(2) : '0.00'
}
},
created() { this.loadAll() },
methods: {
loadAll() { this.loadSummary(); this.loadData() },
loadSummary() {
getWinSummary({ dateStart: this.queryParams.dateStart, dateEnd: this.queryParams.dateEnd }).then(res => {
this.summary = res.data || {}
})
},
loadData() {
this.loading = true
getWinList(this.queryParams).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleReset() {
this.queryParams = { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
this.loadAll()
}
}
}
</script>
<style scoped>
.stat { text-align: center; }
.stat small { color: #909399; }
</style>
@@ -0,0 +1,133 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:12px">
返奖饰品按"结算赢额"匹配返奖区间展示。区间需连续覆盖;未命中时用兜底饰品。
保存时会同步写入 <b>game_mahjong_prize_range</b>(按 prizeId 唯一)。
</el-alert>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column prop="name" label="名称" min-width="160" align="center" show-overflow-tooltip />
<el-table-column label="品质" width="80" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.grade" size="small" :type="gradeType(scope.row.grade)" :style="gradeStyle(scope.row.grade)">{{ gradeLabel(scope.row.grade) }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="饰品图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.imageUrl" :src="getImg(scope.row.imageUrl)" style="width:40px;height:40px" fit="cover" />
</template>
</el-table-column>
<el-table-column label="品质图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.levelImg" :src="getImg(scope.row.levelImg)" style="width:40px;height:40px" fit="contain" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="磨损" width="110" align="center">
<template slot-scope="scope">{{ scope.row.exteriorName || '崭新出厂' }}</template>
</el-table-column>
<el-table-column label="兜底" width="70" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.isFallback === 1" type="danger" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column label="返奖区间" min-width="160" align="center">
<template slot-scope="scope">
<span v-if="scope.row.winLow != null">{{ scope.row.winLow }} ~ {{ scope.row.winHigh }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="排序" width="70" align="center">
<template slot-scope="scope">{{ scope.row.sort }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="编辑奖品"
:visible.sync="dialogVisible"
width="480px"
append-to-body
>
<el-form ref="form" :model="form" label-width="80px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="品质">
<el-select v-model="form.grade" style="width:100%">
<el-option label="1 - 金" :value="1" />
<el-option label="2 - 红" :value="2" />
<el-option label="3 - 紫" :value="3" />
<el-option label="4 - 蓝" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="饰品图片">
<ImageUpload v-model="form.imageUrl" :limit="1" />
</el-form-item>
<el-form-item label="磨损">
<el-select v-model="form.exteriorName" placeholder="请选择磨损度" clearable style="width:100%">
<el-option label="崭新出厂" value="崭新出厂" />
<el-option label="略有磨损" value="略有磨损" />
<el-option label="久经沙场" value="久经沙场" />
<el-option label="破损不堪" value="破损不堪" />
<el-option label="战痕累累" value="战痕累累" />
</el-select>
</el-form-item>
<el-form-item label="兜底饰品">
<el-switch v-model="form.isFallback" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否" />
<div style="color:#909399;font-size:12px;margin-top:4px">未命中区间时随机选择一个兜底饰品返奖</div>
</el-form-item>
<el-form-item label="返奖区间">
<div style="display:flex;align-items:center;gap:8px">
<el-input-number v-model="form.winLow" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最小值" style="flex:1" />
<span>~</span>
<el-input-number v-model="form.winHigh" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最大值" style="flex:1" />
</div>
<div style="color:#909399;font-size:12px;margin-top:4px">玩家结算赢额落在该区间即获得此饰品(最高到 500x 封顶对应的赢额)</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :step="1" style="width:100%" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { prizeList, prizeSave } from '@/api/game/mahjong/prize/api'
import ImageUpload from '@/components/ImageUpload'
export default {
name: 'MahjongPrize',
components: { ImageUpload },
data() {
return { loading: false, list: [], dialogVisible: false, form: {}}
},
created() { this.getList() },
methods: {
getList() {
this.loading = true
prizeList().then(res => { this.list = (res.data && res.data.list) || []; this.loading = false }).catch(() => { this.loading = false })
},
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' },
// 品质配色与饰品品质一致:1金/2红/3紫/4蓝
gradeType(g) { const m = { 1: 'warning', 2: 'danger', 3: '', 4: '' }; return m[g] === undefined ? 'info' : m[g] },
gradeStyle(g) { return g === 3 ? { color: '#a16ee8', background: '#f7f1fe', borderColor: '#e4d5f9' } : null },
gradeLabel(g) { return ({ 1: '金', 2: '红', 3: '紫', 4: '蓝' })[g] || ('品质' + g) },
handleEdit(row) { this.form = { ...row }; this.dialogVisible = true },
submitForm() {
if (!this.form.name || !this.form.name.trim()) return this.$message.warning('名称不能为空')
prizeSave(this.form).then(() => { this.$message.success('保存成功'); this.dialogVisible = false; this.getList() })
}
}
}
</script>
@@ -0,0 +1,105 @@
<template>
<div class="app-container">
<el-form :inline="true" class="demo-form-inline">
<el-form-item label="玩家ID">
<el-input v-model="queryParams.userId" placeholder="输入玩家ID" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="用户类型">
<el-select v-model="queryParams.audience" placeholder="全部" clearable style="width:130px" @change="handleQuery">
<el-option v-for="a in audiences" :key="a.key" :label="a.name" :value="a.key" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="userId" label="玩家ID" width="90" align="center" />
<el-table-column label="用户类型" width="90" align="center">
<template slot-scope="scope">
<el-tag :type="audienceTagType(scope.row.audience)" size="small">{{ audienceLabel(scope.row.audience) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="boxId" label="箱子" width="70" align="center" />
<el-table-column prop="prizeName" label="饰品名称" min-width="150" align="center" show-overflow-tooltip />
<el-table-column label="饰品图片" width="80" align="center">
<template slot-scope="scope">
<el-image v-if="scope.row.prizeImageUrl" :src="getImg(scope.row.prizeImageUrl)" style="width:40px;height:40px" fit="cover" />
</template>
</el-table-column>
<el-table-column prop="cost" label="花费" width="90" align="center" />
<el-table-column prop="settledWin" label="结算赢额" width="100" align="center" />
<el-table-column label="播放状态" width="110" align="center">
<template slot-scope="scope">
<el-tooltip :content="settleTips[scope.row.settleSource] || '历史值'" placement="top">
<el-tag :type="scope.row.settleSource === 'start' ? 'warning' : 'success'" size="small">
{{ settleLabels[scope.row.settleSource] || scope.row.settleSource || '—' }}
</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="createTime" label="时间" width="160" align="center" />
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getRecordList } from '@/api/game/mahjong/record/api'
export default {
name: 'MahjongRecord',
data() {
return {
loading: false, list: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, userId: null, audience: null },
// 身份 4 种:白号独立库存、不进真实经济
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
// 播放状态:start=开局即结算(奖已入背包)但动画没播完;与"结算赢额"不矛盾
settleLabels: { start: '未播完', finish: '已播完', reconnect: '重连补结', scheduled: '定时兜底', force_settle: '强制结算' },
settleTips: {
start: '开局就已结算:钱和饰品当场定下并直接进背包(防断网丢奖)。只是整局动画没播到末帧(玩家中途退出/断网),客户端战绩暂不展示,超 30 分钟兜底放行',
finish: '整局已播到末帧,客户端战绩正常展示(站内信也是此刻才发)'
}
}
},
created() { this.loadData() },
methods: {
audienceLabel(key) {
const a = this.audiences.find(x => x.key === key)
return a ? a.name : (key || '—')
},
audienceTagType(key) {
return { common: '', anchor: 'success', white: 'warning' }[key] || 'info'
},
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.userId) params.userId = this.queryParams.userId
if (this.queryParams.audience) params.audience = this.queryParams.audience
getRecordList(params).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, userId: null, audience: null }; this.loadData() },
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' }
}
}
</script>
@@ -0,0 +1,452 @@
<template>
<div class="app-container reward-pool-page">
<div class="page-head">
<div class="page-head-row">
<el-button type="text" size="mini" :icon="showGuide ? 'el-icon-arrow-up' : 'el-icon-question'" @click="showGuide = !showGuide">
{{ showGuide ? '收起说明' : '配置说明' }}
</el-button>
</div>
<p class="head-tip">
奖池按【箱子 × 身份】独立:每箱每身份一套水位 = 该身份箱内下注 − 箱内返奖 + 该箱该身份人工调整;开局按「(本箱该身份水位 + 本局花费) × 目标RTP」算赔付上限,只有预期赔付不超上限的回合才可被抽中。白号独立库存,水位仅统计与人工调整,不受上限约束(纯概率),不进真实经济。
</p>
<div class="audience-row">
<span class="aud-label">身份:</span>
<el-radio-group v-model="audience" size="small" @change="switchAudience">
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
</el-radio-group>
<span class="aud-tip">三个身份(普通/主播/白号)各自独立水位、独立注水/抽水、独立调整历史。</span>
</div>
<div v-show="showGuide" class="guide-panel">
<div class="guide-col">
<h5><i class="el-icon-info" /> 机制说明</h5>
<ul>
<li>每箱每身份独立水位 = 该身份箱内累计下注 − 累计返奖 + 人工调整;三个身份互不串池。</li>
<li>白号(audience=white)水位独立统计,但按设计不受水位约束,白号出奖=配比即爆率,注水/抽水只影响记录。</li>
<li>抽回合两阶段:固定阶段按分布抽取(确定性);轮末收敛阶段按实时RTP偏差定向选桶纠偏。</li>
<li>水位随下注升高、随返奖降低;顶部总池为当前身份全站汇总,仅展示不可调。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-setting" /> 如何配置</h5>
<ul>
<li>新箱上线或水位偏低:建议批量注水一轮花费的 60%~80%(接近目标RTP),相当于提前垫付整轮返奖预算,保证高倍桶全程可正常抽出。</li>
<li>日常运营小幅补水:比例 5%~20% 即可;某箱长期不出高倍时检查该箱水位,接近 0 或为负则单箱注水。</li>
<li>返奖偏高想收紧:批量或单箱抽水;水位为负时只会出最低倍,请谨慎大额抽水。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-operation" /> 批量调整规则</h5>
<ul>
<li>基数 = 该箱抽干一轮的总花费 = 赌注 × 总回合数。<b>本游戏无投注倍数</b>(price ≡ 赌注)。</li>
<li>每箱调整金额 = 基数 × 比例。例:赌注 1、1000 回合 → 基数 1000,注水 10% = +100。</li>
<li>比例支持 0~1000%;基数为 0 的箱子自动跳过;批量操作记一条汇总历史可审计(调整原因必填,会写进调整历史)。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-data-line" /> 两阶段RTP</h5>
<ul>
<li>本轮实时RTP / 已抽数反映当前轮收敛进度;一轮抽满 N 回合后自动重置账本开新轮。</li>
<li>收敛占比越高,轮末纠偏能力越强;本游戏只有普通旋转,单局封顶 500x。</li>
<li>总回合数 N 与收敛占比在「箱子配置 → 编辑」中配置。</li>
</ul>
</div>
</div>
</div>
<el-row :gutter="16" class="stat-row">
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-blue"><i class="el-icon-coin" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总下注额</small><h3>{{ fmt(pool.totalBet) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-orange"><i class="el-icon-present" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总返奖额</small><h3>{{ fmt(pool.totalWin) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-cyan"><i class="el-icon-wallet" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总池余额</small><h3 :style="{color: Number(pool.balance) < 0 ? '#f56c6c' : '#409eff'}">{{ fmt(pool.balance) }}</h3></div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-green"><i class="el-icon-data-line" /></div>
<div class="sc-body"><small>{{ audienceLabel(audience) }}·实时RTP</small><h3 :style="{color: rtpPct(pool.rtp) > 100 ? '#f56c6c' : '#67c23a'}">{{ rtpPct(pool.rtp).toFixed(1) }}%</h3></div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="batch-card">
<div slot="header">
<b>批量调整(按一轮花费比例)</b>
<span class="sub-tip">身份【{{ audienceLabel(audience) }}】:基数 = 赌注 × 总回合数(随箱子配置动态变化,本游戏无投注倍数)</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<!-- 注:skin.scss 全局隐藏了 inline 表单的 label,所以字段名写成 span,别用 label 属性 -->
<el-form-item>
<span class="fld-label">调整比例</span>
<el-input-number v-model="batchForm.ratio" :min="0.01" :max="1000" :precision="2" :step="5" :controls="false" style="width:120px" placeholder="如 10" />
<span style="margin-left:4px">%</span>
</el-form-item>
<el-form-item>
<span class="fld-label">调整原因<span class="req">*</span></span>
<el-input v-model="batchForm.remark" maxlength="80" placeholder="必填,如:活动补水" style="width:200px" />
</el-form-item>
<el-form-item>
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 注水,预估共 +' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(1)">
<el-button slot="reference" type="success" plain icon="el-icon-top" :loading="batchLoading" :disabled="!batchForm.ratio">批量注水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 抽水,预估共 -' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(2)">
<el-button slot="reference" type="danger" plain icon="el-icon-bottom" :loading="batchLoading" :disabled="!batchForm.ratio">批量抽水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm :title="'重置将把身份【' + audienceLabel(audience) + '】所有箱子的注水/抽水累计值归 0,只保留真实水位(下注−返奖),确定?'" @confirm="submitReset">
<el-button slot="reference" type="warning" plain icon="el-icon-refresh-left" :loading="resetLoading">重置调整</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item v-if="batchForm.ratio">
<span class="est-text">预估总额:{{ fmt(estBatchTotal) }}({{ boxes.length }} 个箱子)</span>
</el-form-item>
</el-form>
</el-card>
<div class="section-head">
<h4 style="margin:0">箱子奖池 · 身份【{{ audienceLabel(audience) }}】</h4>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="loadConfig">刷新</el-button>
</div>
<el-table v-loading="boxLoading" :data="pagedBoxes" size="small" border stripe>
<el-table-column prop="boxId" label="ID" width="60" align="center" />
<el-table-column prop="name" label="箱子" min-width="140" align="center" show-overflow-tooltip />
<el-table-column label="赌注" width="80" align="center">
<template slot-scope="scope">{{ fmt(scope.row.bet) }}</template>
</el-table-column>
<el-table-column label="一轮花费(基数)" width="130" align="center">
<template slot-scope="scope">
<div>{{ fmt(scope.row.cycleCost) }}</div>
<div style="font-size:11px;color:#909399">{{ fmt(scope.row.costPerRound) }} × {{ scope.row.cycleRounds || '—' }}回合</div>
</template>
</el-table-column>
<el-table-column label="箱内下注" min-width="100" align="center">
<template slot-scope="scope">{{ fmt(scope.row.totalBet) }}</template>
</el-table-column>
<el-table-column label="箱内返奖" min-width="100" align="center">
<template slot-scope="scope">{{ fmt(scope.row.totalWin) }}</template>
</el-table-column>
<el-table-column label="人工调整" min-width="90" align="center">
<template slot-scope="scope">
<span :style="{color: signColor(scope.row.adjust)}">{{ fmt(scope.row.adjust) }}</span>
</template>
</el-table-column>
<el-table-column label="水位余额" min-width="100" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.balance) < 0 ? '#f56c6c' : '#409eff'}">{{ fmt(scope.row.balance) }}</b>
</template>
</el-table-column>
<el-table-column label="累计RTP" width="90" align="center">
<template slot-scope="scope">
<span :style="{color: rtpPct(scope.row.rtp) > 100 ? '#f56c6c' : '#67c23a'}">{{ scope.row.rtp == null ? '—' : rtpPct(scope.row.rtp).toFixed(1) + '%' }}</span>
</template>
</el-table-column>
<el-table-column label="本轮RTP/进度" min-width="120" align="center">
<template slot-scope="scope">
<div :style="{color: rtpPct(scope.row.cycleRtp) > 100 ? '#f56c6c' : '#67c23a'}">{{ scope.row.cycleRtp == null ? '—' : rtpPct(scope.row.cycleRtp).toFixed(1) + '%' }}</div>
<div style="font-size:11px;color:#909399">{{ scope.row.cycleDrawn || 0 }} / {{ scope.row.totalRounds || '—' }} 回合</div>
</template>
</el-table-column>
<el-table-column label="收敛占比" width="80" align="center">
<template slot-scope="scope">{{ scope.row.convergeRatio == null ? '—' : (Number(scope.row.convergeRatio) * 100).toFixed(0) + '%' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template slot-scope="scope">
<el-tooltip content="注水(增加水位)" placement="top">
<el-button circle size="mini" type="success" plain icon="el-icon-top" @click="openAdjust(scope.row, 1)" />
</el-tooltip>
<el-tooltip content="抽水(降低水位)" placement="top">
<el-button circle size="mini" type="danger" plain icon="el-icon-bottom" @click="openAdjust(scope.row, 2)" />
</el-tooltip>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:12px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="boxes.length"
:page-size.sync="boxSize"
:current-page.sync="boxPage"
:page-sizes="[10,20,30,50]"
/>
<el-dialog
:title="'调整水位【' + audienceLabel(audience) + '】- ' + (form.boxName || '')"
:visible.sync="dialogVisible"
width="440px"
append-to-body
>
<el-form label-width="90px" @submit.native.prevent>
<el-form-item label="调整类型">
<el-radio-group v-model="form.type">
<el-radio-button :label="1">注水(增加)</el-radio-button>
<el-radio-button :label="2">抽水(减少)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="当前水位">
<b :style="{color: Number(form.balance) < 0 ? '#f56c6c' : '#409eff'}">{{ fmt(form.balance) }}</b>
</el-form-item>
<el-form-item label="调整金额">
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="100" :controls="false" style="width:100%" placeholder="请输入金额" />
</el-form-item>
<el-form-item v-if="form.amount" label="调整后水位">
<b :style="{color: afterBalance < 0 ? '#f56c6c' : '#67c23a'}">{{ fmt(afterBalance) }}</b>
</el-form-item>
<el-form-item label="调整原因" required>
<el-input v-model="form.remark" maxlength="100" placeholder="必填,如:活动补水" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :type="form.type === 1 ? 'success' : 'danger'" :loading="submitting" @click="submit">确定{{ form.type === 1 ? '注水' : '抽水' }}</el-button>
</div>
</el-dialog>
<div class="section-head">
<h4 style="margin:0">调整历史 · 身份【{{ audienceLabel(audience) }}】</h4>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column prop="createTime" label="时间" min-width="150" align="center" />
<el-table-column label="身份" width="80" align="center">
<template slot-scope="scope"><el-tag size="mini">{{ audienceLabel(scope.row.audience) }}</el-tag></template>
</el-table-column>
<el-table-column prop="boxName" label="箱子" min-width="130" align="center" show-overflow-tooltip />
<el-table-column label="类型" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.type === 1 ? 'success' : (scope.row.type === 2 ? 'danger' : 'warning')" size="mini">
{{ scope.row.type === 1 ? '注水' : (scope.row.type === 2 ? '抽水' : '重置') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" min-width="110" align="center">
<template slot-scope="scope">
<span :style="{color: scope.row.type === 1 ? '#67c23a' : (scope.row.type === 2 ? '#f56c6c' : '#e6a23c')}">
{{ scope.row.type === 1 ? '+' : (scope.row.type === 2 ? '-' : '') }}{{ fmt(scope.row.amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="调整前余额" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.balanceBefore == null ? '—' : fmt(scope.row.balanceBefore) }}</template>
</el-table-column>
<el-table-column label="调整后余额" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.balanceAfter == null ? '—' : fmt(scope.row.balanceAfter) }}</template>
</el-table-column>
<el-table-column label="总池余额" min-width="110" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.poolBalance) < 0 ? '#f56c6c' : '#409eff'}">{{ scope.row.poolBalance == null ? '—' : fmt(scope.row.poolBalance) }}</b>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="90" align="center" />
<el-table-column label="备注" min-width="160" align="center" show-overflow-tooltip>
<template slot-scope="scope">
<span :style="{color: scope.row.remark ? '#303133' : '#c0c4cc'}">{{ scope.row.remark || '—' }}</span>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.size"
:current-page.sync="queryParams.page"
:page-sizes="[10,20,50]"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div>
</template>
<script>
import { getPoolConfig, adjustPool, adjustPoolBatch, adjustPoolReset, getPoolLogs } from '@/api/game/mahjong/rewardPool/api'
export default {
name: 'MahjongRewardPool',
data() {
return {
loading: false,
boxLoading: false,
submitting: false,
batchLoading: false,
resetLoading: false,
dialogVisible: false,
showGuide: false,
audience: 'common',
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
pool: { totalBet: 0, totalWin: 0, adjust: 0, balance: 0, rtp: 0 },
boxes: [],
list: [],
total: 0,
boxPage: 1,
boxSize: 10,
form: { boxId: undefined, boxName: '', balance: 0, type: 1, amount: undefined, remark: '' },
batchForm: { ratio: undefined, remark: '' },
queryParams: { page: 1, size: 10, audience: 'common' }
}
},
computed: {
pagedBoxes() {
const start = (this.boxPage - 1) * this.boxSize
return this.boxes.slice(start, start + this.boxSize)
},
estBatchTotal() {
const r = Number(this.batchForm.ratio) || 0
if (r <= 0) return 0
return this.boxes.reduce((s, b) => s + (Number(b.cycleCost) > 0 ? Number(b.cycleCost) * r / 100 : 0), 0)
},
afterBalance() {
const amt = Number(this.form.amount) || 0
return Number(this.form.balance) + (this.form.type === 2 ? -amt : amt)
}
},
created() {
this.loadConfig()
this.loadLogs()
},
methods: {
fmt(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toFixed(2)
},
rtpPct(v) {
const n = Number(v)
return isNaN(n) ? 0 : n * 100
},
signColor(v) {
const n = Number(v)
return n < 0 ? '#f56c6c' : (n > 0 ? '#67c23a' : '#303133')
},
applyConfig(data) {
if (!data) return
this.pool = data.total || this.pool
this.boxes = data.boxes || []
},
audienceLabel(key) {
const a = this.audiences.find(x => x.key === key)
return a ? a.name : (key || '普通')
},
requiredRemark(v) {
if (v && v.trim()) return true
this.$message.warning('请填写调整原因(备注)')
return false
},
switchAudience() {
this.queryParams.audience = this.audience
this.queryParams.page = 1
this.loadConfig()
this.loadLogs()
},
loadConfig() {
this.boxLoading = true
getPoolConfig({ audience: this.audience }).then(res => {
this.applyConfig(res.data)
this.boxLoading = false
}).catch(() => { this.boxLoading = false })
},
loadLogs() {
this.loading = true
this.queryParams.audience = this.audience
getPoolLogs(this.queryParams).then(res => {
this.list = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
openAdjust(row, type) {
this.form = { boxId: row.boxId, boxName: row.name, audience: this.audience, balance: row.balance, type, amount: undefined, remark: '' }
this.dialogVisible = true
},
submit() {
if (!this.form.amount || this.form.amount <= 0) {
this.$message.warning('请输入大于 0 的调整金额')
return
}
if (!this.requiredRemark(this.form.remark)) return
this.submitting = true
adjustPool({ boxId: this.form.boxId, audience: this.audience, type: this.form.type, amount: this.form.amount, remark: this.form.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success((this.form.type === 1 ? '注水' : '抽水') + '成功')
this.dialogVisible = false
this.queryParams.page = 1
this.loadLogs()
this.submitting = false
}).catch(() => { this.submitting = false })
},
submitBatch(type) {
if (!this.batchForm.ratio || this.batchForm.ratio <= 0) {
this.$message.warning('请输入大于 0 的调整比例')
return
}
if (!this.requiredRemark(this.batchForm.remark)) return
this.batchLoading = true
adjustPoolBatch({ audience: this.audience, type, ratio: this.batchForm.ratio, remark: this.batchForm.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success(`批量${type === 1 ? '注水' : '抽水'}完成:${res.data.adjusted} 个箱子,共 ${this.fmt(res.data.totalAmount)}`)
this.batchForm.ratio = undefined
this.batchForm.remark = ''
this.queryParams.page = 1
this.loadLogs()
this.batchLoading = false
}).catch(() => { this.batchLoading = false })
},
submitReset() {
if (!this.requiredRemark(this.batchForm.remark)) return
this.resetLoading = true
adjustPoolReset({ audience: this.audience, remark: this.batchForm.remark.trim() }).then(res => {
this.applyConfig(res.data)
this.$message.success(`重置完成:${res.data.cleared} 个箱子的人工调整已归零(清除净调整 ${this.fmt(res.data.totalCleared)})`)
this.batchForm.remark = ''
this.queryParams.page = 1
this.loadLogs()
this.resetLoading = false
}).catch(() => { this.resetLoading = false })
}
}
}
</script>
<style scoped>
.page-head { margin-bottom: 14px; }
.page-head-row { display: flex; align-items: center; justify-content: space-between; }
.head-tip { color: #909399; font-size: 12px; margin: 6px 0 0; }
.audience-row { display: flex; align-items: center; margin-top: 10px; }
.aud-label { color: #606266; font-size: 13px; margin-right: 6px; }
.aud-tip { color: #909399; font-size: 12px; margin-left: 10px; }
.guide-panel { display: flex; gap: 16px; background: #f7f9fc; border: 1px solid #e4e9f2; border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
.guide-col { flex: 1; min-width: 0; }
.guide-col h5 { margin: 0 0 8px; font-size: 13px; color: #303133; }
.guide-col h5 i { color: #409eff; margin-right: 4px; }
.guide-col ul { margin: 0; padding-left: 16px; }
.guide-col li { font-size: 12px; color: #606266; line-height: 1.9; }
.stat-row { margin-bottom: 16px; }
.stat-card { display: flex; align-items: center; gap: 12px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.04); }
.sc-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.sc-blue { background: #ecf5ff; color: #409eff; }
.sc-orange { background: #fdf6ec; color: #e6a23c; }
.sc-cyan { background: #e8f7f7; color: #13a8a8; }
.sc-green { background: #f0f9eb; color: #67c23a; }
.sc-body small { color: #909399; display: block; }
.sc-body h3 { margin: 2px 0 0; font-size: 18px; }
.batch-card { margin-bottom: 16px; border-radius: 8px; }
.batch-card .sub-tip { color: #909399; font-size: 12px; margin-left: 10px; }
.fld-label { color: #606266; font-size: 13px; margin-right: 6px; }
.fld-label .req { color: #f56c6c; margin-left: 2px; }
.batch-card >>> .el-form-item { margin-bottom: 0; }
.est-text { color: #e6a23c; font-size: 13px; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
</style>
@@ -0,0 +1,82 @@
<template>
<div class="app-container">
<el-alert type="info" :closable="false" style="margin-bottom:12px">
预生成回合池(只读):池里存的是倍率,与赌注无关。<b>actualMul</b> 为本局实际总倍率(已按 500x 封顶);
<b>hasFs</b>=是否自然触发免费旋转;<b>nearMiss</b>=悬念盘(基础旋恰好 2 个胡且第 5 轴无胡)。
</el-alert>
<el-form :inline="true" :model="queryParams" class="demo-form-inline">
<el-form-item label="回合ID">
<el-input v-model="queryParams.roundId" placeholder="输入回合ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="roundList" border style="width:100%">
<el-table-column prop="roundId" label="回合ID" width="100" align="center" />
<el-table-column prop="boxId" label="箱子ID" width="80" align="center" />
<el-table-column prop="bucketId" label="饰品桶" width="80" align="center" />
<el-table-column prop="actualMul" label="返奖倍率" width="100" align="center" />
<el-table-column prop="freeSpinCount" label="免费旋转" width="90" align="center" />
<el-table-column prop="totalRotations" label="总波数" width="90" align="center" />
<el-table-column label="含FS" width="70" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.hasFs === 1" type="success" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column label="悬念盘" width="80" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.nearMiss === 1" type="warning" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="生成时间" min-width="160" align="center" />
</el-table>
<el-pagination
style="margin-top:16px;text-align:right"
background
layout="total, prev, pager, next, sizes"
:total="total"
:page-size.sync="queryParams.pageSize"
:current-page.sync="queryParams.pageNum"
:page-sizes="[10,20,50,100]"
@size-change="loadData"
@current-change="loadData"
/>
</div>
</template>
<script>
import { getRoundList } from '@/api/game/mahjong/round/api'
export default {
name: 'MahjongRound',
data() {
return {
loading: false, roundList: [], total: 0,
queryParams: { pageNum: 1, pageSize: 10, roundId: null, boxType: null }
}
},
created() { this.loadData() },
methods: {
loadData() {
this.loading = true
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
if (this.queryParams.roundId) params.roundId = this.queryParams.roundId
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
getRoundList(params).then(res => {
this.roundList = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
}).catch(() => { this.loading = false })
},
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, roundId: null, boxType: null }; this.loadData() }
}
}
</script>
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>skins-service</artifactId>
<version>4.8.2</version>
</parent>
<description>
麻将胡了
</description>
<artifactId>game-mahjong-service</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-framework</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>service-admin</artifactId>
<version>4.8.2</version>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>service-user</artifactId>
<version>4.8.2</version>
</dependency>
<!-- 消息通知统一发送模块(末帧结算站内信) -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>message-service</artifactId>
<version>4.8.2</version>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>4.8.2</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,56 @@
package com.ruoyi.game.mahjong.constants;
/**
* 麻将胡了的 Redis key 与平台常量。
* <p>
* ponytail: 本模块自持前缀,不写进共享的 {@code com.ruoyi.admin.config.RedisConstants}
* (那是下一步的共享文件改动项;等 RedisConstants 加上 MAHJONG_* 三个键后,把这些常量改为引用它即可,
* 或者保持不变——前缀值本来就是权威口径 {@code ruoyi:mahjong:*})。
*/
public class MahjongConstants {
/** 单局进行中缓存:ruoyi:mahjong:{userId},TTL 30 分钟 */
public static final String GAME_CACHE = "ruoyi:mahjong:";
/** 一轮内 RTP 收敛账本:Hash ruoyi:mahjong:cycle:{boxId}:{audience} */
public static final String GAME_CYCLE = "ruoyi:mahjong:cycle:";
/** 按【箱 × 身份】奖池水位:ruoyi:mahjong:reward:pool:box:{boxId}:{audience}:* */
public static final String GAME_REWARD_POOL_CACHE = "ruoyi:mahjong:reward:pool";
/** 游戏标识(game_type 列 / 背包 box_name / 流水文案) */
public static final String GAME_TYPE = "MAHJONG";
public static final String BOX_NAME = "MAHJONG";
/**
* 单局总倍率封顶 500x(Q4 定案;玫瑰女郎是 1000x,这是两游戏唯一的金额口径差异)。
* 生成端按它截断并写 step.win_capped,运行端按它置 maxWinReached。
*/
public static final int MAX_WIN_CAP_MUL = 500;
/** 免费旋转总旋数上限(首赠 12 + 每多 1 胡 +2,重触发同式;发不满就不发) */
public static final int MAX_FREE_SPINS = 36;
/** 一屏最多 5 个胡(不会出现 6 个及以上)→ 单次赠送只可能是 12/14/16 */
public static final int MAX_SCATTER_PER_SCREEN = 5;
/** 空转局也发最低档饰品:win = max(totalWin, 0.01)(必得饰品兜底,与玫瑰女郎一致) */
public static final String FLOOR_WIN = "0.01";
/**
* 流水/背包 source 占位值 —— 定义在共享枚举里(下一步动作):
* <ul>
* <li>{@code TtAccountRecordSource.MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费")} / {@code MAHJONG_REWARD(69, INPUT, "麻将胡了奖励")}</li>
* <li>{@code TtboxRecordSource.MAHJONG_BOX(15, "麻将")}</li>
* <li>{@code MessageCategoryEnum.GAME_MAHJONG("GAME_MAHJONG", "麻将胡了")}</li>
* </ul>
* 本次任务明确不改共享文件,因此先以本地常量承接;共享枚举补上后,
* 把 {@code MahjongGameService} 里的引用换成枚举常量即可(语义与数值都已对齐,不会有账面漂移)。
*/
public static final int RECORD_SOURCE_SPIN = 68;
public static final int RECORD_SOURCE_REWARD = 69;
public static final int BOX_RECORD_SOURCE = 15;
public static final String MESSAGE_CATEGORY = "GAME_MAHJONG";
public static final String MESSAGE_CATEGORY_TITLE = "麻将胡了";
private MahjongConstants() {
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.contract.request;
import lombok.Data;
/** 麻将胡了战绩分页请求(scope: 0全服/1我的,默认1我的;均分页) */
@Data
public class RecordPageRequest {
/** 记录范围:0=全服,1=我的(默认) */
private Integer scope;
/** 页码,默认 1 */
private Integer pageNum;
/** 每页条数,默认 10 */
private Integer pageSize;
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.contract.request;
import lombok.Data;
/** dospin 请求:非空=开始新游戏;null=继续当前游戏(下一步) */
@Data
public class SpinRequest {
/** 非空=开新局;null=续播下一步 */
private Long boxId;
/**
* 投注倍数:**预留字段**,当前服务端只接受 1(非 1 直接报错)。
* 本游戏只有投注档、没有投注倍数,pool 里存的是倍率,所以以后要开也不用重生成池子。
*/
private Integer betMul;
}
@@ -0,0 +1,16 @@
package com.ruoyi.game.mahjong.contract.response;
import lombok.Data;
import java.math.BigDecimal;
/** 箱子列表项:只有一个普通旋转模式,所以价格恒等于投注档(price == bet) */
@Data
public class BoxResponse {
private Long boxId;
private String boxName;
private BigDecimal basicBet;
private BigDecimal price;
private String boxType;
private String modeName;
}
@@ -0,0 +1,34 @@
package com.ruoyi.game.mahjong.contract.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 麻将胡了战绩列表项(我的记录/全服记录共用,只暴露客户端展示需要的字段) */
@Data
public class RecordItemResponse {
/** 玩家昵称 */
private String nickName;
/** 玩家头像 */
private String avatar;
/** 本局消费(下注金额,cost = box.bet) */
private BigDecimal cost;
/** 本局获得饰品的价格(= 结算金额) */
private BigDecimal settledWin;
/** 饰品图片 */
private String prizeImageUrl;
/** 饰品全名:名称(磨损品质),无品质时仅名称 */
private String prizeName;
/** 开局时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
}
@@ -0,0 +1,47 @@
package com.ruoyi.game.mahjong.contract.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.math.BigDecimal;
/**
* 帧响应(对齐设计方案 §7.1)。
* 与玫瑰女郎的字段差异:spinNum/tumbleNum → reelSpinNum/rotationNum;
* multiplierDetail(炸弹) → goldenDetail + wildConverted;accumulator → rotationMul(本波阶梯倍率)。
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SpinResponse {
private String state; // "PLAYING" | "FINISH"
private Integer reelSpinNum; // 0=基础旋,>0=第 N 次免费旋转
private Integer rotationNum; // 本旋内第几波(级联序号)
private Integer globalStep;
private Integer globalTotalSteps;
private Integer spinsRemaining;
private Object gridState; // 4 行 × 5 列 = 20 格短码矩阵
private Object goldenDetail; // [{"pos","symbol"}] 本帧仍为"金色普通符号"的格
private Object wildConverted; // [{"pos","fromSymbol"}] 本波就地转成百搭的格
private Object waysDetail; // [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]
private Object dropData;
private Object fillData;
private Object scatterTrigger; // {"scatterCount":3,"freeSpins":12,"extraSpins":0}
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
private BigDecimal rotationMul; // 本波阶梯倍率(基础旋 1/2/3/5、FS 2/4/6/10);重连时用它接回档位
private Object retriggerInfo;
private String bigWinLevel; // small | middle | big(仅免费旋转的旋末帧可能有值)
// ---- 仅 FINISH ----
private BigDecimal totalWin; // 总赢额(显示用现金数字)
private BigDecimal totalMul; // 总倍率(= round.actual_mul)
private Boolean maxWinReached; // 500x 封顶标识
private Object prizeInfo; // {id,name,imageUrl,grade,exteriorName,isFallback}
private Long boxRecordId; // 背包记录 ID(末帧「放入背包 / 立即分解」都靠它)
private Long recordId; // 本局记录 ID(战绩用)
}
@@ -0,0 +1,747 @@
package com.ruoyi.game.mahjong.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.domain.other.TtOrnamentsLevel;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import com.ruoyi.game.mahjong.domain.MahjongPrizeRange;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.domain.MahjongRewardLog;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxTemplateMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeOrnamentMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeRangeMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRewardLogMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import com.ruoyi.game.mahjong.repository.MahjongRewardPoolCacheRepository;
import com.ruoyi.game.mahjong.service.MahjongDistributionService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
/**
* 后台管理接口:Base = /admin/mahjong(对齐设计方案 §8 的 7 个页面)。
* 与玫瑰女郎逐行同构,差异:只有 normal 一个模式、桶 16 档、封顶 500x、game_type = MAHJONG。
*/
@Slf4j
@Api(tags = "麻将胡了后台")
@RestController
@RequestMapping("/admin/mahjong")
public class MahjongAdminController extends BaseController {
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongRewardPoolCacheRepository rewardPoolRepository;
@Autowired private MahjongBucketMapper bucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
@Autowired private MahjongPrizeOrnamentMapper prizeOrnamentMapper;
@Autowired private MahjongPrizeRangeMapper prizeRangeMapper;
@Autowired private MahjongBoxTemplateMapper boxTemplateMapper;
@Autowired private MahjongCycleRepository cycleRepository;
@Autowired private MahjongDistributionService distributionService;
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
@Autowired private TtUserService userService;
@Autowired private MahjongRewardLogMapper rewardLogMapper;
// ==================== 箱子配置 ====================
@ApiOperation("箱子列表(含各身份目标/实时 RTP 与一轮总回合)")
@GetMapping("/box/list")
public R boxList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String boxType,
@RequestParam(required = false) String name) {
LambdaQueryWrapper<MahjongBox> qw = new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG");
if (boxType != null && !boxType.isEmpty()) qw.eq(MahjongBox::getBoxType, boxType);
// 名称模糊搜索:页面上的「按名称搜索」框要有实际效果(玫瑰女郎那边这个框是摆设,这里补上了)
if (name != null && !name.isEmpty()) qw.like(MahjongBox::getName, name);
qw.orderByAsc(MahjongBox::getBoxId);
Page<MahjongBox> page = boxMapper.selectPage(new Page<>(pageNum, pageSize), qw);
// 各身份分布一次全量查出(11箱×4身份量小),内存分组,避免逐身份 N+1
Map<Long, Map<Integer, Double>> expectByBox = new HashMap<>();
Map<String, List<MahjongBoxBucket>> distByKey = new HashMap<>();
for (MahjongBoxBucket bb : boxBucketMapper.selectList(null)) {
if (bb.getBoxId() == null || bb.getAudience() == null) continue;
distByKey.computeIfAbsent(bb.getBoxId() + ":" + bb.getAudience(), k -> new ArrayList<>()).add(bb);
}
for (MahjongBox box : page.getRecords()) {
try {
MahjongModeType m = MahjongModeType.fromBoxType(box.getBoxType());
double cm = m.getCostMultiplier();
Map<Integer, Double> bucketMul = expectByBox.computeIfAbsent(box.getBoxId(),
k -> distributionService.loadPoolExpect(k));
box.setModeName(m.getName());
box.setCostMultiplier(BigDecimal.valueOf(cm));
Map<String, BigDecimal> targetMap = new HashMap<>();
Map<String, BigDecimal> actualMap = new HashMap<>();
Map<String, BigDecimal> totalMap = new HashMap<>();
for (String aud : AUDIENCES) {
// 目标RTP:从该身份分布反推 = Σ(count×expect_mul) / Σ(count) / CM × 100
List<MahjongBoxBucket> list = distByKey.get(box.getBoxId() + ":" + aud);
if (list != null && !list.isEmpty()) {
double sumMul = 0; int totalCount = 0;
for (MahjongBoxBucket bb : list) {
int c = bb.getCount() != null ? bb.getCount() : 0;
if (c <= 0) continue;
sumMul += c * bucketMul.getOrDefault(bb.getBucketId(), 0.0);
totalCount += c;
}
if (totalCount > 0) {
targetMap.put(aud, BigDecimal.valueOf(sumMul / totalCount / cm * 100)
.setScale(2, RoundingMode.HALF_UP));
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
totalMap.put(aud, BigDecimal.valueOf(
MahjongDistributionService.deriveTotalRounds(totalCount, cr)));
}
}
// 实时RTP:按 (boxId, audience) 循环账本各查一次(CM ≡ 1,不再除 CM)
BigDecimal r = cycleRepository.realtimeRtp(box.getBoxId(), aud);
if (r != null) actualMap.put(aud, r);
}
box.setTargetRtpByAudience(targetMap);
box.setActualRtpByAudience(actualMap);
box.setTotalRoundsByAudience(totalMap);
box.setActualRtp(actualMap.get("common")); // 兼容保留 common 口径
} catch (Exception ignore) {}
}
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
@Log(title = "箱子配置", action = "编辑", businessType = BusinessType.UPDATE)
@PostMapping("/box/save")
@Transactional(rollbackFor = Exception.class)
public R boxSave(@RequestBody MahjongBox box) {
if (box.getBet() == null || box.getBoxType() == null) return R.fail("下注与模式不能为空");
try {
MahjongModeType m = MahjongModeType.fromBoxType(box.getBoxType());
box.setGameType("MAHJONG");
// cost = bet(costMultiplier ≡ 1):price 恒等于投注档,没有模式倍率
box.setPrice(box.getBet().multiply(BigDecimal.valueOf(m.getCostMultiplier())));
} catch (IllegalArgumentException e) {
return R.fail("无效模式: " + box.getBoxType());
}
if (box.getBoxId() != null && boxMapper.selectById(box.getBoxId()) != null) boxMapper.updateById(box);
else boxMapper.insert(box);
return R.ok(Map.of("boxId", box.getBoxId()));
}
/** 某箱某身份的桶分布:含 convergeCount(收敛账本) + fixedRounds/convergeRounds(按该身份分布反推) */
@ApiOperation("某箱某身份的倍率桶分布")
@GetMapping("/box/buckets/{boxId}")
public R boxBuckets(@PathVariable Long boxId,
@RequestParam(required = false, defaultValue = "common") String audience) {
List<MahjongBoxBucket> list = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
for (MahjongBoxBucket bb : list) {
bb.setConvergeCount(cycleRepository.convergeCount(boxId, audience, bb.getBucketId()));
}
MahjongBox box = boxMapper.selectById(boxId);
int fixedSum = list.stream().mapToInt(bb -> bb.getCount() != null ? bb.getCount() : 0).sum();
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
int fixedRounds = fixedSum;
int convergeRounds = fixedSum > 0 ? Math.max(0, MahjongDistributionService.deriveTotalRounds(fixedSum, cr) - fixedSum) : 0;
// poolExpect:该箱每个桶"池子里实际会赔多少"的均值(= 列表页「目标RTP」用的同一口径)。
// 前端「计算RTP」必须拿它算,否则会拿桶定义的名义 expect_mul(区间中点)算出跟目标RTP不一致的数。
return R.ok(Map.of("list", list, "audience", audience, "fixedRounds", fixedRounds,
"convergeRounds", convergeRounds, "poolExpect", distributionService.loadPoolExpect(boxId)));
}
@Log(title = "箱子配置", action = "保存", businessType = BusinessType.UPDATE)
@ApiOperation("保存某箱某身份的倍率桶分布(items 为固定池回合,反推回写总回合)")
@PostMapping("/box/buckets/save")
@Transactional(rollbackFor = Exception.class)
public R boxBucketsSave(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
if (boxId == null) return R.fail("boxId 不能为空");
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
distributionService.writeDistribution(boxId, audience, items);
cycleRepository.resetCycle(boxId, audience);
return R.ok();
}
@Log(title = "箱子配置", action = "重算分布", businessType = BusinessType.UPDATE)
@ApiOperation("按目标 RTP 重算某箱某身份分布")
@PostMapping("/box/buckets/calc")
public R calcDistribution(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
Integer totalRounds = asInt(params.get("totalRounds"));
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
Double convergeRatio = params.get("convergeRatio") != null ? Double.parseDouble(params.get("convergeRatio").toString()) : null;
MahjongBox box = boxMapper.selectById(boxId);
if (box == null) return R.fail("箱子不存在");
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
double cr = convergeRatio != null ? convergeRatio
: (box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15);
int fixedRoundsN = MahjongDistributionService.deriveFixedRounds(totalRounds != null ? totalRounds : 200, cr);
int canonicalN = MahjongDistributionService.deriveTotalRounds(fixedRoundsN, cr);
box.setTotalRounds(canonicalN);
box.setRtp(BigDecimal.valueOf(rtp));
if (convergeRatio != null) box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio));
boxMapper.updateById(box);
distributionService.reset(boxId, audience);
cycleRepository.resetCycle(boxId, audience);
List<MahjongBoxBucket> saved = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
Map<Integer, Double> bucketMul = distributionService.loadPoolExpect(boxId);
List<Map<String, Object>> result = new ArrayList<>();
int total = 0; double contrib = 0;
for (MahjongBoxBucket bb : saved) {
int c = bb.getCount() != null ? bb.getCount() : 0;
result.add(Map.of("bucketId", bb.getBucketId(), "count", c));
total += c;
contrib += c * bucketMul.getOrDefault(bb.getBucketId(), 0.0);
}
double cm = mode.getCostMultiplier();
double calcRtpValue = total > 0 ? contrib / total / cm * 100 : 0;
return R.ok(Map.of("items", result, "totalRounds", total, "boxTotal", canonicalN,
"convergeRounds", Math.max(0, canonicalN - total),
"calcRtp", String.format("%.1f%%", calcRtpValue)));
}
/** 模拟 payoutCap 约束下的预估实际 RTP */
@PostMapping("/box/buckets/simulate-rtp")
public R simulateRtp(@RequestBody Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
Long boxId = asLong(params.get("boxId"));
double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
if (items == null || items.isEmpty()) return R.ok(Map.of("estimatedRtp", "0.0%"));
Map<Integer, Integer> dist = new LinkedHashMap<>();
for (Map<String, Object> item : items) {
Integer bucketId = asInt(item.get("bucketId"));
Integer count = asInt(item.get("count"));
if (bucketId != null && count != null && count > 0) dist.put(bucketId, count);
}
double est = distributionService.simulateActualRtp(dist, boxId, rtp, 10);
return R.ok(Map.of("estimatedRtp", String.format("%.1f%%", est)));
}
// ==================== 分布模板(按身份) ====================
@GetMapping("/box/templates/{boxId}")
public R templateList(@PathVariable Long boxId,
@RequestParam(required = false, defaultValue = "common") String audience) {
List<MahjongBoxTemplate> list = boxTemplateMapper.selectList(
new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId)
.eq(MahjongBoxTemplate::getAudience, audience)
.orderByDesc(MahjongBoxTemplate::getId));
for (MahjongBoxTemplate t : list) {
t.setItems(distributionService.parseItems(t.getItemsJson()));
t.setItemsJson(null);
}
return R.ok(Map.of("list", list));
}
@Log(title = "箱子配置", action = "保存为模板", businessType = BusinessType.INSERT)
@PostMapping("/box/template/save")
@Transactional(rollbackFor = Exception.class)
public R templateSave(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
if (boxId == null) return R.fail("boxId 不能为空");
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
String name = params.get("name") != null ? params.get("name").toString().trim() : "";
if (name.isEmpty()) return R.fail("模板名称不能为空");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
if (items == null || items.isEmpty()) return R.fail("模板倍率桶分布不能为空");
Long dup = boxTemplateMapper.selectCount(new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId).eq(MahjongBoxTemplate::getAudience, audience)
.eq(MahjongBoxTemplate::getName, name));
if (dup != null && dup > 0) return R.fail("模板名称已存在,请使用不同的名称");
int total = 0;
for (Map<String, Object> item : items) { Integer c = asInt(item.get("count")); if (c != null && c > 0) total += c; }
MahjongBoxTemplate tpl = new MahjongBoxTemplate();
tpl.setBoxId(boxId);
tpl.setAudience(audience);
tpl.setName(name);
tpl.setTotalRounds(total);
tpl.setItemsJson(distributionService.toItemsJson(items));
tpl.setIsActive(0);
boxTemplateMapper.insert(tpl);
return R.ok(Map.of("id", tpl.getId()));
}
@Log(title = "箱子配置", action = "删除", businessType = BusinessType.DELETE)
@DeleteMapping("/box/template/{id}")
public R templateDelete(@PathVariable Long id) {
boxTemplateMapper.deleteById(id);
return R.ok();
}
@Log(title = "箱子配置", action = "应用选中模板", businessType = BusinessType.UPDATE)
@PostMapping("/box/template/apply")
@Transactional(rollbackFor = Exception.class)
public R templateApply(@RequestBody Map<String, Object> params) {
Long templateId = asLong(params.get("templateId"));
if (templateId == null) return R.fail("templateId 不能为空");
MahjongBoxTemplate tpl = boxTemplateMapper.selectById(templateId);
if (tpl == null) return R.fail("模板不存在");
distributionService.writeDistribution(tpl.getBoxId(), tpl.getAudience(), distributionService.parseItems(tpl.getItemsJson()));
cycleRepository.resetCycle(tpl.getBoxId(), tpl.getAudience());
List<MahjongBoxTemplate> siblings = boxTemplateMapper.selectList(new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, tpl.getBoxId()).eq(MahjongBoxTemplate::getAudience, tpl.getAudience()));
for (MahjongBoxTemplate s : siblings) {
int active = s.getId().equals(templateId) ? 1 : 0;
if (!Integer.valueOf(active).equals(s.getIsActive())) { s.setIsActive(active); boxTemplateMapper.updateById(s); }
}
return R.ok();
}
/** 一键重算所有箱子 × 所有身份分布 */
@PostMapping("/box/recalc_all")
public R recalcAll() {
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int done = 0;
for (MahjongBox box : boxes) {
for (String aud : AUDIENCES) {
try {
distributionService.reset(box.getBoxId(), aud);
cycleRepository.resetCycle(box.getBoxId(), aud);
done++;
} catch (Exception ignore) {}
}
}
return R.ok(Map.of("recalculated", done));
}
@Log(title = "箱子配置", action = "一键重算分布", businessType = BusinessType.CLEAN)
/**
* 重算分布(弹窗):按统一参数批量重算。
* 入参:boxType 可选(空=全部模式);audience 可选(空=全部身份);rtp/totalRounds/convergeRatio 非空则统一设置到箱,再按最新配置重算分布。
*/
@PostMapping("/box/recalcBatch")
@Transactional(rollbackFor = Exception.class)
public R recalcBatch(@RequestBody Map<String, Object> params) {
String boxType = params.get("boxType") != null && !params.get("boxType").toString().isEmpty()
? params.get("boxType").toString() : null;
String audience = params.get("audience") != null && !params.get("audience").toString().isEmpty()
? params.get("audience").toString() : null;
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : null;
Integer totalRounds = asInt(params.get("totalRounds"));
Double convergeRatio = params.get("convergeRatio") != null
? Double.parseDouble(params.get("convergeRatio").toString()) : null;
LambdaQueryWrapper<MahjongBox> qw = new LambdaQueryWrapper<MahjongBox>()
.eq(MahjongBox::getGameType, "MAHJONG");
if (boxType != null) qw.eq(MahjongBox::getBoxType, boxType);
List<MahjongBox> boxes = boxMapper.selectList(qw);
if (boxes.isEmpty()) return R.fail("没有匹配的箱子");
List<String> audiences = audience != null ? Collections.singletonList(audience) : AUDIENCES;
int done = 0;
for (MahjongBox box : boxes) {
boolean changed = false;
if (rtp != null) { box.setRtp(BigDecimal.valueOf(rtp)); changed = true; }
if (totalRounds != null && totalRounds > 0) { box.setTotalRounds(totalRounds); changed = true; }
if (convergeRatio != null) { box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio)); changed = true; }
if (changed) boxMapper.updateById(box);
for (String aud : audiences) {
try {
distributionService.reset(box.getBoxId(), aud);
cycleRepository.resetCycle(box.getBoxId(), aud);
done++;
} catch (Exception ignore) {}
}
}
return R.ok(Map.of("recalculated", done, "boxes", boxes.size()));
}
// ==================== 玩家记录 ====================
/** 玩家记录列表:可按玩家ID + 身份筛选;行内回填 audience(默认全部身份,含白号/机器人) */
@GetMapping("/record/list")
public R recordList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String audience) {
LambdaQueryWrapper<MahjongRecord> qw = new LambdaQueryWrapper<>();
// 空 = 全部身份(与奖池页"未选身份默认普通"不同:这里空是不过滤)
if (audience != null && !audience.trim().isEmpty() && !AUDIENCES.contains(audience.trim())) {
return R.fail("身份无效");
}
qw.inSql(MahjongRecord::getUserId, audienceUserSubQuery(audience));
if (userId != null) qw.eq(MahjongRecord::getUserId, userId);
qw.orderByDesc(MahjongRecord::getCreateTime);
Page<MahjongRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
fillAudience(page.getRecords());
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
/**
* 身份 → tt_user 子查询。奖池 **3 池**口径(common/anchor/white;robot 维度已去掉,
* user_type='03' 归入 common),另附封禁/逻辑删除兜底:
* 封禁(status=1)和已删除用户任何身份都不展示;身份为空=全部身份(不再无条件排除白号)。
*/
private static String audienceUserSubQuery(String audience) {
String base = "SELECT user_id FROM tt_user WHERE status != '1' AND del_flag = '0' AND ";
if ("white".equals(audience)) return base + "is_blank_account = 1";
if ("anchor".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND user_type = '01'";
if ("common".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND (user_type IS NULL OR user_type != '01')";
return base + "1 = 1";
}
/** 批量回填每行身份:复用共享身份判定,勿另写一套 */
private void fillAudience(List<MahjongRecord> rows) {
if (rows == null || rows.isEmpty()) return;
Set<Long> ids = rows.stream().map(MahjongRecord::getUserId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (ids.isEmpty()) return;
Map<Long, String> audMap = new HashMap<>();
for (TtUser u : userService.listByIds(ids)) {
if (u.getUserId() != null) {
audMap.put(u.getUserId().longValue(),
com.ruoyi.game.mahjong.enums.MahjongAudience.resolve(u).getCode());
}
}
for (MahjongRecord r : rows) {
r.setAudience(audMap.getOrDefault(r.getUserId(), "common"));
}
}
// ==================== 每日统计 ====================
@GetMapping("/win/summary")
public R winSummary(@RequestParam(required = false) String dateStart,
@RequestParam(required = false) String dateEnd) {
Map<String, Object> row = recordMapper.winSummary(dateStart, dateEnd);
return R.ok(row);
}
/** 每日统计列表:按天聚合 game_mahjong_record(与 winSummary 同源同口径,支持日期筛选) */
@GetMapping("/win/list")
public R winList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String dateStart,
@RequestParam(required = false) String dateEnd) {
List<Map<String, Object>> all = recordMapper.dailyList(dateStart, dateEnd);
int from = Math.max(0, (pageNum - 1) * pageSize);
if (from >= all.size()) return R.ok(Map.of("rows", Collections.emptyList(), "total", all.size()));
return R.ok(Map.of("rows", all.subList(from, Math.min(all.size(), from + pageSize)), "total", all.size()));
}
// ==================== 倍率桶定义 ====================
@GetMapping("/bucket/list")
public R bucketList() {
List<MahjongBucket> list = bucketMapper.selectList(
new LambdaQueryWrapper<MahjongBucket>().orderByAsc(MahjongBucket::getId));
return R.ok(Map.of("list", list));
}
// ==================== 预生成回合(回合管理) ====================
@GetMapping("/round/list")
public R roundList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long roundId,
@RequestParam(required = false) String boxType) {
LambdaQueryWrapper<MahjongRound> qw = new LambdaQueryWrapper<>();
if (roundId != null) qw.eq(MahjongRound::getRoundId, roundId);
// 收集箱子 id→模式,供筛选与回填 boxType
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
Map<Long, String> boxTypeMap = new HashMap<>();
List<Long> filterBoxIds = new ArrayList<>();
for (MahjongBox b : boxes) {
boxTypeMap.put(b.getBoxId(), b.getBoxType());
if (boxType != null && !boxType.isEmpty() && boxType.equals(b.getBoxType())) filterBoxIds.add(b.getBoxId());
}
if (boxType != null && !boxType.isEmpty()) {
if (filterBoxIds.isEmpty()) return R.ok(Map.of("rows", Collections.emptyList(), "total", 0));
qw.in(MahjongRound::getBoxId, filterBoxIds);
}
qw.orderByAsc(MahjongRound::getRoundId);
Page<MahjongRound> page = roundMapper.selectPage(new Page<>(pageNum, pageSize), qw);
for (MahjongRound r : page.getRecords()) r.setBoxType(boxTypeMap.get(r.getBoxId()));
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
// ==================== 返奖饰品(奖品管理) ====================
@GetMapping("/prize/list")
public R prizeList() {
List<MahjongPrizeOrnament> list = prizeOrnamentMapper.selectList(
new LambdaQueryWrapper<MahjongPrizeOrnament>().orderByAsc(MahjongPrizeOrnament::getSort));
List<MahjongPrizeRange> ranges = prizeRangeMapper.selectList(new LambdaQueryWrapper<>());
Map<Long, MahjongPrizeRange> rangeMap = new HashMap<>();
for (MahjongPrizeRange r : ranges) rangeMap.put(r.getPrizeId(), r);
for (MahjongPrizeOrnament o : list) {
MahjongPrizeRange r = rangeMap.get(o.getId());
if (r != null) { o.setWinLow(r.getWinLow()); o.setWinHigh(r.getWinHigh()); }
if (o.getGrade() != null && ornamentsLevelMapper != null) {
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(o.getGrade());
if (lv != null) o.setLevelImg(lv.getLevelImg());
}
}
return R.ok(Map.of("list", list));
}
@Log(title = "奖品管理", action = "编辑", businessType = BusinessType.UPDATE)
@PostMapping("/prize/save")
@Transactional(rollbackFor = Exception.class)
public R prizeSave(@RequestBody MahjongPrizeOrnament prize) {
if (prize.getName() == null || prize.getName().trim().isEmpty()) return R.fail("饰品名称不能为空");
prize.setGameType("MAHJONG");
if (prize.getStatus() == null) prize.setStatus("1");
if (prize.getId() != null && prizeOrnamentMapper.selectById(prize.getId()) != null) prizeOrnamentMapper.updateById(prize);
else prizeOrnamentMapper.insert(prize);
// 同步区间(按 prizeId 唯一)
if (prize.getWinLow() != null && prize.getWinHigh() != null) {
MahjongPrizeRange r = prizeRangeMapper.selectOne(
new LambdaQueryWrapper<MahjongPrizeRange>().eq(MahjongPrizeRange::getPrizeId, prize.getId()).last("LIMIT 1"));
if (r == null) { r = new MahjongPrizeRange(); r.setPrizeId(prize.getId()); }
r.setWinLow(prize.getWinLow());
r.setWinHigh(prize.getWinHigh());
if (r.getId() != null) prizeRangeMapper.updateById(r); else prizeRangeMapper.insert(r);
}
return R.ok(Map.of("id", prize.getId()));
}
// ==================== 水位(按【箱 × 身份】注水/抽水) ====================
/** 奖池身份维度,与共享身份判定对齐 */
private static final List<String> AUDIENCES = Arrays.asList("common", "anchor", "white");
/** 身份入参归一化:空=common;非法返回 null(调用方报错,不静默兜底) */
private static String audienceOf(Object v) {
if (v == null || v.toString().trim().isEmpty()) return "common";
String s = v.toString().trim();
return AUDIENCES.contains(s) ? s : null;
}
/** 奖池配置快照:某身份各箱水位/RTP/本轮进度/收敛占比 + 一轮花费基数 */
@GetMapping("/rewardPool/config")
public R rewardPoolConfig(@RequestParam(required = false) String audience) {
String aud = audienceOf(audience);
if (aud == null) return R.fail("身份无效");
return R.ok(poolConfigMap(aud));
}
/** 组装奖池配置(total + boxes),供 config 与 adjust/batch/reset 复用 */
private Map<String, Object> poolConfigMap(String audience) {
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG").orderByAsc(MahjongBox::getBoxId));
List<Map<String, Object>> boxList = new ArrayList<>();
BigDecimal totalBet = BigDecimal.ZERO, totalWin = BigDecimal.ZERO, totalBalance = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
Long boxId = box.getBoxId();
com.ruoyi.game.mahjong.model.cache.MahjongRewardPoolCache c = rewardPoolRepository.getBoxCache(boxId, audience);
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience);
double cm = 1.0;
try { cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
int rounds = box.getTotalRounds() != null ? box.getTotalRounds() : 0;
BigDecimal costPerRound = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm));
BigDecimal cycleCost = costPerRound.multiply(BigDecimal.valueOf(rounds));
long drawn = cycleRepository.drawn(boxId, audience);
Double cycleRtp = null;
if (drawn > 0 && cm > 0) cycleRtp = cycleRepository.mulSum(boxId, audience) / drawn / cm;
Map<String, Object> m = new LinkedHashMap<>();
m.put("boxId", boxId);
m.put("name", box.getName());
m.put("audience", audience);
m.put("boxType", box.getBoxType());
m.put("bet", box.getBet());
m.put("totalBet", c.getTotalBet());
m.put("totalWin", c.getTotalWin());
m.put("adjust", c.getAdjust());
m.put("balance", balance);
m.put("costPerRound", costPerRound.setScale(2, RoundingMode.HALF_UP));
m.put("cycleRounds", rounds);
m.put("cycleCost", cycleCost.setScale(2, RoundingMode.HALF_UP));
m.put("totalRounds", rounds);
m.put("convergeRatio", box.getRtpConvergeRatio());
m.put("cycleDrawn", drawn);
m.put("cycleRtp", cycleRtp);
m.put("rtp", c.getTotalBet().signum() > 0
? c.getTotalWin().divide(c.getTotalBet(), 4, RoundingMode.HALF_UP) : null);
boxList.add(m);
totalBet = totalBet.add(c.getTotalBet());
totalWin = totalWin.add(c.getTotalWin());
totalBalance = totalBalance.add(balance);
}
Map<String, Object> total = new LinkedHashMap<>();
total.put("audience", audience);
total.put("totalBet", totalBet);
total.put("totalWin", totalWin);
total.put("adjust", rewardPoolRepository.getGlobalAdjust(audience));
total.put("balance", totalBalance);
total.put("rtp", totalBet.signum() > 0 ? totalWin.divide(totalBet, 4, RoundingMode.HALF_UP) : null);
Map<String, Object> result = new LinkedHashMap<>();
result.put("audience", audience);
result.put("total", total);
result.put("boxes", boxList);
return result;
}
@Log(title = "奖池水位", action = "注水/抽水", businessType = BusinessType.GRANT)
/** 逐箱注水/抽水:type=1注水/2抽水,记审计日志(原因必填由前端保证) */
@PostMapping("/rewardPool/adjust")
public R rewardPoolAdjust(@RequestBody Map<String, Object> body) {
String audience = audienceOf(body.get("audience"));
if (audience == null) return R.fail("身份无效");
Long boxId = asLong(body.get("boxId"));
Integer type = asInt(body.get("type"));
BigDecimal amount = asBd(body.get("amount"));
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
MahjongBox box = boxId != null ? boxMapper.selectById(boxId) : null;
if (box == null) return R.fail("箱子不存在");
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
if (amount.signum() <= 0) return R.fail("金额必须大于 0");
BigDecimal before = rewardPoolRepository.getBoxBalance(boxId, audience);
rewardPoolRepository.adjustBox(boxId, audience, type == 2 ? amount.negate() : amount);
BigDecimal after = rewardPoolRepository.getBoxBalance(boxId, audience);
writeLog(boxId, box.getName(), audience, type, amount, before, after, remark);
return R.ok(poolConfigMap(audience));
}
@Log(title = "奖池水位", action = "批量注水/抽水", businessType = BusinessType.GRANT)
/** 批量按一轮花费比例注水/抽水:ratio 百分比(0~1000],记一条汇总日志 */
@PostMapping("/rewardPool/adjustBatch")
public R rewardPoolAdjustBatch(@RequestBody Map<String, Object> body) {
String audience = audienceOf(body.get("audience"));
if (audience == null) return R.fail("身份无效");
Integer type = asInt(body.get("type"));
BigDecimal ratio = asBd(body.get("ratio"));
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
if (ratio.signum() <= 0 || ratio.compareTo(new BigDecimal("1000")) > 0) return R.fail("比例须在 0~1000");
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int adjusted = 0;
BigDecimal totalAmount = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
double cm = 1.0;
try { cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
BigDecimal base = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm))
.multiply(BigDecimal.valueOf(box.getTotalRounds() != null ? box.getTotalRounds() : 0));
if (base.signum() <= 0) continue;
BigDecimal amount = base.multiply(ratio).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
if (amount.signum() <= 0) continue;
rewardPoolRepository.adjustBox(box.getBoxId(), audience, type == 2 ? amount.negate() : amount);
totalAmount = totalAmount.add(amount);
adjusted++;
}
writeLog(null, "批量(" + adjusted + "箱 " + ratio.stripTrailingZeros().toPlainString() + "%)",
audience, type, totalAmount, null, null, remark);
Map<String, Object> r = poolConfigMap(audience);
r.put("adjusted", adjusted);
r.put("totalAmount", totalAmount);
return R.ok(r);
}
@Log(title = "奖池水位", action = "重置调整", businessType = BusinessType.CLEAN)
/** 重置人工调整:该身份所有箱子 adjust 归零,仅保留真实水位;记一条汇总日志 */
@PostMapping("/rewardPool/adjustReset")
public R rewardPoolAdjustReset(@RequestBody(required = false) Map<String, Object> body) {
String audience = audienceOf(body != null ? body.get("audience") : null);
if (audience == null) return R.fail("身份无效");
String remark = body != null && body.get("remark") != null ? body.get("remark").toString() : null;
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int cleared = 0;
BigDecimal totalCleared = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
BigDecimal adjust = rewardPoolRepository.getBoxAdjust(box.getBoxId(), audience);
if (adjust.signum() == 0) continue;
rewardPoolRepository.adjustBox(box.getBoxId(), audience, adjust.negate());
totalCleared = totalCleared.add(adjust.abs());
cleared++;
}
BigDecimal residual = rewardPoolRepository.getGlobalAdjust(audience);
if (residual.signum() != 0) rewardPoolRepository.adjustGlobal(audience, residual.negate());
writeLog(null, "全部(" + cleared + "箱)", audience, 3, totalCleared, null, null, remark);
Map<String, Object> r = poolConfigMap(audience);
r.put("cleared", cleared);
r.put("totalCleared", totalCleared);
return R.ok(r);
}
/** 奖池调整历史(分页,可按身份筛选) */
@GetMapping("/rewardPool/adjustLog")
public R rewardPoolAdjustLog(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String audience) {
String aud = audienceOf(audience);
if (aud == null) return R.fail("身份无效");
Page<MahjongRewardLog> p = rewardLogMapper.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<MahjongRewardLog>()
.eq(MahjongRewardLog::getAudience, aud)
.orderByDesc(MahjongRewardLog::getId));
return R.ok(Map.of("rows", p.getRecords(), "total", p.getTotal()));
}
private void writeLog(Long boxId, String boxName, String audience, Integer type, BigDecimal amount,
BigDecimal before, BigDecimal after, String remark) {
try {
MahjongRewardLog rlog = new MahjongRewardLog();
rlog.setBoxId(boxId);
rlog.setBoxName(boxName);
rlog.setAudience(audience);
rlog.setType(type);
rlog.setAmount(amount);
rlog.setBalanceBefore(before);
rlog.setBalanceAfter(after);
rlog.setPoolBalance(totalPoolBalance(audience));
rlog.setOperator(getUsername());
rlog.setRemark(remark);
rlog.setCreateTime(new java.util.Date());
rewardLogMapper.insert(rlog);
} catch (Exception ignore) {}
}
/** 该身份全站总池余额 = 各箱水位之和(写日志时取操作后快照) */
private BigDecimal totalPoolBalance(String audience) {
List<MahjongBox> list = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
BigDecimal sum = BigDecimal.ZERO;
for (MahjongBox b : list) sum = sum.add(rewardPoolRepository.getBoxBalance(b.getBoxId(), audience));
return sum;
}
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static Long asLong(Object v) {
if (v == null) return null;
try { return Long.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
private static Integer asInt(Object v) {
if (v == null) return null;
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
private static BigDecimal asBd(Object v) {
if (v == null) return BigDecimal.ZERO;
try { return new BigDecimal(v.toString().trim()); } catch (Exception e) { return BigDecimal.ZERO; }
}
}
@@ -0,0 +1,179 @@
package com.ruoyi.game.mahjong.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.game.mahjong.contract.request.RecordPageRequest;
import com.ruoyi.game.mahjong.contract.request.SpinRequest;
import com.ruoyi.game.mahjong.contract.response.BoxResponse;
import com.ruoyi.game.mahjong.contract.response.RecordItemResponse;
import com.ruoyi.game.mahjong.contract.response.SpinResponse;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeOrnamentMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.service.MahjongGameService;
import com.ruoyi.system.service.ISysConfigService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
/** 客户端接口:Base = {host}/api/mahjong,全部 POST + JSON + token 鉴权(对齐设计方案 §7) */
@Slf4j
@Api(tags = "麻将胡了游戏")
@RestController
@RequestMapping("/api/mahjong")
public class MahjongClientController extends BaseController {
@Autowired private MahjongGameService gameService;
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongPrizeOrnamentMapper prizeOrnamentMapper;
@Autowired private ISysConfigService sysConfigService;
/** 进行中的局不展示:结算时 settle_source=start,播完末帧才改 finish;超过单局缓存 TTL(30分钟) 视为已结束兜底 */
private static final long IN_PROGRESS_MS = 30 * 60 * 1000L;
@ApiOperation("获取箱子列表")
@PostMapping("/boxes")
public R boxes() {
List<MahjongBox> list = boxMapper.selectList(new LambdaQueryWrapper<MahjongBox>()
.eq(MahjongBox::getGameType, "MAHJONG").eq(MahjongBox::getStatus, "1")
.orderByAsc(MahjongBox::getBoxId));
List<BoxResponse> boxes = list.stream().map(b -> {
BoxResponse r = new BoxResponse();
r.setBoxId(b.getBoxId());
r.setBoxName(b.getName());
r.setBasicBet(b.getBet());
// cost = box.bet(costMultiplier ≡ 1),所以 price 恒等于投注档
r.setPrice(b.getPrice() != null ? b.getPrice() : b.getBet());
r.setBoxType(b.getBoxType());
try { r.setModeName(MahjongModeType.fromBoxType(b.getBoxType()).getName()); }
catch (Exception e) { r.setModeName(b.getBoxType()); }
return r;
}).collect(Collectors.toList());
return R.ok(Map.of("list", boxes));
}
@ApiOperation("执行旋转(新游戏或继续游戏)")
@PostMapping("/dospin")
public R dospin(@RequestBody SpinRequest req) {
Long userId = getUserId();
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
String mahjongMaintenance = sysConfigService.selectConfigByKey("mahjongMaintenance");
boolean isMaintenance = "1".equals(websiteMaintenance) || "1".equals(mahjongMaintenance);
// 仅"新开一局"(boxId 非空)受维护限制;继续当前对局(boxId 为空)不受影响,可正常播完
boolean isNewGame = req != null && req.getBoxId() != null;
if (isMaintenance && isNewGame) {
if ("1".equals(websiteMaintenance)) return R.fail("网站维护中......");
if ("1".equals(mahjongMaintenance)) return R.fail("麻将胡了维护中......");
}
try {
// betMul 是预留字段:缺省 1,当前服务端只接受 1(无投注倍数、无模式倍率)
MahjongGameService.assertBetMulSupported(req != null ? req.getBetMul() : null);
SpinResponse resp = gameService.doSpin(userId, req);
return R.ok(resp);
} catch (ServiceException se) {
return se.getCode() != null ? R.fail(se.getCode().intValue(), se.getMessage()) : R.fail(se.getMessage());
} catch (Exception e) {
return R.fail(e.getMessage());
}
}
@ApiOperation("查询游戏记录(分页;scope: 0全服/1我的,默认1我的;只含已播完的局)")
@PostMapping("/records")
public R records(@RequestBody(required = false) RecordPageRequest req) {
Long userId = getUserId();
int scope = (req != null && req.getScope() != null) ? req.getScope() : 1;
int pageNum = (req != null && req.getPageNum() != null) ? req.getPageNum() : 1;
int pageSize = (req != null && req.getPageSize() != null) ? req.getPageSize() : 10;
// 用 Java 时间算 cutoff(勿改成 SQL NOW():DB 会话时区是 UTC,create_time 存的是本地时间,会差 8 小时)
Date cutoff = new Date(System.currentTimeMillis() - IN_PROGRESS_MS);
LambdaQueryWrapper<MahjongRecord> qw = new LambdaQueryWrapper<MahjongRecord>()
.eq(MahjongRecord::getSettled, 1)
// 只展示已结束的局:还在播动画的那局 settle_source 仍为 start,等播完(或超时兜底)才进列表
.and(w -> w.ne(MahjongRecord::getSettleSource, "start")
.or().lt(MahjongRecord::getCreateTime, cutoff));
if (scope == 1) {
qw.eq(MahjongRecord::getUserId, userId);
} else {
// 全服:排除机器人/封禁/已删除用户(保留白号,白号可上榜)
qw.inSql(MahjongRecord::getUserId,
"SELECT user_id FROM tt_user WHERE user_type != '03' AND status != '1' AND del_flag = '0'");
}
qw.orderByDesc(MahjongRecord::getCreateTime);
Page<MahjongRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
List<MahjongRecord> records = page.getRecords();
// 昵称/头像(两种 scope 都返回)
Map<Long, Map<String, Object>> userMap = new HashMap<>();
Set<Long> userIds = new HashSet<>();
for (MahjongRecord r : records) userIds.add(r.getUserId());
if (!userIds.isEmpty()) {
for (Map<String, Object> m : recordMapper.getUserInfos(userIds)) {
Object uid = m.get("user_id") != null ? m.get("user_id") : m.get("userId");
if (uid != null) userMap.put(Long.valueOf(uid.toString()), m);
}
}
// 饰品全名 = 名称(磨损品质):新记录按 prize_id 关联,老记录(prize_id 为空)按名称兜底
Map<Long, String> exteriorById = new HashMap<>();
Map<String, String> exteriorByName = new HashMap<>();
for (MahjongPrizeOrnament o : prizeOrnamentMapper.selectList(null)) {
if (o.getExteriorName() == null || o.getExteriorName().isEmpty()) continue;
exteriorById.putIfAbsent(o.getId(), o.getExteriorName());
if (o.getName() != null) exteriorByName.putIfAbsent(o.getName(), o.getExteriorName());
}
List<RecordItemResponse> rows = new ArrayList<>();
for (MahjongRecord r : records) {
RecordItemResponse vo = new RecordItemResponse();
Map<String, Object> u = userMap.get(r.getUserId());
if (u != null) {
vo.setNickName(str(u.get("nick_name")));
vo.setAvatar(str(u.get("avatar")));
}
vo.setCost(r.getCost());
vo.setSettledWin(r.getSettledWin());
vo.setPrizeImageUrl(r.getPrizeImageUrl());
vo.setPrizeName(fullPrizeName(r.getPrizeName(), r.getPrizeId(), exteriorById, exteriorByName));
vo.setCreateTime(r.getCreateTime());
rows.add(vo);
}
return R.ok(Map.of("rows", rows, "total", page.getTotal()));
}
private static String str(Object v) {
return v != null ? v.toString() : null;
}
/** 饰品全名:名称已是全名,有磨损品质时追加 " (品质)" */
private static String fullPrizeName(String name, Long prizeId,
Map<Long, String> exteriorById, Map<String, String> exteriorByName) {
if (name == null) return null;
String exterior = prizeId != null ? exteriorById.get(prizeId) : null;
if (exterior == null) exterior = exteriorByName.get(name);
return exterior == null || exterior.isEmpty() ? name : name + " (" + exterior + ")";
}
@ApiOperation("查询是否有进行中的游戏(断线重连)")
@PostMapping("/hasActive")
public R hasActive() {
Long userId = getUserId();
// 维护期间不强制结算:进行中的对局可正常重连并播完("当前不受影响",仅拦新开一局)
return R.ok(gameService.hasActive(userId));
}
}
@@ -0,0 +1,90 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.Map;
/**
* 箱子配置表 game_mahjong_box —— 只按投注档分箱,共 11 档(1/3/5/10/20/30/50/100/200/300/500)。
* 与玫瑰女郎的关键差异:只有 normal 一个模式,costMultiplier ≡ 1,price ≡ bet。
*/
@Data
@TableName("game_mahjong_box")
public class MahjongBox {
@TableId(value = "box_id", type = IdType.INPUT)
private Long boxId;
@TableField("game_type")
private String gameType;
/** 模式:只有 normal(对齐 MahjongModeType) */
@TableField("box_type")
private String boxType;
private BigDecimal bet;
private String name;
/** 实际扣费 = bet(costMultiplier ≡ 1,无模式倍率、无投注倍数) */
private BigDecimal price;
/** 固定池总回合数(原 total_ornaments) */
@TableField("total_rounds")
private Integer totalRounds;
private BigDecimal rtp;
@TableField("rtp_converge_ratio")
private BigDecimal rtpConvergeRatio;
private Integer sort;
private String status;
@TableField("del_flag")
private String delFlag;
private String remark;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
// ======== transient ========
/** 模式中文名,运行时由 MahjongModeType 填充 */
@TableField(exist = false)
private String modeName;
/** 费用倍率 CM,本游戏恒为 1,运行时填充(保留字段以便与玫瑰女郎同构) */
@TableField(exist = false)
private BigDecimal costMultiplier;
/** 实时RTP,从 game_mahjong_record 统计 */
@TableField(exist = false)
private BigDecimal actualRtp;
/** 各身份分布反推目标RTP(%),运行时填充 {audience: percent} */
@TableField(exist = false)
private Map<String, BigDecimal> targetRtpByAudience;
/** 各身份实时RTP(%),运行时填充 {audience: percent} */
@TableField(exist = false)
private Map<String, BigDecimal> actualRtpByAudience;
/** 各身份一轮总回合(按各自固定池分布反推),运行时填充 {audience: n} */
@TableField(exist = false)
private Map<String, BigDecimal> totalRoundsByAudience;
}
@@ -0,0 +1,52 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 箱子-倍率桶分布表 game_mahjong_box_bucket(多身份)。
* 唯一键 (box_id, bucket_id, audience):16 桶 × 11 箱 × 4 身份 = 704 条。
* 白号(audience=white)独立库存,抽取只递减自己的 used,永不消耗其他身份。
*/
@Data
@TableName("game_mahjong_box_bucket")
public class MahjongBoxBucket {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
/** 桶ID (1-16) */
@TableField("bucket_id")
private Integer bucketId;
/** 身份维度 common/anchor/white(机器人维度已去掉,user_type='03' 归 common) */
private String audience;
/** 分配回合数(加权随机权重) */
private Integer count;
/** 已消耗回合数:剩余 = count - used */
private Integer used;
// ======== transient(SQL JOIN 填充)========
@TableField(exist = false)
private String bucketName;
@TableField(exist = false)
private java.math.BigDecimal expectMul;
/** 剩余 = count - used,SQL 计算填充 */
@TableField(exist = false)
private Integer remaining;
/** 本轮收敛阶段该桶已动态选取回合数(轮次账本) */
@TableField(exist = false)
private Long convergeCount;
}
@@ -0,0 +1,45 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
/** 箱子倍率桶分布模板 game_mahjong_box_template(按身份 audience,抽干补桶依据) */
@Data
@TableName("game_mahjong_box_template")
public class MahjongBoxTemplate {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
private String audience;
private String name;
@TableField("total_rounds")
private Integer totalRounds;
@TableField("items_json")
private String itemsJson;
@TableField("is_active")
private Integer isActive;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/** 反序列化后的分布,回传前端用 */
@TableField(exist = false)
private List<Map<String, Object>> items;
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/** 倍率桶定义表 game_mahjong_bucket(16 桶:0x~500x;对应玫瑰女郎 game_olympus_ornament 的 17 档) */
@Data
@TableName("game_mahjong_bucket")
public class MahjongBucket {
@TableId(type = IdType.INPUT)
private Long id;
private String gameType;
/** 期望倍率(区间均值,最高 500 = MAX_WIN_CAP_MUL) */
private BigDecimal expectMul;
private String name;
/** 落桶区间下限(含) */
private BigDecimal rangeLow;
/** 落桶区间上限(含) */
private BigDecimal rangeHigh;
}
@@ -0,0 +1,36 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/** 返奖饰品定义表 game_mahjong_prize_ornament(必须有:赢额 → 饰品) */
@Data
@TableName("game_mahjong_prize_ornament")
public class MahjongPrizeOrnament {
@TableId(type = IdType.AUTO)
private Long id;
private String gameType;
private String name;
private String imageUrl;
private Integer grade;
private String exteriorName;
private Integer sort;
private String status;
private Integer isFallback;
// ======== transient ========
@TableField(exist = false)
private java.math.BigDecimal winLow;
@TableField(exist = false)
private java.math.BigDecimal winHigh;
@TableField(exist = false)
private String levelImg;
}
@@ -0,0 +1,21 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/** 返奖饰品区间表 game_mahjong_prize_range(必须有:赢额落区间) */
@Data
@TableName("game_mahjong_prize_range")
public class MahjongPrizeRange {
@TableId(type = IdType.AUTO)
private Long id;
private Long prizeId;
private BigDecimal winLow;
private BigDecimal winHigh;
}
@@ -0,0 +1,46 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 玩家游玩记录表 game_mahjong_record —— 每次开局一条(保留全部 prize_* 列,发饰品要走区间匹配) */
@Data
@TableName("game_mahjong_record")
public class MahjongRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long boxId;
private Long roundId;
private BigDecimal cost;
/** 投注倍数:预留字段,当前服务端只接受 1 */
private BigDecimal betMul;
private BigDecimal totalWin;
private Integer settled; // 0未结算 1已结算(幂等守卫)
private String modeType; // 只有 normal
private String rewardType; // gold / ornament
private BigDecimal settledWin;
private Long prizeId;
private String prizeName;
private String prizeImageUrl;
private String prizeLevelImg;
private String settleSource; // start=开局已结算(动画还在播) / finish=末帧播完(已结束,进战绩列表)
/** 身份 common/anchor/white(机器人维度已去掉):**开局时的快照,落库**(水位按 箱×身份 聚合、事后可追溯);
* 后台列表仍会用 tt_user 现推的值覆盖它做展示,两者在身份未变时一致 */
private String audience;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
}
@@ -0,0 +1,52 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 奖池水位调整日志 game_mahjong_reward_log(注水/抽水/重置审计,原因必填由前端保证) */
@Data
@TableName("game_mahjong_reward_log")
public class MahjongRewardLog {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
@TableField("box_name")
private String boxName;
/** 身份:common/anchor/white(奖池按 箱子×身份 拆池;机器人维度已去掉) */
private String audience;
/** 1注水 2抽水 3重置 */
private Integer type;
private BigDecimal amount;
@TableField("balance_before")
private BigDecimal balanceBefore;
@TableField("balance_after")
private BigDecimal balanceAfter;
/** 操作后全站总池余额快照 */
@TableField("pool_balance")
private BigDecimal poolBalance;
private String operator;
private String remark;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}
@@ -0,0 +1,46 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 预生成回合表 game_mahjong_round —— 池里存的是倍率,与投注额无关 */
@Data
@TableName("game_mahjong_round")
public class MahjongRound {
@TableId(value = "round_id", type = IdType.INPUT)
private Long roundId;
private Long boxId;
/** 落入的倍率桶ID (1-16) */
private Integer bucketId;
/** 实际总倍率 = 本局赢额 / bet(≤500 = MAX_WIN_CAP_MUL,已封顶) */
private BigDecimal actualMul;
/** 免费旋转总次数(0=未进FS) */
private Integer freeSpinCount;
/** 总波数(级联序号,原 total_cascades) */
private Integer totalRotations;
/** 是否自然触发免费旋转(0/1) */
private Integer hasFs;
/** 2 胡悬念盘标记(0/1) */
private Integer nearMiss;
private Date createTime;
// ======== transient ========
@TableField(exist = false)
private String boxType;
}
@@ -0,0 +1,83 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/**
* 回合步骤表 game_mahjong_step —— 前端逐步播放的每一帧。
* 与玫瑰女郎的差异:multiplier_detail(炸弹) 删除,改为 golden_detail + rotation_mul;
* accumulator_before/after 改为 rotation_mul(语义=本波阶梯倍率,不跨旋)。
*/
@Data
@TableName("game_mahjong_step")
public class MahjongStep {
@TableId(type = IdType.INPUT)
private Long id;
private Long roundId;
/** 旋序号 (0=基础旋/触发盘, >0=第N次免费旋转,原 free_spin_id) */
private Integer reelSpinId;
/** 本旋内第几波(= 乘数阶梯序号,原 cascade_seq) */
private Integer rotationSeq;
/** 网格状态 JSON 4行×5列 = 20 格短码 */
private String gridState;
/** 本帧盘面上仍为"金色普通符号"的格 JSON [{"pos","symbol"}] */
private String goldenDetail;
/** 本波"金色→百搭"就地转化的格 JSON [{"pos","fromSymbol"}] */
private String wildConverted;
/** 本波中奖明细 JSON [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}] */
private String waysDetail;
/** 本旋累计消除符号 JSON {"A":9,...} */
private String accumulatedSymbols;
/** 获胜者面板 JSON:本旋逐波追加 [{"symbol","count","win"}] */
private String spinWinners;
/** 消除位置 JSON */
private String removedPositions;
/** 下落数据 JSON */
private String dropData;
/** 填充数据 JSON */
private String fillData;
/** 触发免费旋转 JSON {"scatterCount":3,"freeSpins":12,"extraSpins":0} */
private String scatterTrigger;
/** 免费旋转重触发 JSON */
private String retriggerInfo;
/** 本步基础分(倍率,未乘投注额) */
private BigDecimal baseScore;
/** 本旋累计到本步(末帧 = 本旋总额),不是增量 */
private BigDecimal winAmount;
/** 本波阶梯倍率(基础旋 1/2/3/5,FS 2/4/6/10);不跨旋累计 */
private BigDecimal rotationMul;
/** 整局累计赢额(倍率)到本步 */
private BigDecimal spinWin;
/** 本步赢额被 500x 封顶截断 0否1是 */
private Integer winCapped;
/** 爆奖档位(small/middle/big;只在免费旋转里判档,按单旋赢额) */
private String bigwinLevel;
/** 本旋末帧 */
private Integer isFinal;
}
@@ -0,0 +1,25 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 玩家统计表 game_mahjong_user —— 每次结算累加 */
@Data
@TableName("game_mahjong_user")
public class MahjongUser {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long totalSpins;
private BigDecimal totalCost;
private BigDecimal totalWin;
private BigDecimal maxWinMul;
private Date updateTime;
}
@@ -0,0 +1,49 @@
package com.ruoyi.game.mahjong.enums;
import com.ruoyi.domain.entity.sys.TtUser;
/**
* 身份维度(audience):每种身份一套独立的倍率桶数量分布,实现差异化 RTP。
* <p>
* **为什么本模块自带一份,而不是复用 {@code OlympusAudience}**:那一位在
* {@code skins-service/game-olympus-service} 里,而该模块在本仓**不存在**(本次任务已把它的
* Maven module / ruoyi-admin 依赖摘掉换成 game-mahjong-service),新建的麻将模块无法依赖它。
* 设计方案 §6.2 的建议是"直接把 OlympusAudience 上移到共享模块(skins-model)"——那属于**共享文件改动**,
* 本次任务明确不做,所以这里先在本模块内落一份**逐字同构**的判定(口径、优先级、返回码完全一致)。
* 待共享文件改动落地后,把本类替换为共享的 OlympusAudience 即可,行为不变。
* <p>
* 解析优先级(白号最高,压过 user_type):
* is_blank_account==1 → white;user_type=01 → anchor;其余(含 user_type=03 机器人)→ common。
* <p>
* ⚠️ **本游戏只有三个身份维度**:common / anchor / white。原来的 robot 维度已按用户要求去掉
* (2026-09-22)——机器人不再单独成池,`user_type='03'` 归入 common。
*/
public enum MahjongAudience {
COMMON("common"), // 普通用户(user_type=02 及招商/卡商/超管等;**含 user_type=03 机器人**)
ANCHOR("anchor"), // 主播(user_type=01)
WHITE("white"); // 白号(is_blank_account=1,压过 user_type)
private final String code;
MahjongAudience(String code) {
this.code = code;
}
public String getCode() {
return code;
}
/** 白号:纯概率隔离池,不计水位、不消耗其他身份库存 */
public boolean isWhite() {
return this == WHITE;
}
/** 按玩家解析 audience:白号最高优先,压过 user_type。 */
public static MahjongAudience resolve(TtUser player) {
if (player == null) return COMMON;
if (Integer.valueOf(1).equals(player.getIsBlankAccount())) return WHITE;
String userType = player.getUserType();
if ("01".equals(userType)) return ANCHOR;
return COMMON; // 含 user_type=03(机器人):本游戏没有 robot 维度
}
}
@@ -0,0 +1,45 @@
package com.ruoyi.game.mahjong.enums;
/**
* 麻将胡了模式(对齐 mahjong-py config.MODES):
* 只有一个 normal(普通旋转,自然触发免费旋转)。
* <p>
* 与玫瑰女郎的关键差异:底注(ante 2x)与购买免费旋转(buy_fs 50x)都已砍掉,
* 所以 {@code costMultiplier ≡ 1}、{@code price = box.bet},且不再有 need_fs 过滤。
*/
public enum MahjongModeType {
NORMAL("normal", "普通旋转", 1.0);
private final String boxType;
private final String name;
private final double costMultiplier;
MahjongModeType(String boxType, String name, double costMultiplier) {
this.boxType = boxType;
this.name = name;
this.costMultiplier = costMultiplier;
}
public String getBoxType() { return boxType; }
public String getName() { return name; }
/** 恒为 1:cost = box.bet,无模式倍率、无投注倍数 */
public double getCostMultiplier() { return costMultiplier; }
/**
* 该模式可用倍率桶 ID 范围 [minId, maxId],对齐 py 端 MODE_BUCKET_IDS。
* 只有 normal:全 16 档(0x-500x)。高倍桶没有"买 FS"那批回合兜底,只能靠自然触发 FS 供给。
*/
public int[] getBucketRange() {
return new int[]{1, 16};
}
public static MahjongModeType fromBoxType(String boxType) {
for (MahjongModeType t : values()) {
if (t.boxType.equals(boxType)) return t;
}
throw new IllegalArgumentException("Unknown boxType: " + boxType);
}
}
@@ -0,0 +1,32 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 箱子-倍率桶分布 Mapper(多身份 audience)。
* 所有查询/消耗均按 (boxId, audience) 维度隔离,白号不碰其他身份库存。
*/
@Mapper
public interface MahjongBoxBucketMapper extends BaseMapper<MahjongBoxBucket> {
/** 查某箱某身份的桶分布(含 expectMul/remaining 连表填充) */
List<MahjongBoxBucket> selectByBoxIdAndAudience(@Param("boxId") Long boxId,
@Param("audience") String audience);
int batchInsert(@Param("list") List<MahjongBoxBucket> list);
/** 删除某箱某身份的分布(重算/应用模板前清空该身份) */
int deleteByBoxIdAndAudience(@Param("boxId") Long boxId, @Param("audience") String audience);
/** 该身份某桶 used+1(仅固定阶段消耗) */
int incrUsed(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("audience") String audience);
/** 该身份 used 归 0(补桶) */
int resetUsed(@Param("boxId") Long boxId, @Param("audience") String audience);
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import org.apache.ibatis.annotations.Mapper;
/** 箱子配置 game_mahjong_box(11 个投注档,只有 normal 模式)。 */
@Mapper
public interface MahjongBoxMapper extends BaseMapper<MahjongBox> {
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import org.apache.ibatis.annotations.Mapper;
/** 箱子倍率桶分布模板 game_mahjong_box_template(按身份 audience)。 */
@Mapper
public interface MahjongBoxTemplateMapper extends BaseMapper<MahjongBoxTemplate> {
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import org.apache.ibatis.annotations.Mapper;
/** 倍率桶定义 game_mahjong_bucket(16 桶,0x~500x)。 */
@Mapper
public interface MahjongBucketMapper extends BaseMapper<MahjongBucket> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MahjongPrizeOrnamentMapper extends BaseMapper<MahjongPrizeOrnament> {
}
@@ -0,0 +1,50 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongPrizeRange;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Map;
/**
* 返奖饰品区间:按赢额匹配展示饰品。联表 game_mahjong_prize_ornament 取 name/imageUrl/grade,
* 再联 tt_ornaments_level 取 level_img AS levelImageUrl(品质/等级图片,前端 prizeInfo.levelImageUrl 用这个)。
*/
@Mapper
public interface MahjongPrizeRangeMapper extends BaseMapper<MahjongPrizeRange> {
/** 精确区间匹配 win_low < win <= win_high */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE #{win} > r.win_low AND #{win} <= r.win_high AND o.status = '1' "
+ "ORDER BY r.win_low DESC LIMIT 1")
Map<String, Object> findPrizeByWin(@Param("win") BigDecimal win);
/** 兜底饰品(is_fallback=1) */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_ornament o LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.is_fallback = 1 AND o.status = '1' ORDER BY o.sort ASC LIMIT 1")
Map<String, Object> findFallbackPrize();
/** 最低区间饰品 */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.status = '1' ORDER BY r.win_low ASC LIMIT 1")
Map<String, Object> findLowestPrize();
/** 最高区间饰品 */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.status = '1' ORDER BY r.win_high DESC LIMIT 1")
Map<String, Object> findHighestPrize();
}
@@ -0,0 +1,66 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Mapper
public interface MahjongRecordMapper extends BaseMapper<MahjongRecord> {
/** 批量查各箱实时 RTP(排除白号/机器人/封禁/删除,与首页口径一致) */
@Select("<script>SELECT r.box_id AS boxId, ROUND(SUM(r.total_win)/SUM(r.cost)*100, 2) AS rtp "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 AND r.box_id IN "
+ "<foreach collection='boxIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
+ "GROUP BY r.box_id</script>")
List<Map<String, Object>> selectBoxRtpList(@Param("boxIds") List<Long> boxIds);
@Select("<script>SELECT user_id, nick_name, avatar FROM tt_user WHERE user_id IN "
+ "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Map<String, Object>> getUserInfos(@Param("userIds") Set<Long> userIds);
/** 每日统计汇总(排除白号/机器人/封禁/删除) */
@Select("<script>SELECT COUNT(*) AS totalSpins, COALESCE(SUM(r.cost),0) AS totalCost, "
+ "COALESCE(SUM(r.total_win),0) AS totalWin "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 "
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time &gt;= #{dateStart}</if> "
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time &lt; DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
+ "</script>")
Map<String, Object> winSummary(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
/** 每日统计列表:按天聚合(口径与 winSummary 一致,排除白号/机器人/封禁/删除) */
@Select("<script>SELECT DATE_FORMAT(r.create_time, '%Y-%m-%d') AS date, COUNT(*) AS totalSpins, "
+ "COALESCE(SUM(r.cost),0) AS totalCost, COALESCE(SUM(r.total_win),0) AS totalWin, "
+ "COUNT(DISTINCT r.user_id) AS playerCount "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 "
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time &gt;= #{dateStart}</if> "
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time &lt; DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
+ "GROUP BY DATE_FORMAT(r.create_time, '%Y-%m-%d') ORDER BY date DESC</script>")
List<Map<String, Object>> dailyList(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
/**
* 某箱 + 某身份奖池真实累计(按已结算记录派生)。身份口径同 MahjongAudience#resolve,
* **三个身份互斥且并集 = 全部已结算记录**(common/anchor/white;robot 维度已去掉,
* user_type='03' 归入 common)。totalBet=Σcost,totalWin=Σsettled_win(实际发放价值)。
*/
@Select("<script>SELECT COALESCE(SUM(r.cost),0) AS totalBet, COALESCE(SUM(r.settled_win),0) AS totalWin "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ "WHERE r.box_id = #{boxId} AND r.settled = 1 "
+ "<choose>"
+ "<when test=\"audience == 'white'\">AND COALESCE(u.is_blank_account,0) = 1 </when>"
+ "<when test=\"audience == 'anchor'\">AND COALESCE(u.is_blank_account,0) != 1 AND u.user_type = '01' </when>"
+ "<otherwise>AND COALESCE(u.is_blank_account,0) != 1 AND (u.user_type IS NULL OR u.user_type != '01') </otherwise>"
+ "</choose></script>")
Map<String, Object> sumPool(@Param("boxId") Long boxId, @Param("audience") String audience);
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRewardLog;
import org.apache.ibatis.annotations.Mapper;
/** 奖池水位调整日志 game_mahjong_reward_log。 */
@Mapper
public interface MahjongRewardLogMapper extends BaseMapper<MahjongRewardLog> {
}
@@ -0,0 +1,33 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 预生成回合 game_mahjong_round。
* 与玫瑰女郎的差异:桶字段是 bucket_id;没有 need_fs 过滤(本游戏没有"买 FS"模式),
* 但保留 needFreeSpins 参数以便按 free_spin_count 过滤(0=未进FS)。
*/
@Mapper
public interface MahjongRoundMapper extends BaseMapper<MahjongRound> {
MahjongRound pickByBucket(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("needFreeSpins") Boolean needFreeSpins);
MahjongRound pickAnyByBox(@Param("boxId") Long boxId, @Param("needFreeSpins") Boolean needFreeSpins);
/** 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul */
MahjongRound pickClosestMul(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("needFreeSpins") Boolean needFreeSpins,
@Param("desiredMul") BigDecimal desiredMul,
@Param("capMul") BigDecimal capMul);
/** 统计某箱池内各桶实际均值:[{bucketId, sampleCount, avgMul}](配比/RTP 按它算) */
List<Map<String, Object>> avgMulByBucket(@Param("boxId") Long boxId);
}
@@ -0,0 +1,16 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface MahjongStepMapper extends BaseMapper<MahjongStep> {
@Select("SELECT * FROM game_mahjong_step WHERE round_id = #{roundId} ORDER BY id ASC")
List<MahjongStep> selectByRoundId(@Param("roundId") Long roundId);
}
@@ -0,0 +1,14 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MahjongUserMapper extends BaseMapper<MahjongUser> {
@Select("SELECT * FROM game_mahjong_user WHERE user_id = #{userId} LIMIT 1")
MahjongUser selectByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.model;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 选中回合 + 其步骤 + 阶段标记(固定/收敛,决定是否 incrUsed 与 conv 记账) */
@Data
@AllArgsConstructor
public class PickedRound {
private MahjongRound round;
private List<MahjongStep> steps;
private boolean converge;
}
@@ -0,0 +1,56 @@
package com.ruoyi.game.mahjong.model.cache;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/** 麻将胡了单局进行中缓存(Redis,TTL 30 分钟) */
@Data
public class MahjongGameCache implements Serializable {
private Long boxId;
private String boxType; // 只有 normal
private BigDecimal bet; // 投注总额 = box.bet
private BigDecimal cost; // 实际扣费 = bet(costMultiplier ≡ 1)
private BigDecimal betMul; // 预留,当前恒为 1
private Long roundId;
private BigDecimal totalMul; // round.actualMul(≤500)
private Integer freeSpinCount;
private int stepIndex; // 当前已返回步数(1-based)
private List<CachedStep> steps;
private BigDecimal totalWin; // = totalMul × bet
private Long recordId;
private Long boxRecordId;
/** 身份维度 common/anchor/white(机器人维度已去掉),白号隔离依据 */
private String audience;
/** 是否白号:白号不注水/不扣水/不受水位约束 */
private boolean blankAccount;
@Data
public static class CachedStep implements Serializable {
private int reelSpinId;
private int rotationSeq;
private String gridState;
private String goldenDetail;
private String wildConverted;
private String waysDetail; // 本旋累积中奖明细(含各波)
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
/** 本波阶梯倍率(基础旋 1/2/3/5、FS 2/4/6/10,每旋重置);断线重连靠它接回档位 */
private BigDecimal rotationMul;
private int winCapped;
private String bigwinLevel;
private int isFinal;
private String retriggerInfo;
private String dropData;
private String fillData;
private String scatterTrigger;
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.model.cache;
import lombok.Data;
import java.math.BigDecimal;
/**
* 麻将胡了按【箱 × 身份】奖池(水位)缓存。
* 水位 = totalBet − totalWin + adjust(人工注水/抽水),仅真实玩家(白号隔离)。
* 注意 totalWin 口径 = Σ settled_win(饰品价),不是 total_win(游戏内赢额),否则对账会漂。
*/
@Data
public class MahjongRewardPoolCache {
private BigDecimal totalBet;
private BigDecimal totalWin;
private BigDecimal adjust;
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.mahjong.repository;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.game.mahjong.model.cache.MahjongGameCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_CACHE;
/** 单局进行中缓存(Redis,TTL 30 分钟):ruoyi:mahjong:{userId} */
@Component
public class MahjongCacheRepository {
@Autowired
private RedisCache redisCache;
public MahjongGameCache getCache(Long userId) {
return redisCache.getCacheObject(GAME_CACHE + userId);
}
public void setCache(Long userId, MahjongGameCache cache) {
redisCache.setCacheObject(GAME_CACHE + userId, cache, 30, TimeUnit.MINUTES);
}
public void clearCache(Long userId) {
redisCache.deleteObject(GAME_CACHE + userId);
}
}
@@ -0,0 +1,84 @@
package com.ruoyi.game.mahjong.repository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_CYCLE;
/**
* 一轮内 RTP 动态收敛账本,按 (boxId, audience) 隔离。
* Hash key = ruoyi:mahjong:cycle:{boxId}:{audience},字段 drawn/mulSum/conv:{bucketId}。
* 白号(audience=white)也有独立账本,与其他身份完全隔离。
*/
@Component
public class MahjongCycleRepository {
@Autowired
public RedisTemplate redisTemplate;
private static final String F_DRAWN = "drawn";
private static final String F_MUL_SUM = "mulSum";
private static final String F_CONV_PREFIX = "conv:";
private static final long TTL_DAYS = 30;
private String key(Long boxId, String audience) {
return GAME_CYCLE + boxId + ":" + audience;
}
public long drawn(Long boxId, String audience) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_DRAWN);
return v == null ? 0L : toLong(v);
}
public double mulSum(Long boxId, String audience) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_MUL_SUM);
return v == null ? 0d : toDouble(v);
}
/** 记一抽:drawn+1、mulSum+actualMul;收敛阶段额外 conv:{bucketId}+1。 */
public void recordDraw(Long boxId, String audience, Integer bucketId, BigDecimal actualMul, boolean converge) {
String k = key(boxId, audience);
redisTemplate.opsForHash().increment(k, F_DRAWN, 1L);
double mul = actualMul != null ? actualMul.doubleValue() : 0d;
if (mul != 0d) redisTemplate.opsForHash().increment(k, F_MUL_SUM, mul);
if (converge && bucketId != null) {
redisTemplate.opsForHash().increment(k, F_CONV_PREFIX + bucketId, 1L);
}
redisTemplate.expire(k, TTL_DAYS, TimeUnit.DAYS);
}
public void resetCycle(Long boxId, String audience) {
redisTemplate.delete(key(boxId, audience));
}
public long convergeCount(Long boxId, String audience, Integer bucketId) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_CONV_PREFIX + bucketId);
return v == null ? 0L : toLong(v);
}
/**
* 本轮实时 RTP(%) = mulSum / drawn × 100。
* 本游戏 costMultiplier ≡ 1,所以不再除以 CM(玫瑰女郎那版要除,这里刻意不除)。
*/
public BigDecimal realtimeRtp(Long boxId, String audience) {
long d = drawn(boxId, audience);
if (d <= 0) return null;
double rtp = mulSum(boxId, audience) / d * 100;
return BigDecimal.valueOf(rtp).setScale(2, RoundingMode.HALF_UP);
}
private static long toLong(Object v) {
if (v instanceof Number) return ((Number) v).longValue();
try { return Long.parseLong(v.toString().trim()); } catch (Exception e) { return 0L; }
}
private static double toDouble(Object v) {
if (v instanceof Number) return ((Number) v).doubleValue();
try { return Double.parseDouble(v.toString().trim()); } catch (Exception e) { return 0d; }
}
}
@@ -0,0 +1,115 @@
package com.ruoyi.game.mahjong.repository;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.model.cache.MahjongRewardPoolCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_REWARD_POOL_CACHE;
/**
* 麻将胡了按【箱子 × 身份】奖池(水位)仓储。
* 每箱每身份独立键::box:{boxId}:{audience}:dbbet / :dbwin / :adjust;全局 adjust 也按身份一份。
* 水位 = box:bet − box:win + box:adjust(可能为负)。
* <p>
* 记账口径与玫瑰女郎一致:bet/win **由 DB 已结算记录按身份派生**(game_mahjong_record + tt_user),
* Redis 仅做短缓存供 payoutCap/展示快速读取;adjust 为管理员人工注水/抽水(Redis)。
* win 取 Σ settled_win(饰品价),不是 total_win。
*/
@Component
public class MahjongRewardPoolCacheRepository {
@Autowired
public RedisTemplate redisTemplate;
@Autowired
private MahjongRecordMapper recordMapper;
private static final String KEY_BOX_PREFIX = GAME_REWARD_POOL_CACHE + ":box:";
/** 全局人工调整累计(各箱 adjust 之和,仅展示),按身份一份 */
private static final String KEY_ADJUST = GAME_REWARD_POOL_CACHE + ":adjust:";
private static final String KEY_DB_BET = ":dbbet";
private static final String KEY_DB_WIN = ":dbwin";
/** ponytail: DB 派生结果短缓存;DB 为真相,TTL 内只是短暂滞后、自愈,无永久漂移 */
private static final long DB_CACHE_TTL_SECONDS = 5;
private static String boxKey(Long boxId, String audience, String suffix) {
return KEY_BOX_PREFIX + boxId + ":" + audience + suffix;
}
private static String adjustKey(String audience) {
return KEY_ADJUST + audience;
}
/** 管理员逐箱注水/抽水:delta 为正注水、为负抽水;同时累加该身份的全局 adjust 键 */
public void adjustBox(Long boxId, String audience, BigDecimal delta) {
if (boxId == null || delta == null || delta.signum() == 0) return;
redisTemplate.opsForValue().increment(boxKey(boxId, audience, ":adjust"), delta.doubleValue());
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
}
/** 仅调整全局 adjust 键(重置已删除箱子遗留残余),不动箱子键 */
public void adjustGlobal(String audience, BigDecimal delta) {
if (delta == null || delta.signum() == 0) return;
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
}
public BigDecimal getGlobalAdjust(String audience) {
return readValue(adjustKey(audience));
}
public MahjongRewardPoolCache getBoxCache(Long boxId, String audience) {
MahjongRewardPoolCache cache = new MahjongRewardPoolCache();
cache.setTotalBet(dbPoolTotal(boxId, audience, KEY_DB_BET));
cache.setTotalWin(dbPoolTotal(boxId, audience, KEY_DB_WIN));
cache.setAdjust(readValue(boxKey(boxId, audience, ":adjust")));
return cache;
}
public BigDecimal getBoxAdjust(Long boxId, String audience) {
return readValue(boxKey(boxId, audience, ":adjust"));
}
/** 箱子×身份水位 = 该身份箱内累计投注 − 累计返奖 + 人工调整(可能为负) */
public BigDecimal getBoxBalance(Long boxId, String audience) {
MahjongRewardPoolCache c = getBoxCache(boxId, audience);
return c.getTotalBet().subtract(c.getTotalWin()).add(c.getAdjust())
.setScale(2, java.math.RoundingMode.HALF_UP);
}
/** 读取某箱某身份 bet/win 的 DB 派生值(短缓存;缓存缺失时按 record 聚合重算) */
private BigDecimal dbPoolTotal(Long boxId, String audience, String suffix) {
String key = boxKey(boxId, audience, suffix);
Object v = redisTemplate.opsForValue().get(key);
if (v != null) return toBd(v);
refreshDbPool(boxId, audience);
return toBd(redisTemplate.opsForValue().get(key));
}
private void refreshDbPool(Long boxId, String audience) {
Map<String, Object> row = boxId != null ? recordMapper.sumPool(boxId, audience) : null;
BigDecimal bet = toBd(row != null ? row.get("totalBet") : null);
BigDecimal win = toBd(row != null ? row.get("totalWin") : null);
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_BET), bet.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_WIN), win.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
}
private BigDecimal readValue(String key) {
return toBd(redisTemplate.opsForValue().get(key));
}
private static BigDecimal toBd(Object v) {
if (v == null) return BigDecimal.ZERO;
try {
return new BigDecimal(v.toString());
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
}
@@ -0,0 +1,350 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxTemplateMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* 麻将胡了倍率桶分布计算 / 重置服务(按身份 audience)。
* 算法逐行移植自 OlympusDistributionService(目标 RTP → 幂律权重二分 → 单调约束 → 微调逼近);
* 与玫瑰女郎的差异只有两处:
* <ol>
* <li>表名/字段名 ornament→bucket(附录 A 映射);</li>
* <li>{@code costMultiplier ≡ 1},所以目标均值就是 targetRtp 本身,没有 CM 换算。</li>
* </ol>
*/
@Service
public class MahjongDistributionService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongBoxTemplateMapper boxTemplateMapper;
@Autowired private MahjongBucketMapper bucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
/**
* 桶"实际发出期望"覆盖:桶 16 展示 500x(区间上界),但单局封顶 500,
* 真正发出去的是 min(原始分, 500);生成端种的大奖目标集中在 500 附近而不是均匀分布。
* 配比计算(calcRounds)与后台「目标RTP」都按"实际会发出去多少钱"算,所以用实测值。
* TODO(骨架): 生成端落地后按实测均值校准这个覆盖值(玫瑰女郎用的 835 是它 1000x 表的实测值)。
*/
private static final Map<Integer, Double> PAYOUT_EXPECT_OVERRIDE = Map.of(16, 420.0);
/** 倍率桶 ID → 实际发出期望(受封顶影响的桶用覆盖值,其余用 DB 的 expect_mul)。 */
public Map<Integer, Double> loadBucketMul() {
Map<Integer, Double> m = new HashMap<>();
for (MahjongBucket b : bucketMapper.selectList(null)) {
if (b.getId() == null) continue;
int id = b.getId().intValue();
Double override = PAYOUT_EXPECT_OVERRIDE.get(id);
if (override != null) { m.put(id, override); continue; }
m.put(id, b.getExpectMul() != null ? b.getExpectMul().doubleValue() : 0.0);
}
return m;
}
/** 该桶池子里一条回合都没有时才回退名义期望:只要池子有回合,它的均值就是这个箱子的真实期望 */
private static final int MIN_POOL_SAMPLE = 1;
/**
* 桶 → **这个箱子的池子实际会赔出去多少**的期望(AVG(actual_mul),按 boxId 统计)。
* 用名义 expect_mul 算配比会产生系统性偏差,所以按该箱自己的池内均值算。
*/
public Map<Integer, Double> loadPoolExpect(Long boxId) {
Map<Integer, Double> expect = new HashMap<>(loadBucketMul());
if (boxId == null) return expect;
try {
for (Map<String, Object> row : roundMapper.avgMulByBucket(boxId)) {
Object idObj = row.get("bucketId");
Object nObj = row.get("sampleCount");
Object avgObj = row.get("avgMul");
if (idObj == null || avgObj == null) continue;
int id = ((Number) idObj).intValue();
long n = nObj instanceof Number ? ((Number) nObj).longValue() : 0L;
double avg = ((Number) avgObj).doubleValue();
if (!expect.containsKey(id)) continue;
if (n < MIN_POOL_SAMPLE || avg <= 0) continue;
expect.put(id, avg);
}
} catch (Exception ignore) {
// 池子还没生成/查询异常 → 用名义值兜底
}
return expect;
}
/**
* 按目标 RTP 动态计算各倍率桶回合数(幂律 power 二分搜索 + 单调 + 微调)。
* @param mode 模式(本游戏只有 normal) @param boxId 箱子 @param totalRounds 固定池回合数 @param targetRtp 目标 RTP(如 0.8)
*/
public Map<Integer, Integer> calcRounds(MahjongModeType mode, Long boxId, int totalRounds, double targetRtp) {
// costMultiplier ≡ 1 → 目标均值就是 targetRtp
double cm = mode.getCostMultiplier();
double targetAvg = targetRtp * cm;
Map<Integer, Double> bucketMul = loadPoolExpect(boxId);
// 基础权重:模式桶区间 [lo,hi] 内 30×0.6^k 等比递减
int[] range = mode.getBucketRange();
int lo = range[0], hi = range[1];
int n = Math.max(1, hi - lo + 1);
double[][] baseWeights = new double[n][2];
for (int i = 0; i < n; i++) {
baseWeights[i][0] = lo + i;
baseWeights[i][1] = 30.0 * Math.pow(0.6, i);
}
int nBuckets = baseWeights.length;
// 回合数不足:每桶至少 1,均摊
if (totalRounds <= nBuckets) {
Map<Integer, Integer> flat = new LinkedHashMap<>();
for (double[] w : baseWeights) flat.put((int) w[0], 1);
return flat;
}
int remainingRounds = totalRounds - nBuckets;
double guaranteedMulSum = 0;
for (double[] w : baseWeights) guaranteedMulSum += bucketMul.getOrDefault((int) w[0], 0.0);
double targetTotalMul = targetAvg * totalRounds;
double remainingTargetMul = targetTotalMul - guaranteedMulSum;
double remainingTargetAvg = remainingTargetMul / remainingRounds;
// 二分 power
double power = 1.0, plo = 0.2, phi = 10.0;
for (int iter = 0; iter < 30; iter++) {
double sumW = 0, sumWMul = 0;
for (double[] w : baseWeights) {
double weight = Math.pow(w[1], power);
sumW += weight;
sumWMul += weight * bucketMul.getOrDefault((int) w[0], 0.0);
}
double avg = sumW > 0 ? sumWMul / sumW : 0;
if (Math.abs(avg - remainingTargetAvg) < Math.max(remainingTargetAvg * 0.01, 0.001)) break;
if (avg < remainingTargetAvg) phi = power; else plo = power;
power = (plo + phi) / 2;
}
Map<Integer, Integer> result = new LinkedHashMap<>();
for (double[] w : baseWeights) result.put((int) w[0], 1);
double totalW = 0;
Map<Integer, Double> adjWeights = new LinkedHashMap<>();
for (double[] w : baseWeights) {
double weight = Math.pow(w[1], power);
adjWeights.put((int) w[0], weight);
totalW += weight;
}
int rem = remainingRounds;
for (double[] w : baseWeights) {
int bucketId = (int) w[0];
int extra = totalW > 0 ? (int) Math.round(adjWeights.get(bucketId) / totalW * remainingRounds) : 0;
int put = Math.min(extra, rem);
if (put > 0) { result.put(bucketId, result.get(bucketId) + put); rem -= put; }
}
if (rem > 0) { int first = (int) baseWeights[0][0]; result.put(first, result.get(first) + rem); }
// 单调:count[i] >= count[i+1]
List<Integer> sortedIds = new ArrayList<>(result.keySet());
sortedIds.sort(Integer::compareTo);
for (int i = sortedIds.size() - 2; i >= 0; i--) {
int cur = sortedIds.get(i), next = sortedIds.get(i + 1);
if (result.get(cur) < result.get(next)) result.put(cur, result.get(next));
}
int curTotal = result.values().stream().mapToInt(Integer::intValue).sum();
int diff = totalRounds - curTotal;
if (diff != 0) { int first = sortedIds.get(0); result.put(first, result.get(first) + diff); }
// 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
for (int iter = 0; iter < 400; iter++) {
double currentMul = 0;
for (Map.Entry<Integer, Integer> e : result.entrySet())
currentMul += e.getValue() * bucketMul.getOrDefault(e.getKey(), 0.0);
double deltaMul = targetAvg * totalRounds - currentMul;
if (Math.abs(deltaMul) < 0.01) break;
int bestLo = -1, bestHi = -1;
double bestDiff = Math.abs(deltaMul);
for (int loId : sortedIds) {
if (result.get(loId) <= 1) continue;
for (int hiId : sortedIds) {
if (loId == hiId) continue;
double change = bucketMul.getOrDefault(hiId, 0.0) - bucketMul.getOrDefault(loId, 0.0);
if (Math.abs(change) < 0.001) continue;
double nd = Math.abs(deltaMul - change);
if (nd < bestDiff) {
boolean ok = true;
int ca = result.get(loId) - 1, idxLo = sortedIds.indexOf(loId);
if (idxLo < sortedIds.size() - 1 && ca < result.get(sortedIds.get(idxLo + 1))) ok = false;
int ca2 = result.get(hiId) + 1, idxHi = sortedIds.indexOf(hiId);
if (idxHi > 0 && ca2 > result.get(sortedIds.get(idxHi - 1))) ok = false;
if (sortedIds.indexOf(loId) < sortedIds.indexOf(hiId) && ca < ca2) ok = false;
if (ok) { bestDiff = nd; bestLo = loId; bestHi = hiId; }
}
}
}
if (bestLo < 0) break;
result.put(bestLo, result.get(bestLo) - 1);
result.put(bestHi, result.get(bestHi) + 1);
}
return result;
}
/** 模拟 payoutCap 约束下的实际 RTP(%):桶的赔付期望按该箱池内实际均值算。CM ≡ 1。 */
public double simulateActualRtp(Map<Integer, Integer> distribution, Long boxId, double rtp, int simRounds) {
if (distribution == null || distribution.isEmpty() || simRounds <= 0) return 0;
Map<Integer, Double> bucketMul = loadPoolExpect(boxId);
double cm = 1.0;
if (boxId != null) {
MahjongBox box = boxMapper.selectById(boxId);
if (box != null) cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier();
}
if (cm <= 0) return 0;
List<Double> mulList = new ArrayList<>();
for (Map.Entry<Integer, Integer> e : distribution.entrySet()) {
if (e.getValue() == null || e.getValue() <= 0) continue;
double mul = bucketMul.getOrDefault(e.getKey(), 0.0);
for (int i = 0; i < e.getValue(); i++) mulList.add(mul);
}
if (mulList.isEmpty()) return 0;
Random rnd = new Random();
double totalRtpSum = 0;
for (int round = 0; round < simRounds; round++) {
Collections.shuffle(mulList, rnd);
double balance = 0, totalPayout = 0, totalCost = 0;
for (double mul : mulList) {
double payoutCap = Math.max(0, balance + cm) * rtp;
double actualMul = (mul <= payoutCap) ? mul : 0;
balance += cm - actualMul;
totalPayout += actualMul;
totalCost += cm;
}
totalRtpSum += totalCost > 0 ? totalPayout / totalCost : 0;
}
return (totalRtpSum / simRounds) * 100;
}
/** 由固定池回合数反推总回合 N = round(fixed/(1-cr))。 */
public static int deriveTotalRounds(int fixedRounds, double convergeRatio) {
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
return (int) Math.round(fixedRounds / (1.0 - cr));
}
/** 由总回合 N 算固定池回合数 fixed = round(N*(1-cr))。 */
public static int deriveFixedRounds(int totalRounds, double convergeRatio) {
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
return (int) Math.round(totalRounds * (1.0 - cr));
}
private static double convergeRatioOf(MahjongBox box) {
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
if (cr < 0) cr = 0;
if (cr > 0.9) cr = 0.9;
return cr;
}
/** 重算某箱某身份分布:按目标 RTP 分配固定池 count,used 归 0。 */
@Transactional(rollbackFor = Exception.class)
public void reset(Long boxId, String audience) {
MahjongBox box = boxMapper.selectById(boxId);
if (box == null) return;
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
int totalRounds = box.getTotalRounds() != null ? box.getTotalRounds() : 200;
double targetRtp = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
int fixedRounds = deriveFixedRounds(totalRounds, convergeRatioOf(box));
if (fixedRounds < 1) fixedRounds = totalRounds;
Map<Integer, Integer> dist = calcRounds(mode, boxId, fixedRounds, targetRtp);
List<MahjongBoxBucket> list = new ArrayList<>();
for (Map.Entry<Integer, Integer> e : dist.entrySet()) {
if (e.getValue() == null || e.getValue() <= 0) continue;
MahjongBoxBucket bb = new MahjongBoxBucket();
bb.setBoxId(boxId);
bb.setBucketId(e.getKey());
bb.setAudience(audience);
bb.setCount(e.getValue());
bb.setUsed(0);
list.add(bb);
}
boxBucketMapper.deleteByBoxIdAndAudience(boxId, audience);
if (!list.isEmpty()) boxBucketMapper.batchInsert(list);
}
/** 应用模板分布到某箱某身份(count 取模板、used 归 0),并按固定池反推回写总回合。 */
@Transactional(rollbackFor = Exception.class)
public void writeDistribution(Long boxId, String audience, List<Map<String, Object>> items) {
boxBucketMapper.deleteByBoxIdAndAudience(boxId, audience);
int total = 0;
List<MahjongBoxBucket> list = new ArrayList<>();
if (items != null) {
for (Map<String, Object> item : items) {
Integer bucketId = toInt(item.get("bucketId"));
Integer count = toInt(item.get("count"));
if (bucketId == null || count == null || count <= 0) continue;
MahjongBoxBucket bb = new MahjongBoxBucket();
bb.setBoxId(boxId);
bb.setBucketId(bucketId);
bb.setAudience(audience);
bb.setCount(count);
bb.setUsed(0);
list.add(bb);
total += count;
}
}
if (!list.isEmpty()) boxBucketMapper.batchInsert(list);
MahjongBox box = boxMapper.selectById(boxId);
if (box != null) {
box.setTotalRounds(deriveTotalRounds(total, convergeRatioOf(box)));
boxMapper.updateById(box);
}
}
/** 某箱某身份抽干补桶:优先按该身份生效模板重写;否则仅 used 归 0。 */
@Transactional(rollbackFor = Exception.class)
public void refillFromConfig(Long boxId, String audience) {
MahjongBoxTemplate active = boxTemplateMapper.selectOne(
new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId)
.eq(MahjongBoxTemplate::getAudience, audience)
.eq(MahjongBoxTemplate::getIsActive, 1)
.orderByDesc(MahjongBoxTemplate::getId).last("LIMIT 1"));
if (active != null && active.getItemsJson() != null) {
List<Map<String, Object>> items = parseItems(active.getItemsJson());
if (!items.isEmpty()) { writeDistribution(boxId, audience, items); return; }
}
boxBucketMapper.resetUsed(boxId, audience);
}
public List<MahjongBoxBucket> distribution(Long boxId, String audience) {
return boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
}
public List<Map<String, Object>> parseItems(String itemsJson) {
try {
return OBJECT_MAPPER.readValue(itemsJson, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
public String toItemsJson(Object items) {
try { return OBJECT_MAPPER.writeValueAsString(items); } catch (Exception e) { return "[]"; }
}
private static Integer toInt(Object v) {
if (v == null) return null;
if (v instanceof Number) return ((Number) v).intValue();
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
}
@@ -0,0 +1,554 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.ruoyi.admin.mapper.TtBoxRecordsMapper;
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.entity.TtBoxRecords;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.domain.other.TtOrnamentsLevel;
import com.ruoyi.game.mahjong.constants.MahjongConstants;
import com.ruoyi.game.mahjong.contract.request.SpinRequest;
import com.ruoyi.game.mahjong.contract.response.SpinResponse;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import com.ruoyi.game.mahjong.domain.MahjongUser;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeRangeMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.mapper.MahjongUserMapper;
import com.ruoyi.game.mahjong.model.PickedRound;
import com.ruoyi.game.mahjong.model.cache.MahjongGameCache;
import com.ruoyi.game.mahjong.repository.MahjongCacheRepository;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import com.ruoyi.game.mahjong.repository.MahjongRewardPoolCacheRepository;
import com.ruoyi.message.service.MessageService;
import com.ruoyi.user.service.ApiUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.admin.config.RedisConstants.COMMON_LOCK;
/**
* 麻将胡了核心游戏服务:dospin 开局/播放/结算 + 多身份选池 + 按箱水位 + 白号隔离。
* <p>
* 与玫瑰女郎逐行同构,只有以下**有意差异**(其余一律照抄):
* <ol>
* <li>{@code cost = box.bet}(costMultiplier ≡ 1、无模式倍率、无投注倍数;betMul 只做预留且只接受 1);</li>
* <li>挑回合不带 needFreeSpins(没有买 FS 模式);</li>
* <li>单局封顶 {@code MAX_WIN_CAP_MUL = 500}(玫瑰女郎 1000);</li>
* <li>本波阶梯倍率字段是 {@code rotationMul}(母本的 accumulator 语义);</li>
* <li>流水/背包 source 待共享枚举补齐(见 MahjongConstants 的说明)。</li>
* </ol>
* 本类已完成开局事务/结算/续播/重连的完整骨架接线,业务细节(如 FS 的 spinsRemaining 口径)
* 随生成端 schema 落地后按 §6.4/§7.1 微调。
*/
@Slf4j
@Service
public class MahjongGameService {
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongRoundPicker roundPicker;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongUserMapper userMapper;
@Autowired private MahjongPrizeRangeMapper prizeRangeMapper;
@Autowired private MahjongDistributionService distributionService;
@Autowired private MahjongCacheRepository cacheRepository;
@Autowired private MahjongCycleRepository cycleRepository;
@Autowired private MahjongRewardPoolCacheRepository rewardPoolRepository;
@Autowired private TtUserService userService;
@Autowired private ApiUserService apiUserService;
@Autowired private RedisLock redisLock;
@Autowired private TransactionTemplate transactionTemplate;
@Autowired(required = false) private TtBoxRecordsMapper boxRecordsMapper;
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
@Autowired private MessageService messageService;
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
/** 单局封顶:总倍率 ≥ 此值视为已 500x 封顶 */
private static final BigDecimal MAX_WIN_CAP_MUL = BigDecimal.valueOf(MahjongConstants.MAX_WIN_CAP_MUL);
/** 免费旋转总旋数上限(首赠 12 + 每多 1 胡 +2,重触发同式;发不满就不发) */
private static final int MAX_FREE_SPINS = MahjongConstants.MAX_FREE_SPINS;
/**
* 需改共享文件的接线点(本次任务明确不改共享文件):
* {@code TtAccountRecordSource.MAHJONG_SPIN(68)}。共享枚举补齐后把本行换成 {@code MAHJONG_SPIN}。
* TODO(共享文件): 见 MahjongConstants#RECORD_SOURCE_SPIN。
*/
private static final TtAccountRecordSource SPIN_SOURCE = TtAccountRecordSource.OLYMPUS_SPIN;
// ============================================================
// dospin 主入口
// ============================================================
public SpinResponse doSpin(Long userId, SpinRequest req) throws Exception {
Boolean lock = redisLock.tryLock(COMMON_LOCK + userId, 3L, 7L, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(lock)) throw new RuntimeException("请求频繁,请稍后再试");
try {
MahjongGameCache cache = cacheRepository.getCache(userId);
return cache == null ? startNewGame(userId, req != null ? req.getBoxId() : null) : continueGame(userId, cache);
} finally {
redisLock.unlock(COMMON_LOCK + userId);
}
}
// ============================================================
// 新游戏:扣费 + 多身份选池 + 缓存步骤 + 返回第一步
// ============================================================
private SpinResponse startNewGame(Long userId, Long boxId) {
if (boxId == null) throw new IllegalArgumentException("boxId 不能为空");
MahjongBox box = boxMapper.selectById(boxId);
if (box == null || !"1".equals(box.getStatus())) throw new IllegalArgumentException("箱子不可用");
// 只有一个 normal 模式:cost = bet(无模式倍率、无投注倍数)
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
BigDecimal bet = coalesce(box.getBet());
BigDecimal cost = bet.multiply(BigDecimal.valueOf(mode.getCostMultiplier()));
R<Void> rechargeCheck = apiUserService.checkRecharge(userId.intValue());
if (R.isError(rechargeCheck)) throw new ServiceException("玩家未充值不能游玩任何游戏", 703);
TtUser player = userService.getById(userId);
if (player == null || player.getAccountAmount() == null || player.getAccountAmount().compareTo(cost) < 0) {
throw new ServiceException("账户余额不足", 702);
}
// 身份解析:本模块自带一份与 OlympusAudience 逐字同构的判定(见 MahjongAudience 的说明)
com.ruoyi.game.mahjong.enums.MahjongAudience audience =
com.ruoyi.game.mahjong.enums.MahjongAudience.resolve(player);
boolean isBlank = audience.isWhite();
// 没有 buy_fs 模式 → 永远不需要"必须有 FS"的回合
Boolean needFreeSpins = null;
// 水位赔付上限:真实玩家按 (本箱水位 + 本局成本) × rtp 约束;白号 payoutCap=null(纯概率、不受水位)
BigDecimal payoutCap = null;
if (!isBlank) {
double ratio = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience.getCode()).add(cost);
if (balance.signum() < 0) balance = BigDecimal.ZERO;
payoutCap = scale2(balance.multiply(BigDecimal.valueOf(ratio)));
}
PickedRound picked = roundPicker.pick(boxId, audience.getCode(), needFreeSpins, bet, payoutCap);
if (picked == null || picked.getRound() == null) throw new RuntimeException("该箱子暂无可用回合");
BigDecimal totalMul = coalesce(picked.getRound().getActualMul());
BigDecimal totalWin = scale2(totalMul.multiply(bet));
List<MahjongGameCache.CachedStep> cachedSteps = new ArrayList<>();
for (MahjongStep s : picked.getSteps()) {
MahjongGameCache.CachedStep cs = new MahjongGameCache.CachedStep();
cs.setReelSpinId(coalesce0(s.getReelSpinId()));
cs.setRotationSeq(coalesce0(s.getRotationSeq()));
cs.setGridState(s.getGridState());
cs.setGoldenDetail(s.getGoldenDetail());
cs.setWildConverted(s.getWildConverted());
cs.setWaysDetail(s.getWaysDetail());
cs.setWinAmount(s.getWinAmount());
cs.setSpinWin(coalesce(s.getSpinWin()));
cs.setRotationMul(coalesce(s.getRotationMul()));
cs.setWinCapped(coalesce0(s.getWinCapped()));
cs.setBigwinLevel(s.getBigwinLevel());
cs.setIsFinal(coalesce0(s.getIsFinal()));
cs.setRetriggerInfo(s.getRetriggerInfo());
cs.setDropData(s.getDropData());
cs.setFillData(s.getFillData());
cs.setScatterTrigger(s.getScatterTrigger());
cachedSteps.add(cs);
}
MahjongGameCache cache = new MahjongGameCache();
cache.setBoxId(boxId);
cache.setBoxType(box.getBoxType());
cache.setBet(bet);
cache.setCost(cost);
cache.setBetMul(BigDecimal.ONE);
cache.setRoundId(picked.getRound().getRoundId());
cache.setTotalMul(totalMul);
cache.setFreeSpinCount(coalesce0(picked.getRound().getFreeSpinCount()));
cache.setStepIndex(1);
cache.setSteps(cachedSteps);
cache.setTotalWin(totalWin);
cache.setAudience(audience.getCode());
cache.setBlankAccount(isBlank);
boolean firstIsFinal = cachedSteps.size() == 1;
transactionTemplate.executeWithoutResult(status -> {
R<?> deductR = userService.updateUserAccount(userId.intValue(), cost.negate(), SPIN_SOURCE);
if (R.isError(deductR)) throw new RuntimeException(deductR.getMsg());
MahjongRecord record = new MahjongRecord();
record.setUserId(userId);
record.setBoxId(boxId);
record.setRoundId(picked.getRound().getRoundId());
record.setCost(cost);
record.setBetMul(BigDecimal.ONE);
record.setTotalWin(BigDecimal.ZERO);
record.setModeType(box.getBoxType());
record.setAudience(audience.getCode()); // 身份快照落库:水位按 箱×身份 聚合,事后可追溯
record.setSettled(0);
record.setCreateTime(new Date());
recordMapper.insert(record);
cache.setRecordId(record.getId());
// 仅固定阶段消耗对应 audience 一个回合(白号消耗自己的 white 库存,不碰其他身份)
if (!picked.isConverge()) {
boxBucketMapper.incrUsed(boxId, picked.getRound().getBucketId(), audience.getCode());
}
// 开局即结算:回合结果已确定,立即将饰品写入背包(与扣费同事务,中途退出/刷新无损)
settleAtStart(userId, cache);
});
// 事务已提交(钱已扣、奖已落库)。以下全是 Redis 缓存/节奏账本/补桶,非资金操作:
// 一旦失败必须吞掉——否则接口返回 500,玩家以为没开局而重试,会再开一局、再扣一次钱。
try {
cycleRepository.recordDraw(boxId, audience.getCode(), picked.getRound().getBucketId(),
coalesce(picked.getRound().getActualMul()), picked.isConverge());
int fixedSum = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience.getCode()).stream()
.mapToInt(bb -> bb.getCount() != null ? bb.getCount() : 0).sum();
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
int cycleN = fixedSum > 0 ? MahjongDistributionService.deriveTotalRounds(fixedSum, cr) : coalesce0(box.getTotalRounds());
if (cycleN > 0 && cycleRepository.drawn(boxId, audience.getCode()) >= cycleN) {
cycleRepository.resetCycle(boxId, audience.getCode());
distributionService.refillFromConfig(boxId, audience.getCode());
}
if (firstIsFinal) {
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal(MahjongConstants.FLOOR_WIN));
cacheRepository.clearCache(userId);
} else {
cacheRepository.setCache(userId, cache);
}
} catch (Exception e) {
log.error("mahjong 开局后置处理失败(扣费与发奖已提交,不影响本局结果)userId={} boxId={}", userId, boxId, e);
}
// 单步局开局即播完,通知此刻发;多步局等末帧(continueGame)
if (firstIsFinal) sendWinNotice(userId, cache);
return buildStepResponse(cache, cachedSteps.get(0), 1, firstIsFinal);
}
// ============================================================
// 继续游戏:取下一步
// ============================================================
private SpinResponse continueGame(Long userId, MahjongGameCache cache) {
int nextIdx = cache.getStepIndex() + 1;
if (nextIdx > cache.getSteps().size()) {
cacheRepository.clearCache(userId);
throw new RuntimeException("游戏已结束");
}
MahjongGameCache.CachedStep step = cache.getSteps().get(nextIdx - 1);
cache.setStepIndex(nextIdx);
if (nextIdx == cache.getSteps().size()) {
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal(MahjongConstants.FLOOR_WIN));
// 末帧已播完:这一局才算结束,战绩列表(我的/全服)才会展示
recordMapper.update(null, new LambdaUpdateWrapper<MahjongRecord>()
.eq(MahjongRecord::getId, cache.getRecordId())
.set(MahjongRecord::getSettleSource, "finish"));
// 末帧到了才发结算通知(开局发会在动画播完前剧透),失败只记日志不影响本局
sendWinNotice(userId, cache);
// 已在开局结算入背包,末帧仅揭晓;回填 boxRecordId 供前端选择保留/立即分解
SpinResponse resp = buildStepResponse(cache, step, nextIdx, true);
cacheRepository.clearCache(userId);
return resp;
}
cacheRepository.setCache(userId, cache);
return buildStepResponse(cache, step, nextIdx, false);
}
/**
* 结算通知(站内信):只在"这一局播完"的时刻发——单步局=开局,多步局=末帧。
* 开局就发会在动画播完前把结果剧透给玩家(饰品开局已入背包,通知是唯一会提前泄露的通道)。
*/
private void sendWinNotice(Long userId, MahjongGameCache cache) {
if (cache.getRecordId() == null) return;
try {
MahjongRecord rec = recordMapper.selectById(cache.getRecordId());
if (rec == null || rec.getPrizeName() == null) return;
String priceStr = rec.getSettledWin() == null ? "0" : rec.getSettledWin().stripTrailingZeros().toPlainString();
// TODO(共享文件): 共享枚举补上 MessageCategoryEnum.GAME_MAHJONG 后,改用它的 getTitle()/getCode()
messageService.sendNotice(
userId,
MahjongConstants.MESSAGE_CATEGORY_TITLE,
"麻将胡了挑战结算:您本局获得饰品 " + rec.getPrizeName() + "(价值 ¥" + priceStr + ")",
MahjongConstants.MESSAGE_CATEGORY);
} catch (Exception e) {
log.error("mahjong 结算通知发送失败 userId={} recordId={}", userId, cache.getRecordId(), e);
}
}
// ============================================================
// 开局即结算(事务中,饰品直接写入背包,中途退出/刷新无损)
// ============================================================
private void settleAtStart(Long userId, MahjongGameCache cache) {
BigDecimal totalWin = cache.getTotalWin();
// 空转局也发最低档饰品:win = max(totalWin, 0.01)(必得饰品兜底,与玫瑰女郎逐行一致)
if (totalWin == null || totalWin.compareTo(BigDecimal.ZERO) <= 0) totalWin = new BigDecimal(MahjongConstants.FLOOR_WIN);
BigDecimal win = scale2(totalWin);
Map<String, Object> prize = findPrizeWithFallback(win);
Long prizeId = asLong(prize != null ? prize.get("id") : null);
String prizeName = prize != null ? (String) prize.get("name") : null;
Integer prizeGrade = prize != null && prize.get("grade") instanceof Integer ? (Integer) prize.get("grade") : 1;
String prizeImageUrl = prize != null ? (String) prize.get("imageUrl") : null;
String prizeExteriorName = prize != null ? (String) prize.get("exteriorName") : null;
String prizeLevelImg = prize != null ? (String) prize.get("levelImageUrl") : null;
// 幂等守卫。settle_source:开局写 start,客户端播完末帧才改 finish(战绩列表只展示已结束的局);
// 单步局开局即播完,直接写 finish。
boolean playedOut = cache.getSteps() == null || cache.getSteps().size() <= 1;
int guard = recordMapper.update(null, new LambdaUpdateWrapper<MahjongRecord>()
.eq(MahjongRecord::getId, cache.getRecordId())
.eq(MahjongRecord::getSettled, 0)
.set(MahjongRecord::getSettled, 1)
.set(MahjongRecord::getRewardType, "ornament")
.set(MahjongRecord::getTotalWin, totalWin)
.set(MahjongRecord::getSettledWin, win)
.set(MahjongRecord::getPrizeId, prizeId)
.set(MahjongRecord::getSettleSource, playedOut ? "finish" : "start")
.set(MahjongRecord::getPrizeName, prizeName)
.set(MahjongRecord::getPrizeImageUrl, prizeImageUrl)
.set(MahjongRecord::getPrizeLevelImg, prizeLevelImg));
if (guard == 0) { cacheRepository.clearCache(userId); return; }
// 写入背包
// TODO(共享文件): 共享枚举补上 TtboxRecordSource.MAHJONG_BOX(15) 后,
// 把 MahjongConstants.BOX_RECORD_SOURCE 换成 MAHJONG_BOX.getCode()(数值已经是 15,不会漂)。
if (boxRecordsMapper != null && prizeId != null) {
if (prizeLevelImg == null && ornamentsLevelMapper != null) {
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(prizeGrade);
if (lv != null) prizeLevelImg = lv.getLevelImg();
}
TtBoxRecords bi = TtBoxRecords.builder()
.userId(userId.intValue()).holderUserId(userId.intValue())
.boxId(cache.getBoxId() != null ? cache.getBoxId().intValue() : 0)
.boxName(MahjongConstants.BOX_NAME).boxPrice(cache.getCost())
.ornamentId(prizeId).ornamentsPrice(win)
.ornamentName(prizeName).imageUrl(prizeImageUrl)
.marketHashName(prizeExteriorName != null ? prizeName + " (" + prizeExteriorName + ")" : prizeName)
.ornamentsLevelId(prizeGrade != null ? prizeGrade : 1)
.ornamentLevelImg(prizeLevelImg)
.source(MahjongConstants.BOX_RECORD_SOURCE)
.status(0).isShow(1).createTime(new Date())
.build();
boxRecordsMapper.insert(bi);
cache.setBoxRecordId(bi.getId());
}
// 更新用户统计
MahjongUser user = userMapper.selectByUserId(userId);
if (user == null) {
user = new MahjongUser();
user.setUserId(userId);
user.setTotalSpins(1L);
user.setTotalCost(cache.getCost());
user.setTotalWin(totalWin);
user.setMaxWinMul(cache.getTotalMul());
userMapper.insert(user);
} else {
user.setTotalSpins(coalesceL(user.getTotalSpins()) + 1L);
user.setTotalCost(coalesce(user.getTotalCost()).add(cache.getCost()));
user.setTotalWin(coalesce(user.getTotalWin()).add(totalWin));
if (cache.getTotalMul() != null && cache.getTotalMul().compareTo(coalesce(user.getMaxWinMul())) > 0)
user.setMaxWinMul(cache.getTotalMul());
userMapper.updateById(user);
}
}
// ============================================================
// 断线重连
// ============================================================
public Map<String, Object> hasActive(Long userId) {
MahjongGameCache cache = cacheRepository.getCache(userId);
// 开局即结算模型:无缓存即无进行中动画可恢复(饰品已在背包),直接返回
if (cache == null) return Map.of("hasActive", false);
List<MahjongGameCache.CachedStep> steps = cache.getSteps();
int played = Math.min(Math.max(cache.getStepIndex(), 0), steps.size());
BigDecimal bet = coalesce(cache.getBet());
// 已播累计直接取最后一步的 spin_win(本局累计),不能逐帧加 win_amount:
// win_amount 是"本旋内累计",同一旋的每一波都是同一个累计量的快照,相加会把中间波重复计入。
BigDecimal playedWin = played > 0
? scale2(coalesce(steps.get(played - 1).getSpinWin()).multiply(bet))
: BigDecimal.ZERO;
Map<String, Object> result = new HashMap<>();
result.put("hasActive", true);
result.put("boxId", cache.getBoxId());
result.put("boxType", cache.getBoxType());
result.put("bet", cache.getBet());
result.put("cost", cache.getCost());
result.put("stepIndex", cache.getStepIndex());
result.put("totalSteps", steps.size());
result.put("freeSpinCount", cache.getFreeSpinCount());
result.put("spinsRemaining", spinsRemainingAfter(steps, played));
result.put("totalWin", scale2(playedWin));
result.put("roundId", cache.getRoundId());
// 客户端用 lastStep.rotationMul 接回当前波次(否则重连后阶梯会从第一档重算)
if (played > 0) result.put("lastStep", buildStepResponse(cache, steps.get(played - 1), played, false));
return result;
}
// ============================================================
// 构建单步响应
// ============================================================
private SpinResponse buildStepResponse(MahjongGameCache cache, MahjongGameCache.CachedStep cs, int idx, boolean isFinal) {
BigDecimal bet = coalesce(cache.getBet());
SpinResponse resp = new SpinResponse();
resp.setState(isFinal ? "FINISH" : "PLAYING");
resp.setRotationNum(cs.getRotationSeq());
resp.setReelSpinNum(cs.getReelSpinId());
resp.setGridState(toJson(cs.getGridState()));
resp.setGoldenDetail(toJson(cs.getGoldenDetail()));
resp.setWildConverted(toJson(cs.getWildConverted()));
// ways_detail 里的 win 是"本笔金额",按 bet 放大;payBase/ways 只留档不下发金额
resp.setWaysDetail(scaleMoneyList(cs.getWaysDetail(), bet, new String[]{"win"}));
resp.setDropData(toJson(cs.getDropData()));
resp.setFillData(toJson(cs.getFillData()));
resp.setScatterTrigger(toJson(cs.getScatterTrigger()));
resp.setWinAmount(scale2(coalesce(cs.getWinAmount()).multiply(bet)));
resp.setSpinWin(scale2(coalesce(cs.getSpinWin()).multiply(bet)));
// 本波阶梯倍率(未 ×bet):重连时前端靠它接回档位
resp.setRotationMul(scale2(coalesce(cs.getRotationMul())));
if (cs.getRetriggerInfo() != null && !cs.getRetriggerInfo().isEmpty())
resp.setRetriggerInfo(toJson(cs.getRetriggerInfo()));
// 爆奖动画:py 已按单旋赢额判档写入 bigwin_level,直接透传
if (cs.getBigwinLevel() != null && !cs.getBigwinLevel().isEmpty()) resp.setBigWinLevel(cs.getBigwinLevel());
resp.setGlobalStep(idx);
resp.setGlobalTotalSteps(cache.getSteps().size());
// 剩余免费旋转:每帧都给(前端直接显示倒计);500x 封顶时下面覆盖成 0
resp.setSpinsRemaining(spinsRemainingAfter(cache.getSteps(), idx));
if (isFinal) {
resp.setTotalWin(scale2(cache.getTotalWin()));
resp.setTotalMul(scale2(cache.getTotalMul()));
// 封顶判定:totalMul ≥ 500 → maxWinReached=true、spinsRemaining=0
if (coalesce(cache.getTotalMul()).compareTo(MAX_WIN_CAP_MUL) >= 0) {
resp.setMaxWinReached(true);
resp.setSpinsRemaining(0);
}
// 开局已结算入背包:末帧揭晓饰品 + 回填 boxRecordId 供前端选择保留/立即分解
try {
BigDecimal win = scale2(cache.getTotalWin());
if (win.compareTo(BigDecimal.ZERO) <= 0) win = new BigDecimal(MahjongConstants.FLOOR_WIN);
Map<String, Object> prize = findPrizeWithFallback(win);
if (prize != null && !prize.isEmpty()) resp.setPrizeInfo(prize);
} catch (Exception ignore) {}
resp.setBoxRecordId(cache.getBoxRecordId());
resp.setRecordId(cache.getRecordId());
}
return resp;
}
// ============================================================
// 工具
// ============================================================
private Map<String, Object> findPrizeWithFallback(BigDecimal totalWin) {
Map<String, Object> prize = prizeRangeMapper.findPrizeByWin(totalWin);
if (prize != null && !prize.isEmpty()) { prize.put("isFallback", false); return prize; }
prize = prizeRangeMapper.findFallbackPrize();
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findLowestPrize();
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findHighestPrize();
if (prize != null) prize.put("isFallback", true);
return prize;
}
private static BigDecimal scale2(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(2, java.math.RoundingMode.HALF_UP); }
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static int coalesce0(Integer v) { return v != null ? v : 0; }
private static long coalesceL(Long v) { return v != null ? v : 0L; }
private static Long asLong(Object v) {
if (v == null) return null;
if (v instanceof Long) return (Long) v;
if (v instanceof Number) return ((Number) v).longValue();
try { return Long.valueOf(v.toString()); } catch (Exception e) { return null; }
}
private static Object toJson(String s) {
if (s == null || s.isEmpty()) return null;
try { return MAPPER.readTree(s); } catch (Exception e) { return s; }
}
/**
* 剩余免费旋转 = 已发放 − 已播完的 FS 旋数(played = 算到第几步,含这一步;中间波帧不减,旋末帧才 −1)。
* 已发放只算玩家**已经看到**的:触发帧的 freeSpins(12/14/16)+ 已播步里的 retriggerInfo.extraSpins,
* 封顶 36(封顶后 3+ 个胡的旋仍会写 retrigger_info 但不再加旋,所以这里必须夹 36)。
* TODO(生成端): retrigger_info 的旋数额度字段名待与 py 端 schema 对齐后微调。
*/
private static int spinsRemainingAfter(List<MahjongGameCache.CachedStep> steps, int played) {
int awarded = 0, completed = 0;
for (int i = 0; i < played && i < steps.size(); i++) {
MahjongGameCache.CachedStep s = steps.get(i);
awarded += jsonInt(s.getScatterTrigger(), "freeSpins") + jsonInt(s.getRetriggerInfo(), "extraSpins");
if (s.getIsFinal() == 1 && s.getReelSpinId() > 0) completed++;
}
return Math.max(0, Math.min(awarded, MAX_FREE_SPINS) - completed);
}
/** 取 JSON 对象里的整数字段(缺失 / null / 解析失败一律按 0) */
private static int jsonInt(String json, String key) {
if (json == null || json.isEmpty()) return 0;
try {
com.fasterxml.jackson.databind.JsonNode node = MAPPER.readTree(json).get(key);
return node == null ? 0 : node.asInt();
} catch (Exception e) { return 0; }
}
/** 解析 JSON 数组,把指定金额字段 ×bet */
@SuppressWarnings("unchecked")
private static Object scaleMoneyList(String json, BigDecimal bet, String[] keys) {
if (json == null || json.isEmpty()) return null;
try {
List<Map<String, Object>> list = MAPPER.readValue(json, List.class);
for (Map<String, Object> m : list) scaleKeys(m, bet, keys);
return list;
} catch (Exception e) { return toJson(json); }
}
private static void scaleKeys(Map<String, Object> m, BigDecimal bet, String[] keys) {
for (String key : keys) {
Object v = m.get(key);
if (v instanceof Number) {
m.put(key, BigDecimal.valueOf(((Number) v).doubleValue())
.multiply(coalesce(bet)).setScale(2, java.math.RoundingMode.HALF_UP));
}
}
}
/** 校验预留的投注倍数:当前服务端只接受 1(缺省 1);非 1 直接拒绝,不影响任何池子结构。 */
public static void assertBetMulSupported(Integer betMul) {
if (betMul != null && betMul != 1) {
throw new ServiceException("暂不支持投注倍数", 500);
}
}
}
@@ -0,0 +1,280 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import com.ruoyi.game.mahjong.mapper.MahjongStepMapper;
import com.ruoyi.game.mahjong.model.PickedRound;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* 麻将胡了回合选择器(多身份 + 两阶段 + 白号隔离)。
*
* 每个身份(audience)读自己的桶分布与轮次账本:
* - 白号(payoutCap==null):纯概率原逻辑,不进两阶段、不受水位约束,但消耗自己的 white 库存(isConverge=false);
* - 真实身份(payoutCap!=null):固定阶段(payoutCap 过滤+剩余加权) → 收敛阶段(按 desiredNextMul 在水位内选桶)。
*
* 与玫瑰女郎的差异:{@code costMultiplier ≡ 1}(所以 targetMul = rtp,不再 ×CM),
* 且调用方**永远不传 needFreeSpins**(没有"买 FS"模式)——保留该参数只为与母本同构。
*/
@Service
public class MahjongRoundPicker {
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
@Autowired private MahjongStepMapper stepMapper;
@Autowired private MahjongDistributionService distributionService;
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongCycleRepository cycleRepository;
private static final double DEFAULT_CONVERGE_RATIO = 0.15;
public PickedRound pick(Long boxId, String audience, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
List<MahjongBoxBucket> dist = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
if (dist.isEmpty()) throw new RuntimeException("Box " + boxId + " audience=" + audience + " 暂无倍率桶分布数据");
// 白号 / 无水位约束:纯概率原逻辑(消耗自己的库存,不受水位)
if (payoutCap == null) {
int totalRemaining = dist.stream().mapToInt(bb -> safe(bb.getRemaining())).sum();
if (totalRemaining <= 0) {
distributionService.refillFromConfig(boxId, audience);
dist = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
}
Integer bucketId = weightedPickByRemaining(dist);
if (bucketId == null) bucketId = dist.get(0).getBucketId();
MahjongRound r = resolveRound(boxId, bucketId, needFreeSpins, bet, null);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
}
// 真实玩家:两阶段
MahjongBox box = boxMapper.selectById(boxId);
double cm = 1.0, rtp = 0.8, convergeRatio = DEFAULT_CONVERGE_RATIO;
if (box != null) {
try { cm = com.ruoyi.game.mahjong.enums.MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
if (box.getRtp() != null) rtp = box.getRtp().doubleValue();
if (box.getRtpConvergeRatio() != null) convergeRatio = box.getRtpConvergeRatio().doubleValue();
}
if (convergeRatio < 0) convergeRatio = 0;
if (convergeRatio > 0.9) convergeRatio = 0.9;
// 一轮总回合 N 按该身份固定池分布反推(身份级独立),无分布时回退箱子级 total_rounds
int fixedSum = dist.stream().mapToInt(bb -> safe(bb.getCount())).sum();
int N;
if (fixedSum > 0) {
N = (int) Math.round(fixedSum / (1.0 - convergeRatio));
} else {
N = (box != null && box.getTotalRounds() != null && box.getTotalRounds() > 0)
? box.getTotalRounds() : fixedSum;
}
int fixedRounds = (int) Math.round(N * (1 - convergeRatio));
long drawn = cycleRepository.drawn(boxId, audience);
int totalRemaining = dist.stream().mapToInt(bb -> safe(bb.getRemaining())).sum();
// 固定阶段
if (drawn < fixedRounds && totalRemaining > 0) {
Integer bucketId = pickFixedBucketId(dist, bet, payoutCap);
if (bucketId == null) bucketId = dist.get(0).getBucketId();
MahjongRound r = resolveRound(boxId, bucketId, needFreeSpins, bet, payoutCap);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
}
// 收敛阶段
double targetMul = rtp * cm;
long remainingDraws = Math.max(1, N - drawn);
double remainingBudget = N * targetMul - cycleRepository.mulSum(boxId, audience);
double desiredNextMul = Math.max(0, remainingBudget / remainingDraws);
MahjongRound r = pickConverge(boxId, dist, needFreeSpins, bet, payoutCap, desiredNextMul);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), true);
}
private Integer pickFixedBucketId(List<MahjongBoxBucket> dist, BigDecimal bet, BigDecimal payoutCap) {
List<MahjongBoxBucket> eligible = new ArrayList<>();
for (MahjongBoxBucket bb : dist) {
if (Math.max(0, safe(bb.getRemaining())) <= 0) continue;
if (expectPayout(bb, bet).compareTo(payoutCap) <= 0) eligible.add(bb);
}
return !eligible.isEmpty() ? weightedPickByRemaining(eligible) : minPayoutBucketId(dist, bet);
}
private MahjongRound resolveRound(Long boxId, Integer bucketId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
MahjongRound round = pickRoundWithCap(boxId, bucketId, needFreeSpins, bet, payoutCap);
if (round == null) round = copyFromSameBucket(boxId, bucketId, needFreeSpins);
if (round == null && needFreeSpins != null) {
round = roundMapper.pickByBucket(boxId, bucketId, null);
if (round == null) round = copyFromSameBucket(boxId, bucketId, null);
}
if (round == null) throw new RuntimeException("暂无可用回合数据");
return round;
}
/** 桶内随机取一条,真实玩家还要 actual_mul×bet ≤ payoutCap(最多重抽 5 次,抽不到就退回原条) */
private MahjongRound pickRoundWithCap(Long boxId, Integer bucketId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
MahjongRound round = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (round == null || payoutCap == null) return round;
BigDecimal b = bet != null ? bet : BigDecimal.ZERO;
if (withinCap(round, b, payoutCap)) return round;
for (int i = 0; i < 5; i++) {
MahjongRound r = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (r != null && withinCap(r, b, payoutCap)) return r;
}
return round;
}
private MahjongRound pickConverge(Long boxId, List<MahjongBoxBucket> dist, Boolean needFreeSpins,
BigDecimal bet, BigDecimal payoutCap, double desiredNextMul) {
BigDecimal b = (bet != null && bet.signum() > 0) ? bet : BigDecimal.ONE;
List<MahjongBoxBucket> allowed = new ArrayList<>();
for (MahjongBoxBucket bb : dist) {
if (expectPayout(bb, b).compareTo(payoutCap) <= 0) allowed.add(bb);
}
MahjongBoxBucket chosen = allowed.isEmpty() ? minExpectMul(dist) : closestExpectMul(allowed, desiredNextMul);
Integer bucketId = chosen.getBucketId();
double capMul = payoutCap.doubleValue() / b.doubleValue();
double effectiveDesired = Math.min(desiredNextMul, capMul);
BigDecimal desiredBd = BigDecimal.valueOf(effectiveDesired).setScale(4, RoundingMode.HALF_UP);
BigDecimal capBd = BigDecimal.valueOf(capMul).setScale(4, RoundingMode.HALF_UP);
MahjongRound round = roundMapper.pickClosestMul(boxId, bucketId, needFreeSpins, desiredBd, capBd);
if (round == null && needFreeSpins != null) round = roundMapper.pickClosestMul(boxId, bucketId, null, desiredBd, capBd);
if (round == null) round = copyFromSameBucket(boxId, bucketId, needFreeSpins);
if (round == null) round = copyFromSameBucket(boxId, bucketId, null);
if (round == null) throw new RuntimeException("收敛阶段暂无可用回合数据");
return round;
}
private MahjongBoxBucket closestExpectMul(List<MahjongBoxBucket> list, double desired) {
List<MahjongBoxBucket> sorted = new ArrayList<>(list);
sorted.sort(Comparator.comparingDouble(bb -> {
double mul = bb.getExpectMul() != null ? bb.getExpectMul().doubleValue() : 0;
return Math.abs(mul - desired);
}));
int pickCount = Math.min(3, sorted.size());
return sorted.get(ThreadLocalRandom.current().nextInt(pickCount));
}
private MahjongBoxBucket minExpectMul(List<MahjongBoxBucket> list) {
MahjongBoxBucket best = list.get(0);
double bestMul = Double.MAX_VALUE;
for (MahjongBoxBucket bb : list) {
double mul = bb.getExpectMul() != null ? bb.getExpectMul().doubleValue() : 0;
if (mul < bestMul) { bestMul = mul; best = bb; }
}
return best;
}
/** 同桶复制已有回合(新ID,动画数据原样拷贝),保证一定有回合可用。 */
private MahjongRound copyFromSameBucket(Long boxId, Integer bucketId, Boolean needFreeSpins) {
MahjongRound template = null;
for (int i = 0; i < 10; i++) {
template = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (template != null && nz(template.getActualMul()).signum() > 0) break;
}
if (template == null) {
for (int i = 0; i < 10; i++) {
template = roundMapper.pickAnyByBox(boxId, needFreeSpins);
if (template != null && nz(template.getActualMul()).signum() > 0) break;
}
if (template == null) return null;
}
List<MahjongStep> templateSteps = stepMapper.selectByRoundId(template.getRoundId());
Long newRoundId = nextRoundId();
long newStepId = nextStepId();
for (MahjongStep ts : templateSteps) {
MahjongStep ns = new MahjongStep();
ns.setId(newStepId++);
ns.setRoundId(newRoundId);
ns.setReelSpinId(ts.getReelSpinId());
ns.setRotationSeq(ts.getRotationSeq());
ns.setGridState(ts.getGridState());
ns.setGoldenDetail(ts.getGoldenDetail());
ns.setWildConverted(ts.getWildConverted());
ns.setWaysDetail(ts.getWaysDetail());
ns.setAccumulatedSymbols(ts.getAccumulatedSymbols());
ns.setSpinWinners(ts.getSpinWinners());
ns.setRemovedPositions(ts.getRemovedPositions());
ns.setDropData(ts.getDropData());
ns.setFillData(ts.getFillData());
ns.setScatterTrigger(ts.getScatterTrigger());
ns.setRetriggerInfo(ts.getRetriggerInfo());
ns.setBaseScore(ts.getBaseScore());
ns.setWinAmount(ts.getWinAmount());
ns.setRotationMul(ts.getRotationMul());
ns.setSpinWin(ts.getSpinWin());
ns.setWinCapped(ts.getWinCapped());
ns.setBigwinLevel(ts.getBigwinLevel());
ns.setIsFinal(ts.getIsFinal());
stepMapper.insert(ns);
}
MahjongRound copy = new MahjongRound();
copy.setRoundId(newRoundId);
copy.setBoxId(boxId);
copy.setBucketId(bucketId);
copy.setActualMul(template.getActualMul());
copy.setFreeSpinCount(template.getFreeSpinCount());
copy.setTotalRotations(template.getTotalRotations());
copy.setHasFs(template.getHasFs());
copy.setNearMiss(template.getNearMiss());
copy.setCreateTime(new Date());
roundMapper.insert(copy);
return copy;
}
private Long nextRoundId() {
List<MahjongRound> max = roundMapper.selectList(
new LambdaQueryWrapper<MahjongRound>().orderByDesc(MahjongRound::getRoundId).last("limit 1"));
return max.isEmpty() ? 1L : max.get(0).getRoundId() + 1;
}
private Long nextStepId() {
List<MahjongStep> max = stepMapper.selectList(
new LambdaQueryWrapper<MahjongStep>().orderByDesc(MahjongStep::getId).last("limit 1"));
return max.isEmpty() ? 1L : max.get(0).getId() + 1;
}
private BigDecimal expectPayout(MahjongBoxBucket bb, BigDecimal bet) {
BigDecimal mul = bb.getExpectMul() != null ? bb.getExpectMul() : BigDecimal.ZERO;
return mul.multiply(bet != null ? bet : BigDecimal.ZERO);
}
private Integer weightedPickByRemaining(List<MahjongBoxBucket> list) {
if (list.isEmpty()) return null;
int totalWeight = list.stream().mapToInt(bb -> Math.max(0, safe(bb.getRemaining()))).sum();
if (totalWeight <= 0) return list.get(0).getBucketId();
int r = ThreadLocalRandom.current().nextInt(totalWeight);
int acc = 0;
for (MahjongBoxBucket bb : list) {
acc += Math.max(0, safe(bb.getRemaining()));
if (r < acc) return bb.getBucketId();
}
return list.get(0).getBucketId();
}
private Integer minPayoutBucketId(List<MahjongBoxBucket> dist, BigDecimal bet) {
MahjongBoxBucket best = null;
for (MahjongBoxBucket bb : dist) {
if (Math.max(0, safe(bb.getRemaining())) <= 0) continue;
if (best == null || expectPayout(bb, bet).compareTo(expectPayout(best, bet)) < 0) best = bb;
}
return best != null ? best.getBucketId() : dist.get(0).getBucketId();
}
private boolean withinCap(MahjongRound round, BigDecimal bet, BigDecimal payoutCap) {
BigDecimal mul = nz(round.getActualMul());
return mul.signum() > 0 && mul.multiply(bet).compareTo(payoutCap) <= 0;
}
private static BigDecimal nz(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static int safe(Integer v) { return v != null ? v : 0; }
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper">
<!-- 按 (boxId, audience) 查桶分布,连表带出 expectMul,计算 remaining=count-used -->
<select id="selectByBoxIdAndAudience" resultType="com.ruoyi.game.mahjong.domain.MahjongBoxBucket">
SELECT bb.*, b.name AS bucketName, b.expect_mul AS expectMul,
(bb.count - bb.used) AS remaining
FROM game_mahjong_box_bucket bb
LEFT JOIN game_mahjong_bucket b ON b.id = bb.bucket_id
WHERE bb.box_id = #{boxId} AND bb.audience = #{audience}
ORDER BY bb.bucket_id ASC
</select>
<insert id="batchInsert">
INSERT INTO game_mahjong_box_bucket (box_id, bucket_id, audience, count, used) VALUES
<foreach collection="list" item="it" separator=",">
(#{it.boxId}, #{it.bucketId}, #{it.audience}, #{it.count}, #{it.used})
</foreach>
</insert>
<delete id="deleteByBoxIdAndAudience">
DELETE FROM game_mahjong_box_bucket WHERE box_id = #{boxId} AND audience = #{audience}
</delete>
<update id="incrUsed">
UPDATE game_mahjong_box_bucket SET used = used + 1
WHERE box_id = #{boxId} AND bucket_id = #{bucketId} AND audience = #{audience}
</update>
<update id="resetUsed">
UPDATE game_mahjong_box_bucket SET used = 0
WHERE box_id = #{boxId} AND audience = #{audience}
</update>
</mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.game.mahjong.mapper.MahjongRoundMapper">
<select id="pickByBucket" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId} AND bucket_id = #{bucketId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY RAND() LIMIT 1
</select>
<select id="pickAnyByBox" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY RAND() LIMIT 1
</select>
<!-- 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul,从 top-10 随机取1 -->
<select id="pickClosestMul" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM (
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId} AND bucket_id = #{bucketId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY (CASE WHEN actual_mul &lt;= #{capMul} THEN 0 ELSE 1 END),
ABS(actual_mul - #{desiredMul})
LIMIT 10
) AS closest_pool
ORDER BY RAND() LIMIT 1
</select>
<!-- 统计某个箱子池子里每个桶"实际赔出去多少"的均值:配比计算与后台「目标RTP」按它算 -->
<select id="avgMulByBucket" resultType="java.util.Map">
SELECT bucket_id AS bucketId, COUNT(*) AS sampleCount, AVG(actual_mul) AS avgMul
FROM game_mahjong_round
WHERE box_id = #{boxId}
GROUP BY bucket_id
</select>
</mapper>
+1 -1
View File
@@ -19,7 +19,7 @@
<module>service-playingmethod</module>
<module>service-task</module>
<module>game-wheel-service</module>
<module>game-olympus-service</module>
<module>game-mahjong-service</module>
<module>reward-service</module>
</modules>