麻将胡了

This commit is contained in:
2026-09-22 15:39:53 +08:00
parent 8f8d2e042c
commit 470d1471e5
70 changed files with 6497 additions and 150 deletions
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>skins-service</artifactId>
<version>4.8.2</version>
</parent>
<description>
麻将胡了
</description>
<artifactId>game-mahjong-service</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-framework</artifactId>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>service-admin</artifactId>
<version>4.8.2</version>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>service-user</artifactId>
<version>4.8.2</version>
</dependency>
<!-- 消息通知统一发送模块(末帧结算站内信) -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>message-service</artifactId>
<version>4.8.2</version>
</dependency>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
<version>4.8.2</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,56 @@
package com.ruoyi.game.mahjong.constants;
/**
* 麻将胡了的 Redis key 与平台常量。
* <p>
* ponytail: 本模块自持前缀,不写进共享的 {@code com.ruoyi.admin.config.RedisConstants}
* (那是下一步的共享文件改动项;等 RedisConstants 加上 MAHJONG_* 三个键后,把这些常量改为引用它即可,
* 或者保持不变——前缀值本来就是权威口径 {@code ruoyi:mahjong:*})。
*/
public class MahjongConstants {
/** 单局进行中缓存:ruoyi:mahjong:{userId},TTL 30 分钟 */
public static final String GAME_CACHE = "ruoyi:mahjong:";
/** 一轮内 RTP 收敛账本:Hash ruoyi:mahjong:cycle:{boxId}:{audience} */
public static final String GAME_CYCLE = "ruoyi:mahjong:cycle:";
/** 按【箱 × 身份】奖池水位:ruoyi:mahjong:reward:pool:box:{boxId}:{audience}:* */
public static final String GAME_REWARD_POOL_CACHE = "ruoyi:mahjong:reward:pool";
/** 游戏标识(game_type 列 / 背包 box_name / 流水文案) */
public static final String GAME_TYPE = "MAHJONG";
public static final String BOX_NAME = "MAHJONG";
/**
* 单局总倍率封顶 500x(Q4 定案;玫瑰女郎是 1000x,这是两游戏唯一的金额口径差异)。
* 生成端按它截断并写 step.win_capped,运行端按它置 maxWinReached。
*/
public static final int MAX_WIN_CAP_MUL = 500;
/** 免费旋转总旋数上限(首赠 12 + 每多 1 胡 +2,重触发同式;发不满就不发) */
public static final int MAX_FREE_SPINS = 36;
/** 一屏最多 5 个胡(不会出现 6 个及以上)→ 单次赠送只可能是 12/14/16 */
public static final int MAX_SCATTER_PER_SCREEN = 5;
/** 空转局也发最低档饰品:win = max(totalWin, 0.01)(必得饰品兜底,与玫瑰女郎一致) */
public static final String FLOOR_WIN = "0.01";
/**
* 流水/背包 source 占位值 —— 定义在共享枚举里(下一步动作):
* <ul>
* <li>{@code TtAccountRecordSource.MAHJONG_SPIN(68, OUTPUT, "麻将胡了消费")} / {@code MAHJONG_REWARD(69, INPUT, "麻将胡了奖励")}</li>
* <li>{@code TtboxRecordSource.MAHJONG_BOX(15, "麻将")}</li>
* <li>{@code MessageCategoryEnum.GAME_MAHJONG("GAME_MAHJONG", "麻将胡了")}</li>
* </ul>
* 本次任务明确不改共享文件,因此先以本地常量承接;共享枚举补上后,
* 把 {@code MahjongGameService} 里的引用换成枚举常量即可(语义与数值都已对齐,不会有账面漂移)。
*/
public static final int RECORD_SOURCE_SPIN = 68;
public static final int RECORD_SOURCE_REWARD = 69;
public static final int BOX_RECORD_SOURCE = 15;
public static final String MESSAGE_CATEGORY = "GAME_MAHJONG";
public static final String MESSAGE_CATEGORY_TITLE = "麻将胡了";
private MahjongConstants() {
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.contract.request;
import lombok.Data;
/** 麻将胡了战绩分页请求(scope: 0全服/1我的,默认1我的;均分页) */
@Data
public class RecordPageRequest {
/** 记录范围:0=全服,1=我的(默认) */
private Integer scope;
/** 页码,默认 1 */
private Integer pageNum;
/** 每页条数,默认 10 */
private Integer pageSize;
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.contract.request;
import lombok.Data;
/** dospin 请求:非空=开始新游戏;null=继续当前游戏(下一步) */
@Data
public class SpinRequest {
/** 非空=开新局;null=续播下一步 */
private Long boxId;
/**
* 投注倍数:**预留字段**,当前服务端只接受 1(非 1 直接报错)。
* 本游戏只有投注档、没有投注倍数,pool 里存的是倍率,所以以后要开也不用重生成池子。
*/
private Integer betMul;
}
@@ -0,0 +1,16 @@
package com.ruoyi.game.mahjong.contract.response;
import lombok.Data;
import java.math.BigDecimal;
/** 箱子列表项:只有一个普通旋转模式,所以价格恒等于投注档(price == bet) */
@Data
public class BoxResponse {
private Long boxId;
private String boxName;
private BigDecimal basicBet;
private BigDecimal price;
private String boxType;
private String modeName;
}
@@ -0,0 +1,34 @@
package com.ruoyi.game.mahjong.contract.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 麻将胡了战绩列表项(我的记录/全服记录共用,只暴露客户端展示需要的字段) */
@Data
public class RecordItemResponse {
/** 玩家昵称 */
private String nickName;
/** 玩家头像 */
private String avatar;
/** 本局消费(下注金额,cost = box.bet) */
private BigDecimal cost;
/** 本局获得饰品的价格(= 结算金额) */
private BigDecimal settledWin;
/** 饰品图片 */
private String prizeImageUrl;
/** 饰品全名:名称(磨损品质),无品质时仅名称 */
private String prizeName;
/** 开局时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
}
@@ -0,0 +1,47 @@
package com.ruoyi.game.mahjong.contract.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.math.BigDecimal;
/**
* 帧响应(对齐设计方案 §7.1)。
* 与玫瑰女郎的字段差异:spinNum/tumbleNum → reelSpinNum/rotationNum;
* multiplierDetail(炸弹) → goldenDetail + wildConverted;accumulator → rotationMul(本波阶梯倍率)。
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SpinResponse {
private String state; // "PLAYING" | "FINISH"
private Integer reelSpinNum; // 0=基础旋,>0=第 N 次免费旋转
private Integer rotationNum; // 本旋内第几波(级联序号)
private Integer globalStep;
private Integer globalTotalSteps;
private Integer spinsRemaining;
private Object gridState; // 4 行 × 5 列 = 20 格短码矩阵
private Object goldenDetail; // [{"pos","symbol"}] 本帧仍为"金色普通符号"的格
private Object wildConverted; // [{"pos","fromSymbol"}] 本波就地转成百搭的格
private Object waysDetail; // [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]
private Object dropData;
private Object fillData;
private Object scatterTrigger; // {"scatterCount":3,"freeSpins":12,"extraSpins":0}
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
private BigDecimal rotationMul; // 本波阶梯倍率(基础旋 1/2/3/5、FS 2/4/6/10);重连时用它接回档位
private Object retriggerInfo;
private String bigWinLevel; // small | middle | big(仅免费旋转的旋末帧可能有值)
// ---- 仅 FINISH ----
private BigDecimal totalWin; // 总赢额(显示用现金数字)
private BigDecimal totalMul; // 总倍率(= round.actual_mul)
private Boolean maxWinReached; // 500x 封顶标识
private Object prizeInfo; // {id,name,imageUrl,grade,exteriorName,isFallback}
private Long boxRecordId; // 背包记录 ID(末帧「放入背包 / 立即分解」都靠它)
private Long recordId; // 本局记录 ID(战绩用)
}
@@ -0,0 +1,747 @@
package com.ruoyi.game.mahjong.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.domain.other.TtOrnamentsLevel;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import com.ruoyi.game.mahjong.domain.MahjongPrizeRange;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.domain.MahjongRewardLog;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxTemplateMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeOrnamentMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeRangeMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRewardLogMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import com.ruoyi.game.mahjong.repository.MahjongRewardPoolCacheRepository;
import com.ruoyi.game.mahjong.service.MahjongDistributionService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
/**
* 后台管理接口:Base = /admin/mahjong(对齐设计方案 §8 的 7 个页面)。
* 与玫瑰女郎逐行同构,差异:只有 normal 一个模式、桶 16 档、封顶 500x、game_type = MAHJONG。
*/
@Slf4j
@Api(tags = "麻将胡了后台")
@RestController
@RequestMapping("/admin/mahjong")
public class MahjongAdminController extends BaseController {
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongRewardPoolCacheRepository rewardPoolRepository;
@Autowired private MahjongBucketMapper bucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
@Autowired private MahjongPrizeOrnamentMapper prizeOrnamentMapper;
@Autowired private MahjongPrizeRangeMapper prizeRangeMapper;
@Autowired private MahjongBoxTemplateMapper boxTemplateMapper;
@Autowired private MahjongCycleRepository cycleRepository;
@Autowired private MahjongDistributionService distributionService;
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
@Autowired private TtUserService userService;
@Autowired private MahjongRewardLogMapper rewardLogMapper;
// ==================== 箱子配置 ====================
@ApiOperation("箱子列表(含各身份目标/实时 RTP 与一轮总回合)")
@GetMapping("/box/list")
public R boxList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String boxType,
@RequestParam(required = false) String name) {
LambdaQueryWrapper<MahjongBox> qw = new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG");
if (boxType != null && !boxType.isEmpty()) qw.eq(MahjongBox::getBoxType, boxType);
// 名称模糊搜索:页面上的「按名称搜索」框要有实际效果(玫瑰女郎那边这个框是摆设,这里补上了)
if (name != null && !name.isEmpty()) qw.like(MahjongBox::getName, name);
qw.orderByAsc(MahjongBox::getBoxId);
Page<MahjongBox> page = boxMapper.selectPage(new Page<>(pageNum, pageSize), qw);
// 各身份分布一次全量查出(11箱×4身份量小),内存分组,避免逐身份 N+1
Map<Long, Map<Integer, Double>> expectByBox = new HashMap<>();
Map<String, List<MahjongBoxBucket>> distByKey = new HashMap<>();
for (MahjongBoxBucket bb : boxBucketMapper.selectList(null)) {
if (bb.getBoxId() == null || bb.getAudience() == null) continue;
distByKey.computeIfAbsent(bb.getBoxId() + ":" + bb.getAudience(), k -> new ArrayList<>()).add(bb);
}
for (MahjongBox box : page.getRecords()) {
try {
MahjongModeType m = MahjongModeType.fromBoxType(box.getBoxType());
double cm = m.getCostMultiplier();
Map<Integer, Double> bucketMul = expectByBox.computeIfAbsent(box.getBoxId(),
k -> distributionService.loadPoolExpect(k));
box.setModeName(m.getName());
box.setCostMultiplier(BigDecimal.valueOf(cm));
Map<String, BigDecimal> targetMap = new HashMap<>();
Map<String, BigDecimal> actualMap = new HashMap<>();
Map<String, BigDecimal> totalMap = new HashMap<>();
for (String aud : AUDIENCES) {
// 目标RTP:从该身份分布反推 = Σ(count×expect_mul) / Σ(count) / CM × 100
List<MahjongBoxBucket> list = distByKey.get(box.getBoxId() + ":" + aud);
if (list != null && !list.isEmpty()) {
double sumMul = 0; int totalCount = 0;
for (MahjongBoxBucket bb : list) {
int c = bb.getCount() != null ? bb.getCount() : 0;
if (c <= 0) continue;
sumMul += c * bucketMul.getOrDefault(bb.getBucketId(), 0.0);
totalCount += c;
}
if (totalCount > 0) {
targetMap.put(aud, BigDecimal.valueOf(sumMul / totalCount / cm * 100)
.setScale(2, RoundingMode.HALF_UP));
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
totalMap.put(aud, BigDecimal.valueOf(
MahjongDistributionService.deriveTotalRounds(totalCount, cr)));
}
}
// 实时RTP:按 (boxId, audience) 循环账本各查一次(CM ≡ 1,不再除 CM)
BigDecimal r = cycleRepository.realtimeRtp(box.getBoxId(), aud);
if (r != null) actualMap.put(aud, r);
}
box.setTargetRtpByAudience(targetMap);
box.setActualRtpByAudience(actualMap);
box.setTotalRoundsByAudience(totalMap);
box.setActualRtp(actualMap.get("common")); // 兼容保留 common 口径
} catch (Exception ignore) {}
}
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
@Log(title = "箱子配置", action = "编辑", businessType = BusinessType.UPDATE)
@PostMapping("/box/save")
@Transactional(rollbackFor = Exception.class)
public R boxSave(@RequestBody MahjongBox box) {
if (box.getBet() == null || box.getBoxType() == null) return R.fail("下注与模式不能为空");
try {
MahjongModeType m = MahjongModeType.fromBoxType(box.getBoxType());
box.setGameType("MAHJONG");
// cost = bet(costMultiplier ≡ 1):price 恒等于投注档,没有模式倍率
box.setPrice(box.getBet().multiply(BigDecimal.valueOf(m.getCostMultiplier())));
} catch (IllegalArgumentException e) {
return R.fail("无效模式: " + box.getBoxType());
}
if (box.getBoxId() != null && boxMapper.selectById(box.getBoxId()) != null) boxMapper.updateById(box);
else boxMapper.insert(box);
return R.ok(Map.of("boxId", box.getBoxId()));
}
/** 某箱某身份的桶分布:含 convergeCount(收敛账本) + fixedRounds/convergeRounds(按该身份分布反推) */
@ApiOperation("某箱某身份的倍率桶分布")
@GetMapping("/box/buckets/{boxId}")
public R boxBuckets(@PathVariable Long boxId,
@RequestParam(required = false, defaultValue = "common") String audience) {
List<MahjongBoxBucket> list = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
for (MahjongBoxBucket bb : list) {
bb.setConvergeCount(cycleRepository.convergeCount(boxId, audience, bb.getBucketId()));
}
MahjongBox box = boxMapper.selectById(boxId);
int fixedSum = list.stream().mapToInt(bb -> bb.getCount() != null ? bb.getCount() : 0).sum();
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
int fixedRounds = fixedSum;
int convergeRounds = fixedSum > 0 ? Math.max(0, MahjongDistributionService.deriveTotalRounds(fixedSum, cr) - fixedSum) : 0;
// poolExpect:该箱每个桶"池子里实际会赔多少"的均值(= 列表页「目标RTP」用的同一口径)。
// 前端「计算RTP」必须拿它算,否则会拿桶定义的名义 expect_mul(区间中点)算出跟目标RTP不一致的数。
return R.ok(Map.of("list", list, "audience", audience, "fixedRounds", fixedRounds,
"convergeRounds", convergeRounds, "poolExpect", distributionService.loadPoolExpect(boxId)));
}
@Log(title = "箱子配置", action = "保存", businessType = BusinessType.UPDATE)
@ApiOperation("保存某箱某身份的倍率桶分布(items 为固定池回合,反推回写总回合)")
@PostMapping("/box/buckets/save")
@Transactional(rollbackFor = Exception.class)
public R boxBucketsSave(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
if (boxId == null) return R.fail("boxId 不能为空");
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
distributionService.writeDistribution(boxId, audience, items);
cycleRepository.resetCycle(boxId, audience);
return R.ok();
}
@Log(title = "箱子配置", action = "重算分布", businessType = BusinessType.UPDATE)
@ApiOperation("按目标 RTP 重算某箱某身份分布")
@PostMapping("/box/buckets/calc")
public R calcDistribution(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
Integer totalRounds = asInt(params.get("totalRounds"));
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
Double convergeRatio = params.get("convergeRatio") != null ? Double.parseDouble(params.get("convergeRatio").toString()) : null;
MahjongBox box = boxMapper.selectById(boxId);
if (box == null) return R.fail("箱子不存在");
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
double cr = convergeRatio != null ? convergeRatio
: (box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15);
int fixedRoundsN = MahjongDistributionService.deriveFixedRounds(totalRounds != null ? totalRounds : 200, cr);
int canonicalN = MahjongDistributionService.deriveTotalRounds(fixedRoundsN, cr);
box.setTotalRounds(canonicalN);
box.setRtp(BigDecimal.valueOf(rtp));
if (convergeRatio != null) box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio));
boxMapper.updateById(box);
distributionService.reset(boxId, audience);
cycleRepository.resetCycle(boxId, audience);
List<MahjongBoxBucket> saved = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
Map<Integer, Double> bucketMul = distributionService.loadPoolExpect(boxId);
List<Map<String, Object>> result = new ArrayList<>();
int total = 0; double contrib = 0;
for (MahjongBoxBucket bb : saved) {
int c = bb.getCount() != null ? bb.getCount() : 0;
result.add(Map.of("bucketId", bb.getBucketId(), "count", c));
total += c;
contrib += c * bucketMul.getOrDefault(bb.getBucketId(), 0.0);
}
double cm = mode.getCostMultiplier();
double calcRtpValue = total > 0 ? contrib / total / cm * 100 : 0;
return R.ok(Map.of("items", result, "totalRounds", total, "boxTotal", canonicalN,
"convergeRounds", Math.max(0, canonicalN - total),
"calcRtp", String.format("%.1f%%", calcRtpValue)));
}
/** 模拟 payoutCap 约束下的预估实际 RTP */
@PostMapping("/box/buckets/simulate-rtp")
public R simulateRtp(@RequestBody Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
Long boxId = asLong(params.get("boxId"));
double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : 0.8;
if (items == null || items.isEmpty()) return R.ok(Map.of("estimatedRtp", "0.0%"));
Map<Integer, Integer> dist = new LinkedHashMap<>();
for (Map<String, Object> item : items) {
Integer bucketId = asInt(item.get("bucketId"));
Integer count = asInt(item.get("count"));
if (bucketId != null && count != null && count > 0) dist.put(bucketId, count);
}
double est = distributionService.simulateActualRtp(dist, boxId, rtp, 10);
return R.ok(Map.of("estimatedRtp", String.format("%.1f%%", est)));
}
// ==================== 分布模板(按身份) ====================
@GetMapping("/box/templates/{boxId}")
public R templateList(@PathVariable Long boxId,
@RequestParam(required = false, defaultValue = "common") String audience) {
List<MahjongBoxTemplate> list = boxTemplateMapper.selectList(
new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId)
.eq(MahjongBoxTemplate::getAudience, audience)
.orderByDesc(MahjongBoxTemplate::getId));
for (MahjongBoxTemplate t : list) {
t.setItems(distributionService.parseItems(t.getItemsJson()));
t.setItemsJson(null);
}
return R.ok(Map.of("list", list));
}
@Log(title = "箱子配置", action = "保存为模板", businessType = BusinessType.INSERT)
@PostMapping("/box/template/save")
@Transactional(rollbackFor = Exception.class)
public R templateSave(@RequestBody Map<String, Object> params) {
Long boxId = asLong(params.get("boxId"));
if (boxId == null) return R.fail("boxId 不能为空");
String audience = params.get("audience") != null ? params.get("audience").toString() : "common";
String name = params.get("name") != null ? params.get("name").toString().trim() : "";
if (name.isEmpty()) return R.fail("模板名称不能为空");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
if (items == null || items.isEmpty()) return R.fail("模板倍率桶分布不能为空");
Long dup = boxTemplateMapper.selectCount(new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId).eq(MahjongBoxTemplate::getAudience, audience)
.eq(MahjongBoxTemplate::getName, name));
if (dup != null && dup > 0) return R.fail("模板名称已存在,请使用不同的名称");
int total = 0;
for (Map<String, Object> item : items) { Integer c = asInt(item.get("count")); if (c != null && c > 0) total += c; }
MahjongBoxTemplate tpl = new MahjongBoxTemplate();
tpl.setBoxId(boxId);
tpl.setAudience(audience);
tpl.setName(name);
tpl.setTotalRounds(total);
tpl.setItemsJson(distributionService.toItemsJson(items));
tpl.setIsActive(0);
boxTemplateMapper.insert(tpl);
return R.ok(Map.of("id", tpl.getId()));
}
@Log(title = "箱子配置", action = "删除", businessType = BusinessType.DELETE)
@DeleteMapping("/box/template/{id}")
public R templateDelete(@PathVariable Long id) {
boxTemplateMapper.deleteById(id);
return R.ok();
}
@Log(title = "箱子配置", action = "应用选中模板", businessType = BusinessType.UPDATE)
@PostMapping("/box/template/apply")
@Transactional(rollbackFor = Exception.class)
public R templateApply(@RequestBody Map<String, Object> params) {
Long templateId = asLong(params.get("templateId"));
if (templateId == null) return R.fail("templateId 不能为空");
MahjongBoxTemplate tpl = boxTemplateMapper.selectById(templateId);
if (tpl == null) return R.fail("模板不存在");
distributionService.writeDistribution(tpl.getBoxId(), tpl.getAudience(), distributionService.parseItems(tpl.getItemsJson()));
cycleRepository.resetCycle(tpl.getBoxId(), tpl.getAudience());
List<MahjongBoxTemplate> siblings = boxTemplateMapper.selectList(new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, tpl.getBoxId()).eq(MahjongBoxTemplate::getAudience, tpl.getAudience()));
for (MahjongBoxTemplate s : siblings) {
int active = s.getId().equals(templateId) ? 1 : 0;
if (!Integer.valueOf(active).equals(s.getIsActive())) { s.setIsActive(active); boxTemplateMapper.updateById(s); }
}
return R.ok();
}
/** 一键重算所有箱子 × 所有身份分布 */
@PostMapping("/box/recalc_all")
public R recalcAll() {
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int done = 0;
for (MahjongBox box : boxes) {
for (String aud : AUDIENCES) {
try {
distributionService.reset(box.getBoxId(), aud);
cycleRepository.resetCycle(box.getBoxId(), aud);
done++;
} catch (Exception ignore) {}
}
}
return R.ok(Map.of("recalculated", done));
}
@Log(title = "箱子配置", action = "一键重算分布", businessType = BusinessType.CLEAN)
/**
* 重算分布(弹窗):按统一参数批量重算。
* 入参:boxType 可选(空=全部模式);audience 可选(空=全部身份);rtp/totalRounds/convergeRatio 非空则统一设置到箱,再按最新配置重算分布。
*/
@PostMapping("/box/recalcBatch")
@Transactional(rollbackFor = Exception.class)
public R recalcBatch(@RequestBody Map<String, Object> params) {
String boxType = params.get("boxType") != null && !params.get("boxType").toString().isEmpty()
? params.get("boxType").toString() : null;
String audience = params.get("audience") != null && !params.get("audience").toString().isEmpty()
? params.get("audience").toString() : null;
Double rtp = params.get("rtp") != null ? Double.parseDouble(params.get("rtp").toString()) : null;
Integer totalRounds = asInt(params.get("totalRounds"));
Double convergeRatio = params.get("convergeRatio") != null
? Double.parseDouble(params.get("convergeRatio").toString()) : null;
LambdaQueryWrapper<MahjongBox> qw = new LambdaQueryWrapper<MahjongBox>()
.eq(MahjongBox::getGameType, "MAHJONG");
if (boxType != null) qw.eq(MahjongBox::getBoxType, boxType);
List<MahjongBox> boxes = boxMapper.selectList(qw);
if (boxes.isEmpty()) return R.fail("没有匹配的箱子");
List<String> audiences = audience != null ? Collections.singletonList(audience) : AUDIENCES;
int done = 0;
for (MahjongBox box : boxes) {
boolean changed = false;
if (rtp != null) { box.setRtp(BigDecimal.valueOf(rtp)); changed = true; }
if (totalRounds != null && totalRounds > 0) { box.setTotalRounds(totalRounds); changed = true; }
if (convergeRatio != null) { box.setRtpConvergeRatio(BigDecimal.valueOf(convergeRatio)); changed = true; }
if (changed) boxMapper.updateById(box);
for (String aud : audiences) {
try {
distributionService.reset(box.getBoxId(), aud);
cycleRepository.resetCycle(box.getBoxId(), aud);
done++;
} catch (Exception ignore) {}
}
}
return R.ok(Map.of("recalculated", done, "boxes", boxes.size()));
}
// ==================== 玩家记录 ====================
/** 玩家记录列表:可按玩家ID + 身份筛选;行内回填 audience(默认全部身份,含白号/机器人) */
@GetMapping("/record/list")
public R recordList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long userId,
@RequestParam(required = false) String audience) {
LambdaQueryWrapper<MahjongRecord> qw = new LambdaQueryWrapper<>();
// 空 = 全部身份(与奖池页"未选身份默认普通"不同:这里空是不过滤)
if (audience != null && !audience.trim().isEmpty() && !AUDIENCES.contains(audience.trim())) {
return R.fail("身份无效");
}
qw.inSql(MahjongRecord::getUserId, audienceUserSubQuery(audience));
if (userId != null) qw.eq(MahjongRecord::getUserId, userId);
qw.orderByDesc(MahjongRecord::getCreateTime);
Page<MahjongRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
fillAudience(page.getRecords());
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
/**
* 身份 → tt_user 子查询。奖池 **3 池**口径(common/anchor/white;robot 维度已去掉,
* user_type='03' 归入 common),另附封禁/逻辑删除兜底:
* 封禁(status=1)和已删除用户任何身份都不展示;身份为空=全部身份(不再无条件排除白号)。
*/
private static String audienceUserSubQuery(String audience) {
String base = "SELECT user_id FROM tt_user WHERE status != '1' AND del_flag = '0' AND ";
if ("white".equals(audience)) return base + "is_blank_account = 1";
if ("anchor".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND user_type = '01'";
if ("common".equals(audience)) return base + "COALESCE(is_blank_account,0) != 1 AND (user_type IS NULL OR user_type != '01')";
return base + "1 = 1";
}
/** 批量回填每行身份:复用共享身份判定,勿另写一套 */
private void fillAudience(List<MahjongRecord> rows) {
if (rows == null || rows.isEmpty()) return;
Set<Long> ids = rows.stream().map(MahjongRecord::getUserId)
.filter(Objects::nonNull).collect(Collectors.toSet());
if (ids.isEmpty()) return;
Map<Long, String> audMap = new HashMap<>();
for (TtUser u : userService.listByIds(ids)) {
if (u.getUserId() != null) {
audMap.put(u.getUserId().longValue(),
com.ruoyi.game.mahjong.enums.MahjongAudience.resolve(u).getCode());
}
}
for (MahjongRecord r : rows) {
r.setAudience(audMap.getOrDefault(r.getUserId(), "common"));
}
}
// ==================== 每日统计 ====================
@GetMapping("/win/summary")
public R winSummary(@RequestParam(required = false) String dateStart,
@RequestParam(required = false) String dateEnd) {
Map<String, Object> row = recordMapper.winSummary(dateStart, dateEnd);
return R.ok(row);
}
/** 每日统计列表:按天聚合 game_mahjong_record(与 winSummary 同源同口径,支持日期筛选) */
@GetMapping("/win/list")
public R winList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String dateStart,
@RequestParam(required = false) String dateEnd) {
List<Map<String, Object>> all = recordMapper.dailyList(dateStart, dateEnd);
int from = Math.max(0, (pageNum - 1) * pageSize);
if (from >= all.size()) return R.ok(Map.of("rows", Collections.emptyList(), "total", all.size()));
return R.ok(Map.of("rows", all.subList(from, Math.min(all.size(), from + pageSize)), "total", all.size()));
}
// ==================== 倍率桶定义 ====================
@GetMapping("/bucket/list")
public R bucketList() {
List<MahjongBucket> list = bucketMapper.selectList(
new LambdaQueryWrapper<MahjongBucket>().orderByAsc(MahjongBucket::getId));
return R.ok(Map.of("list", list));
}
// ==================== 预生成回合(回合管理) ====================
@GetMapping("/round/list")
public R roundList(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long roundId,
@RequestParam(required = false) String boxType) {
LambdaQueryWrapper<MahjongRound> qw = new LambdaQueryWrapper<>();
if (roundId != null) qw.eq(MahjongRound::getRoundId, roundId);
// 收集箱子 id→模式,供筛选与回填 boxType
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
Map<Long, String> boxTypeMap = new HashMap<>();
List<Long> filterBoxIds = new ArrayList<>();
for (MahjongBox b : boxes) {
boxTypeMap.put(b.getBoxId(), b.getBoxType());
if (boxType != null && !boxType.isEmpty() && boxType.equals(b.getBoxType())) filterBoxIds.add(b.getBoxId());
}
if (boxType != null && !boxType.isEmpty()) {
if (filterBoxIds.isEmpty()) return R.ok(Map.of("rows", Collections.emptyList(), "total", 0));
qw.in(MahjongRound::getBoxId, filterBoxIds);
}
qw.orderByAsc(MahjongRound::getRoundId);
Page<MahjongRound> page = roundMapper.selectPage(new Page<>(pageNum, pageSize), qw);
for (MahjongRound r : page.getRecords()) r.setBoxType(boxTypeMap.get(r.getBoxId()));
return R.ok(Map.of("rows", page.getRecords(), "total", page.getTotal()));
}
// ==================== 返奖饰品(奖品管理) ====================
@GetMapping("/prize/list")
public R prizeList() {
List<MahjongPrizeOrnament> list = prizeOrnamentMapper.selectList(
new LambdaQueryWrapper<MahjongPrizeOrnament>().orderByAsc(MahjongPrizeOrnament::getSort));
List<MahjongPrizeRange> ranges = prizeRangeMapper.selectList(new LambdaQueryWrapper<>());
Map<Long, MahjongPrizeRange> rangeMap = new HashMap<>();
for (MahjongPrizeRange r : ranges) rangeMap.put(r.getPrizeId(), r);
for (MahjongPrizeOrnament o : list) {
MahjongPrizeRange r = rangeMap.get(o.getId());
if (r != null) { o.setWinLow(r.getWinLow()); o.setWinHigh(r.getWinHigh()); }
if (o.getGrade() != null && ornamentsLevelMapper != null) {
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(o.getGrade());
if (lv != null) o.setLevelImg(lv.getLevelImg());
}
}
return R.ok(Map.of("list", list));
}
@Log(title = "奖品管理", action = "编辑", businessType = BusinessType.UPDATE)
@PostMapping("/prize/save")
@Transactional(rollbackFor = Exception.class)
public R prizeSave(@RequestBody MahjongPrizeOrnament prize) {
if (prize.getName() == null || prize.getName().trim().isEmpty()) return R.fail("饰品名称不能为空");
prize.setGameType("MAHJONG");
if (prize.getStatus() == null) prize.setStatus("1");
if (prize.getId() != null && prizeOrnamentMapper.selectById(prize.getId()) != null) prizeOrnamentMapper.updateById(prize);
else prizeOrnamentMapper.insert(prize);
// 同步区间(按 prizeId 唯一)
if (prize.getWinLow() != null && prize.getWinHigh() != null) {
MahjongPrizeRange r = prizeRangeMapper.selectOne(
new LambdaQueryWrapper<MahjongPrizeRange>().eq(MahjongPrizeRange::getPrizeId, prize.getId()).last("LIMIT 1"));
if (r == null) { r = new MahjongPrizeRange(); r.setPrizeId(prize.getId()); }
r.setWinLow(prize.getWinLow());
r.setWinHigh(prize.getWinHigh());
if (r.getId() != null) prizeRangeMapper.updateById(r); else prizeRangeMapper.insert(r);
}
return R.ok(Map.of("id", prize.getId()));
}
// ==================== 水位(按【箱 × 身份】注水/抽水) ====================
/** 奖池身份维度,与共享身份判定对齐 */
private static final List<String> AUDIENCES = Arrays.asList("common", "anchor", "white");
/** 身份入参归一化:空=common;非法返回 null(调用方报错,不静默兜底) */
private static String audienceOf(Object v) {
if (v == null || v.toString().trim().isEmpty()) return "common";
String s = v.toString().trim();
return AUDIENCES.contains(s) ? s : null;
}
/** 奖池配置快照:某身份各箱水位/RTP/本轮进度/收敛占比 + 一轮花费基数 */
@GetMapping("/rewardPool/config")
public R rewardPoolConfig(@RequestParam(required = false) String audience) {
String aud = audienceOf(audience);
if (aud == null) return R.fail("身份无效");
return R.ok(poolConfigMap(aud));
}
/** 组装奖池配置(total + boxes),供 config 与 adjust/batch/reset 复用 */
private Map<String, Object> poolConfigMap(String audience) {
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG").orderByAsc(MahjongBox::getBoxId));
List<Map<String, Object>> boxList = new ArrayList<>();
BigDecimal totalBet = BigDecimal.ZERO, totalWin = BigDecimal.ZERO, totalBalance = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
Long boxId = box.getBoxId();
com.ruoyi.game.mahjong.model.cache.MahjongRewardPoolCache c = rewardPoolRepository.getBoxCache(boxId, audience);
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience);
double cm = 1.0;
try { cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
int rounds = box.getTotalRounds() != null ? box.getTotalRounds() : 0;
BigDecimal costPerRound = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm));
BigDecimal cycleCost = costPerRound.multiply(BigDecimal.valueOf(rounds));
long drawn = cycleRepository.drawn(boxId, audience);
Double cycleRtp = null;
if (drawn > 0 && cm > 0) cycleRtp = cycleRepository.mulSum(boxId, audience) / drawn / cm;
Map<String, Object> m = new LinkedHashMap<>();
m.put("boxId", boxId);
m.put("name", box.getName());
m.put("audience", audience);
m.put("boxType", box.getBoxType());
m.put("bet", box.getBet());
m.put("totalBet", c.getTotalBet());
m.put("totalWin", c.getTotalWin());
m.put("adjust", c.getAdjust());
m.put("balance", balance);
m.put("costPerRound", costPerRound.setScale(2, RoundingMode.HALF_UP));
m.put("cycleRounds", rounds);
m.put("cycleCost", cycleCost.setScale(2, RoundingMode.HALF_UP));
m.put("totalRounds", rounds);
m.put("convergeRatio", box.getRtpConvergeRatio());
m.put("cycleDrawn", drawn);
m.put("cycleRtp", cycleRtp);
m.put("rtp", c.getTotalBet().signum() > 0
? c.getTotalWin().divide(c.getTotalBet(), 4, RoundingMode.HALF_UP) : null);
boxList.add(m);
totalBet = totalBet.add(c.getTotalBet());
totalWin = totalWin.add(c.getTotalWin());
totalBalance = totalBalance.add(balance);
}
Map<String, Object> total = new LinkedHashMap<>();
total.put("audience", audience);
total.put("totalBet", totalBet);
total.put("totalWin", totalWin);
total.put("adjust", rewardPoolRepository.getGlobalAdjust(audience));
total.put("balance", totalBalance);
total.put("rtp", totalBet.signum() > 0 ? totalWin.divide(totalBet, 4, RoundingMode.HALF_UP) : null);
Map<String, Object> result = new LinkedHashMap<>();
result.put("audience", audience);
result.put("total", total);
result.put("boxes", boxList);
return result;
}
@Log(title = "奖池水位", action = "注水/抽水", businessType = BusinessType.GRANT)
/** 逐箱注水/抽水:type=1注水/2抽水,记审计日志(原因必填由前端保证) */
@PostMapping("/rewardPool/adjust")
public R rewardPoolAdjust(@RequestBody Map<String, Object> body) {
String audience = audienceOf(body.get("audience"));
if (audience == null) return R.fail("身份无效");
Long boxId = asLong(body.get("boxId"));
Integer type = asInt(body.get("type"));
BigDecimal amount = asBd(body.get("amount"));
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
MahjongBox box = boxId != null ? boxMapper.selectById(boxId) : null;
if (box == null) return R.fail("箱子不存在");
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
if (amount.signum() <= 0) return R.fail("金额必须大于 0");
BigDecimal before = rewardPoolRepository.getBoxBalance(boxId, audience);
rewardPoolRepository.adjustBox(boxId, audience, type == 2 ? amount.negate() : amount);
BigDecimal after = rewardPoolRepository.getBoxBalance(boxId, audience);
writeLog(boxId, box.getName(), audience, type, amount, before, after, remark);
return R.ok(poolConfigMap(audience));
}
@Log(title = "奖池水位", action = "批量注水/抽水", businessType = BusinessType.GRANT)
/** 批量按一轮花费比例注水/抽水:ratio 百分比(0~1000],记一条汇总日志 */
@PostMapping("/rewardPool/adjustBatch")
public R rewardPoolAdjustBatch(@RequestBody Map<String, Object> body) {
String audience = audienceOf(body.get("audience"));
if (audience == null) return R.fail("身份无效");
Integer type = asInt(body.get("type"));
BigDecimal ratio = asBd(body.get("ratio"));
String remark = body.get("remark") != null ? body.get("remark").toString() : null;
if (type == null || (type != 1 && type != 2)) return R.fail("调整类型无效");
if (ratio.signum() <= 0 || ratio.compareTo(new BigDecimal("1000")) > 0) return R.fail("比例须在 0~1000");
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int adjusted = 0;
BigDecimal totalAmount = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
double cm = 1.0;
try { cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
BigDecimal base = coalesce(box.getBet()).multiply(BigDecimal.valueOf(cm))
.multiply(BigDecimal.valueOf(box.getTotalRounds() != null ? box.getTotalRounds() : 0));
if (base.signum() <= 0) continue;
BigDecimal amount = base.multiply(ratio).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
if (amount.signum() <= 0) continue;
rewardPoolRepository.adjustBox(box.getBoxId(), audience, type == 2 ? amount.negate() : amount);
totalAmount = totalAmount.add(amount);
adjusted++;
}
writeLog(null, "批量(" + adjusted + "箱 " + ratio.stripTrailingZeros().toPlainString() + "%)",
audience, type, totalAmount, null, null, remark);
Map<String, Object> r = poolConfigMap(audience);
r.put("adjusted", adjusted);
r.put("totalAmount", totalAmount);
return R.ok(r);
}
@Log(title = "奖池水位", action = "重置调整", businessType = BusinessType.CLEAN)
/** 重置人工调整:该身份所有箱子 adjust 归零,仅保留真实水位;记一条汇总日志 */
@PostMapping("/rewardPool/adjustReset")
public R rewardPoolAdjustReset(@RequestBody(required = false) Map<String, Object> body) {
String audience = audienceOf(body != null ? body.get("audience") : null);
if (audience == null) return R.fail("身份无效");
String remark = body != null && body.get("remark") != null ? body.get("remark").toString() : null;
List<MahjongBox> boxes = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
int cleared = 0;
BigDecimal totalCleared = BigDecimal.ZERO;
for (MahjongBox box : boxes) {
BigDecimal adjust = rewardPoolRepository.getBoxAdjust(box.getBoxId(), audience);
if (adjust.signum() == 0) continue;
rewardPoolRepository.adjustBox(box.getBoxId(), audience, adjust.negate());
totalCleared = totalCleared.add(adjust.abs());
cleared++;
}
BigDecimal residual = rewardPoolRepository.getGlobalAdjust(audience);
if (residual.signum() != 0) rewardPoolRepository.adjustGlobal(audience, residual.negate());
writeLog(null, "全部(" + cleared + "箱)", audience, 3, totalCleared, null, null, remark);
Map<String, Object> r = poolConfigMap(audience);
r.put("cleared", cleared);
r.put("totalCleared", totalCleared);
return R.ok(r);
}
/** 奖池调整历史(分页,可按身份筛选) */
@GetMapping("/rewardPool/adjustLog")
public R rewardPoolAdjustLog(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String audience) {
String aud = audienceOf(audience);
if (aud == null) return R.fail("身份无效");
Page<MahjongRewardLog> p = rewardLogMapper.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<MahjongRewardLog>()
.eq(MahjongRewardLog::getAudience, aud)
.orderByDesc(MahjongRewardLog::getId));
return R.ok(Map.of("rows", p.getRecords(), "total", p.getTotal()));
}
private void writeLog(Long boxId, String boxName, String audience, Integer type, BigDecimal amount,
BigDecimal before, BigDecimal after, String remark) {
try {
MahjongRewardLog rlog = new MahjongRewardLog();
rlog.setBoxId(boxId);
rlog.setBoxName(boxName);
rlog.setAudience(audience);
rlog.setType(type);
rlog.setAmount(amount);
rlog.setBalanceBefore(before);
rlog.setBalanceAfter(after);
rlog.setPoolBalance(totalPoolBalance(audience));
rlog.setOperator(getUsername());
rlog.setRemark(remark);
rlog.setCreateTime(new java.util.Date());
rewardLogMapper.insert(rlog);
} catch (Exception ignore) {}
}
/** 该身份全站总池余额 = 各箱水位之和(写日志时取操作后快照) */
private BigDecimal totalPoolBalance(String audience) {
List<MahjongBox> list = boxMapper.selectList(
new LambdaQueryWrapper<MahjongBox>().eq(MahjongBox::getGameType, "MAHJONG"));
BigDecimal sum = BigDecimal.ZERO;
for (MahjongBox b : list) sum = sum.add(rewardPoolRepository.getBoxBalance(b.getBoxId(), audience));
return sum;
}
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static Long asLong(Object v) {
if (v == null) return null;
try { return Long.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
private static Integer asInt(Object v) {
if (v == null) return null;
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
private static BigDecimal asBd(Object v) {
if (v == null) return BigDecimal.ZERO;
try { return new BigDecimal(v.toString().trim()); } catch (Exception e) { return BigDecimal.ZERO; }
}
}
@@ -0,0 +1,179 @@
package com.ruoyi.game.mahjong.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.game.mahjong.contract.request.RecordPageRequest;
import com.ruoyi.game.mahjong.contract.request.SpinRequest;
import com.ruoyi.game.mahjong.contract.response.BoxResponse;
import com.ruoyi.game.mahjong.contract.response.RecordItemResponse;
import com.ruoyi.game.mahjong.contract.response.SpinResponse;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeOrnamentMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.service.MahjongGameService;
import com.ruoyi.system.service.ISysConfigService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
/** 客户端接口:Base = {host}/api/mahjong,全部 POST + JSON + token 鉴权(对齐设计方案 §7) */
@Slf4j
@Api(tags = "麻将胡了游戏")
@RestController
@RequestMapping("/api/mahjong")
public class MahjongClientController extends BaseController {
@Autowired private MahjongGameService gameService;
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongPrizeOrnamentMapper prizeOrnamentMapper;
@Autowired private ISysConfigService sysConfigService;
/** 进行中的局不展示:结算时 settle_source=start,播完末帧才改 finish;超过单局缓存 TTL(30分钟) 视为已结束兜底 */
private static final long IN_PROGRESS_MS = 30 * 60 * 1000L;
@ApiOperation("获取箱子列表")
@PostMapping("/boxes")
public R boxes() {
List<MahjongBox> list = boxMapper.selectList(new LambdaQueryWrapper<MahjongBox>()
.eq(MahjongBox::getGameType, "MAHJONG").eq(MahjongBox::getStatus, "1")
.orderByAsc(MahjongBox::getBoxId));
List<BoxResponse> boxes = list.stream().map(b -> {
BoxResponse r = new BoxResponse();
r.setBoxId(b.getBoxId());
r.setBoxName(b.getName());
r.setBasicBet(b.getBet());
// cost = box.bet(costMultiplier ≡ 1),所以 price 恒等于投注档
r.setPrice(b.getPrice() != null ? b.getPrice() : b.getBet());
r.setBoxType(b.getBoxType());
try { r.setModeName(MahjongModeType.fromBoxType(b.getBoxType()).getName()); }
catch (Exception e) { r.setModeName(b.getBoxType()); }
return r;
}).collect(Collectors.toList());
return R.ok(Map.of("list", boxes));
}
@ApiOperation("执行旋转(新游戏或继续游戏)")
@PostMapping("/dospin")
public R dospin(@RequestBody SpinRequest req) {
Long userId = getUserId();
String websiteMaintenance = sysConfigService.selectConfigByKey("websiteMaintenance");
String mahjongMaintenance = sysConfigService.selectConfigByKey("mahjongMaintenance");
boolean isMaintenance = "1".equals(websiteMaintenance) || "1".equals(mahjongMaintenance);
// 仅"新开一局"(boxId 非空)受维护限制;继续当前对局(boxId 为空)不受影响,可正常播完
boolean isNewGame = req != null && req.getBoxId() != null;
if (isMaintenance && isNewGame) {
if ("1".equals(websiteMaintenance)) return R.fail("网站维护中......");
if ("1".equals(mahjongMaintenance)) return R.fail("麻将胡了维护中......");
}
try {
// betMul 是预留字段:缺省 1,当前服务端只接受 1(无投注倍数、无模式倍率)
MahjongGameService.assertBetMulSupported(req != null ? req.getBetMul() : null);
SpinResponse resp = gameService.doSpin(userId, req);
return R.ok(resp);
} catch (ServiceException se) {
return se.getCode() != null ? R.fail(se.getCode().intValue(), se.getMessage()) : R.fail(se.getMessage());
} catch (Exception e) {
return R.fail(e.getMessage());
}
}
@ApiOperation("查询游戏记录(分页;scope: 0全服/1我的,默认1我的;只含已播完的局)")
@PostMapping("/records")
public R records(@RequestBody(required = false) RecordPageRequest req) {
Long userId = getUserId();
int scope = (req != null && req.getScope() != null) ? req.getScope() : 1;
int pageNum = (req != null && req.getPageNum() != null) ? req.getPageNum() : 1;
int pageSize = (req != null && req.getPageSize() != null) ? req.getPageSize() : 10;
// 用 Java 时间算 cutoff(勿改成 SQL NOW():DB 会话时区是 UTC,create_time 存的是本地时间,会差 8 小时)
Date cutoff = new Date(System.currentTimeMillis() - IN_PROGRESS_MS);
LambdaQueryWrapper<MahjongRecord> qw = new LambdaQueryWrapper<MahjongRecord>()
.eq(MahjongRecord::getSettled, 1)
// 只展示已结束的局:还在播动画的那局 settle_source 仍为 start,等播完(或超时兜底)才进列表
.and(w -> w.ne(MahjongRecord::getSettleSource, "start")
.or().lt(MahjongRecord::getCreateTime, cutoff));
if (scope == 1) {
qw.eq(MahjongRecord::getUserId, userId);
} else {
// 全服:排除机器人/封禁/已删除用户(保留白号,白号可上榜)
qw.inSql(MahjongRecord::getUserId,
"SELECT user_id FROM tt_user WHERE user_type != '03' AND status != '1' AND del_flag = '0'");
}
qw.orderByDesc(MahjongRecord::getCreateTime);
Page<MahjongRecord> page = recordMapper.selectPage(new Page<>(pageNum, pageSize), qw);
List<MahjongRecord> records = page.getRecords();
// 昵称/头像(两种 scope 都返回)
Map<Long, Map<String, Object>> userMap = new HashMap<>();
Set<Long> userIds = new HashSet<>();
for (MahjongRecord r : records) userIds.add(r.getUserId());
if (!userIds.isEmpty()) {
for (Map<String, Object> m : recordMapper.getUserInfos(userIds)) {
Object uid = m.get("user_id") != null ? m.get("user_id") : m.get("userId");
if (uid != null) userMap.put(Long.valueOf(uid.toString()), m);
}
}
// 饰品全名 = 名称(磨损品质):新记录按 prize_id 关联,老记录(prize_id 为空)按名称兜底
Map<Long, String> exteriorById = new HashMap<>();
Map<String, String> exteriorByName = new HashMap<>();
for (MahjongPrizeOrnament o : prizeOrnamentMapper.selectList(null)) {
if (o.getExteriorName() == null || o.getExteriorName().isEmpty()) continue;
exteriorById.putIfAbsent(o.getId(), o.getExteriorName());
if (o.getName() != null) exteriorByName.putIfAbsent(o.getName(), o.getExteriorName());
}
List<RecordItemResponse> rows = new ArrayList<>();
for (MahjongRecord r : records) {
RecordItemResponse vo = new RecordItemResponse();
Map<String, Object> u = userMap.get(r.getUserId());
if (u != null) {
vo.setNickName(str(u.get("nick_name")));
vo.setAvatar(str(u.get("avatar")));
}
vo.setCost(r.getCost());
vo.setSettledWin(r.getSettledWin());
vo.setPrizeImageUrl(r.getPrizeImageUrl());
vo.setPrizeName(fullPrizeName(r.getPrizeName(), r.getPrizeId(), exteriorById, exteriorByName));
vo.setCreateTime(r.getCreateTime());
rows.add(vo);
}
return R.ok(Map.of("rows", rows, "total", page.getTotal()));
}
private static String str(Object v) {
return v != null ? v.toString() : null;
}
/** 饰品全名:名称已是全名,有磨损品质时追加 " (品质)" */
private static String fullPrizeName(String name, Long prizeId,
Map<Long, String> exteriorById, Map<String, String> exteriorByName) {
if (name == null) return null;
String exterior = prizeId != null ? exteriorById.get(prizeId) : null;
if (exterior == null) exterior = exteriorByName.get(name);
return exterior == null || exterior.isEmpty() ? name : name + " (" + exterior + ")";
}
@ApiOperation("查询是否有进行中的游戏(断线重连)")
@PostMapping("/hasActive")
public R hasActive() {
Long userId = getUserId();
// 维护期间不强制结算:进行中的对局可正常重连并播完("当前不受影响",仅拦新开一局)
return R.ok(gameService.hasActive(userId));
}
}
@@ -0,0 +1,90 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.Map;
/**
* 箱子配置表 game_mahjong_box —— 只按投注档分箱,共 11 档(1/3/5/10/20/30/50/100/200/300/500)。
* 与玫瑰女郎的关键差异:只有 normal 一个模式,costMultiplier ≡ 1,price ≡ bet。
*/
@Data
@TableName("game_mahjong_box")
public class MahjongBox {
@TableId(value = "box_id", type = IdType.INPUT)
private Long boxId;
@TableField("game_type")
private String gameType;
/** 模式:只有 normal(对齐 MahjongModeType) */
@TableField("box_type")
private String boxType;
private BigDecimal bet;
private String name;
/** 实际扣费 = bet(costMultiplier ≡ 1,无模式倍率、无投注倍数) */
private BigDecimal price;
/** 固定池总回合数(原 total_ornaments) */
@TableField("total_rounds")
private Integer totalRounds;
private BigDecimal rtp;
@TableField("rtp_converge_ratio")
private BigDecimal rtpConvergeRatio;
private Integer sort;
private String status;
@TableField("del_flag")
private String delFlag;
private String remark;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
@TableField("update_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime;
// ======== transient ========
/** 模式中文名,运行时由 MahjongModeType 填充 */
@TableField(exist = false)
private String modeName;
/** 费用倍率 CM,本游戏恒为 1,运行时填充(保留字段以便与玫瑰女郎同构) */
@TableField(exist = false)
private BigDecimal costMultiplier;
/** 实时RTP,从 game_mahjong_record 统计 */
@TableField(exist = false)
private BigDecimal actualRtp;
/** 各身份分布反推目标RTP(%),运行时填充 {audience: percent} */
@TableField(exist = false)
private Map<String, BigDecimal> targetRtpByAudience;
/** 各身份实时RTP(%),运行时填充 {audience: percent} */
@TableField(exist = false)
private Map<String, BigDecimal> actualRtpByAudience;
/** 各身份一轮总回合(按各自固定池分布反推),运行时填充 {audience: n} */
@TableField(exist = false)
private Map<String, BigDecimal> totalRoundsByAudience;
}
@@ -0,0 +1,52 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 箱子-倍率桶分布表 game_mahjong_box_bucket(多身份)。
* 唯一键 (box_id, bucket_id, audience):16 桶 × 11 箱 × 4 身份 = 704 条。
* 白号(audience=white)独立库存,抽取只递减自己的 used,永不消耗其他身份。
*/
@Data
@TableName("game_mahjong_box_bucket")
public class MahjongBoxBucket {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
/** 桶ID (1-16) */
@TableField("bucket_id")
private Integer bucketId;
/** 身份维度 common/anchor/white(机器人维度已去掉,user_type='03' 归 common) */
private String audience;
/** 分配回合数(加权随机权重) */
private Integer count;
/** 已消耗回合数:剩余 = count - used */
private Integer used;
// ======== transient(SQL JOIN 填充)========
@TableField(exist = false)
private String bucketName;
@TableField(exist = false)
private java.math.BigDecimal expectMul;
/** 剩余 = count - used,SQL 计算填充 */
@TableField(exist = false)
private Integer remaining;
/** 本轮收敛阶段该桶已动态选取回合数(轮次账本) */
@TableField(exist = false)
private Long convergeCount;
}
@@ -0,0 +1,45 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
/** 箱子倍率桶分布模板 game_mahjong_box_template(按身份 audience,抽干补桶依据) */
@Data
@TableName("game_mahjong_box_template")
public class MahjongBoxTemplate {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
private String audience;
private String name;
@TableField("total_rounds")
private Integer totalRounds;
@TableField("items_json")
private String itemsJson;
@TableField("is_active")
private Integer isActive;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
/** 反序列化后的分布,回传前端用 */
@TableField(exist = false)
private List<Map<String, Object>> items;
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/** 倍率桶定义表 game_mahjong_bucket(16 桶:0x~500x;对应玫瑰女郎 game_olympus_ornament 的 17 档) */
@Data
@TableName("game_mahjong_bucket")
public class MahjongBucket {
@TableId(type = IdType.INPUT)
private Long id;
private String gameType;
/** 期望倍率(区间均值,最高 500 = MAX_WIN_CAP_MUL) */
private BigDecimal expectMul;
private String name;
/** 落桶区间下限(含) */
private BigDecimal rangeLow;
/** 落桶区间上限(含) */
private BigDecimal rangeHigh;
}
@@ -0,0 +1,36 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/** 返奖饰品定义表 game_mahjong_prize_ornament(必须有:赢额 → 饰品) */
@Data
@TableName("game_mahjong_prize_ornament")
public class MahjongPrizeOrnament {
@TableId(type = IdType.AUTO)
private Long id;
private String gameType;
private String name;
private String imageUrl;
private Integer grade;
private String exteriorName;
private Integer sort;
private String status;
private Integer isFallback;
// ======== transient ========
@TableField(exist = false)
private java.math.BigDecimal winLow;
@TableField(exist = false)
private java.math.BigDecimal winHigh;
@TableField(exist = false)
private String levelImg;
}
@@ -0,0 +1,21 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/** 返奖饰品区间表 game_mahjong_prize_range(必须有:赢额落区间) */
@Data
@TableName("game_mahjong_prize_range")
public class MahjongPrizeRange {
@TableId(type = IdType.AUTO)
private Long id;
private Long prizeId;
private BigDecimal winLow;
private BigDecimal winHigh;
}
@@ -0,0 +1,46 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 玩家游玩记录表 game_mahjong_record —— 每次开局一条(保留全部 prize_* 列,发饰品要走区间匹配) */
@Data
@TableName("game_mahjong_record")
public class MahjongRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long boxId;
private Long roundId;
private BigDecimal cost;
/** 投注倍数:预留字段,当前服务端只接受 1 */
private BigDecimal betMul;
private BigDecimal totalWin;
private Integer settled; // 0未结算 1已结算(幂等守卫)
private String modeType; // 只有 normal
private String rewardType; // gold / ornament
private BigDecimal settledWin;
private Long prizeId;
private String prizeName;
private String prizeImageUrl;
private String prizeLevelImg;
private String settleSource; // start=开局已结算(动画还在播) / finish=末帧播完(已结束,进战绩列表)
/** 身份 common/anchor/white(机器人维度已去掉):**开局时的快照,落库**(水位按 箱×身份 聚合、事后可追溯);
* 后台列表仍会用 tt_user 现推的值覆盖它做展示,两者在身份未变时一致 */
private String audience;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
}
@@ -0,0 +1,52 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 奖池水位调整日志 game_mahjong_reward_log(注水/抽水/重置审计,原因必填由前端保证) */
@Data
@TableName("game_mahjong_reward_log")
public class MahjongRewardLog {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("box_id")
private Long boxId;
@TableField("box_name")
private String boxName;
/** 身份:common/anchor/white(奖池按 箱子×身份 拆池;机器人维度已去掉) */
private String audience;
/** 1注水 2抽水 3重置 */
private Integer type;
private BigDecimal amount;
@TableField("balance_before")
private BigDecimal balanceBefore;
@TableField("balance_after")
private BigDecimal balanceAfter;
/** 操作后全站总池余额快照 */
@TableField("pool_balance")
private BigDecimal poolBalance;
private String operator;
private String remark;
@TableField("create_time")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}
@@ -0,0 +1,46 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 预生成回合表 game_mahjong_round —— 池里存的是倍率,与投注额无关 */
@Data
@TableName("game_mahjong_round")
public class MahjongRound {
@TableId(value = "round_id", type = IdType.INPUT)
private Long roundId;
private Long boxId;
/** 落入的倍率桶ID (1-16) */
private Integer bucketId;
/** 实际总倍率 = 本局赢额 / bet(≤500 = MAX_WIN_CAP_MUL,已封顶) */
private BigDecimal actualMul;
/** 免费旋转总次数(0=未进FS) */
private Integer freeSpinCount;
/** 总波数(级联序号,原 total_cascades) */
private Integer totalRotations;
/** 是否自然触发免费旋转(0/1) */
private Integer hasFs;
/** 2 胡悬念盘标记(0/1) */
private Integer nearMiss;
private Date createTime;
// ======== transient ========
@TableField(exist = false)
private String boxType;
}
@@ -0,0 +1,83 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/**
* 回合步骤表 game_mahjong_step —— 前端逐步播放的每一帧。
* 与玫瑰女郎的差异:multiplier_detail(炸弹) 删除,改为 golden_detail + rotation_mul;
* accumulator_before/after 改为 rotation_mul(语义=本波阶梯倍率,不跨旋)。
*/
@Data
@TableName("game_mahjong_step")
public class MahjongStep {
@TableId(type = IdType.INPUT)
private Long id;
private Long roundId;
/** 旋序号 (0=基础旋/触发盘, >0=第N次免费旋转,原 free_spin_id) */
private Integer reelSpinId;
/** 本旋内第几波(= 乘数阶梯序号,原 cascade_seq) */
private Integer rotationSeq;
/** 网格状态 JSON 4行×5列 = 20 格短码 */
private String gridState;
/** 本帧盘面上仍为"金色普通符号"的格 JSON [{"pos","symbol"}] */
private String goldenDetail;
/** 本波"金色→百搭"就地转化的格 JSON [{"pos","fromSymbol"}] */
private String wildConverted;
/** 本波中奖明细 JSON [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}] */
private String waysDetail;
/** 本旋累计消除符号 JSON {"A":9,...} */
private String accumulatedSymbols;
/** 获胜者面板 JSON:本旋逐波追加 [{"symbol","count","win"}] */
private String spinWinners;
/** 消除位置 JSON */
private String removedPositions;
/** 下落数据 JSON */
private String dropData;
/** 填充数据 JSON */
private String fillData;
/** 触发免费旋转 JSON {"scatterCount":3,"freeSpins":12,"extraSpins":0} */
private String scatterTrigger;
/** 免费旋转重触发 JSON */
private String retriggerInfo;
/** 本步基础分(倍率,未乘投注额) */
private BigDecimal baseScore;
/** 本旋累计到本步(末帧 = 本旋总额),不是增量 */
private BigDecimal winAmount;
/** 本波阶梯倍率(基础旋 1/2/3/5,FS 2/4/6/10);不跨旋累计 */
private BigDecimal rotationMul;
/** 整局累计赢额(倍率)到本步 */
private BigDecimal spinWin;
/** 本步赢额被 500x 封顶截断 0否1是 */
private Integer winCapped;
/** 爆奖档位(small/middle/big;只在免费旋转里判档,按单旋赢额) */
private String bigwinLevel;
/** 本旋末帧 */
private Integer isFinal;
}
@@ -0,0 +1,25 @@
package com.ruoyi.game.mahjong.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** 玩家统计表 game_mahjong_user —— 每次结算累加 */
@Data
@TableName("game_mahjong_user")
public class MahjongUser {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long totalSpins;
private BigDecimal totalCost;
private BigDecimal totalWin;
private BigDecimal maxWinMul;
private Date updateTime;
}
@@ -0,0 +1,49 @@
package com.ruoyi.game.mahjong.enums;
import com.ruoyi.domain.entity.sys.TtUser;
/**
* 身份维度(audience):每种身份一套独立的倍率桶数量分布,实现差异化 RTP。
* <p>
* **为什么本模块自带一份,而不是复用 {@code OlympusAudience}**:那一位在
* {@code skins-service/game-olympus-service} 里,而该模块在本仓**不存在**(本次任务已把它的
* Maven module / ruoyi-admin 依赖摘掉换成 game-mahjong-service),新建的麻将模块无法依赖它。
* 设计方案 §6.2 的建议是"直接把 OlympusAudience 上移到共享模块(skins-model)"——那属于**共享文件改动**,
* 本次任务明确不做,所以这里先在本模块内落一份**逐字同构**的判定(口径、优先级、返回码完全一致)。
* 待共享文件改动落地后,把本类替换为共享的 OlympusAudience 即可,行为不变。
* <p>
* 解析优先级(白号最高,压过 user_type):
* is_blank_account==1 → white;user_type=01 → anchor;其余(含 user_type=03 机器人)→ common。
* <p>
* ⚠️ **本游戏只有三个身份维度**:common / anchor / white。原来的 robot 维度已按用户要求去掉
* (2026-09-22)——机器人不再单独成池,`user_type='03'` 归入 common。
*/
public enum MahjongAudience {
COMMON("common"), // 普通用户(user_type=02 及招商/卡商/超管等;**含 user_type=03 机器人**)
ANCHOR("anchor"), // 主播(user_type=01)
WHITE("white"); // 白号(is_blank_account=1,压过 user_type)
private final String code;
MahjongAudience(String code) {
this.code = code;
}
public String getCode() {
return code;
}
/** 白号:纯概率隔离池,不计水位、不消耗其他身份库存 */
public boolean isWhite() {
return this == WHITE;
}
/** 按玩家解析 audience:白号最高优先,压过 user_type。 */
public static MahjongAudience resolve(TtUser player) {
if (player == null) return COMMON;
if (Integer.valueOf(1).equals(player.getIsBlankAccount())) return WHITE;
String userType = player.getUserType();
if ("01".equals(userType)) return ANCHOR;
return COMMON; // 含 user_type=03(机器人):本游戏没有 robot 维度
}
}
@@ -0,0 +1,45 @@
package com.ruoyi.game.mahjong.enums;
/**
* 麻将胡了模式(对齐 mahjong-py config.MODES):
* 只有一个 normal(普通旋转,自然触发免费旋转)。
* <p>
* 与玫瑰女郎的关键差异:底注(ante 2x)与购买免费旋转(buy_fs 50x)都已砍掉,
* 所以 {@code costMultiplier ≡ 1}、{@code price = box.bet},且不再有 need_fs 过滤。
*/
public enum MahjongModeType {
NORMAL("normal", "普通旋转", 1.0);
private final String boxType;
private final String name;
private final double costMultiplier;
MahjongModeType(String boxType, String name, double costMultiplier) {
this.boxType = boxType;
this.name = name;
this.costMultiplier = costMultiplier;
}
public String getBoxType() { return boxType; }
public String getName() { return name; }
/** 恒为 1:cost = box.bet,无模式倍率、无投注倍数 */
public double getCostMultiplier() { return costMultiplier; }
/**
* 该模式可用倍率桶 ID 范围 [minId, maxId],对齐 py 端 MODE_BUCKET_IDS。
* 只有 normal:全 16 档(0x-500x)。高倍桶没有"买 FS"那批回合兜底,只能靠自然触发 FS 供给。
*/
public int[] getBucketRange() {
return new int[]{1, 16};
}
public static MahjongModeType fromBoxType(String boxType) {
for (MahjongModeType t : values()) {
if (t.boxType.equals(boxType)) return t;
}
throw new IllegalArgumentException("Unknown boxType: " + boxType);
}
}
@@ -0,0 +1,32 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 箱子-倍率桶分布 Mapper(多身份 audience)。
* 所有查询/消耗均按 (boxId, audience) 维度隔离,白号不碰其他身份库存。
*/
@Mapper
public interface MahjongBoxBucketMapper extends BaseMapper<MahjongBoxBucket> {
/** 查某箱某身份的桶分布(含 expectMul/remaining 连表填充) */
List<MahjongBoxBucket> selectByBoxIdAndAudience(@Param("boxId") Long boxId,
@Param("audience") String audience);
int batchInsert(@Param("list") List<MahjongBoxBucket> list);
/** 删除某箱某身份的分布(重算/应用模板前清空该身份) */
int deleteByBoxIdAndAudience(@Param("boxId") Long boxId, @Param("audience") String audience);
/** 该身份某桶 used+1(仅固定阶段消耗) */
int incrUsed(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("audience") String audience);
/** 该身份 used 归 0(补桶) */
int resetUsed(@Param("boxId") Long boxId, @Param("audience") String audience);
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import org.apache.ibatis.annotations.Mapper;
/** 箱子配置 game_mahjong_box(11 个投注档,只有 normal 模式)。 */
@Mapper
public interface MahjongBoxMapper extends BaseMapper<MahjongBox> {
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import org.apache.ibatis.annotations.Mapper;
/** 箱子倍率桶分布模板 game_mahjong_box_template(按身份 audience)。 */
@Mapper
public interface MahjongBoxTemplateMapper extends BaseMapper<MahjongBoxTemplate> {
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import org.apache.ibatis.annotations.Mapper;
/** 倍率桶定义 game_mahjong_bucket(16 桶,0x~500x)。 */
@Mapper
public interface MahjongBucketMapper extends BaseMapper<MahjongBucket> {
}
@@ -0,0 +1,9 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongPrizeOrnament;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MahjongPrizeOrnamentMapper extends BaseMapper<MahjongPrizeOrnament> {
}
@@ -0,0 +1,50 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongPrizeRange;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.Map;
/**
* 返奖饰品区间:按赢额匹配展示饰品。联表 game_mahjong_prize_ornament 取 name/imageUrl/grade,
* 再联 tt_ornaments_level 取 level_img AS levelImageUrl(品质/等级图片,前端 prizeInfo.levelImageUrl 用这个)。
*/
@Mapper
public interface MahjongPrizeRangeMapper extends BaseMapper<MahjongPrizeRange> {
/** 精确区间匹配 win_low < win <= win_high */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE #{win} > r.win_low AND #{win} <= r.win_high AND o.status = '1' "
+ "ORDER BY r.win_low DESC LIMIT 1")
Map<String, Object> findPrizeByWin(@Param("win") BigDecimal win);
/** 兜底饰品(is_fallback=1) */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_ornament o LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.is_fallback = 1 AND o.status = '1' ORDER BY o.sort ASC LIMIT 1")
Map<String, Object> findFallbackPrize();
/** 最低区间饰品 */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.status = '1' ORDER BY r.win_low ASC LIMIT 1")
Map<String, Object> findLowestPrize();
/** 最高区间饰品 */
@Select("SELECT o.id, o.name, o.image_url AS imageUrl, o.grade, o.exterior_name AS exteriorName, "
+ "l.level_img AS levelImageUrl "
+ "FROM game_mahjong_prize_range r JOIN game_mahjong_prize_ornament o ON o.id = r.prize_id "
+ "LEFT JOIN tt_ornaments_level l ON l.id = o.grade "
+ "WHERE o.status = '1' ORDER BY r.win_high DESC LIMIT 1")
Map<String, Object> findHighestPrize();
}
@@ -0,0 +1,66 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Mapper
public interface MahjongRecordMapper extends BaseMapper<MahjongRecord> {
/** 批量查各箱实时 RTP(排除白号/机器人/封禁/删除,与首页口径一致) */
@Select("<script>SELECT r.box_id AS boxId, ROUND(SUM(r.total_win)/SUM(r.cost)*100, 2) AS rtp "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 AND r.box_id IN "
+ "<foreach collection='boxIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
+ "GROUP BY r.box_id</script>")
List<Map<String, Object>> selectBoxRtpList(@Param("boxIds") List<Long> boxIds);
@Select("<script>SELECT user_id, nick_name, avatar FROM tt_user WHERE user_id IN "
+ "<foreach collection='userIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Map<String, Object>> getUserInfos(@Param("userIds") Set<Long> userIds);
/** 每日统计汇总(排除白号/机器人/封禁/删除) */
@Select("<script>SELECT COUNT(*) AS totalSpins, COALESCE(SUM(r.cost),0) AS totalCost, "
+ "COALESCE(SUM(r.total_win),0) AS totalWin "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 "
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time &gt;= #{dateStart}</if> "
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time &lt; DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
+ "</script>")
Map<String, Object> winSummary(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
/** 每日统计列表:按天聚合(口径与 winSummary 一致,排除白号/机器人/封禁/删除) */
@Select("<script>SELECT DATE_FORMAT(r.create_time, '%Y-%m-%d') AS date, COUNT(*) AS totalSpins, "
+ "COALESCE(SUM(r.cost),0) AS totalCost, COALESCE(SUM(r.total_win),0) AS totalWin, "
+ "COUNT(DISTINCT r.user_id) AS playerCount "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ " AND u.is_blank_account != 1 AND u.user_type != '03' AND u.status != '1' AND u.del_flag = '0' "
+ "WHERE r.settled = 1 "
+ "<if test='dateStart != null and dateStart != \"\"'> AND r.create_time &gt;= #{dateStart}</if> "
+ "<if test='dateEnd != null and dateEnd != \"\"'> AND r.create_time &lt; DATE_ADD(#{dateEnd}, INTERVAL 1 DAY)</if> "
+ "GROUP BY DATE_FORMAT(r.create_time, '%Y-%m-%d') ORDER BY date DESC</script>")
List<Map<String, Object>> dailyList(@Param("dateStart") String dateStart, @Param("dateEnd") String dateEnd);
/**
* 某箱 + 某身份奖池真实累计(按已结算记录派生)。身份口径同 MahjongAudience#resolve,
* **三个身份互斥且并集 = 全部已结算记录**(common/anchor/white;robot 维度已去掉,
* user_type='03' 归入 common)。totalBet=Σcost,totalWin=Σsettled_win(实际发放价值)。
*/
@Select("<script>SELECT COALESCE(SUM(r.cost),0) AS totalBet, COALESCE(SUM(r.settled_win),0) AS totalWin "
+ "FROM game_mahjong_record r INNER JOIN tt_user u ON u.user_id = r.user_id "
+ "WHERE r.box_id = #{boxId} AND r.settled = 1 "
+ "<choose>"
+ "<when test=\"audience == 'white'\">AND COALESCE(u.is_blank_account,0) = 1 </when>"
+ "<when test=\"audience == 'anchor'\">AND COALESCE(u.is_blank_account,0) != 1 AND u.user_type = '01' </when>"
+ "<otherwise>AND COALESCE(u.is_blank_account,0) != 1 AND (u.user_type IS NULL OR u.user_type != '01') </otherwise>"
+ "</choose></script>")
Map<String, Object> sumPool(@Param("boxId") Long boxId, @Param("audience") String audience);
}
@@ -0,0 +1,10 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRewardLog;
import org.apache.ibatis.annotations.Mapper;
/** 奖池水位调整日志 game_mahjong_reward_log。 */
@Mapper
public interface MahjongRewardLogMapper extends BaseMapper<MahjongRewardLog> {
}
@@ -0,0 +1,33 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 预生成回合 game_mahjong_round。
* 与玫瑰女郎的差异:桶字段是 bucket_id;没有 need_fs 过滤(本游戏没有"买 FS"模式),
* 但保留 needFreeSpins 参数以便按 free_spin_count 过滤(0=未进FS)。
*/
@Mapper
public interface MahjongRoundMapper extends BaseMapper<MahjongRound> {
MahjongRound pickByBucket(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("needFreeSpins") Boolean needFreeSpins);
MahjongRound pickAnyByBox(@Param("boxId") Long boxId, @Param("needFreeSpins") Boolean needFreeSpins);
/** 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul */
MahjongRound pickClosestMul(@Param("boxId") Long boxId, @Param("bucketId") Integer bucketId,
@Param("needFreeSpins") Boolean needFreeSpins,
@Param("desiredMul") BigDecimal desiredMul,
@Param("capMul") BigDecimal capMul);
/** 统计某箱池内各桶实际均值:[{bucketId, sampleCount, avgMul}](配比/RTP 按它算) */
List<Map<String, Object>> avgMulByBucket(@Param("boxId") Long boxId);
}
@@ -0,0 +1,16 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface MahjongStepMapper extends BaseMapper<MahjongStep> {
@Select("SELECT * FROM game_mahjong_step WHERE round_id = #{roundId} ORDER BY id ASC")
List<MahjongStep> selectByRoundId(@Param("roundId") Long roundId);
}
@@ -0,0 +1,14 @@
package com.ruoyi.game.mahjong.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.game.mahjong.domain.MahjongUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MahjongUserMapper extends BaseMapper<MahjongUser> {
@Select("SELECT * FROM game_mahjong_user WHERE user_id = #{userId} LIMIT 1")
MahjongUser selectByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.model;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/** 选中回合 + 其步骤 + 阶段标记(固定/收敛,决定是否 incrUsed 与 conv 记账) */
@Data
@AllArgsConstructor
public class PickedRound {
private MahjongRound round;
private List<MahjongStep> steps;
private boolean converge;
}
@@ -0,0 +1,56 @@
package com.ruoyi.game.mahjong.model.cache;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
/** 麻将胡了单局进行中缓存(Redis,TTL 30 分钟) */
@Data
public class MahjongGameCache implements Serializable {
private Long boxId;
private String boxType; // 只有 normal
private BigDecimal bet; // 投注总额 = box.bet
private BigDecimal cost; // 实际扣费 = bet(costMultiplier ≡ 1)
private BigDecimal betMul; // 预留,当前恒为 1
private Long roundId;
private BigDecimal totalMul; // round.actualMul(≤500)
private Integer freeSpinCount;
private int stepIndex; // 当前已返回步数(1-based)
private List<CachedStep> steps;
private BigDecimal totalWin; // = totalMul × bet
private Long recordId;
private Long boxRecordId;
/** 身份维度 common/anchor/white(机器人维度已去掉),白号隔离依据 */
private String audience;
/** 是否白号:白号不注水/不扣水/不受水位约束 */
private boolean blankAccount;
@Data
public static class CachedStep implements Serializable {
private int reelSpinId;
private int rotationSeq;
private String gridState;
private String goldenDetail;
private String wildConverted;
private String waysDetail; // 本旋累积中奖明细(含各波)
private BigDecimal winAmount; // 本旋累计(该旋末帧 = 这一旋的总额)
private BigDecimal spinWin; // 本局累计(末帧 = totalWin)
/** 本波阶梯倍率(基础旋 1/2/3/5、FS 2/4/6/10,每旋重置);断线重连靠它接回档位 */
private BigDecimal rotationMul;
private int winCapped;
private String bigwinLevel;
private int isFinal;
private String retriggerInfo;
private String dropData;
private String fillData;
private String scatterTrigger;
}
}
@@ -0,0 +1,17 @@
package com.ruoyi.game.mahjong.model.cache;
import lombok.Data;
import java.math.BigDecimal;
/**
* 麻将胡了按【箱 × 身份】奖池(水位)缓存。
* 水位 = totalBet − totalWin + adjust(人工注水/抽水),仅真实玩家(白号隔离)。
* 注意 totalWin 口径 = Σ settled_win(饰品价),不是 total_win(游戏内赢额),否则对账会漂。
*/
@Data
public class MahjongRewardPoolCache {
private BigDecimal totalBet;
private BigDecimal totalWin;
private BigDecimal adjust;
}
@@ -0,0 +1,30 @@
package com.ruoyi.game.mahjong.repository;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.game.mahjong.model.cache.MahjongGameCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_CACHE;
/** 单局进行中缓存(Redis,TTL 30 分钟):ruoyi:mahjong:{userId} */
@Component
public class MahjongCacheRepository {
@Autowired
private RedisCache redisCache;
public MahjongGameCache getCache(Long userId) {
return redisCache.getCacheObject(GAME_CACHE + userId);
}
public void setCache(Long userId, MahjongGameCache cache) {
redisCache.setCacheObject(GAME_CACHE + userId, cache, 30, TimeUnit.MINUTES);
}
public void clearCache(Long userId) {
redisCache.deleteObject(GAME_CACHE + userId);
}
}
@@ -0,0 +1,84 @@
package com.ruoyi.game.mahjong.repository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_CYCLE;
/**
* 一轮内 RTP 动态收敛账本,按 (boxId, audience) 隔离。
* Hash key = ruoyi:mahjong:cycle:{boxId}:{audience},字段 drawn/mulSum/conv:{bucketId}。
* 白号(audience=white)也有独立账本,与其他身份完全隔离。
*/
@Component
public class MahjongCycleRepository {
@Autowired
public RedisTemplate redisTemplate;
private static final String F_DRAWN = "drawn";
private static final String F_MUL_SUM = "mulSum";
private static final String F_CONV_PREFIX = "conv:";
private static final long TTL_DAYS = 30;
private String key(Long boxId, String audience) {
return GAME_CYCLE + boxId + ":" + audience;
}
public long drawn(Long boxId, String audience) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_DRAWN);
return v == null ? 0L : toLong(v);
}
public double mulSum(Long boxId, String audience) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_MUL_SUM);
return v == null ? 0d : toDouble(v);
}
/** 记一抽:drawn+1、mulSum+actualMul;收敛阶段额外 conv:{bucketId}+1。 */
public void recordDraw(Long boxId, String audience, Integer bucketId, BigDecimal actualMul, boolean converge) {
String k = key(boxId, audience);
redisTemplate.opsForHash().increment(k, F_DRAWN, 1L);
double mul = actualMul != null ? actualMul.doubleValue() : 0d;
if (mul != 0d) redisTemplate.opsForHash().increment(k, F_MUL_SUM, mul);
if (converge && bucketId != null) {
redisTemplate.opsForHash().increment(k, F_CONV_PREFIX + bucketId, 1L);
}
redisTemplate.expire(k, TTL_DAYS, TimeUnit.DAYS);
}
public void resetCycle(Long boxId, String audience) {
redisTemplate.delete(key(boxId, audience));
}
public long convergeCount(Long boxId, String audience, Integer bucketId) {
Object v = redisTemplate.opsForHash().get(key(boxId, audience), F_CONV_PREFIX + bucketId);
return v == null ? 0L : toLong(v);
}
/**
* 本轮实时 RTP(%) = mulSum / drawn × 100。
* 本游戏 costMultiplier ≡ 1,所以不再除以 CM(玫瑰女郎那版要除,这里刻意不除)。
*/
public BigDecimal realtimeRtp(Long boxId, String audience) {
long d = drawn(boxId, audience);
if (d <= 0) return null;
double rtp = mulSum(boxId, audience) / d * 100;
return BigDecimal.valueOf(rtp).setScale(2, RoundingMode.HALF_UP);
}
private static long toLong(Object v) {
if (v instanceof Number) return ((Number) v).longValue();
try { return Long.parseLong(v.toString().trim()); } catch (Exception e) { return 0L; }
}
private static double toDouble(Object v) {
if (v instanceof Number) return ((Number) v).doubleValue();
try { return Double.parseDouble(v.toString().trim()); } catch (Exception e) { return 0d; }
}
}
@@ -0,0 +1,115 @@
package com.ruoyi.game.mahjong.repository;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.model.cache.MahjongRewardPoolCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.game.mahjong.constants.MahjongConstants.GAME_REWARD_POOL_CACHE;
/**
* 麻将胡了按【箱子 × 身份】奖池(水位)仓储。
* 每箱每身份独立键::box:{boxId}:{audience}:dbbet / :dbwin / :adjust;全局 adjust 也按身份一份。
* 水位 = box:bet − box:win + box:adjust(可能为负)。
* <p>
* 记账口径与玫瑰女郎一致:bet/win **由 DB 已结算记录按身份派生**(game_mahjong_record + tt_user),
* Redis 仅做短缓存供 payoutCap/展示快速读取;adjust 为管理员人工注水/抽水(Redis)。
* win 取 Σ settled_win(饰品价),不是 total_win。
*/
@Component
public class MahjongRewardPoolCacheRepository {
@Autowired
public RedisTemplate redisTemplate;
@Autowired
private MahjongRecordMapper recordMapper;
private static final String KEY_BOX_PREFIX = GAME_REWARD_POOL_CACHE + ":box:";
/** 全局人工调整累计(各箱 adjust 之和,仅展示),按身份一份 */
private static final String KEY_ADJUST = GAME_REWARD_POOL_CACHE + ":adjust:";
private static final String KEY_DB_BET = ":dbbet";
private static final String KEY_DB_WIN = ":dbwin";
/** ponytail: DB 派生结果短缓存;DB 为真相,TTL 内只是短暂滞后、自愈,无永久漂移 */
private static final long DB_CACHE_TTL_SECONDS = 5;
private static String boxKey(Long boxId, String audience, String suffix) {
return KEY_BOX_PREFIX + boxId + ":" + audience + suffix;
}
private static String adjustKey(String audience) {
return KEY_ADJUST + audience;
}
/** 管理员逐箱注水/抽水:delta 为正注水、为负抽水;同时累加该身份的全局 adjust 键 */
public void adjustBox(Long boxId, String audience, BigDecimal delta) {
if (boxId == null || delta == null || delta.signum() == 0) return;
redisTemplate.opsForValue().increment(boxKey(boxId, audience, ":adjust"), delta.doubleValue());
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
}
/** 仅调整全局 adjust 键(重置已删除箱子遗留残余),不动箱子键 */
public void adjustGlobal(String audience, BigDecimal delta) {
if (delta == null || delta.signum() == 0) return;
redisTemplate.opsForValue().increment(adjustKey(audience), delta.doubleValue());
}
public BigDecimal getGlobalAdjust(String audience) {
return readValue(adjustKey(audience));
}
public MahjongRewardPoolCache getBoxCache(Long boxId, String audience) {
MahjongRewardPoolCache cache = new MahjongRewardPoolCache();
cache.setTotalBet(dbPoolTotal(boxId, audience, KEY_DB_BET));
cache.setTotalWin(dbPoolTotal(boxId, audience, KEY_DB_WIN));
cache.setAdjust(readValue(boxKey(boxId, audience, ":adjust")));
return cache;
}
public BigDecimal getBoxAdjust(Long boxId, String audience) {
return readValue(boxKey(boxId, audience, ":adjust"));
}
/** 箱子×身份水位 = 该身份箱内累计投注 − 累计返奖 + 人工调整(可能为负) */
public BigDecimal getBoxBalance(Long boxId, String audience) {
MahjongRewardPoolCache c = getBoxCache(boxId, audience);
return c.getTotalBet().subtract(c.getTotalWin()).add(c.getAdjust())
.setScale(2, java.math.RoundingMode.HALF_UP);
}
/** 读取某箱某身份 bet/win 的 DB 派生值(短缓存;缓存缺失时按 record 聚合重算) */
private BigDecimal dbPoolTotal(Long boxId, String audience, String suffix) {
String key = boxKey(boxId, audience, suffix);
Object v = redisTemplate.opsForValue().get(key);
if (v != null) return toBd(v);
refreshDbPool(boxId, audience);
return toBd(redisTemplate.opsForValue().get(key));
}
private void refreshDbPool(Long boxId, String audience) {
Map<String, Object> row = boxId != null ? recordMapper.sumPool(boxId, audience) : null;
BigDecimal bet = toBd(row != null ? row.get("totalBet") : null);
BigDecimal win = toBd(row != null ? row.get("totalWin") : null);
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_BET), bet.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
redisTemplate.opsForValue().set(boxKey(boxId, audience, KEY_DB_WIN), win.doubleValue(), DB_CACHE_TTL_SECONDS, TimeUnit.SECONDS);
}
private BigDecimal readValue(String key) {
return toBd(redisTemplate.opsForValue().get(key));
}
private static BigDecimal toBd(Object v) {
if (v == null) return BigDecimal.ZERO;
try {
return new BigDecimal(v.toString());
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
}
@@ -0,0 +1,350 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongBoxTemplate;
import com.ruoyi.game.mahjong.domain.MahjongBucket;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxTemplateMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* 麻将胡了倍率桶分布计算 / 重置服务(按身份 audience)。
* 算法逐行移植自 OlympusDistributionService(目标 RTP → 幂律权重二分 → 单调约束 → 微调逼近);
* 与玫瑰女郎的差异只有两处:
* <ol>
* <li>表名/字段名 ornament→bucket(附录 A 映射);</li>
* <li>{@code costMultiplier ≡ 1},所以目标均值就是 targetRtp 本身,没有 CM 换算。</li>
* </ol>
*/
@Service
public class MahjongDistributionService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongBoxTemplateMapper boxTemplateMapper;
@Autowired private MahjongBucketMapper bucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
/**
* 桶"实际发出期望"覆盖:桶 16 展示 500x(区间上界),但单局封顶 500,
* 真正发出去的是 min(原始分, 500);生成端种的大奖目标集中在 500 附近而不是均匀分布。
* 配比计算(calcRounds)与后台「目标RTP」都按"实际会发出去多少钱"算,所以用实测值。
* TODO(骨架): 生成端落地后按实测均值校准这个覆盖值(玫瑰女郎用的 835 是它 1000x 表的实测值)。
*/
private static final Map<Integer, Double> PAYOUT_EXPECT_OVERRIDE = Map.of(16, 420.0);
/** 倍率桶 ID → 实际发出期望(受封顶影响的桶用覆盖值,其余用 DB 的 expect_mul)。 */
public Map<Integer, Double> loadBucketMul() {
Map<Integer, Double> m = new HashMap<>();
for (MahjongBucket b : bucketMapper.selectList(null)) {
if (b.getId() == null) continue;
int id = b.getId().intValue();
Double override = PAYOUT_EXPECT_OVERRIDE.get(id);
if (override != null) { m.put(id, override); continue; }
m.put(id, b.getExpectMul() != null ? b.getExpectMul().doubleValue() : 0.0);
}
return m;
}
/** 该桶池子里一条回合都没有时才回退名义期望:只要池子有回合,它的均值就是这个箱子的真实期望 */
private static final int MIN_POOL_SAMPLE = 1;
/**
* 桶 → **这个箱子的池子实际会赔出去多少**的期望(AVG(actual_mul),按 boxId 统计)。
* 用名义 expect_mul 算配比会产生系统性偏差,所以按该箱自己的池内均值算。
*/
public Map<Integer, Double> loadPoolExpect(Long boxId) {
Map<Integer, Double> expect = new HashMap<>(loadBucketMul());
if (boxId == null) return expect;
try {
for (Map<String, Object> row : roundMapper.avgMulByBucket(boxId)) {
Object idObj = row.get("bucketId");
Object nObj = row.get("sampleCount");
Object avgObj = row.get("avgMul");
if (idObj == null || avgObj == null) continue;
int id = ((Number) idObj).intValue();
long n = nObj instanceof Number ? ((Number) nObj).longValue() : 0L;
double avg = ((Number) avgObj).doubleValue();
if (!expect.containsKey(id)) continue;
if (n < MIN_POOL_SAMPLE || avg <= 0) continue;
expect.put(id, avg);
}
} catch (Exception ignore) {
// 池子还没生成/查询异常 → 用名义值兜底
}
return expect;
}
/**
* 按目标 RTP 动态计算各倍率桶回合数(幂律 power 二分搜索 + 单调 + 微调)。
* @param mode 模式(本游戏只有 normal) @param boxId 箱子 @param totalRounds 固定池回合数 @param targetRtp 目标 RTP(如 0.8)
*/
public Map<Integer, Integer> calcRounds(MahjongModeType mode, Long boxId, int totalRounds, double targetRtp) {
// costMultiplier ≡ 1 → 目标均值就是 targetRtp
double cm = mode.getCostMultiplier();
double targetAvg = targetRtp * cm;
Map<Integer, Double> bucketMul = loadPoolExpect(boxId);
// 基础权重:模式桶区间 [lo,hi] 内 30×0.6^k 等比递减
int[] range = mode.getBucketRange();
int lo = range[0], hi = range[1];
int n = Math.max(1, hi - lo + 1);
double[][] baseWeights = new double[n][2];
for (int i = 0; i < n; i++) {
baseWeights[i][0] = lo + i;
baseWeights[i][1] = 30.0 * Math.pow(0.6, i);
}
int nBuckets = baseWeights.length;
// 回合数不足:每桶至少 1,均摊
if (totalRounds <= nBuckets) {
Map<Integer, Integer> flat = new LinkedHashMap<>();
for (double[] w : baseWeights) flat.put((int) w[0], 1);
return flat;
}
int remainingRounds = totalRounds - nBuckets;
double guaranteedMulSum = 0;
for (double[] w : baseWeights) guaranteedMulSum += bucketMul.getOrDefault((int) w[0], 0.0);
double targetTotalMul = targetAvg * totalRounds;
double remainingTargetMul = targetTotalMul - guaranteedMulSum;
double remainingTargetAvg = remainingTargetMul / remainingRounds;
// 二分 power
double power = 1.0, plo = 0.2, phi = 10.0;
for (int iter = 0; iter < 30; iter++) {
double sumW = 0, sumWMul = 0;
for (double[] w : baseWeights) {
double weight = Math.pow(w[1], power);
sumW += weight;
sumWMul += weight * bucketMul.getOrDefault((int) w[0], 0.0);
}
double avg = sumW > 0 ? sumWMul / sumW : 0;
if (Math.abs(avg - remainingTargetAvg) < Math.max(remainingTargetAvg * 0.01, 0.001)) break;
if (avg < remainingTargetAvg) phi = power; else plo = power;
power = (plo + phi) / 2;
}
Map<Integer, Integer> result = new LinkedHashMap<>();
for (double[] w : baseWeights) result.put((int) w[0], 1);
double totalW = 0;
Map<Integer, Double> adjWeights = new LinkedHashMap<>();
for (double[] w : baseWeights) {
double weight = Math.pow(w[1], power);
adjWeights.put((int) w[0], weight);
totalW += weight;
}
int rem = remainingRounds;
for (double[] w : baseWeights) {
int bucketId = (int) w[0];
int extra = totalW > 0 ? (int) Math.round(adjWeights.get(bucketId) / totalW * remainingRounds) : 0;
int put = Math.min(extra, rem);
if (put > 0) { result.put(bucketId, result.get(bucketId) + put); rem -= put; }
}
if (rem > 0) { int first = (int) baseWeights[0][0]; result.put(first, result.get(first) + rem); }
// 单调:count[i] >= count[i+1]
List<Integer> sortedIds = new ArrayList<>(result.keySet());
sortedIds.sort(Integer::compareTo);
for (int i = sortedIds.size() - 2; i >= 0; i--) {
int cur = sortedIds.get(i), next = sortedIds.get(i + 1);
if (result.get(cur) < result.get(next)) result.put(cur, result.get(next));
}
int curTotal = result.values().stream().mapToInt(Integer::intValue).sum();
int diff = totalRounds - curTotal;
if (diff != 0) { int first = sortedIds.get(0); result.put(first, result.get(first) + diff); }
// 微调:任取两个桶搬 1 个(保持单调),只要更接近目标
for (int iter = 0; iter < 400; iter++) {
double currentMul = 0;
for (Map.Entry<Integer, Integer> e : result.entrySet())
currentMul += e.getValue() * bucketMul.getOrDefault(e.getKey(), 0.0);
double deltaMul = targetAvg * totalRounds - currentMul;
if (Math.abs(deltaMul) < 0.01) break;
int bestLo = -1, bestHi = -1;
double bestDiff = Math.abs(deltaMul);
for (int loId : sortedIds) {
if (result.get(loId) <= 1) continue;
for (int hiId : sortedIds) {
if (loId == hiId) continue;
double change = bucketMul.getOrDefault(hiId, 0.0) - bucketMul.getOrDefault(loId, 0.0);
if (Math.abs(change) < 0.001) continue;
double nd = Math.abs(deltaMul - change);
if (nd < bestDiff) {
boolean ok = true;
int ca = result.get(loId) - 1, idxLo = sortedIds.indexOf(loId);
if (idxLo < sortedIds.size() - 1 && ca < result.get(sortedIds.get(idxLo + 1))) ok = false;
int ca2 = result.get(hiId) + 1, idxHi = sortedIds.indexOf(hiId);
if (idxHi > 0 && ca2 > result.get(sortedIds.get(idxHi - 1))) ok = false;
if (sortedIds.indexOf(loId) < sortedIds.indexOf(hiId) && ca < ca2) ok = false;
if (ok) { bestDiff = nd; bestLo = loId; bestHi = hiId; }
}
}
}
if (bestLo < 0) break;
result.put(bestLo, result.get(bestLo) - 1);
result.put(bestHi, result.get(bestHi) + 1);
}
return result;
}
/** 模拟 payoutCap 约束下的实际 RTP(%):桶的赔付期望按该箱池内实际均值算。CM ≡ 1。 */
public double simulateActualRtp(Map<Integer, Integer> distribution, Long boxId, double rtp, int simRounds) {
if (distribution == null || distribution.isEmpty() || simRounds <= 0) return 0;
Map<Integer, Double> bucketMul = loadPoolExpect(boxId);
double cm = 1.0;
if (boxId != null) {
MahjongBox box = boxMapper.selectById(boxId);
if (box != null) cm = MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier();
}
if (cm <= 0) return 0;
List<Double> mulList = new ArrayList<>();
for (Map.Entry<Integer, Integer> e : distribution.entrySet()) {
if (e.getValue() == null || e.getValue() <= 0) continue;
double mul = bucketMul.getOrDefault(e.getKey(), 0.0);
for (int i = 0; i < e.getValue(); i++) mulList.add(mul);
}
if (mulList.isEmpty()) return 0;
Random rnd = new Random();
double totalRtpSum = 0;
for (int round = 0; round < simRounds; round++) {
Collections.shuffle(mulList, rnd);
double balance = 0, totalPayout = 0, totalCost = 0;
for (double mul : mulList) {
double payoutCap = Math.max(0, balance + cm) * rtp;
double actualMul = (mul <= payoutCap) ? mul : 0;
balance += cm - actualMul;
totalPayout += actualMul;
totalCost += cm;
}
totalRtpSum += totalCost > 0 ? totalPayout / totalCost : 0;
}
return (totalRtpSum / simRounds) * 100;
}
/** 由固定池回合数反推总回合 N = round(fixed/(1-cr))。 */
public static int deriveTotalRounds(int fixedRounds, double convergeRatio) {
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
return (int) Math.round(fixedRounds / (1.0 - cr));
}
/** 由总回合 N 算固定池回合数 fixed = round(N*(1-cr))。 */
public static int deriveFixedRounds(int totalRounds, double convergeRatio) {
double cr = convergeRatio < 0 ? 0 : (convergeRatio > 0.9 ? 0.9 : convergeRatio);
return (int) Math.round(totalRounds * (1.0 - cr));
}
private static double convergeRatioOf(MahjongBox box) {
double cr = (box != null && box.getRtpConvergeRatio() != null) ? box.getRtpConvergeRatio().doubleValue() : 0.15;
if (cr < 0) cr = 0;
if (cr > 0.9) cr = 0.9;
return cr;
}
/** 重算某箱某身份分布:按目标 RTP 分配固定池 count,used 归 0。 */
@Transactional(rollbackFor = Exception.class)
public void reset(Long boxId, String audience) {
MahjongBox box = boxMapper.selectById(boxId);
if (box == null) return;
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
int totalRounds = box.getTotalRounds() != null ? box.getTotalRounds() : 200;
double targetRtp = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
int fixedRounds = deriveFixedRounds(totalRounds, convergeRatioOf(box));
if (fixedRounds < 1) fixedRounds = totalRounds;
Map<Integer, Integer> dist = calcRounds(mode, boxId, fixedRounds, targetRtp);
List<MahjongBoxBucket> list = new ArrayList<>();
for (Map.Entry<Integer, Integer> e : dist.entrySet()) {
if (e.getValue() == null || e.getValue() <= 0) continue;
MahjongBoxBucket bb = new MahjongBoxBucket();
bb.setBoxId(boxId);
bb.setBucketId(e.getKey());
bb.setAudience(audience);
bb.setCount(e.getValue());
bb.setUsed(0);
list.add(bb);
}
boxBucketMapper.deleteByBoxIdAndAudience(boxId, audience);
if (!list.isEmpty()) boxBucketMapper.batchInsert(list);
}
/** 应用模板分布到某箱某身份(count 取模板、used 归 0),并按固定池反推回写总回合。 */
@Transactional(rollbackFor = Exception.class)
public void writeDistribution(Long boxId, String audience, List<Map<String, Object>> items) {
boxBucketMapper.deleteByBoxIdAndAudience(boxId, audience);
int total = 0;
List<MahjongBoxBucket> list = new ArrayList<>();
if (items != null) {
for (Map<String, Object> item : items) {
Integer bucketId = toInt(item.get("bucketId"));
Integer count = toInt(item.get("count"));
if (bucketId == null || count == null || count <= 0) continue;
MahjongBoxBucket bb = new MahjongBoxBucket();
bb.setBoxId(boxId);
bb.setBucketId(bucketId);
bb.setAudience(audience);
bb.setCount(count);
bb.setUsed(0);
list.add(bb);
total += count;
}
}
if (!list.isEmpty()) boxBucketMapper.batchInsert(list);
MahjongBox box = boxMapper.selectById(boxId);
if (box != null) {
box.setTotalRounds(deriveTotalRounds(total, convergeRatioOf(box)));
boxMapper.updateById(box);
}
}
/** 某箱某身份抽干补桶:优先按该身份生效模板重写;否则仅 used 归 0。 */
@Transactional(rollbackFor = Exception.class)
public void refillFromConfig(Long boxId, String audience) {
MahjongBoxTemplate active = boxTemplateMapper.selectOne(
new LambdaQueryWrapper<MahjongBoxTemplate>()
.eq(MahjongBoxTemplate::getBoxId, boxId)
.eq(MahjongBoxTemplate::getAudience, audience)
.eq(MahjongBoxTemplate::getIsActive, 1)
.orderByDesc(MahjongBoxTemplate::getId).last("LIMIT 1"));
if (active != null && active.getItemsJson() != null) {
List<Map<String, Object>> items = parseItems(active.getItemsJson());
if (!items.isEmpty()) { writeDistribution(boxId, audience, items); return; }
}
boxBucketMapper.resetUsed(boxId, audience);
}
public List<MahjongBoxBucket> distribution(Long boxId, String audience) {
return boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
}
public List<Map<String, Object>> parseItems(String itemsJson) {
try {
return OBJECT_MAPPER.readValue(itemsJson, new TypeReference<List<Map<String, Object>>>() {});
} catch (Exception e) {
return new ArrayList<>();
}
}
public String toItemsJson(Object items) {
try { return OBJECT_MAPPER.writeValueAsString(items); } catch (Exception e) { return "[]"; }
}
private static Integer toInt(Object v) {
if (v == null) return null;
if (v instanceof Number) return ((Number) v).intValue();
try { return Integer.valueOf(v.toString().trim()); } catch (Exception e) { return null; }
}
}
@@ -0,0 +1,554 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.ruoyi.admin.mapper.TtBoxRecordsMapper;
import com.ruoyi.admin.mapper.TtOrnamentsLevelMapper;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.domain.common.constant.TtAccountRecordSource;
import com.ruoyi.domain.entity.TtBoxRecords;
import com.ruoyi.domain.entity.sys.TtUser;
import com.ruoyi.domain.other.TtOrnamentsLevel;
import com.ruoyi.game.mahjong.constants.MahjongConstants;
import com.ruoyi.game.mahjong.contract.request.SpinRequest;
import com.ruoyi.game.mahjong.contract.response.SpinResponse;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongRecord;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import com.ruoyi.game.mahjong.domain.MahjongUser;
import com.ruoyi.game.mahjong.enums.MahjongModeType;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongPrizeRangeMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRecordMapper;
import com.ruoyi.game.mahjong.mapper.MahjongUserMapper;
import com.ruoyi.game.mahjong.model.PickedRound;
import com.ruoyi.game.mahjong.model.cache.MahjongGameCache;
import com.ruoyi.game.mahjong.repository.MahjongCacheRepository;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import com.ruoyi.game.mahjong.repository.MahjongRewardPoolCacheRepository;
import com.ruoyi.message.service.MessageService;
import com.ruoyi.user.service.ApiUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.TimeUnit;
import static com.ruoyi.admin.config.RedisConstants.COMMON_LOCK;
/**
* 麻将胡了核心游戏服务:dospin 开局/播放/结算 + 多身份选池 + 按箱水位 + 白号隔离。
* <p>
* 与玫瑰女郎逐行同构,只有以下**有意差异**(其余一律照抄):
* <ol>
* <li>{@code cost = box.bet}(costMultiplier ≡ 1、无模式倍率、无投注倍数;betMul 只做预留且只接受 1);</li>
* <li>挑回合不带 needFreeSpins(没有买 FS 模式);</li>
* <li>单局封顶 {@code MAX_WIN_CAP_MUL = 500}(玫瑰女郎 1000);</li>
* <li>本波阶梯倍率字段是 {@code rotationMul}(母本的 accumulator 语义);</li>
* <li>流水/背包 source 待共享枚举补齐(见 MahjongConstants 的说明)。</li>
* </ol>
* 本类已完成开局事务/结算/续播/重连的完整骨架接线,业务细节(如 FS 的 spinsRemaining 口径)
* 随生成端 schema 落地后按 §6.4/§7.1 微调。
*/
@Slf4j
@Service
public class MahjongGameService {
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongRoundPicker roundPicker;
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRecordMapper recordMapper;
@Autowired private MahjongUserMapper userMapper;
@Autowired private MahjongPrizeRangeMapper prizeRangeMapper;
@Autowired private MahjongDistributionService distributionService;
@Autowired private MahjongCacheRepository cacheRepository;
@Autowired private MahjongCycleRepository cycleRepository;
@Autowired private MahjongRewardPoolCacheRepository rewardPoolRepository;
@Autowired private TtUserService userService;
@Autowired private ApiUserService apiUserService;
@Autowired private RedisLock redisLock;
@Autowired private TransactionTemplate transactionTemplate;
@Autowired(required = false) private TtBoxRecordsMapper boxRecordsMapper;
@Autowired(required = false) private TtOrnamentsLevelMapper ornamentsLevelMapper;
@Autowired private MessageService messageService;
private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
/** 单局封顶:总倍率 ≥ 此值视为已 500x 封顶 */
private static final BigDecimal MAX_WIN_CAP_MUL = BigDecimal.valueOf(MahjongConstants.MAX_WIN_CAP_MUL);
/** 免费旋转总旋数上限(首赠 12 + 每多 1 胡 +2,重触发同式;发不满就不发) */
private static final int MAX_FREE_SPINS = MahjongConstants.MAX_FREE_SPINS;
/**
* 需改共享文件的接线点(本次任务明确不改共享文件):
* {@code TtAccountRecordSource.MAHJONG_SPIN(68)}。共享枚举补齐后把本行换成 {@code MAHJONG_SPIN}。
* TODO(共享文件): 见 MahjongConstants#RECORD_SOURCE_SPIN。
*/
private static final TtAccountRecordSource SPIN_SOURCE = TtAccountRecordSource.OLYMPUS_SPIN;
// ============================================================
// dospin 主入口
// ============================================================
public SpinResponse doSpin(Long userId, SpinRequest req) throws Exception {
Boolean lock = redisLock.tryLock(COMMON_LOCK + userId, 3L, 7L, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(lock)) throw new RuntimeException("请求频繁,请稍后再试");
try {
MahjongGameCache cache = cacheRepository.getCache(userId);
return cache == null ? startNewGame(userId, req != null ? req.getBoxId() : null) : continueGame(userId, cache);
} finally {
redisLock.unlock(COMMON_LOCK + userId);
}
}
// ============================================================
// 新游戏:扣费 + 多身份选池 + 缓存步骤 + 返回第一步
// ============================================================
private SpinResponse startNewGame(Long userId, Long boxId) {
if (boxId == null) throw new IllegalArgumentException("boxId 不能为空");
MahjongBox box = boxMapper.selectById(boxId);
if (box == null || !"1".equals(box.getStatus())) throw new IllegalArgumentException("箱子不可用");
// 只有一个 normal 模式:cost = bet(无模式倍率、无投注倍数)
MahjongModeType mode = MahjongModeType.fromBoxType(box.getBoxType());
BigDecimal bet = coalesce(box.getBet());
BigDecimal cost = bet.multiply(BigDecimal.valueOf(mode.getCostMultiplier()));
R<Void> rechargeCheck = apiUserService.checkRecharge(userId.intValue());
if (R.isError(rechargeCheck)) throw new ServiceException("玩家未充值不能游玩任何游戏", 703);
TtUser player = userService.getById(userId);
if (player == null || player.getAccountAmount() == null || player.getAccountAmount().compareTo(cost) < 0) {
throw new ServiceException("账户余额不足", 702);
}
// 身份解析:本模块自带一份与 OlympusAudience 逐字同构的判定(见 MahjongAudience 的说明)
com.ruoyi.game.mahjong.enums.MahjongAudience audience =
com.ruoyi.game.mahjong.enums.MahjongAudience.resolve(player);
boolean isBlank = audience.isWhite();
// 没有 buy_fs 模式 → 永远不需要"必须有 FS"的回合
Boolean needFreeSpins = null;
// 水位赔付上限:真实玩家按 (本箱水位 + 本局成本) × rtp 约束;白号 payoutCap=null(纯概率、不受水位)
BigDecimal payoutCap = null;
if (!isBlank) {
double ratio = box.getRtp() != null ? box.getRtp().doubleValue() : 0.8;
BigDecimal balance = rewardPoolRepository.getBoxBalance(boxId, audience.getCode()).add(cost);
if (balance.signum() < 0) balance = BigDecimal.ZERO;
payoutCap = scale2(balance.multiply(BigDecimal.valueOf(ratio)));
}
PickedRound picked = roundPicker.pick(boxId, audience.getCode(), needFreeSpins, bet, payoutCap);
if (picked == null || picked.getRound() == null) throw new RuntimeException("该箱子暂无可用回合");
BigDecimal totalMul = coalesce(picked.getRound().getActualMul());
BigDecimal totalWin = scale2(totalMul.multiply(bet));
List<MahjongGameCache.CachedStep> cachedSteps = new ArrayList<>();
for (MahjongStep s : picked.getSteps()) {
MahjongGameCache.CachedStep cs = new MahjongGameCache.CachedStep();
cs.setReelSpinId(coalesce0(s.getReelSpinId()));
cs.setRotationSeq(coalesce0(s.getRotationSeq()));
cs.setGridState(s.getGridState());
cs.setGoldenDetail(s.getGoldenDetail());
cs.setWildConverted(s.getWildConverted());
cs.setWaysDetail(s.getWaysDetail());
cs.setWinAmount(s.getWinAmount());
cs.setSpinWin(coalesce(s.getSpinWin()));
cs.setRotationMul(coalesce(s.getRotationMul()));
cs.setWinCapped(coalesce0(s.getWinCapped()));
cs.setBigwinLevel(s.getBigwinLevel());
cs.setIsFinal(coalesce0(s.getIsFinal()));
cs.setRetriggerInfo(s.getRetriggerInfo());
cs.setDropData(s.getDropData());
cs.setFillData(s.getFillData());
cs.setScatterTrigger(s.getScatterTrigger());
cachedSteps.add(cs);
}
MahjongGameCache cache = new MahjongGameCache();
cache.setBoxId(boxId);
cache.setBoxType(box.getBoxType());
cache.setBet(bet);
cache.setCost(cost);
cache.setBetMul(BigDecimal.ONE);
cache.setRoundId(picked.getRound().getRoundId());
cache.setTotalMul(totalMul);
cache.setFreeSpinCount(coalesce0(picked.getRound().getFreeSpinCount()));
cache.setStepIndex(1);
cache.setSteps(cachedSteps);
cache.setTotalWin(totalWin);
cache.setAudience(audience.getCode());
cache.setBlankAccount(isBlank);
boolean firstIsFinal = cachedSteps.size() == 1;
transactionTemplate.executeWithoutResult(status -> {
R<?> deductR = userService.updateUserAccount(userId.intValue(), cost.negate(), SPIN_SOURCE);
if (R.isError(deductR)) throw new RuntimeException(deductR.getMsg());
MahjongRecord record = new MahjongRecord();
record.setUserId(userId);
record.setBoxId(boxId);
record.setRoundId(picked.getRound().getRoundId());
record.setCost(cost);
record.setBetMul(BigDecimal.ONE);
record.setTotalWin(BigDecimal.ZERO);
record.setModeType(box.getBoxType());
record.setAudience(audience.getCode()); // 身份快照落库:水位按 箱×身份 聚合,事后可追溯
record.setSettled(0);
record.setCreateTime(new Date());
recordMapper.insert(record);
cache.setRecordId(record.getId());
// 仅固定阶段消耗对应 audience 一个回合(白号消耗自己的 white 库存,不碰其他身份)
if (!picked.isConverge()) {
boxBucketMapper.incrUsed(boxId, picked.getRound().getBucketId(), audience.getCode());
}
// 开局即结算:回合结果已确定,立即将饰品写入背包(与扣费同事务,中途退出/刷新无损)
settleAtStart(userId, cache);
});
// 事务已提交(钱已扣、奖已落库)。以下全是 Redis 缓存/节奏账本/补桶,非资金操作:
// 一旦失败必须吞掉——否则接口返回 500,玩家以为没开局而重试,会再开一局、再扣一次钱。
try {
cycleRepository.recordDraw(boxId, audience.getCode(), picked.getRound().getBucketId(),
coalesce(picked.getRound().getActualMul()), picked.isConverge());
int fixedSum = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience.getCode()).stream()
.mapToInt(bb -> bb.getCount() != null ? bb.getCount() : 0).sum();
double cr = box.getRtpConvergeRatio() != null ? box.getRtpConvergeRatio().doubleValue() : 0.15;
int cycleN = fixedSum > 0 ? MahjongDistributionService.deriveTotalRounds(fixedSum, cr) : coalesce0(box.getTotalRounds());
if (cycleN > 0 && cycleRepository.drawn(boxId, audience.getCode()) >= cycleN) {
cycleRepository.resetCycle(boxId, audience.getCode());
distributionService.refillFromConfig(boxId, audience.getCode());
}
if (firstIsFinal) {
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal(MahjongConstants.FLOOR_WIN));
cacheRepository.clearCache(userId);
} else {
cacheRepository.setCache(userId, cache);
}
} catch (Exception e) {
log.error("mahjong 开局后置处理失败(扣费与发奖已提交,不影响本局结果)userId={} boxId={}", userId, boxId, e);
}
// 单步局开局即播完,通知此刻发;多步局等末帧(continueGame)
if (firstIsFinal) sendWinNotice(userId, cache);
return buildStepResponse(cache, cachedSteps.get(0), 1, firstIsFinal);
}
// ============================================================
// 继续游戏:取下一步
// ============================================================
private SpinResponse continueGame(Long userId, MahjongGameCache cache) {
int nextIdx = cache.getStepIndex() + 1;
if (nextIdx > cache.getSteps().size()) {
cacheRepository.clearCache(userId);
throw new RuntimeException("游戏已结束");
}
MahjongGameCache.CachedStep step = cache.getSteps().get(nextIdx - 1);
cache.setStepIndex(nextIdx);
if (nextIdx == cache.getSteps().size()) {
if (cache.getTotalWin().compareTo(BigDecimal.ZERO) <= 0) cache.setTotalWin(new BigDecimal(MahjongConstants.FLOOR_WIN));
// 末帧已播完:这一局才算结束,战绩列表(我的/全服)才会展示
recordMapper.update(null, new LambdaUpdateWrapper<MahjongRecord>()
.eq(MahjongRecord::getId, cache.getRecordId())
.set(MahjongRecord::getSettleSource, "finish"));
// 末帧到了才发结算通知(开局发会在动画播完前剧透),失败只记日志不影响本局
sendWinNotice(userId, cache);
// 已在开局结算入背包,末帧仅揭晓;回填 boxRecordId 供前端选择保留/立即分解
SpinResponse resp = buildStepResponse(cache, step, nextIdx, true);
cacheRepository.clearCache(userId);
return resp;
}
cacheRepository.setCache(userId, cache);
return buildStepResponse(cache, step, nextIdx, false);
}
/**
* 结算通知(站内信):只在"这一局播完"的时刻发——单步局=开局,多步局=末帧。
* 开局就发会在动画播完前把结果剧透给玩家(饰品开局已入背包,通知是唯一会提前泄露的通道)。
*/
private void sendWinNotice(Long userId, MahjongGameCache cache) {
if (cache.getRecordId() == null) return;
try {
MahjongRecord rec = recordMapper.selectById(cache.getRecordId());
if (rec == null || rec.getPrizeName() == null) return;
String priceStr = rec.getSettledWin() == null ? "0" : rec.getSettledWin().stripTrailingZeros().toPlainString();
// TODO(共享文件): 共享枚举补上 MessageCategoryEnum.GAME_MAHJONG 后,改用它的 getTitle()/getCode()
messageService.sendNotice(
userId,
MahjongConstants.MESSAGE_CATEGORY_TITLE,
"麻将胡了挑战结算:您本局获得饰品 " + rec.getPrizeName() + "(价值 ¥" + priceStr + ")",
MahjongConstants.MESSAGE_CATEGORY);
} catch (Exception e) {
log.error("mahjong 结算通知发送失败 userId={} recordId={}", userId, cache.getRecordId(), e);
}
}
// ============================================================
// 开局即结算(事务中,饰品直接写入背包,中途退出/刷新无损)
// ============================================================
private void settleAtStart(Long userId, MahjongGameCache cache) {
BigDecimal totalWin = cache.getTotalWin();
// 空转局也发最低档饰品:win = max(totalWin, 0.01)(必得饰品兜底,与玫瑰女郎逐行一致)
if (totalWin == null || totalWin.compareTo(BigDecimal.ZERO) <= 0) totalWin = new BigDecimal(MahjongConstants.FLOOR_WIN);
BigDecimal win = scale2(totalWin);
Map<String, Object> prize = findPrizeWithFallback(win);
Long prizeId = asLong(prize != null ? prize.get("id") : null);
String prizeName = prize != null ? (String) prize.get("name") : null;
Integer prizeGrade = prize != null && prize.get("grade") instanceof Integer ? (Integer) prize.get("grade") : 1;
String prizeImageUrl = prize != null ? (String) prize.get("imageUrl") : null;
String prizeExteriorName = prize != null ? (String) prize.get("exteriorName") : null;
String prizeLevelImg = prize != null ? (String) prize.get("levelImageUrl") : null;
// 幂等守卫。settle_source:开局写 start,客户端播完末帧才改 finish(战绩列表只展示已结束的局);
// 单步局开局即播完,直接写 finish。
boolean playedOut = cache.getSteps() == null || cache.getSteps().size() <= 1;
int guard = recordMapper.update(null, new LambdaUpdateWrapper<MahjongRecord>()
.eq(MahjongRecord::getId, cache.getRecordId())
.eq(MahjongRecord::getSettled, 0)
.set(MahjongRecord::getSettled, 1)
.set(MahjongRecord::getRewardType, "ornament")
.set(MahjongRecord::getTotalWin, totalWin)
.set(MahjongRecord::getSettledWin, win)
.set(MahjongRecord::getPrizeId, prizeId)
.set(MahjongRecord::getSettleSource, playedOut ? "finish" : "start")
.set(MahjongRecord::getPrizeName, prizeName)
.set(MahjongRecord::getPrizeImageUrl, prizeImageUrl)
.set(MahjongRecord::getPrizeLevelImg, prizeLevelImg));
if (guard == 0) { cacheRepository.clearCache(userId); return; }
// 写入背包
// TODO(共享文件): 共享枚举补上 TtboxRecordSource.MAHJONG_BOX(15) 后,
// 把 MahjongConstants.BOX_RECORD_SOURCE 换成 MAHJONG_BOX.getCode()(数值已经是 15,不会漂)。
if (boxRecordsMapper != null && prizeId != null) {
if (prizeLevelImg == null && ornamentsLevelMapper != null) {
TtOrnamentsLevel lv = ornamentsLevelMapper.selectById(prizeGrade);
if (lv != null) prizeLevelImg = lv.getLevelImg();
}
TtBoxRecords bi = TtBoxRecords.builder()
.userId(userId.intValue()).holderUserId(userId.intValue())
.boxId(cache.getBoxId() != null ? cache.getBoxId().intValue() : 0)
.boxName(MahjongConstants.BOX_NAME).boxPrice(cache.getCost())
.ornamentId(prizeId).ornamentsPrice(win)
.ornamentName(prizeName).imageUrl(prizeImageUrl)
.marketHashName(prizeExteriorName != null ? prizeName + " (" + prizeExteriorName + ")" : prizeName)
.ornamentsLevelId(prizeGrade != null ? prizeGrade : 1)
.ornamentLevelImg(prizeLevelImg)
.source(MahjongConstants.BOX_RECORD_SOURCE)
.status(0).isShow(1).createTime(new Date())
.build();
boxRecordsMapper.insert(bi);
cache.setBoxRecordId(bi.getId());
}
// 更新用户统计
MahjongUser user = userMapper.selectByUserId(userId);
if (user == null) {
user = new MahjongUser();
user.setUserId(userId);
user.setTotalSpins(1L);
user.setTotalCost(cache.getCost());
user.setTotalWin(totalWin);
user.setMaxWinMul(cache.getTotalMul());
userMapper.insert(user);
} else {
user.setTotalSpins(coalesceL(user.getTotalSpins()) + 1L);
user.setTotalCost(coalesce(user.getTotalCost()).add(cache.getCost()));
user.setTotalWin(coalesce(user.getTotalWin()).add(totalWin));
if (cache.getTotalMul() != null && cache.getTotalMul().compareTo(coalesce(user.getMaxWinMul())) > 0)
user.setMaxWinMul(cache.getTotalMul());
userMapper.updateById(user);
}
}
// ============================================================
// 断线重连
// ============================================================
public Map<String, Object> hasActive(Long userId) {
MahjongGameCache cache = cacheRepository.getCache(userId);
// 开局即结算模型:无缓存即无进行中动画可恢复(饰品已在背包),直接返回
if (cache == null) return Map.of("hasActive", false);
List<MahjongGameCache.CachedStep> steps = cache.getSteps();
int played = Math.min(Math.max(cache.getStepIndex(), 0), steps.size());
BigDecimal bet = coalesce(cache.getBet());
// 已播累计直接取最后一步的 spin_win(本局累计),不能逐帧加 win_amount:
// win_amount 是"本旋内累计",同一旋的每一波都是同一个累计量的快照,相加会把中间波重复计入。
BigDecimal playedWin = played > 0
? scale2(coalesce(steps.get(played - 1).getSpinWin()).multiply(bet))
: BigDecimal.ZERO;
Map<String, Object> result = new HashMap<>();
result.put("hasActive", true);
result.put("boxId", cache.getBoxId());
result.put("boxType", cache.getBoxType());
result.put("bet", cache.getBet());
result.put("cost", cache.getCost());
result.put("stepIndex", cache.getStepIndex());
result.put("totalSteps", steps.size());
result.put("freeSpinCount", cache.getFreeSpinCount());
result.put("spinsRemaining", spinsRemainingAfter(steps, played));
result.put("totalWin", scale2(playedWin));
result.put("roundId", cache.getRoundId());
// 客户端用 lastStep.rotationMul 接回当前波次(否则重连后阶梯会从第一档重算)
if (played > 0) result.put("lastStep", buildStepResponse(cache, steps.get(played - 1), played, false));
return result;
}
// ============================================================
// 构建单步响应
// ============================================================
private SpinResponse buildStepResponse(MahjongGameCache cache, MahjongGameCache.CachedStep cs, int idx, boolean isFinal) {
BigDecimal bet = coalesce(cache.getBet());
SpinResponse resp = new SpinResponse();
resp.setState(isFinal ? "FINISH" : "PLAYING");
resp.setRotationNum(cs.getRotationSeq());
resp.setReelSpinNum(cs.getReelSpinId());
resp.setGridState(toJson(cs.getGridState()));
resp.setGoldenDetail(toJson(cs.getGoldenDetail()));
resp.setWildConverted(toJson(cs.getWildConverted()));
// ways_detail 里的 win 是"本笔金额",按 bet 放大;payBase/ways 只留档不下发金额
resp.setWaysDetail(scaleMoneyList(cs.getWaysDetail(), bet, new String[]{"win"}));
resp.setDropData(toJson(cs.getDropData()));
resp.setFillData(toJson(cs.getFillData()));
resp.setScatterTrigger(toJson(cs.getScatterTrigger()));
resp.setWinAmount(scale2(coalesce(cs.getWinAmount()).multiply(bet)));
resp.setSpinWin(scale2(coalesce(cs.getSpinWin()).multiply(bet)));
// 本波阶梯倍率(未 ×bet):重连时前端靠它接回档位
resp.setRotationMul(scale2(coalesce(cs.getRotationMul())));
if (cs.getRetriggerInfo() != null && !cs.getRetriggerInfo().isEmpty())
resp.setRetriggerInfo(toJson(cs.getRetriggerInfo()));
// 爆奖动画:py 已按单旋赢额判档写入 bigwin_level,直接透传
if (cs.getBigwinLevel() != null && !cs.getBigwinLevel().isEmpty()) resp.setBigWinLevel(cs.getBigwinLevel());
resp.setGlobalStep(idx);
resp.setGlobalTotalSteps(cache.getSteps().size());
// 剩余免费旋转:每帧都给(前端直接显示倒计);500x 封顶时下面覆盖成 0
resp.setSpinsRemaining(spinsRemainingAfter(cache.getSteps(), idx));
if (isFinal) {
resp.setTotalWin(scale2(cache.getTotalWin()));
resp.setTotalMul(scale2(cache.getTotalMul()));
// 封顶判定:totalMul ≥ 500 → maxWinReached=true、spinsRemaining=0
if (coalesce(cache.getTotalMul()).compareTo(MAX_WIN_CAP_MUL) >= 0) {
resp.setMaxWinReached(true);
resp.setSpinsRemaining(0);
}
// 开局已结算入背包:末帧揭晓饰品 + 回填 boxRecordId 供前端选择保留/立即分解
try {
BigDecimal win = scale2(cache.getTotalWin());
if (win.compareTo(BigDecimal.ZERO) <= 0) win = new BigDecimal(MahjongConstants.FLOOR_WIN);
Map<String, Object> prize = findPrizeWithFallback(win);
if (prize != null && !prize.isEmpty()) resp.setPrizeInfo(prize);
} catch (Exception ignore) {}
resp.setBoxRecordId(cache.getBoxRecordId());
resp.setRecordId(cache.getRecordId());
}
return resp;
}
// ============================================================
// 工具
// ============================================================
private Map<String, Object> findPrizeWithFallback(BigDecimal totalWin) {
Map<String, Object> prize = prizeRangeMapper.findPrizeByWin(totalWin);
if (prize != null && !prize.isEmpty()) { prize.put("isFallback", false); return prize; }
prize = prizeRangeMapper.findFallbackPrize();
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findLowestPrize();
if (prize == null || prize.isEmpty()) prize = prizeRangeMapper.findHighestPrize();
if (prize != null) prize.put("isFallback", true);
return prize;
}
private static BigDecimal scale2(BigDecimal v) { return v == null ? BigDecimal.ZERO : v.setScale(2, java.math.RoundingMode.HALF_UP); }
private static BigDecimal coalesce(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static int coalesce0(Integer v) { return v != null ? v : 0; }
private static long coalesceL(Long v) { return v != null ? v : 0L; }
private static Long asLong(Object v) {
if (v == null) return null;
if (v instanceof Long) return (Long) v;
if (v instanceof Number) return ((Number) v).longValue();
try { return Long.valueOf(v.toString()); } catch (Exception e) { return null; }
}
private static Object toJson(String s) {
if (s == null || s.isEmpty()) return null;
try { return MAPPER.readTree(s); } catch (Exception e) { return s; }
}
/**
* 剩余免费旋转 = 已发放 − 已播完的 FS 旋数(played = 算到第几步,含这一步;中间波帧不减,旋末帧才 −1)。
* 已发放只算玩家**已经看到**的:触发帧的 freeSpins(12/14/16)+ 已播步里的 retriggerInfo.extraSpins,
* 封顶 36(封顶后 3+ 个胡的旋仍会写 retrigger_info 但不再加旋,所以这里必须夹 36)。
* TODO(生成端): retrigger_info 的旋数额度字段名待与 py 端 schema 对齐后微调。
*/
private static int spinsRemainingAfter(List<MahjongGameCache.CachedStep> steps, int played) {
int awarded = 0, completed = 0;
for (int i = 0; i < played && i < steps.size(); i++) {
MahjongGameCache.CachedStep s = steps.get(i);
awarded += jsonInt(s.getScatterTrigger(), "freeSpins") + jsonInt(s.getRetriggerInfo(), "extraSpins");
if (s.getIsFinal() == 1 && s.getReelSpinId() > 0) completed++;
}
return Math.max(0, Math.min(awarded, MAX_FREE_SPINS) - completed);
}
/** 取 JSON 对象里的整数字段(缺失 / null / 解析失败一律按 0) */
private static int jsonInt(String json, String key) {
if (json == null || json.isEmpty()) return 0;
try {
com.fasterxml.jackson.databind.JsonNode node = MAPPER.readTree(json).get(key);
return node == null ? 0 : node.asInt();
} catch (Exception e) { return 0; }
}
/** 解析 JSON 数组,把指定金额字段 ×bet */
@SuppressWarnings("unchecked")
private static Object scaleMoneyList(String json, BigDecimal bet, String[] keys) {
if (json == null || json.isEmpty()) return null;
try {
List<Map<String, Object>> list = MAPPER.readValue(json, List.class);
for (Map<String, Object> m : list) scaleKeys(m, bet, keys);
return list;
} catch (Exception e) { return toJson(json); }
}
private static void scaleKeys(Map<String, Object> m, BigDecimal bet, String[] keys) {
for (String key : keys) {
Object v = m.get(key);
if (v instanceof Number) {
m.put(key, BigDecimal.valueOf(((Number) v).doubleValue())
.multiply(coalesce(bet)).setScale(2, java.math.RoundingMode.HALF_UP));
}
}
}
/** 校验预留的投注倍数:当前服务端只接受 1(缺省 1);非 1 直接拒绝,不影响任何池子结构。 */
public static void assertBetMulSupported(Integer betMul) {
if (betMul != null && betMul != 1) {
throw new ServiceException("暂不支持投注倍数", 500);
}
}
}
@@ -0,0 +1,280 @@
package com.ruoyi.game.mahjong.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.game.mahjong.domain.MahjongBox;
import com.ruoyi.game.mahjong.domain.MahjongBoxBucket;
import com.ruoyi.game.mahjong.domain.MahjongRound;
import com.ruoyi.game.mahjong.domain.MahjongStep;
import com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper;
import com.ruoyi.game.mahjong.mapper.MahjongBoxMapper;
import com.ruoyi.game.mahjong.mapper.MahjongRoundMapper;
import com.ruoyi.game.mahjong.mapper.MahjongStepMapper;
import com.ruoyi.game.mahjong.model.PickedRound;
import com.ruoyi.game.mahjong.repository.MahjongCycleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
/**
* 麻将胡了回合选择器(多身份 + 两阶段 + 白号隔离)。
*
* 每个身份(audience)读自己的桶分布与轮次账本:
* - 白号(payoutCap==null):纯概率原逻辑,不进两阶段、不受水位约束,但消耗自己的 white 库存(isConverge=false);
* - 真实身份(payoutCap!=null):固定阶段(payoutCap 过滤+剩余加权) → 收敛阶段(按 desiredNextMul 在水位内选桶)。
*
* 与玫瑰女郎的差异:{@code costMultiplier ≡ 1}(所以 targetMul = rtp,不再 ×CM),
* 且调用方**永远不传 needFreeSpins**(没有"买 FS"模式)——保留该参数只为与母本同构。
*/
@Service
public class MahjongRoundPicker {
@Autowired private MahjongBoxBucketMapper boxBucketMapper;
@Autowired private MahjongRoundMapper roundMapper;
@Autowired private MahjongStepMapper stepMapper;
@Autowired private MahjongDistributionService distributionService;
@Autowired private MahjongBoxMapper boxMapper;
@Autowired private MahjongCycleRepository cycleRepository;
private static final double DEFAULT_CONVERGE_RATIO = 0.15;
public PickedRound pick(Long boxId, String audience, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
List<MahjongBoxBucket> dist = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
if (dist.isEmpty()) throw new RuntimeException("Box " + boxId + " audience=" + audience + " 暂无倍率桶分布数据");
// 白号 / 无水位约束:纯概率原逻辑(消耗自己的库存,不受水位)
if (payoutCap == null) {
int totalRemaining = dist.stream().mapToInt(bb -> safe(bb.getRemaining())).sum();
if (totalRemaining <= 0) {
distributionService.refillFromConfig(boxId, audience);
dist = boxBucketMapper.selectByBoxIdAndAudience(boxId, audience);
}
Integer bucketId = weightedPickByRemaining(dist);
if (bucketId == null) bucketId = dist.get(0).getBucketId();
MahjongRound r = resolveRound(boxId, bucketId, needFreeSpins, bet, null);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
}
// 真实玩家:两阶段
MahjongBox box = boxMapper.selectById(boxId);
double cm = 1.0, rtp = 0.8, convergeRatio = DEFAULT_CONVERGE_RATIO;
if (box != null) {
try { cm = com.ruoyi.game.mahjong.enums.MahjongModeType.fromBoxType(box.getBoxType()).getCostMultiplier(); } catch (Exception ignore) {}
if (box.getRtp() != null) rtp = box.getRtp().doubleValue();
if (box.getRtpConvergeRatio() != null) convergeRatio = box.getRtpConvergeRatio().doubleValue();
}
if (convergeRatio < 0) convergeRatio = 0;
if (convergeRatio > 0.9) convergeRatio = 0.9;
// 一轮总回合 N 按该身份固定池分布反推(身份级独立),无分布时回退箱子级 total_rounds
int fixedSum = dist.stream().mapToInt(bb -> safe(bb.getCount())).sum();
int N;
if (fixedSum > 0) {
N = (int) Math.round(fixedSum / (1.0 - convergeRatio));
} else {
N = (box != null && box.getTotalRounds() != null && box.getTotalRounds() > 0)
? box.getTotalRounds() : fixedSum;
}
int fixedRounds = (int) Math.round(N * (1 - convergeRatio));
long drawn = cycleRepository.drawn(boxId, audience);
int totalRemaining = dist.stream().mapToInt(bb -> safe(bb.getRemaining())).sum();
// 固定阶段
if (drawn < fixedRounds && totalRemaining > 0) {
Integer bucketId = pickFixedBucketId(dist, bet, payoutCap);
if (bucketId == null) bucketId = dist.get(0).getBucketId();
MahjongRound r = resolveRound(boxId, bucketId, needFreeSpins, bet, payoutCap);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), false);
}
// 收敛阶段
double targetMul = rtp * cm;
long remainingDraws = Math.max(1, N - drawn);
double remainingBudget = N * targetMul - cycleRepository.mulSum(boxId, audience);
double desiredNextMul = Math.max(0, remainingBudget / remainingDraws);
MahjongRound r = pickConverge(boxId, dist, needFreeSpins, bet, payoutCap, desiredNextMul);
return new PickedRound(r, stepMapper.selectByRoundId(r.getRoundId()), true);
}
private Integer pickFixedBucketId(List<MahjongBoxBucket> dist, BigDecimal bet, BigDecimal payoutCap) {
List<MahjongBoxBucket> eligible = new ArrayList<>();
for (MahjongBoxBucket bb : dist) {
if (Math.max(0, safe(bb.getRemaining())) <= 0) continue;
if (expectPayout(bb, bet).compareTo(payoutCap) <= 0) eligible.add(bb);
}
return !eligible.isEmpty() ? weightedPickByRemaining(eligible) : minPayoutBucketId(dist, bet);
}
private MahjongRound resolveRound(Long boxId, Integer bucketId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
MahjongRound round = pickRoundWithCap(boxId, bucketId, needFreeSpins, bet, payoutCap);
if (round == null) round = copyFromSameBucket(boxId, bucketId, needFreeSpins);
if (round == null && needFreeSpins != null) {
round = roundMapper.pickByBucket(boxId, bucketId, null);
if (round == null) round = copyFromSameBucket(boxId, bucketId, null);
}
if (round == null) throw new RuntimeException("暂无可用回合数据");
return round;
}
/** 桶内随机取一条,真实玩家还要 actual_mul×bet ≤ payoutCap(最多重抽 5 次,抽不到就退回原条) */
private MahjongRound pickRoundWithCap(Long boxId, Integer bucketId, Boolean needFreeSpins, BigDecimal bet, BigDecimal payoutCap) {
MahjongRound round = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (round == null || payoutCap == null) return round;
BigDecimal b = bet != null ? bet : BigDecimal.ZERO;
if (withinCap(round, b, payoutCap)) return round;
for (int i = 0; i < 5; i++) {
MahjongRound r = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (r != null && withinCap(r, b, payoutCap)) return r;
}
return round;
}
private MahjongRound pickConverge(Long boxId, List<MahjongBoxBucket> dist, Boolean needFreeSpins,
BigDecimal bet, BigDecimal payoutCap, double desiredNextMul) {
BigDecimal b = (bet != null && bet.signum() > 0) ? bet : BigDecimal.ONE;
List<MahjongBoxBucket> allowed = new ArrayList<>();
for (MahjongBoxBucket bb : dist) {
if (expectPayout(bb, b).compareTo(payoutCap) <= 0) allowed.add(bb);
}
MahjongBoxBucket chosen = allowed.isEmpty() ? minExpectMul(dist) : closestExpectMul(allowed, desiredNextMul);
Integer bucketId = chosen.getBucketId();
double capMul = payoutCap.doubleValue() / b.doubleValue();
double effectiveDesired = Math.min(desiredNextMul, capMul);
BigDecimal desiredBd = BigDecimal.valueOf(effectiveDesired).setScale(4, RoundingMode.HALF_UP);
BigDecimal capBd = BigDecimal.valueOf(capMul).setScale(4, RoundingMode.HALF_UP);
MahjongRound round = roundMapper.pickClosestMul(boxId, bucketId, needFreeSpins, desiredBd, capBd);
if (round == null && needFreeSpins != null) round = roundMapper.pickClosestMul(boxId, bucketId, null, desiredBd, capBd);
if (round == null) round = copyFromSameBucket(boxId, bucketId, needFreeSpins);
if (round == null) round = copyFromSameBucket(boxId, bucketId, null);
if (round == null) throw new RuntimeException("收敛阶段暂无可用回合数据");
return round;
}
private MahjongBoxBucket closestExpectMul(List<MahjongBoxBucket> list, double desired) {
List<MahjongBoxBucket> sorted = new ArrayList<>(list);
sorted.sort(Comparator.comparingDouble(bb -> {
double mul = bb.getExpectMul() != null ? bb.getExpectMul().doubleValue() : 0;
return Math.abs(mul - desired);
}));
int pickCount = Math.min(3, sorted.size());
return sorted.get(ThreadLocalRandom.current().nextInt(pickCount));
}
private MahjongBoxBucket minExpectMul(List<MahjongBoxBucket> list) {
MahjongBoxBucket best = list.get(0);
double bestMul = Double.MAX_VALUE;
for (MahjongBoxBucket bb : list) {
double mul = bb.getExpectMul() != null ? bb.getExpectMul().doubleValue() : 0;
if (mul < bestMul) { bestMul = mul; best = bb; }
}
return best;
}
/** 同桶复制已有回合(新ID,动画数据原样拷贝),保证一定有回合可用。 */
private MahjongRound copyFromSameBucket(Long boxId, Integer bucketId, Boolean needFreeSpins) {
MahjongRound template = null;
for (int i = 0; i < 10; i++) {
template = roundMapper.pickByBucket(boxId, bucketId, needFreeSpins);
if (template != null && nz(template.getActualMul()).signum() > 0) break;
}
if (template == null) {
for (int i = 0; i < 10; i++) {
template = roundMapper.pickAnyByBox(boxId, needFreeSpins);
if (template != null && nz(template.getActualMul()).signum() > 0) break;
}
if (template == null) return null;
}
List<MahjongStep> templateSteps = stepMapper.selectByRoundId(template.getRoundId());
Long newRoundId = nextRoundId();
long newStepId = nextStepId();
for (MahjongStep ts : templateSteps) {
MahjongStep ns = new MahjongStep();
ns.setId(newStepId++);
ns.setRoundId(newRoundId);
ns.setReelSpinId(ts.getReelSpinId());
ns.setRotationSeq(ts.getRotationSeq());
ns.setGridState(ts.getGridState());
ns.setGoldenDetail(ts.getGoldenDetail());
ns.setWildConverted(ts.getWildConverted());
ns.setWaysDetail(ts.getWaysDetail());
ns.setAccumulatedSymbols(ts.getAccumulatedSymbols());
ns.setSpinWinners(ts.getSpinWinners());
ns.setRemovedPositions(ts.getRemovedPositions());
ns.setDropData(ts.getDropData());
ns.setFillData(ts.getFillData());
ns.setScatterTrigger(ts.getScatterTrigger());
ns.setRetriggerInfo(ts.getRetriggerInfo());
ns.setBaseScore(ts.getBaseScore());
ns.setWinAmount(ts.getWinAmount());
ns.setRotationMul(ts.getRotationMul());
ns.setSpinWin(ts.getSpinWin());
ns.setWinCapped(ts.getWinCapped());
ns.setBigwinLevel(ts.getBigwinLevel());
ns.setIsFinal(ts.getIsFinal());
stepMapper.insert(ns);
}
MahjongRound copy = new MahjongRound();
copy.setRoundId(newRoundId);
copy.setBoxId(boxId);
copy.setBucketId(bucketId);
copy.setActualMul(template.getActualMul());
copy.setFreeSpinCount(template.getFreeSpinCount());
copy.setTotalRotations(template.getTotalRotations());
copy.setHasFs(template.getHasFs());
copy.setNearMiss(template.getNearMiss());
copy.setCreateTime(new Date());
roundMapper.insert(copy);
return copy;
}
private Long nextRoundId() {
List<MahjongRound> max = roundMapper.selectList(
new LambdaQueryWrapper<MahjongRound>().orderByDesc(MahjongRound::getRoundId).last("limit 1"));
return max.isEmpty() ? 1L : max.get(0).getRoundId() + 1;
}
private Long nextStepId() {
List<MahjongStep> max = stepMapper.selectList(
new LambdaQueryWrapper<MahjongStep>().orderByDesc(MahjongStep::getId).last("limit 1"));
return max.isEmpty() ? 1L : max.get(0).getId() + 1;
}
private BigDecimal expectPayout(MahjongBoxBucket bb, BigDecimal bet) {
BigDecimal mul = bb.getExpectMul() != null ? bb.getExpectMul() : BigDecimal.ZERO;
return mul.multiply(bet != null ? bet : BigDecimal.ZERO);
}
private Integer weightedPickByRemaining(List<MahjongBoxBucket> list) {
if (list.isEmpty()) return null;
int totalWeight = list.stream().mapToInt(bb -> Math.max(0, safe(bb.getRemaining()))).sum();
if (totalWeight <= 0) return list.get(0).getBucketId();
int r = ThreadLocalRandom.current().nextInt(totalWeight);
int acc = 0;
for (MahjongBoxBucket bb : list) {
acc += Math.max(0, safe(bb.getRemaining()));
if (r < acc) return bb.getBucketId();
}
return list.get(0).getBucketId();
}
private Integer minPayoutBucketId(List<MahjongBoxBucket> dist, BigDecimal bet) {
MahjongBoxBucket best = null;
for (MahjongBoxBucket bb : dist) {
if (Math.max(0, safe(bb.getRemaining())) <= 0) continue;
if (best == null || expectPayout(bb, bet).compareTo(expectPayout(best, bet)) < 0) best = bb;
}
return best != null ? best.getBucketId() : dist.get(0).getBucketId();
}
private boolean withinCap(MahjongRound round, BigDecimal bet, BigDecimal payoutCap) {
BigDecimal mul = nz(round.getActualMul());
return mul.signum() > 0 && mul.multiply(bet).compareTo(payoutCap) <= 0;
}
private static BigDecimal nz(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
private static int safe(Integer v) { return v != null ? v : 0; }
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.game.mahjong.mapper.MahjongBoxBucketMapper">
<!-- 按 (boxId, audience) 查桶分布,连表带出 expectMul,计算 remaining=count-used -->
<select id="selectByBoxIdAndAudience" resultType="com.ruoyi.game.mahjong.domain.MahjongBoxBucket">
SELECT bb.*, b.name AS bucketName, b.expect_mul AS expectMul,
(bb.count - bb.used) AS remaining
FROM game_mahjong_box_bucket bb
LEFT JOIN game_mahjong_bucket b ON b.id = bb.bucket_id
WHERE bb.box_id = #{boxId} AND bb.audience = #{audience}
ORDER BY bb.bucket_id ASC
</select>
<insert id="batchInsert">
INSERT INTO game_mahjong_box_bucket (box_id, bucket_id, audience, count, used) VALUES
<foreach collection="list" item="it" separator=",">
(#{it.boxId}, #{it.bucketId}, #{it.audience}, #{it.count}, #{it.used})
</foreach>
</insert>
<delete id="deleteByBoxIdAndAudience">
DELETE FROM game_mahjong_box_bucket WHERE box_id = #{boxId} AND audience = #{audience}
</delete>
<update id="incrUsed">
UPDATE game_mahjong_box_bucket SET used = used + 1
WHERE box_id = #{boxId} AND bucket_id = #{bucketId} AND audience = #{audience}
</update>
<update id="resetUsed">
UPDATE game_mahjong_box_bucket SET used = 0
WHERE box_id = #{boxId} AND audience = #{audience}
</update>
</mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.game.mahjong.mapper.MahjongRoundMapper">
<select id="pickByBucket" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId} AND bucket_id = #{bucketId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY RAND() LIMIT 1
</select>
<select id="pickAnyByBox" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY RAND() LIMIT 1
</select>
<!-- 收敛阶段:桶内取 actual_mul 最接近 desiredMul 的回合,优先 actual_mul ≤ capMul,从 top-10 随机取1 -->
<select id="pickClosestMul" resultType="com.ruoyi.game.mahjong.domain.MahjongRound">
SELECT * FROM (
SELECT * FROM game_mahjong_round
WHERE box_id = #{boxId} AND bucket_id = #{bucketId}
<if test="needFreeSpins != null and needFreeSpins == true">AND free_spin_count &gt; 0</if>
<if test="needFreeSpins != null and needFreeSpins == false">AND free_spin_count = 0</if>
ORDER BY (CASE WHEN actual_mul &lt;= #{capMul} THEN 0 ELSE 1 END),
ABS(actual_mul - #{desiredMul})
LIMIT 10
) AS closest_pool
ORDER BY RAND() LIMIT 1
</select>
<!-- 统计某个箱子池子里每个桶"实际赔出去多少"的均值:配比计算与后台「目标RTP」按它算 -->
<select id="avgMulByBucket" resultType="java.util.Map">
SELECT bucket_id AS bucketId, COUNT(*) AS sampleCount, AVG(actual_mul) AS avgMul
FROM game_mahjong_round
WHERE box_id = #{boxId}
GROUP BY bucket_id
</select>
</mapper>