From 470d1471e5236335892dca7d105238544a055c69 Mon Sep 17 00:00:00 2001 From: Fdily <2949549024@qq.com> Date: Tue, 22 Sep 2026 15:39:53 +0800 Subject: [PATCH] =?UTF-8?q?=E9=BA=BB=E5=B0=86=E8=83=A1=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/麻将胡了设计方案.md | 365 +++++---- mahjong-py/README.md | 38 + mahjong-py/mahjong/__init__.py | 7 + mahjong-py/mahjong/config.py | 166 ++++ mahjong-py/mahjong/engine.py | 279 +++++++ mahjong-py/mahjong/models.py | 58 ++ mahjong-py/mahjong_simulator.py | 99 +++ mahjong-py/mahjong_sys_menu.sql | 27 + mahjong-py/schema.sql | 298 +++++++ mahjong-py/tests/test_engine.py | 203 +++++ ruoyi-admin/pom.xml | 2 +- ruoyi-ui/src/api/game/mahjong/box/api.js | 62 ++ ruoyi-ui/src/api/game/mahjong/bucket/api.js | 6 + ruoyi-ui/src/api/game/mahjong/dailywin/api.js | 11 + ruoyi-ui/src/api/game/mahjong/prize/api.js | 11 + ruoyi-ui/src/api/game/mahjong/record/api.js | 7 + .../src/api/game/mahjong/rewardPool/api.js | 29 + ruoyi-ui/src/api/game/mahjong/round/api.js | 6 + ruoyi-ui/src/views/game/mahjong/box/index.vue | 448 +++++++++++ .../src/views/game/mahjong/bucket/index.vue | 33 + .../src/views/game/mahjong/dailywin/index.vue | 92 +++ .../src/views/game/mahjong/prize/index.vue | 133 ++++ .../src/views/game/mahjong/record/index.vue | 105 +++ .../views/game/mahjong/rewardPool/index.vue | 452 +++++++++++ .../src/views/game/mahjong/round/index.vue | 82 ++ skins-service/game-mahjong-service/pom.xml | 51 ++ .../mahjong/constants/MahjongConstants.java | 56 ++ .../contract/request/RecordPageRequest.java | 17 + .../mahjong/contract/request/SpinRequest.java | 17 + .../contract/response/BoxResponse.java | 16 + .../contract/response/RecordItemResponse.java | 34 + .../contract/response/SpinResponse.java | 47 ++ .../controller/MahjongAdminController.java | 747 ++++++++++++++++++ .../controller/MahjongClientController.java | 179 +++++ .../ruoyi/game/mahjong/domain/MahjongBox.java | 90 +++ .../game/mahjong/domain/MahjongBoxBucket.java | 52 ++ .../mahjong/domain/MahjongBoxTemplate.java | 45 ++ .../game/mahjong/domain/MahjongBucket.java | 30 + .../mahjong/domain/MahjongPrizeOrnament.java | 36 + .../mahjong/domain/MahjongPrizeRange.java | 21 + .../game/mahjong/domain/MahjongRecord.java | 46 ++ .../game/mahjong/domain/MahjongRewardLog.java | 52 ++ .../game/mahjong/domain/MahjongRound.java | 46 ++ .../game/mahjong/domain/MahjongStep.java | 83 ++ .../game/mahjong/domain/MahjongUser.java | 25 + .../game/mahjong/enums/MahjongAudience.java | 49 ++ .../game/mahjong/enums/MahjongModeType.java | 45 ++ .../mapper/MahjongBoxBucketMapper.java | 32 + .../game/mahjong/mapper/MahjongBoxMapper.java | 10 + .../mapper/MahjongBoxTemplateMapper.java | 10 + .../mahjong/mapper/MahjongBucketMapper.java | 10 + .../mapper/MahjongPrizeOrnamentMapper.java | 9 + .../mapper/MahjongPrizeRangeMapper.java | 50 ++ .../mahjong/mapper/MahjongRecordMapper.java | 66 ++ .../mapper/MahjongRewardLogMapper.java | 10 + .../mahjong/mapper/MahjongRoundMapper.java | 33 + .../mahjong/mapper/MahjongStepMapper.java | 16 + .../mahjong/mapper/MahjongUserMapper.java | 14 + .../ruoyi/game/mahjong/model/PickedRound.java | 17 + .../mahjong/model/cache/MahjongGameCache.java | 56 ++ .../model/cache/MahjongRewardPoolCache.java | 17 + .../repository/MahjongCacheRepository.java | 30 + .../repository/MahjongCycleRepository.java | 84 ++ .../MahjongRewardPoolCacheRepository.java | 115 +++ .../service/MahjongDistributionService.java | 350 ++++++++ .../mahjong/service/MahjongGameService.java | 554 +++++++++++++ .../mahjong/service/MahjongRoundPicker.java | 280 +++++++ .../mapper/mahjong/MahjongBoxBucketMapper.xml | 36 + .../mapper/mahjong/MahjongRoundMapper.xml | 43 + skins-service/pom.xml | 2 +- 70 files changed, 6497 insertions(+), 150 deletions(-) create mode 100644 mahjong-py/README.md create mode 100644 mahjong-py/mahjong/__init__.py create mode 100644 mahjong-py/mahjong/config.py create mode 100644 mahjong-py/mahjong/engine.py create mode 100644 mahjong-py/mahjong/models.py create mode 100644 mahjong-py/mahjong_simulator.py create mode 100644 mahjong-py/mahjong_sys_menu.sql create mode 100644 mahjong-py/schema.sql create mode 100644 mahjong-py/tests/test_engine.py create mode 100644 ruoyi-ui/src/api/game/mahjong/box/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/bucket/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/dailywin/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/prize/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/record/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/rewardPool/api.js create mode 100644 ruoyi-ui/src/api/game/mahjong/round/api.js create mode 100644 ruoyi-ui/src/views/game/mahjong/box/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/bucket/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/dailywin/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/prize/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/record/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/rewardPool/index.vue create mode 100644 ruoyi-ui/src/views/game/mahjong/round/index.vue create mode 100644 skins-service/game-mahjong-service/pom.xml create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/constants/MahjongConstants.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/RecordPageRequest.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/SpinRequest.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/BoxResponse.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/RecordItemResponse.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/SpinResponse.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongAdminController.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongClientController.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBox.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxBucket.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxTemplate.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBucket.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeOrnament.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeRange.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRecord.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRewardLog.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRound.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongStep.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongUser.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongAudience.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongModeType.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxBucketMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxTemplateMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBucketMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeOrnamentMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeRangeMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRecordMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRewardLogMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRoundMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongStepMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongUserMapper.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/PickedRound.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongGameCache.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongRewardPoolCache.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCacheRepository.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCycleRepository.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongRewardPoolCacheRepository.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongDistributionService.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongGameService.java create mode 100644 skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongRoundPicker.java create mode 100644 skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongBoxBucketMapper.xml create mode 100644 skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongRoundMapper.xml diff --git a/docs/麻将胡了设计方案.md b/docs/麻将胡了设计方案.md index 7a30c9f..c79e226 100644 --- a/docs/麻将胡了设计方案.md +++ b/docs/麻将胡了设计方案.md @@ -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//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` 抽样。 diff --git a/mahjong-py/README.md b/mahjong-py/README.md new file mode 100644 index 0000000..4834ba6 --- /dev/null +++ b/mahjong-py/README.md @@ -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` 已经支持外部传盘面,构造器接上去即可。 diff --git a/mahjong-py/mahjong/__init__.py b/mahjong-py/mahjong/__init__.py new file mode 100644 index 0000000..06367d4 --- /dev/null +++ b/mahjong-py/mahjong/__init__.py @@ -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 diff --git a/mahjong-py/mahjong/config.py b/mahjong-py/mahjong/config.py new file mode 100644 index 0000000..8d53b74 --- /dev/null +++ b/mahjong-py/mahjong/config.py @@ -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` 那种冒烟规模 diff --git a/mahjong-py/mahjong/engine.py b/mahjong-py/mahjong/engine.py new file mode 100644 index 0000000..b454f0c --- /dev/null +++ b/mahjong-py/mahjong/engine.py @@ -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_ diff --git a/mahjong-py/mahjong/models.py b/mahjong-py/mahjong/models.py new file mode 100644 index 0000000..c750cd3 --- /dev/null +++ b/mahjong-py/mahjong/models.py @@ -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 diff --git a/mahjong-py/mahjong_simulator.py b/mahjong-py/mahjong_simulator.py new file mode 100644 index 0000000..30e0359 --- /dev/null +++ b/mahjong-py/mahjong_simulator.py @@ -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() diff --git a/mahjong-py/mahjong_sys_menu.sql b/mahjong-py/mahjong_sys_menu.sql new file mode 100644 index 0000000..45a7930 --- /dev/null +++ b/mahjong-py/mahjong_sys_menu.sql @@ -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(), '麻将胡了·返奖饰品与返奖区间'); \ No newline at end of file diff --git a/mahjong-py/schema.sql b/mahjong-py/schema.sql new file mode 100644 index 0000000..6093e4b --- /dev/null +++ b/mahjong-py/schema.sql @@ -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 -p < 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 两档必须单独注) +-- ============================================================ diff --git a/mahjong-py/tests/test_engine.py b/mahjong-py/tests/test_engine.py new file mode 100644 index 0000000..a3e0139 --- /dev/null +++ b/mahjong-py/tests/test_engine.py @@ -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全部通过 ✅") diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 9f9f389..baf32e5 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -97,7 +97,7 @@ com.ruoyi - game-olympus-service + game-mahjong-service ${ruoyi.version} diff --git a/ruoyi-ui/src/api/game/mahjong/box/api.js b/ruoyi-ui/src/api/game/mahjong/box/api.js new file mode 100644 index 0000000..06f57d8 --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/box/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/bucket/api.js b/ruoyi-ui/src/api/game/mahjong/bucket/api.js new file mode 100644 index 0000000..69e13a5 --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/bucket/api.js @@ -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' }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/dailywin/api.js b/ruoyi-ui/src/api/game/mahjong/dailywin/api.js new file mode 100644 index 0000000..7baa704 --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/dailywin/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/prize/api.js b/ruoyi-ui/src/api/game/mahjong/prize/api.js new file mode 100644 index 0000000..516361c --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/prize/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/record/api.js b/ruoyi-ui/src/api/game/mahjong/record/api.js new file mode 100644 index 0000000..9cbd0b0 --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/record/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/rewardPool/api.js b/ruoyi-ui/src/api/game/mahjong/rewardPool/api.js new file mode 100644 index 0000000..173516b --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/rewardPool/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/api/game/mahjong/round/api.js b/ruoyi-ui/src/api/game/mahjong/round/api.js new file mode 100644 index 0000000..c301d4f --- /dev/null +++ b/ruoyi-ui/src/api/game/mahjong/round/api.js @@ -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 }) +} diff --git a/ruoyi-ui/src/views/game/mahjong/box/index.vue b/ruoyi-ui/src/views/game/mahjong/box/index.vue new file mode 100644 index 0000000..17a55c9 --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/box/index.vue @@ -0,0 +1,448 @@ + + + diff --git a/ruoyi-ui/src/views/game/mahjong/bucket/index.vue b/ruoyi-ui/src/views/game/mahjong/bucket/index.vue new file mode 100644 index 0000000..23e0854 --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/bucket/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/ruoyi-ui/src/views/game/mahjong/dailywin/index.vue b/ruoyi-ui/src/views/game/mahjong/dailywin/index.vue new file mode 100644 index 0000000..56c31ca --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/dailywin/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/ruoyi-ui/src/views/game/mahjong/prize/index.vue b/ruoyi-ui/src/views/game/mahjong/prize/index.vue new file mode 100644 index 0000000..5c9478a --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/prize/index.vue @@ -0,0 +1,133 @@ + + + diff --git a/ruoyi-ui/src/views/game/mahjong/record/index.vue b/ruoyi-ui/src/views/game/mahjong/record/index.vue new file mode 100644 index 0000000..db2e778 --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/record/index.vue @@ -0,0 +1,105 @@ + + + diff --git a/ruoyi-ui/src/views/game/mahjong/rewardPool/index.vue b/ruoyi-ui/src/views/game/mahjong/rewardPool/index.vue new file mode 100644 index 0000000..d17d49a --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/rewardPool/index.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/ruoyi-ui/src/views/game/mahjong/round/index.vue b/ruoyi-ui/src/views/game/mahjong/round/index.vue new file mode 100644 index 0000000..131a945 --- /dev/null +++ b/ruoyi-ui/src/views/game/mahjong/round/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/skins-service/game-mahjong-service/pom.xml b/skins-service/game-mahjong-service/pom.xml new file mode 100644 index 0000000..caff7a2 --- /dev/null +++ b/skins-service/game-mahjong-service/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + com.ruoyi + skins-service + 4.8.2 + + + + 麻将胡了 + + + game-mahjong-service + + + 8 + 8 + UTF-8 + + + + + com.ruoyi + ruoyi-framework + + + com.ruoyi + service-admin + 4.8.2 + + + com.ruoyi + service-user + 4.8.2 + + + + com.ruoyi + message-service + 4.8.2 + + + com.ruoyi + ruoyi-common + 4.8.2 + + + diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/constants/MahjongConstants.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/constants/MahjongConstants.java new file mode 100644 index 0000000..e5a33c9 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/constants/MahjongConstants.java @@ -0,0 +1,56 @@ +package com.ruoyi.game.mahjong.constants; + +/** + * 麻将胡了的 Redis key 与平台常量。 + *

+ * 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 占位值 —— 定义在共享枚举里(下一步动作): + *

    + *
  • {@code TtAccountRecordSource.MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费")} / {@code MAHJONG_REWARD(69, INPUT, "麻将胡了奖励")}
  • + *
  • {@code TtboxRecordSource.MAHJONG_BOX(15, "麻将")}
  • + *
  • {@code MessageCategoryEnum.GAME_MAHJONG("GAME_MAHJONG", "麻将胡了")}
  • + *
+ * 本次任务明确不改共享文件,因此先以本地常量承接;共享枚举补上后, + * 把 {@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() { + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/RecordPageRequest.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/RecordPageRequest.java new file mode 100644 index 0000000..f09f45a --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/RecordPageRequest.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/SpinRequest.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/SpinRequest.java new file mode 100644 index 0000000..af4dbd4 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/request/SpinRequest.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/BoxResponse.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/BoxResponse.java new file mode 100644 index 0000000..40b3c36 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/BoxResponse.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/RecordItemResponse.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/RecordItemResponse.java new file mode 100644 index 0000000..0f4b779 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/RecordItemResponse.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/SpinResponse.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/SpinResponse.java new file mode 100644 index 0000000..df6df77 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/contract/response/SpinResponse.java @@ -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(战绩用) +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongAdminController.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongAdminController.java new file mode 100644 index 0000000..169bd78 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongAdminController.java @@ -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 qw = new LambdaQueryWrapper().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 page = boxMapper.selectPage(new Page<>(pageNum, pageSize), qw); + + // 各身份分布一次全量查出(11箱×4身份量小),内存分组,避免逐身份 N+1 + Map> expectByBox = new HashMap<>(); + Map> 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 bucketMul = expectByBox.computeIfAbsent(box.getBoxId(), + k -> distributionService.loadPoolExpect(k)); + box.setModeName(m.getName()); + box.setCostMultiplier(BigDecimal.valueOf(cm)); + Map targetMap = new HashMap<>(); + Map actualMap = new HashMap<>(); + Map totalMap = new HashMap<>(); + for (String aud : AUDIENCES) { + // 目标RTP:从该身份分布反推 = Σ(count×expect_mul) / Σ(count) / CM × 100 + List 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 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 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> items = (List>) 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 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 saved = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience); + Map bucketMul = distributionService.loadPoolExpect(boxId); + List> 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 params) { + @SuppressWarnings("unchecked") + List> items = (List>) 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 dist = new LinkedHashMap<>(); + for (Map 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 list = boxTemplateMapper.selectList( + new LambdaQueryWrapper() + .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 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> items = (List>) params.get("items"); + if (items == null || items.isEmpty()) return R.fail("模板倍率桶分布不能为空"); + Long dup = boxTemplateMapper.selectCount(new LambdaQueryWrapper() + .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 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 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 siblings = boxTemplateMapper.selectList(new LambdaQueryWrapper() + .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 boxes = boxMapper.selectList( + new LambdaQueryWrapper().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 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 qw = new LambdaQueryWrapper() + .eq(MahjongBox::getGameType, "MAHJONG"); + if (boxType != null) qw.eq(MahjongBox::getBoxType, boxType); + List boxes = boxMapper.selectList(qw); + if (boxes.isEmpty()) return R.fail("没有匹配的箱子"); + + List 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 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 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 rows) { + if (rows == null || rows.isEmpty()) return; + Set ids = rows.stream().map(MahjongRecord::getUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (ids.isEmpty()) return; + Map 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 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> 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 list = bucketMapper.selectList( + new LambdaQueryWrapper().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 qw = new LambdaQueryWrapper<>(); + if (roundId != null) qw.eq(MahjongRound::getRoundId, roundId); + // 收集箱子 id→模式,供筛选与回填 boxType + List boxes = boxMapper.selectList( + new LambdaQueryWrapper().eq(MahjongBox::getGameType, "MAHJONG")); + Map boxTypeMap = new HashMap<>(); + List 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 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 list = prizeOrnamentMapper.selectList( + new LambdaQueryWrapper().orderByAsc(MahjongPrizeOrnament::getSort)); + List ranges = prizeRangeMapper.selectList(new LambdaQueryWrapper<>()); + Map 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().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 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 poolConfigMap(String audience) { + List boxes = boxMapper.selectList( + new LambdaQueryWrapper().eq(MahjongBox::getGameType, "MAHJONG").orderByAsc(MahjongBox::getBoxId)); + List> 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 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 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 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 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 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 boxes = boxMapper.selectList( + new LambdaQueryWrapper().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 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 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 boxes = boxMapper.selectList( + new LambdaQueryWrapper().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 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 p = rewardLogMapper.selectPage(new Page<>(page, size), + new LambdaQueryWrapper() + .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 list = boxMapper.selectList( + new LambdaQueryWrapper().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; } + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongClientController.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongClientController.java new file mode 100644 index 0000000..73045df --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/controller/MahjongClientController.java @@ -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 list = boxMapper.selectList(new LambdaQueryWrapper() + .eq(MahjongBox::getGameType, "MAHJONG").eq(MahjongBox::getStatus, "1") + .orderByAsc(MahjongBox::getBoxId)); + List 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 qw = new LambdaQueryWrapper() + .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 page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw); + List records = page.getRecords(); + + // 昵称/头像(两种 scope 都返回) + Map> userMap = new HashMap<>(); + Set userIds = new HashSet<>(); + for (MahjongRecord r : records) userIds.add(r.getUserId()); + if (!userIds.isEmpty()) { + for (Map 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 exteriorById = new HashMap<>(); + Map 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 rows = new ArrayList<>(); + for (MahjongRecord r : records) { + RecordItemResponse vo = new RecordItemResponse(); + Map 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 exteriorById, Map 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)); + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBox.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBox.java new file mode 100644 index 0000000..eddaae2 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBox.java @@ -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 targetRtpByAudience; + + /** 各身份实时RTP(%),运行时填充 {audience: percent} */ + @TableField(exist = false) + private Map actualRtpByAudience; + + /** 各身份一轮总回合(按各自固定池分布反推),运行时填充 {audience: n} */ + @TableField(exist = false) + private Map totalRoundsByAudience; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxBucket.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxBucket.java new file mode 100644 index 0000000..7f4848e --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxBucket.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxTemplate.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxTemplate.java new file mode 100644 index 0000000..dd1b712 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBoxTemplate.java @@ -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> items; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBucket.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBucket.java new file mode 100644 index 0000000..247bdc5 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongBucket.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeOrnament.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeOrnament.java new file mode 100644 index 0000000..4af460d --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeOrnament.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeRange.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeRange.java new file mode 100644 index 0000000..bf1bc3d --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongPrizeRange.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRecord.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRecord.java new file mode 100644 index 0000000..044d23b --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRecord.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRewardLog.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRewardLog.java new file mode 100644 index 0000000..0fdd739 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRewardLog.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRound.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRound.java new file mode 100644 index 0000000..b16c91c --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongRound.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongStep.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongStep.java new file mode 100644 index 0000000..ab9ec2d --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongStep.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongUser.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongUser.java new file mode 100644 index 0000000..5e4156a --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/domain/MahjongUser.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongAudience.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongAudience.java new file mode 100644 index 0000000..7293970 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongAudience.java @@ -0,0 +1,49 @@ +package com.ruoyi.game.mahjong.enums; + +import com.ruoyi.domain.entity.sys.TtUser; + +/** + * 身份维度(audience):每种身份一套独立的倍率桶数量分布,实现差异化 RTP。 + *

+ * **为什么本模块自带一份,而不是复用 {@code OlympusAudience}**:那一位在 + * {@code skins-service/game-olympus-service} 里,而该模块在本仓**不存在**(本次任务已把它的 + * Maven module / ruoyi-admin 依赖摘掉换成 game-mahjong-service),新建的麻将模块无法依赖它。 + * 设计方案 §6.2 的建议是"直接把 OlympusAudience 上移到共享模块(skins-model)"——那属于**共享文件改动**, + * 本次任务明确不做,所以这里先在本模块内落一份**逐字同构**的判定(口径、优先级、返回码完全一致)。 + * 待共享文件改动落地后,把本类替换为共享的 OlympusAudience 即可,行为不变。 + *

+ * 解析优先级(白号最高,压过 user_type): + * is_blank_account==1 → white;user_type=01 → anchor;其余(含 user_type=03 机器人)→ common。 + *

+ * ⚠️ **本游戏只有三个身份维度**: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 维度 + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongModeType.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongModeType.java new file mode 100644 index 0000000..a840a7b --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/enums/MahjongModeType.java @@ -0,0 +1,45 @@ +package com.ruoyi.game.mahjong.enums; + +/** + * 麻将胡了模式(对齐 mahjong-py config.MODES): + * 只有一个 normal(普通旋转,自然触发免费旋转)。 + *

+ * 与玫瑰女郎的关键差异:底注(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); + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxBucketMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxBucketMapper.java new file mode 100644 index 0000000..0be783c --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxBucketMapper.java @@ -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 { + + /** 查某箱某身份的桶分布(含 expectMul/remaining 连表填充) */ + List selectByBoxIdAndAudience(@Param("boxId") Long boxId, + @Param("audience") String audience); + + int batchInsert(@Param("list") List 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); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxMapper.java new file mode 100644 index 0000000..712b7ab --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxMapper.java @@ -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 { +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxTemplateMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxTemplateMapper.java new file mode 100644 index 0000000..66424b2 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBoxTemplateMapper.java @@ -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 { +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBucketMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBucketMapper.java new file mode 100644 index 0000000..d545e14 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongBucketMapper.java @@ -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 { +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeOrnamentMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeOrnamentMapper.java new file mode 100644 index 0000000..bb7d8ee --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeOrnamentMapper.java @@ -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 { +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeRangeMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeRangeMapper.java new file mode 100644 index 0000000..a668fb5 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongPrizeRangeMapper.java @@ -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 { + + /** 精确区间匹配 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 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 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 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 findHighestPrize(); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRecordMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRecordMapper.java new file mode 100644 index 0000000..dd21dd3 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRecordMapper.java @@ -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 { + + /** 批量查各箱实时 RTP(排除白号/机器人/封禁/删除,与首页口径一致) */ + @Select("") + List> selectBoxRtpList(@Param("boxIds") List boxIds); + + @Select("") + List> getUserInfos(@Param("userIds") Set userIds); + + /** 每日统计汇总(排除白号/机器人/封禁/删除) */ + @Select("") + Map winSummary(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd); + + /** 每日统计列表:按天聚合(口径与 winSummary 一致,排除白号/机器人/封禁/删除) */ + @Select("") + List> 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("") + Map sumPool(@Param("boxId") Long boxId, @Param("audience") String audience); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRewardLogMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRewardLogMapper.java new file mode 100644 index 0000000..516cd46 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRewardLogMapper.java @@ -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 { +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRoundMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRoundMapper.java new file mode 100644 index 0000000..c5a999f --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongRoundMapper.java @@ -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 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> avgMulByBucket(@Param("boxId") Long boxId); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongStepMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongStepMapper.java new file mode 100644 index 0000000..b45595b --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongStepMapper.java @@ -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 { + + @Select("SELECT * FROM game_mahjong_step WHERE round_id = #{roundId} ORDER BY id ASC") + List selectByRoundId(@Param("roundId") Long roundId); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongUserMapper.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongUserMapper.java new file mode 100644 index 0000000..9806cc2 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/mapper/MahjongUserMapper.java @@ -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 { + + @Select("SELECT * FROM game_mahjong_user WHERE user_id = #{userId} LIMIT 1") + MahjongUser selectByUserId(@Param("userId") Long userId); +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/PickedRound.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/PickedRound.java new file mode 100644 index 0000000..e7b1ecd --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/PickedRound.java @@ -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 steps; + private boolean converge; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongGameCache.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongGameCache.java new file mode 100644 index 0000000..2e0bdf7 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongGameCache.java @@ -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 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; + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongRewardPoolCache.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongRewardPoolCache.java new file mode 100644 index 0000000..25acbaa --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/model/cache/MahjongRewardPoolCache.java @@ -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; +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCacheRepository.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCacheRepository.java new file mode 100644 index 0000000..7b49934 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCacheRepository.java @@ -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); + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCycleRepository.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCycleRepository.java new file mode 100644 index 0000000..12b4249 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongCycleRepository.java @@ -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; } + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongRewardPoolCacheRepository.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongRewardPoolCacheRepository.java new file mode 100644 index 0000000..2cfafa3 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/repository/MahjongRewardPoolCacheRepository.java @@ -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(可能为负)。 + *

+ * 记账口径与玫瑰女郎一致: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 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; + } + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongDistributionService.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongDistributionService.java new file mode 100644 index 0000000..50af406 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongDistributionService.java @@ -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 → 幂律权重二分 → 单调约束 → 微调逼近); + * 与玫瑰女郎的差异只有两处: + *

    + *
  1. 表名/字段名 ornament→bucket(附录 A 映射);
  2. + *
  3. {@code costMultiplier ≡ 1},所以目标均值就是 targetRtp 本身,没有 CM 换算。
  4. + *
+ */ +@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 PAYOUT_EXPECT_OVERRIDE = Map.of(16, 420.0); + + /** 倍率桶 ID → 实际发出期望(受封顶影响的桶用覆盖值,其余用 DB 的 expect_mul)。 */ + public Map loadBucketMul() { + Map 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 loadPoolExpect(Long boxId) { + Map expect = new HashMap<>(loadBucketMul()); + if (boxId == null) return expect; + try { + for (Map 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 calcRounds(MahjongModeType mode, Long boxId, int totalRounds, double targetRtp) { + // costMultiplier ≡ 1 → 目标均值就是 targetRtp + double cm = mode.getCostMultiplier(); + double targetAvg = targetRtp * cm; + Map 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 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 result = new LinkedHashMap<>(); + for (double[] w : baseWeights) result.put((int) w[0], 1); + double totalW = 0; + Map 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 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 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 distribution, Long boxId, double rtp, int simRounds) { + if (distribution == null || distribution.isEmpty() || simRounds <= 0) return 0; + Map 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 mulList = new ArrayList<>(); + for (Map.Entry 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 dist = calcRounds(mode, boxId, fixedRounds, targetRtp); + List list = new ArrayList<>(); + for (Map.Entry 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> items) { + boxBucketMapper.deleteByBoxIdAndAudience(boxId, audience); + int total = 0; + List list = new ArrayList<>(); + if (items != null) { + for (Map 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() + .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> items = parseItems(active.getItemsJson()); + if (!items.isEmpty()) { writeDistribution(boxId, audience, items); return; } + } + boxBucketMapper.resetUsed(boxId, audience); + } + + public List distribution(Long boxId, String audience) { + return boxBucketMapper.selectByBoxIdAndAudience(boxId, audience); + } + + public List> parseItems(String itemsJson) { + try { + return OBJECT_MAPPER.readValue(itemsJson, new TypeReference>>() {}); + } 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; } + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongGameService.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongGameService.java new file mode 100644 index 0000000..06ec61e --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongGameService.java @@ -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 开局/播放/结算 + 多身份选池 + 按箱水位 + 白号隔离。 + *

+ * 与玫瑰女郎逐行同构,只有以下**有意差异**(其余一律照抄): + *

    + *
  1. {@code cost = box.bet}(costMultiplier ≡ 1、无模式倍率、无投注倍数;betMul 只做预留且只接受 1);
  2. + *
  3. 挑回合不带 needFreeSpins(没有买 FS 模式);
  4. + *
  5. 单局封顶 {@code MAX_WIN_CAP_MUL = 500}(玫瑰女郎 1000);
  6. + *
  7. 本波阶梯倍率字段是 {@code rotationMul}(母本的 accumulator 语义);
  8. + *
  9. 流水/背包 source 待共享枚举补齐(见 MahjongConstants 的说明)。
  10. + *
+ * 本类已完成开局事务/结算/续播/重连的完整骨架接线,业务细节(如 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 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 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() + .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 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() + .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 hasActive(Long userId) { + MahjongGameCache cache = cacheRepository.getCache(userId); + // 开局即结算模型:无缓存即无进行中动画可恢复(饰品已在背包),直接返回 + if (cache == null) return Map.of("hasActive", false); + List 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 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 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 findPrizeWithFallback(BigDecimal totalWin) { + Map 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 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> list = MAPPER.readValue(json, List.class); + for (Map m : list) scaleKeys(m, bet, keys); + return list; + } catch (Exception e) { return toJson(json); } + } + + private static void scaleKeys(Map 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); + } + } +} diff --git a/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongRoundPicker.java b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongRoundPicker.java new file mode 100644 index 0000000..7c94e0a --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/java/com/ruoyi/game/mahjong/service/MahjongRoundPicker.java @@ -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 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 dist, BigDecimal bet, BigDecimal payoutCap) { + List 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 dist, Boolean needFreeSpins, + BigDecimal bet, BigDecimal payoutCap, double desiredNextMul) { + BigDecimal b = (bet != null && bet.signum() > 0) ? bet : BigDecimal.ONE; + List 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 list, double desired) { + List 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 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 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 max = roundMapper.selectList( + new LambdaQueryWrapper().orderByDesc(MahjongRound::getRoundId).last("limit 1")); + return max.isEmpty() ? 1L : max.get(0).getRoundId() + 1; + } + + private Long nextStepId() { + List max = stepMapper.selectList( + new LambdaQueryWrapper().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 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 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; } +} diff --git a/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongBoxBucketMapper.xml b/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongBoxBucketMapper.xml new file mode 100644 index 0000000..ae39012 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongBoxBucketMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + INSERT INTO game_mahjong_box_bucket (box_id, bucket_id, audience, count, used) VALUES + + (#{it.boxId}, #{it.bucketId}, #{it.audience}, #{it.count}, #{it.used}) + + + + + DELETE FROM game_mahjong_box_bucket WHERE box_id = #{boxId} AND audience = #{audience} + + + + UPDATE game_mahjong_box_bucket SET used = used + 1 + WHERE box_id = #{boxId} AND bucket_id = #{bucketId} AND audience = #{audience} + + + + UPDATE game_mahjong_box_bucket SET used = 0 + WHERE box_id = #{boxId} AND audience = #{audience} + + + diff --git a/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongRoundMapper.xml b/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongRoundMapper.xml new file mode 100644 index 0000000..f007390 --- /dev/null +++ b/skins-service/game-mahjong-service/src/main/resources/mapper/mahjong/MahjongRoundMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/skins-service/pom.xml b/skins-service/pom.xml index cd631c3..2a52c55 100644 --- a/skins-service/pom.xml +++ b/skins-service/pom.xml @@ -19,7 +19,7 @@ service-playingmethod service-task game-wheel-service - game-olympus-service + game-mahjong-service reward-service