feat(结算): 实现血战麻将查叫与退税功能

新增局终处理逻辑,当牌墙耗尽且有多家未胡时:
1. 退税:未听牌玩家需退还此前杠分收入
2. 查叫:未听牌玩家向听牌玩家赔付最大理论点炮值
新增 SettlementType.TUI_SHUI 和 SettlementType.CHA_JIAO 结算类型
新增 ReadyHandOption 记录最优听牌选项

支持一炮多响裁决,新增 ResponseActionResolutionBatch 承载多赢家结果
在 GameSession 中新增 settlementHistory 保留结算记录供复用
更新开发文档要求加强关键区域的中文注释
This commit is contained in:
hujun
2026-03-20 15:05:00 +08:00
parent 34809fd0f3
commit b84d0e8980
11 changed files with 501 additions and 78 deletions

View File

@@ -29,12 +29,30 @@
- `明杠/点杠`:放杠者单独支付 2 分
- `补杠`:所有未胡玩家各支付 1 分
- `暗杠`:所有未胡玩家各支付 2 分
- `GameSession` 已新增 `PostGangContext`,只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
- `GameSession` 已新增
- `PostGangContext`:只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
- `settlementHistory`:记录已应用结算结果,供局终 `退税/查叫` 直接复用
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`
- 查叫/退税已接入局终处理:
- 触发时机:牌墙耗尽且仍有多家未胡时
- 处理顺序:先 `退税`,再 `查叫`
- `退税`:未听牌玩家若此前有杠分收入,需要按原支付关系逐笔退还
- `查叫`:未听牌玩家向仍在场且已听牌玩家,按对方理论最大可胡牌型的点炮赔分支付
- 已新增 `SettlementType.TUI_SHUI``SettlementType.CHA_JIAO`
- 已新增 `ReadyHandOption`,用于查叫时记录最优听牌目标牌与对应结算明细
- 一炮多响已接入响应裁决:
- `HU` 现在支持多赢家同窗裁决,只要响应窗口内有多个 `HU` 声明,就会一起结算
- `PENG/GANG` 仍保持单赢家,顺位规则不变
- 抢杠胡窗口也会复用这套多赢家 `HU` 裁决路径
- 已新增 `ResponseActionResolutionBatch` 承载多赢家响应结果
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
- 当前仍未实现:
- 自摸加番/加底地方变体
- 天胡、地胡
- 查叫、退税、过水不胡、一炮多响
- 文档约定已`docs/DEVELOPMENT_PLAN.md`后续前端、后端、数据库表结构与 SQL 脚本都需要补充必要中文注释,重点说明复杂规则、关键字段、状态切换、约束原因和索引用途。
- 最小验证:`cd backend && mvn clean test`,当前 48 个测试通过。
- 过水不胡
- 文档约定已加强`docs/DEVELOPMENT_PLAN.md`
- 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释
- 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释
- 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程必须补中文注释说明业务含义、约束原因、字段口径和回滚要点
- 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域
- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。

View File

@@ -16,6 +16,8 @@ public class GameSession {
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
// 保留已应用结算结果,供局终退税与查叫直接复用,避免从事件载荷反推。
private final List<SettlementResult> settlementHistory;
private ResponseActionWindow pendingResponseActionWindow;
// 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。
private PostGangContext postGangContext;
@@ -27,6 +29,7 @@ public class GameSession {
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
this.settlementHistory = new ArrayList<>();
this.responseActionSelections = new LinkedHashMap<>();
}
@@ -50,6 +53,10 @@ public class GameSession {
return events;
}
public List<SettlementResult> getSettlementHistory() {
return settlementHistory;
}
public ResponseActionWindow getPendingResponseActionWindow() {
return pendingResponseActionWindow;
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public record ReadyHandOption(
String triggerTile,
SettlementDetail settlementDetail
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record ResponseActionResolutionBatch(
ActionType actionType,
List<ResponseActionResolution> resolutions
) {
}

View File

@@ -6,5 +6,7 @@ public enum SettlementType {
ZI_MO_HU,
BU_GANG,
MING_GANG,
AN_GANG
AN_GANG,
TUI_SHUI,
CHA_JIAO
}

View File

@@ -3,6 +3,7 @@ package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.MeldGroup;
import com.xuezhanmaster.game.domain.MeldType;
import com.xuezhanmaster.game.domain.ReadyHandOption;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.Tile;
@@ -13,6 +14,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Component
public class BloodBattleScoringService {
@@ -61,6 +63,22 @@ public class BloodBattleScoringService {
return new SettlementDetail(BASE_SCORE, 0, CONCEALED_GANG_SCORE, List.of());
}
public Optional<ReadyHandOption> findBestReadyHandOption(GameSeat seat) {
ReadyHandOption bestOption = null;
for (Tile candidateTile : buildAllTileCandidates()) {
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), candidateTile)) {
continue;
}
GameSeat virtualSeat = copySeatWithClaimedTile(seat, candidateTile);
SettlementDetail settlementDetail = buildDiscardHuDetail(virtualSeat, false, false);
ReadyHandOption candidateOption = new ReadyHandOption(candidateTile.getDisplayName(), settlementDetail);
if (bestOption == null || isBetterReadyHandOption(candidateOption, bestOption)) {
bestOption = candidateOption;
}
}
return Optional.ofNullable(bestOption);
}
private SettlementDetail buildHuDetail(
GameSeat winnerSeat,
boolean robbingGang,
@@ -164,4 +182,35 @@ public class BloodBattleScoringService {
case MING_GANG, BU_GANG, AN_GANG -> 4;
};
}
private List<Tile> buildAllTileCandidates() {
List<Tile> candidates = new ArrayList<>();
for (TileSuit tileSuit : TileSuit.values()) {
for (int rank = 1; rank <= 9; rank++) {
candidates.add(new Tile(tileSuit, rank));
}
}
return candidates;
}
private GameSeat copySeatWithClaimedTile(GameSeat seat, Tile claimedTile) {
GameSeat virtualSeat = new GameSeat(seat.getSeatNo(), seat.isAi(), seat.getPlayerId(), seat.getNickname());
virtualSeat.setLackSuit(seat.getLackSuit());
virtualSeat.setWon(seat.isWon());
virtualSeat.addScore(seat.getScore());
virtualSeat.getHandTiles().addAll(seat.getHandTiles());
virtualSeat.getMeldGroups().addAll(seat.getMeldGroups());
virtualSeat.receiveTile(claimedTile);
return virtualSeat;
}
private boolean isBetterReadyHandOption(ReadyHandOption candidate, ReadyHandOption currentBest) {
if (candidate.settlementDetail().paymentScore() != currentBest.settlementDetail().paymentScore()) {
return candidate.settlementDetail().paymentScore() > currentBest.settlementDetail().paymentScore();
}
if (candidate.settlementDetail().totalFan() != currentBest.settlementDetail().totalFan()) {
return candidate.settlementDetail().totalFan() > currentBest.settlementDetail().totalFan();
}
return candidate.triggerTile().compareTo(currentBest.triggerTile()) < 0;
}
}

View File

@@ -7,11 +7,15 @@ import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.PostGangContext;
import com.xuezhanmaster.game.domain.ReadyHandOption;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
@@ -35,9 +39,11 @@ import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
@@ -49,6 +55,7 @@ public class GameSessionService {
private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final ResponseActionResolver responseActionResolver;
private final SettlementService settlementService;
private final BloodBattleScoringService scoringService;
private final HuEvaluator huEvaluator;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
@@ -63,6 +70,7 @@ public class GameSessionService {
ResponseActionWindowBuilder responseActionWindowBuilder,
ResponseActionResolver responseActionResolver,
SettlementService settlementService,
BloodBattleScoringService scoringService,
HuEvaluator huEvaluator,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
@@ -75,6 +83,7 @@ public class GameSessionService {
this.responseActionWindowBuilder = responseActionWindowBuilder;
this.responseActionResolver = responseActionResolver;
this.settlementService = settlementService;
this.scoringService = scoringService;
this.huEvaluator = huEvaluator;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
@@ -155,17 +164,16 @@ public class GameSessionService {
);
}
private void moveToNextSeat(GameTable table, String gameId) {
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
private void moveToNextSeat(GameSession session) {
GameTable table = session.getTable();
if (finishTableIfNeeded(session)) {
return;
}
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
if (nextSeatOptional.isEmpty()) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
@@ -173,8 +181,8 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeat.getSeatNo());
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
}
private void autoPlayBots(GameSession session) {
@@ -182,7 +190,7 @@ public class GameSessionService {
while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (currentSeat.isWon()) {
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
continue;
}
if (!currentSeat.isAi()) {
@@ -248,7 +256,7 @@ public class GameSessionService {
)
);
appendAndPublish(session, events);
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
if (table.getPhase() == GamePhase.FINISHED) {
return;
}
@@ -434,14 +442,14 @@ public class GameSessionService {
return;
}
Optional<ResponseActionResolution> resolution = responseActionResolver.resolve(
Optional<ResponseActionResolutionBatch> resolutionBatch = responseActionResolver.resolve(
responseActionWindow,
session.getResponseActionSelections(),
session.getTable().getSeats().size()
);
if (resolution.isPresent()) {
executeResolvedResponseAction(session, responseActionWindow, resolution.get());
if (resolutionBatch.isPresent()) {
executeResolvedResponseAction(session, responseActionWindow, resolutionBatch.get());
return;
}
@@ -497,7 +505,7 @@ public class GameSessionService {
private void continueAfterDiscardWithoutResponse(GameSession session) {
session.clearPostGangContext();
moveToNextSeat(session.getTable(), session.getGameId());
moveToNextSeat(session);
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
@@ -505,13 +513,13 @@ public class GameSessionService {
private void executeResolvedResponseAction(
GameSession session,
ResponseActionWindow responseActionWindow,
ResponseActionResolution resolution
ResponseActionResolutionBatch resolutionBatch
) {
closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType());
switch (resolution.actionType()) {
case PENG -> executePeng(session, resolution);
case GANG -> executeGang(session, resolution);
case HU -> executeHu(session, responseActionWindow, resolution);
closeResponseWindowWithResolution(session, responseActionWindow, resolutionBatch.actionType());
switch (resolutionBatch.actionType()) {
case PENG -> executePeng(session, resolutionBatch.resolutions().get(0));
case GANG -> executeGang(session, resolutionBatch.resolutions().get(0));
case HU -> executeHu(session, responseActionWindow, resolutionBatch.resolutions());
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
}
}
@@ -576,9 +584,7 @@ public class GameSessionService {
claimedTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -602,19 +608,22 @@ public class GameSessionService {
private void executeHu(
GameSession session,
ResponseActionWindow responseActionWindow,
ResponseActionResolution resolution
List<ResponseActionResolution> resolutions
) {
Tile claimedTile = parseTile(resolution.triggerTile());
ResponseActionResolution primaryResolution = resolutions.get(0);
Tile claimedTile = parseTile(primaryResolution.triggerTile());
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
GameSeat sourceSeat = table.getSeats().get(primaryResolution.sourceSeatNo());
winnerSeat.receiveTile(claimedTile);
if (isSupplementalGangWindow(responseActionWindow)) {
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
} else {
sourceSeat.removeMatchingDiscardTile(claimedTile);
}
// 一炮多响时多个赢家共用同一张弃牌或补杠牌,牌源只移除一次,赢家逐个补牌并独立结算。
for (ResponseActionResolution resolution : resolutions) {
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
winnerSeat.receiveTile(claimedTile);
winnerSeat.declareHu();
appendAndPublish(session, GameEvent.responseActionDeclared(
@@ -636,7 +645,8 @@ public class GameSessionService {
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
} else {
continue;
}
appendSettlementEvents(session, settlementService.settleDiscardHu(
table,
winnerSeat.getSeatNo(),
@@ -648,9 +658,7 @@ public class GameSessionService {
}
session.clearPostGangContext();
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -681,13 +689,11 @@ public class GameSessionService {
));
session.clearPostGangContext();
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
@@ -714,9 +720,7 @@ public class GameSessionService {
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -776,9 +780,7 @@ public class GameSessionService {
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -816,6 +818,7 @@ public class GameSessionService {
}
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
session.getSettlementHistory().add(settlementResult);
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
appendAndPublish(session, GameEvent.scoreChanged(
@@ -828,6 +831,99 @@ public class GameSessionService {
}
}
private boolean finishTableIfNeeded(GameSession session) {
GameTable table = session.getTable();
if (!shouldFinishTable(table)) {
return false;
}
// 血战流局场景下,牌墙耗尽但仍有多人未胡,需要先做退税和查叫,再真正结束牌局。
if (table.getWallTiles().isEmpty() && countActiveSeats(table) > 1) {
applyDrawEndSettlements(session);
}
session.clearPostGangContext();
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return true;
}
private void applyDrawEndSettlements(GameSession session) {
Map<Integer, ReadyHandOption> readyHandOptions = buildReadyHandOptions(session.getTable());
Set<Integer> notReadySeatNos = session.getTable().getSeats().stream()
.filter(seat -> !seat.isWon())
.map(GameSeat::getSeatNo)
.filter(seatNo -> !readyHandOptions.containsKey(seatNo))
.collect(java.util.stream.Collectors.toSet());
applyTaxRefunds(session, notReadySeatNos);
applyChaJiao(session, readyHandOptions, notReadySeatNos);
}
private Map<Integer, ReadyHandOption> buildReadyHandOptions(GameTable table) {
Map<Integer, ReadyHandOption> readyHandOptions = new LinkedHashMap<>();
for (GameSeat seat : table.getSeats()) {
if (seat.isWon()) {
continue;
}
scoringService.findBestReadyHandOption(seat)
.ifPresent(option -> readyHandOptions.put(seat.getSeatNo(), option));
}
return readyHandOptions;
}
private void applyTaxRefunds(GameSession session, Set<Integer> notReadySeatNos) {
if (notReadySeatNos.isEmpty()) {
return;
}
// 退税只针对未听牌玩家此前“已经拿到手”的杠分收入,按原支付关系逐笔退回。
for (SettlementResult settlementResult : List.copyOf(session.getSettlementHistory())) {
if (!isGangSettlement(settlementResult) || !notReadySeatNos.contains(settlementResult.actorSeatNo())) {
continue;
}
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
if (scoreChange.delta() >= 0) {
continue;
}
appendSettlementEvents(session, settlementService.settleTaxRefund(
session.getTable(),
scoreChange.seatNo(),
settlementResult.actorSeatNo(),
-scoreChange.delta(),
settlementResult.triggerTile()
));
}
}
}
private void applyChaJiao(
GameSession session,
Map<Integer, ReadyHandOption> readyHandOptions,
Set<Integer> notReadySeatNos
) {
if (notReadySeatNos.isEmpty()) {
return;
}
// 查叫按“未听牌向所有听牌玩家分别赔付其最大理论点炮值”处理,先不做花猪等额外分支。
for (Integer notReadySeatNo : notReadySeatNos) {
for (Map.Entry<Integer, ReadyHandOption> entry : readyHandOptions.entrySet()) {
ReadyHandOption readyHandOption = entry.getValue();
SettlementDetail settlementDetail = readyHandOption.settlementDetail();
appendSettlementEvents(session, settlementService.settleChaJiao(
session.getTable(),
entry.getKey(),
notReadySeatNo,
readyHandOption.triggerTile(),
settlementDetail
));
}
}
}
private boolean isGangSettlement(SettlementResult settlementResult) {
return settlementResult.settlementType() == SettlementType.MING_GANG
|| settlementResult.settlementType() == SettlementType.BU_GANG
|| settlementResult.settlementType() == SettlementType.AN_GANG;
}
private boolean shouldFinishTable(GameTable table) {
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
}

View File

@@ -2,27 +2,52 @@ package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Component
public class ResponseActionResolver {
public Optional<ResponseActionResolution> resolve(ResponseActionWindow window, Map<Integer, ActionType> selections, int seatCount) {
return selections.entrySet().stream()
.filter(entry -> entry.getValue() != ActionType.PASS)
.min(Comparator
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
public Optional<ResponseActionResolutionBatch> resolve(
ResponseActionWindow window,
Map<Integer, ActionType> selections,
int seatCount
) {
// 只要有胡牌声明,就按一炮多响返回全部胡牌赢家,其它动作全部失效。
List<ResponseActionResolution> huResolutions = selections.entrySet().stream()
.filter(entry -> entry.getValue() == ActionType.HU)
.sorted(Comparator.comparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
.map(entry -> new ResponseActionResolution(
entry.getValue(),
entry.getKey(),
window.sourceSeatNo(),
window.triggerTile()
))
.toList();
if (!huResolutions.isEmpty()) {
// 一炮多响时保留所有胡牌赢家,后续由执行层逐个结算。
return Optional.of(new ResponseActionResolutionBatch(ActionType.HU, huResolutions));
}
return selections.entrySet().stream()
.filter(entry -> entry.getValue() != ActionType.PASS)
.min(Comparator
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
.map(entry -> new ResponseActionResolutionBatch(
entry.getValue(),
List.of(new ResponseActionResolution(
entry.getValue(),
entry.getKey(),
window.sourceSeatNo(),
window.triggerTile()
))
));
}

View File

@@ -191,6 +191,49 @@ public class SettlementService {
);
}
public SettlementResult settleTaxRefund(
GameTable table,
int recipientSeatNo,
int sourceSeatNo,
int refundAmount,
String triggerTile
) {
return applySettlement(
table,
SettlementType.TUI_SHUI,
ActionType.GANG,
recipientSeatNo,
sourceSeatNo,
triggerTile,
buildFlatDetail(refundAmount),
orderedDeltas(recipientSeatNo, refundAmount, sourceSeatNo, -refundAmount)
);
}
public SettlementResult settleChaJiao(
GameTable table,
int winnerSeatNo,
int sourceSeatNo,
String triggerTile,
SettlementDetail settlementDetail
) {
return applySettlement(
table,
SettlementType.CHA_JIAO,
ActionType.HU,
winnerSeatNo,
sourceSeatNo,
triggerTile,
settlementDetail,
orderedDeltas(
winnerSeatNo,
settlementDetail.paymentScore(),
sourceSeatNo,
-settlementDetail.paymentScore()
)
);
}
private SettlementResult applySettlement(
GameTable table,
SettlementType settlementType,
@@ -228,4 +271,8 @@ public class SettlementService {
deltas.put(sourceSeatNo, sourceDelta);
return deltas;
}
private SettlementDetail buildFlatDetail(int paymentScore) {
return new SettlementDetail(1, 0, paymentScore, List.of());
}
}

View File

@@ -5,6 +5,7 @@ import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.domain.SettlementType;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.service.GameActionProcessor;
@@ -42,6 +43,7 @@ class GameSessionServiceTest {
new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(),
new SettlementService(bloodBattleScoringService),
bloodBattleScoringService,
huEvaluator,
new StrategyService(),
new PlayerVisibilityService(),
@@ -644,6 +646,124 @@ class GameSessionServiceTest {
assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO");
}
@Test
void shouldRefundGangScoresForNotReadySeatWhenWallExhausted() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
prepareTaxRefundGangHand(session.getTable().getSeats().get(0), "3万");
setNextWallTile(session, "9万");
GameStateResponse afterGang = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
);
session.getTable().getWallTiles().clear();
removeMatchingTilesFromOtherSeats(session, "9万", 0);
GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "9万");
assertThat(afterGang.selfSeat().score()).isEqualTo(6);
assertThat(afterDiscard.phase()).isEqualTo("FINISHED");
assertThat(settlementResultsOfType(session, SettlementType.TUI_SHUI)).hasSize(3);
assertThat(afterDiscard.seats()).extracting(seat -> seat.score()).contains(0, 0, 0, 0);
}
@Test
void shouldApplyChaJiaoFromNotReadySeatToReadySeatWhenWallExhausted() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
prepareNotReadyHand(session.getTable().getSeats().get(0));
prepareWinningHand(session.getTable().getSeats().get(1));
session.getTable().getSeats().get(2).declareHu();
session.getTable().getSeats().get(3).declareHu();
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "1万", 1);
removeMatchingTilesFromOtherSeats(session, "1万", 0);
session.getTable().getWallTiles().clear();
GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "1万");
assertThat(afterDiscard.phase()).isEqualTo("FINISHED");
assertThat(afterDiscard.seats().get(0).score()).isEqualTo(-1);
assertThat(afterDiscard.seats().get(1).score()).isEqualTo(1);
assertThat(settlementResultsOfType(session, SettlementType.CHA_JIAO)).hasSize(1);
assertThat(latestSettlementFanCodes(session)).isEmpty();
}
@Test
void shouldAllowMultipleHuWinnersOnSingleDiscard() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
prepareWinningHand(session.getTable().getSeats().get(1));
prepareWinningHand(session.getTable().getSeats().get(2));
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "9筒", 1);
removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1, 2);
gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", "9筒", 0)
);
afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-3", "HU", "9筒", 0)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterHu.phase()).isEqualTo("PLAYING");
assertThat(afterHu.currentSeatNo()).isEqualTo(3);
assertThat(afterHu.seats().get(0).score()).isEqualTo(-2);
assertThat(afterHu.seats().get(1).won()).isTrue();
assertThat(afterHu.seats().get(2).won()).isTrue();
assertThat(afterHu.seats().get(1).score()).isEqualTo(1);
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
assertThat(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2);
}
@Test
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
@@ -814,6 +934,41 @@ class GameSessionServiceTest {
seat.receiveTile(new Tile(TileSuit.TONG, 9));
}
private void prepareTaxRefundGangHand(GameSeat seat, String gangTileDisplayName) {
seat.getHandTiles().clear();
Tile gangTile = parseTile(gangTileDisplayName);
for (int i = 0; i < 4; i++) {
seat.receiveTile(gangTile);
}
seat.receiveTile(new Tile(TileSuit.WAN, 1));
seat.receiveTile(new Tile(TileSuit.WAN, 4));
seat.receiveTile(new Tile(TileSuit.WAN, 7));
seat.receiveTile(new Tile(TileSuit.TONG, 2));
seat.receiveTile(new Tile(TileSuit.TONG, 5));
seat.receiveTile(new Tile(TileSuit.TONG, 8));
seat.receiveTile(new Tile(TileSuit.TIAO, 1));
seat.receiveTile(new Tile(TileSuit.TIAO, 4));
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
seat.receiveTile(new Tile(TileSuit.WAN, 8));
}
private void prepareNotReadyHand(GameSeat seat) {
seat.getHandTiles().clear();
seat.receiveTile(new Tile(TileSuit.WAN, 2));
seat.receiveTile(new Tile(TileSuit.WAN, 5));
seat.receiveTile(new Tile(TileSuit.WAN, 8));
seat.receiveTile(new Tile(TileSuit.TONG, 1));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 7));
seat.receiveTile(new Tile(TileSuit.TIAO, 2));
seat.receiveTile(new Tile(TileSuit.TIAO, 5));
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
seat.receiveTile(new Tile(TileSuit.WAN, 9));
seat.receiveTile(new Tile(TileSuit.TONG, 9));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.WAN, 3));
}
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
seat.getHandTiles().clear();
seat.getMeldGroups().clear();
@@ -836,6 +991,13 @@ class GameSessionServiceTest {
session.getTable().getWallTiles().set(0, parseTile(tileDisplayName));
}
private java.util.List<SettlementType> settlementResultsOfType(GameSession session, SettlementType settlementType) {
return session.getSettlementHistory().stream()
.map(settlementResult -> settlementResult.settlementType())
.filter(type -> type == settlementType)
.toList();
}
@SuppressWarnings("unchecked")
private java.util.List<String> latestSettlementFanCodes(GameSession session) {
return session.getEvents().stream()

View File

@@ -234,10 +234,11 @@
### 5.3 注释与脚本约定
- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名语义。
- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。
- 后续数据库表结构、迁移脚本、初始化 SQL存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因关键索引用途。
- 注释要求遵守 `KISS`:只解释不直观的意图、约束边界,不写“变量赋值”这类冗余注释。
- 后端新增或修改的业务代码,必须为复杂规则、关键字段、状态切换、结算口径和跨阶段流程补充适量中文注释,不能只保留方法名级别语义。
- 前端新增或修改的页面、组件与状态逻辑,必须为复杂交互、实时消息消费、动作面板联动和视图状态切换补充适量中文注释,避免后续拆页或联调时理解断层。
- 后续数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,也必须补充必要中文注释,重点说明业务含义、约束原因、字段口径、回填策略和关键索引用途。
- 注释要求遵守 `KISS`可以适当多写,但只解释不直观的意图、约束边界和取舍,不写“变量赋值”这类冗余注释。
- 对以下高风险区域,中文注释默认视为必需项:麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本。
### 5.2 最关键的系统约束