diff --git a/.serena/memories/blood_battle_scoring_v1.md b/.serena/memories/blood_battle_scoring_v1.md index 8fff1be..c1e9dea 100644 --- a/.serena/memories/blood_battle_scoring_v1.md +++ b/.serena/memories/blood_battle_scoring_v1.md @@ -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 个测试通过。 \ No newline at end of file + - 过水不胡 +- 文档约定已加强到 `docs/DEVELOPMENT_PLAN.md`: + - 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释 + - 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释 + - 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,必须补中文注释说明业务含义、约束原因、字段口径和回滚要点 + - 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域 +- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。 \ No newline at end of file diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java index be9a3b6..978dec4 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java @@ -16,6 +16,8 @@ public class GameSession { private final Instant createdAt; private final GameTable table; private final List events; + // 保留已应用结算结果,供局终退税与查叫直接复用,避免从事件载荷反推。 + private final List 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 getSettlementHistory() { + return settlementHistory; + } + public ResponseActionWindow getPendingResponseActionWindow() { return pendingResponseActionWindow; } diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ReadyHandOption.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ReadyHandOption.java new file mode 100644 index 0000000..38792c1 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ReadyHandOption.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.game.domain; + +public record ReadyHandOption( + String triggerTile, + SettlementDetail settlementDetail +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolutionBatch.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolutionBatch.java new file mode 100644 index 0000000..b1b640e --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolutionBatch.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.game.domain; + +import java.util.List; + +public record ResponseActionResolutionBatch( + ActionType actionType, + List resolutions +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java index ec0ee4d..1042a40 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java @@ -6,5 +6,7 @@ public enum SettlementType { ZI_MO_HU, BU_GANG, MING_GANG, - AN_GANG + AN_GANG, + TUI_SHUI, + CHA_JIAO } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java b/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java index fd1b989..d4580b9 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java @@ -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 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 buildAllTileCandidates() { + List 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; + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java index f8c4a5b..494f288 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -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 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 resolution = responseActionResolver.resolve( + Optional 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,55 +608,57 @@ public class GameSessionService { private void executeHu( GameSession session, ResponseActionWindow responseActionWindow, - ResponseActionResolution resolution + List 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); } - winnerSeat.declareHu(); + // 一炮多响时多个赢家共用同一张弃牌或补杠牌,牌源只移除一次,赢家逐个补牌并独立结算。 + for (ResponseActionResolution resolution : resolutions) { + GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); + winnerSeat.receiveTile(claimedTile); + winnerSeat.declareHu(); - appendAndPublish(session, GameEvent.responseActionDeclared( - session.getGameId(), - GameEventType.HU_DECLARED, - winnerSeat.getSeatNo(), - sourceSeat.getSeatNo(), - claimedTile.getDisplayName() - )); - // 牌墙已空时,这次点炮对应“最后一张牌后的弃牌胡”,按海底炮加番。 - boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow) - && isPostGangDiscard(session, sourceSeat.getSeatNo()); - boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow) - && table.getWallTiles().isEmpty(); - if (isSupplementalGangWindow(responseActionWindow)) { - appendSettlementEvents(session, settlementService.settleRobbingGangHu( - table, + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.HU_DECLARED, winnerSeat.getSeatNo(), sourceSeat.getSeatNo(), claimedTile.getDisplayName() )); - } else { + // 牌墙已空时,这次点炮对应“最后一张牌后的弃牌胡”,按海底炮加番。 + boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow) + && isPostGangDiscard(session, sourceSeat.getSeatNo()); + boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow) + && table.getWallTiles().isEmpty(); + if (isSupplementalGangWindow(responseActionWindow)) { + appendSettlementEvents(session, settlementService.settleRobbingGangHu( + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + continue; + } appendSettlementEvents(session, settlementService.settleDiscardHu( - table, - winnerSeat.getSeatNo(), - sourceSeat.getSeatNo(), - claimedTile.getDisplayName(), - gangShangPao, - haiDiPao + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName(), + gangShangPao, + haiDiPao )); } 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 readyHandOptions = buildReadyHandOptions(session.getTable()); + Set 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 buildReadyHandOptions(GameTable table) { + Map 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 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 readyHandOptions, + Set notReadySeatNos + ) { + if (notReadySeatNos.isEmpty()) { + return; + } + // 查叫按“未听牌向所有听牌玩家分别赔付其最大理论点炮值”处理,先不做花猪等额外分支。 + for (Integer notReadySeatNo : notReadySeatNos) { + for (Map.Entry 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; } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionResolver.java b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionResolver.java index d68f8f9..4324deb 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionResolver.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionResolver.java @@ -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 resolve(ResponseActionWindow window, Map selections, int seatCount) { - return selections.entrySet().stream() - .filter(entry -> entry.getValue() != ActionType.PASS) - .min(Comparator - .comparingInt((Map.Entry entry) -> priority(entry.getValue())) - .thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount))) + public Optional resolve( + ResponseActionWindow window, + Map selections, + int seatCount + ) { + // 只要有胡牌声明,就按一炮多响返回全部胡牌赢家,其它动作全部失效。 + List 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 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() + )) )); } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java index 3bfc850..cac1ec6 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java @@ -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()); + } } diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java index b52da8d..c8e6505 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -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 settlementResultsOfType(GameSession session, SettlementType settlementType) { + return session.getSettlementHistory().stream() + .map(settlementResult -> settlementResult.settlementType()) + .filter(type -> type == settlementType) + .toList(); + } + @SuppressWarnings("unchecked") private java.util.List latestSettlementFanCodes(GameSession session) { return session.getEvents().stream() diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 7bceb49..5be4e32 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -234,10 +234,11 @@ ### 5.3 注释与脚本约定 -- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名猜语义。 -- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。 -- 后续数据库表结构、迁移脚本、初始化 SQL 与存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因与关键索引用途。 -- 注释要求遵守 `KISS`:只解释不直观的意图、约束和边界,不写“变量赋值”这类冗余注释。 +- 后端新增或修改的业务代码,必须为复杂规则、关键字段、状态切换、结算口径和跨阶段流程补充适量中文注释,不能只保留方法名级别语义。 +- 前端新增或修改的页面、组件与状态逻辑,必须为复杂交互、实时消息消费、动作面板联动和视图状态切换补充适量中文注释,避免后续拆页或联调时理解断层。 +- 后续数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,也必须补充必要中文注释,重点说明业务含义、约束原因、字段口径、回填策略和关键索引用途。 +- 注释要求遵守 `KISS`:可以适当多写,但只解释不直观的意图、约束、边界和取舍,不写“变量赋值”这类冗余注释。 +- 对以下高风险区域,中文注释默认视为必需项:麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本。 ### 5.2 最关键的系统约束