Compare commits
2
Commits
e61399ecd6
...
69e1a9531f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e1a9531f | ||
|
|
470d1471e5 |
+217
-148
@@ -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` 抽样。
|
||||
|
||||
@@ -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` 已经支持外部传盘面,构造器接上去即可。
|
||||
@@ -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
|
||||
@@ -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` 那种冒烟规模
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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(), '麻将胡了·返奖饰品与返奖区间');
|
||||
@@ -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 两档必须单独注)
|
||||
-- ============================================================
|
||||
@@ -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
@@ -97,7 +97,7 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>game-olympus-service</artifactId>
|
||||
<artifactId>game-mahjong-service</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -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> |
|
||||
收敛回合: <b>{{ convergeRoundsDerived }}</b> | 总回合: <b>{{ totalRoundsDerived }}</b> |
|
||||
计算RTP(池内实际均值): <b>{{ bucketRTP }}%</b> | 目标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>
|
||||
+56
@@ -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() {
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+47
@@ -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(战绩用)
|
||||
}
|
||||
+747
@@ -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; }
|
||||
}
|
||||
}
|
||||
+179
@@ -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));
|
||||
}
|
||||
}
|
||||
+90
@@ -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;
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+49
@@ -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 维度
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+50
@@ -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();
|
||||
}
|
||||
+66
@@ -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 >= #{dateStart}</if> "
|
||||
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time < 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 >= #{dateStart}</if> "
|
||||
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time < 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);
|
||||
}
|
||||
+10
@@ -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> {
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+84
@@ -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; }
|
||||
}
|
||||
}
|
||||
+115
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+350
@@ -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; }
|
||||
}
|
||||
}
|
||||
+554
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -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; }
|
||||
}
|
||||
+36
@@ -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>
|
||||
+43
@@ -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 > 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 > 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 > 0</if>
|
||||
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
|
||||
ORDER BY (CASE WHEN actual_mul <= #{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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user