From 34809fd0f3a384cd7bd984d304cc0c1861878102 Mon Sep 17 00:00:00 2001 From: hujun Date: Fri, 20 Mar 2026 14:50:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A1=E5=88=86):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=A1=80=E6=88=98=E8=AE=A1=E5=88=86V1=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增血战计分服务,支持七对、清一色等基础番型及杠上花等特殊加番 - 扩展结算结果结构,包含番型明细与支付分数计算 - 新增PostGangContext记录杠后补摸窗口,用于判断杠上花/杠上炮 - 完善胡牌判定器,新增七对和对对胡识别方法 - 更新开发计划文档,补充注释规范要求 - 添加计分相关单元测试,确保核心逻辑正确性 --- .../java/document_symbols_cache_v23-06-25.pkl | Bin 7906 -> 102264 bytes .serena/memories/blood_battle_scoring_v1.md | 40 ++++ .../game/domain/GameSession.java | 14 ++ .../game/domain/PostGangContext.java | 7 + .../game/domain/SettlementDetail.java | 11 + .../game/domain/SettlementFan.java | 8 + .../game/domain/SettlementResult.java | 1 + .../xuezhanmaster/game/event/GameEvent.java | 20 ++ .../service/BloodBattleScoringService.java | 167 +++++++++++++ .../game/service/GameSessionService.java | 46 +++- .../game/service/HuEvaluator.java | 56 +++++ .../game/service/SettlementService.java | 74 ++++-- .../game/event/GameEventTest.java | 13 ++ .../BloodBattleScoringServiceTest.java | 128 ++++++++++ .../game/service/GameSessionServiceTest.java | 219 +++++++++++++++++- .../game/service/HuEvaluatorTest.java | 22 ++ docs/DEVELOPMENT_PLAN.md | 7 + 17 files changed, 804 insertions(+), 29 deletions(-) create mode 100644 .serena/memories/blood_battle_scoring_v1.md create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/PostGangContext.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/SettlementDetail.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/SettlementFan.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java create mode 100644 backend/src/test/java/com/xuezhanmaster/game/service/BloodBattleScoringServiceTest.java diff --git a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl index 0e28b2a499016357aa8a9ab9cc7592c514dd4532..bd81720b0a93b2080d66908fd7bdc281ebe8721f 100644 GIT binary patch literal 102264 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 diff --git a/.serena/memories/blood_battle_scoring_v1.md b/.serena/memories/blood_battle_scoring_v1.md new file mode 100644 index 0000000..8fff1be --- /dev/null +++ b/.serena/memories/blood_battle_scoring_v1.md @@ -0,0 +1,40 @@ +# 正式血战计分 V1(2026-03-20) +- 已从工程占位分切换到“最小可扩展正式版”计分骨架。 +- 新增后端规则服务:`backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java` +- `SettlementResult` 已扩展 `settlementDetail`,结算事件 `SETTLEMENT_APPLIED` 现在会携带: + - `baseScore` + - `totalFan` + - `paymentScore` + - `fans`(番型明细) +- 当前 V1 已支持的基础番型/加番: + - `七对`:2 番 + - `对对胡`:1 番 + - `金钩钓`:1 番 + - `清一色`:2 番 + - `根`:每个 1 番 + - `抢杠胡`:1 番 + - `杠上花`:1 番 + - `杠上炮`:1 番 + - `海底捞月`:1 番 + - `海底炮`:1 番 +- 当前胡牌计分口径:`paymentScore = 1 << totalFan` + - 点炮胡:放炮者单独支付 `paymentScore` + - 自摸胡:所有未胡玩家各支付 `paymentScore` + - 抢杠胡:按胡牌番型 + `抢杠胡` 1 番,由补杠方单独支付 + - 杠上花:在自摸胡基础上额外加 1 番 + - 杠上炮:在点炮胡基础上额外加 1 番 + - 海底捞月:自摸胡时若牌墙已空,额外加 1 番 + - 海底炮:点炮胡时若牌墙已空,额外加 1 番 +- 当前杠分口径: + - `明杠/点杠`:放杠者单独支付 2 分 + - `补杠`:所有未胡玩家各支付 1 分 + - `暗杠`:所有未胡玩家各支付 2 分 +- `GameSession` 已新增 `PostGangContext`,只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`。 +- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`。 +- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。 +- 当前仍未实现: + - 自摸加番/加底地方变体 + - 天胡、地胡 + - 查叫、退税、过水不胡、一炮多响 +- 文档约定已补到 `docs/DEVELOPMENT_PLAN.md`:后续前端、后端、数据库表结构与 SQL 脚本都需要补充必要中文注释,重点说明复杂规则、关键字段、状态切换、约束原因和索引用途。 +- 最小验证:`cd backend && mvn clean test`,当前 48 个测试通过。 \ 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 fc73f4c..be9a3b6 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java @@ -17,6 +17,8 @@ public class GameSession { private final GameTable table; private final List events; private ResponseActionWindow pendingResponseActionWindow; + // 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。 + private PostGangContext postGangContext; private final Map responseActionSelections; public GameSession(String roomId, GameTable table) { @@ -56,6 +58,14 @@ public class GameSession { this.pendingResponseActionWindow = pendingResponseActionWindow; } + public PostGangContext getPostGangContext() { + return postGangContext; + } + + public void setPostGangContext(PostGangContext postGangContext) { + this.postGangContext = postGangContext; + } + public Map getResponseActionSelections() { return responseActionSelections; } @@ -63,4 +73,8 @@ public class GameSession { public void clearResponseActionSelections() { responseActionSelections.clear(); } + + public void clearPostGangContext() { + this.postGangContext = null; + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/PostGangContext.java b/backend/src/main/java/com/xuezhanmaster/game/domain/PostGangContext.java new file mode 100644 index 0000000..3d0b812 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/PostGangContext.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.game.domain; + +public record PostGangContext( + int seatNo, + String tileDisplayName +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementDetail.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementDetail.java new file mode 100644 index 0000000..bb241a3 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementDetail.java @@ -0,0 +1,11 @@ +package com.xuezhanmaster.game.domain; + +import java.util.List; + +public record SettlementDetail( + int baseScore, + int totalFan, + int paymentScore, + List fans +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementFan.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementFan.java new file mode 100644 index 0000000..30fe12a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementFan.java @@ -0,0 +1,8 @@ +package com.xuezhanmaster.game.domain; + +public record SettlementFan( + String code, + String label, + int fan +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java index e121fd6..77b4656 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java @@ -8,6 +8,7 @@ public record SettlementResult( int actorSeatNo, int sourceSeatNo, String triggerTile, + SettlementDetail settlementDetail, List scoreChanges ) { } diff --git a/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java b/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java index 99900a3..e308833 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java +++ b/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java @@ -1,6 +1,8 @@ package com.xuezhanmaster.game.event; import com.xuezhanmaster.game.domain.ScoreChange; +import com.xuezhanmaster.game.domain.SettlementDetail; +import com.xuezhanmaster.game.domain.SettlementFan; import com.xuezhanmaster.game.domain.SettlementResult; import java.time.Instant; @@ -92,6 +94,7 @@ public record GameEvent( payload.put("actorSeatNo", settlementResult.actorSeatNo()); payload.put("sourceSeatNo", settlementResult.sourceSeatNo()); payload.put("triggerTile", settlementResult.triggerTile()); + payload.put("settlementDetail", toSettlementDetailPayload(settlementResult.settlementDetail())); payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges())); return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload); } @@ -143,4 +146,21 @@ public record GameEvent( } return payload; } + + private static Map toSettlementDetailPayload(SettlementDetail settlementDetail) { + Map payload = new LinkedHashMap<>(); + payload.put("baseScore", settlementDetail.baseScore()); + payload.put("totalFan", settlementDetail.totalFan()); + payload.put("paymentScore", settlementDetail.paymentScore()); + List> fans = new ArrayList<>(); + for (SettlementFan settlementFan : settlementDetail.fans()) { + Map item = new LinkedHashMap<>(); + item.put("code", settlementFan.code()); + item.put("label", settlementFan.label()); + item.put("fan", settlementFan.fan()); + fans.add(item); + } + payload.put("fans", fans); + return payload; + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java b/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java new file mode 100644 index 0000000..fd1b989 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java @@ -0,0 +1,167 @@ +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.SettlementDetail; +import com.xuezhanmaster.game.domain.SettlementFan; +import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class BloodBattleScoringService { + + private static final int BASE_SCORE = 1; + private static final int EXPOSED_GANG_SCORE = 2; + private static final int SUPPLEMENTAL_GANG_SCORE = 1; + private static final int CONCEALED_GANG_SCORE = 2; + + private final HuEvaluator huEvaluator; + + public BloodBattleScoringService(HuEvaluator huEvaluator) { + this.huEvaluator = huEvaluator; + } + + public SettlementDetail buildDiscardHuDetail( + GameSeat winnerSeat, + boolean gangShangPao, + boolean haiDiPao + ) { + return buildHuDetail(winnerSeat, false, false, gangShangPao, false, haiDiPao); + } + + public SettlementDetail buildSelfDrawHuDetail( + GameSeat winnerSeat, + boolean gangShangHua, + boolean haiDiLaoYue + ) { + // V1 先采用统一基础口径,不叠加地方“自摸加底/加番”变体。 + return buildHuDetail(winnerSeat, false, gangShangHua, false, haiDiLaoYue, false); + } + + public SettlementDetail buildRobbingGangHuDetail(GameSeat winnerSeat) { + return buildHuDetail(winnerSeat, true, false, false, false, false); + } + + public SettlementDetail buildExposedGangDetail() { + return new SettlementDetail(BASE_SCORE, 0, EXPOSED_GANG_SCORE, List.of()); + } + + public SettlementDetail buildSupplementalGangDetail() { + return new SettlementDetail(BASE_SCORE, 0, SUPPLEMENTAL_GANG_SCORE, List.of()); + } + + public SettlementDetail buildConcealedGangDetail() { + return new SettlementDetail(BASE_SCORE, 0, CONCEALED_GANG_SCORE, List.of()); + } + + private SettlementDetail buildHuDetail( + GameSeat winnerSeat, + boolean robbingGang, + boolean gangShangHua, + boolean gangShangPao, + boolean haiDiLaoYue, + boolean haiDiPao + ) { + List fans = new ArrayList<>(); + if (huEvaluator.isSevenPairs(winnerSeat.getHandTiles())) { + fans.add(new SettlementFan("QI_DUI", "七对", 2)); + } else { + if (huEvaluator.isPengPengHu(winnerSeat.getHandTiles())) { + fans.add(new SettlementFan("DUI_DUI_HU", "对对胡", 1)); + } + if (isJinGouDiao(winnerSeat)) { + fans.add(new SettlementFan("JIN_GOU_DIAO", "金钩钓", 1)); + } + } + if (isQingYiSe(winnerSeat)) { + fans.add(new SettlementFan("QING_YI_SE", "清一色", 2)); + } + + int genCount = countGen(winnerSeat); + for (int i = 0; i < genCount; i++) { + fans.add(new SettlementFan("GEN", "根", 1)); + } + if (gangShangHua) { + fans.add(new SettlementFan("GANG_SHANG_HUA", "杠上花", 1)); + } + if (gangShangPao) { + fans.add(new SettlementFan("GANG_SHANG_PAO", "杠上炮", 1)); + } + if (haiDiLaoYue) { + fans.add(new SettlementFan("HAI_DI_LAO_YUE", "海底捞月", 1)); + } + if (haiDiPao) { + fans.add(new SettlementFan("HAI_DI_PAO", "海底炮", 1)); + } + if (robbingGang) { + fans.add(new SettlementFan("QIANG_GANG_HU", "抢杠胡", 1)); + } + + int totalFan = fans.stream() + .mapToInt(SettlementFan::fan) + .sum(); + int paymentScore = BASE_SCORE << totalFan; + return new SettlementDetail(BASE_SCORE, totalFan, paymentScore, List.copyOf(fans)); + } + + private boolean isJinGouDiao(GameSeat winnerSeat) { + return winnerSeat.getMeldGroups().size() == 4 + && winnerSeat.getHandTiles().size() == 2 + && winnerSeat.getHandTiles().get(0).equals(winnerSeat.getHandTiles().get(1)); + } + + private boolean isQingYiSe(GameSeat winnerSeat) { + TileSuit firstSuit = null; + for (Tile tile : winnerSeat.getHandTiles()) { + if (firstSuit == null) { + firstSuit = tile.getSuit(); + continue; + } + if (tile.getSuit() != firstSuit) { + return false; + } + } + for (MeldGroup meldGroup : winnerSeat.getMeldGroups()) { + Tile meldTile = meldGroup.tile(); + if (firstSuit == null) { + firstSuit = meldTile.getSuit(); + continue; + } + if (meldTile.getSuit() != firstSuit) { + return false; + } + } + return firstSuit != null; + } + + private int countGen(GameSeat winnerSeat) { + Map tileCounts = new LinkedHashMap<>(); + for (Tile tile : winnerSeat.getHandTiles()) { + tileCounts.merge(tile, 1, Integer::sum); + } + for (MeldGroup meldGroup : winnerSeat.getMeldGroups()) { + tileCounts.merge(meldGroup.tile(), tileCountForMeld(meldGroup.type()), Integer::sum); + } + int genCount = 0; + for (Integer count : tileCounts.values()) { + if (count == 4) { + genCount++; + } + } + return genCount; + } + + private int tileCountForMeld(MeldType meldType) { + return switch (meldType) { + case PENG -> 3; + case MING_GANG, BU_GANG, AN_GANG -> 4; + }; + } +} 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 a29712e..f8c4a5b 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -6,6 +6,7 @@ import com.xuezhanmaster.game.domain.GamePhase; 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.ResponseActionResolution; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionWindow; @@ -495,6 +496,7 @@ public class GameSessionService { } private void continueAfterDiscardWithoutResponse(GameSession session) { + session.clearPostGangContext(); moveToNextSeat(session.getTable(), session.getGameId()); autoPlayBots(session); notifyActionIfHumanTurn(session); @@ -526,6 +528,7 @@ public class GameSessionService { } private void executePeng(GameSession session, ResponseActionResolution resolution) { + session.clearPostGangContext(); Tile claimedTile = parseTile(resolution.triggerTile()); GameTable table = session.getTable(); GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); @@ -548,6 +551,7 @@ public class GameSessionService { } private void executeGang(GameSession session, ResponseActionResolution resolution) { + session.clearPostGangContext(); Tile claimedTile = parseTile(resolution.triggerTile()); GameTable table = session.getTable(); GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); @@ -580,6 +584,7 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); continueFromResolvedActionTurn(session, winnerSeat); @@ -619,6 +624,11 @@ public class GameSessionService { 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, @@ -628,12 +638,15 @@ public class GameSessionService { )); } else { appendSettlementEvents(session, settlementService.settleDiscardHu( - table, - winnerSeat.getSeatNo(), - sourceSeat.getSeatNo(), - claimedTile.getDisplayName() + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName(), + gangShangPao, + haiDiPao )); } + session.clearPostGangContext(); if (shouldFinishTable(table)) { table.setPhase(GamePhase.FINISHED); @@ -647,6 +660,9 @@ public class GameSessionService { private void executeSelfDrawHu(GameSession session, String userId) { GameTable table = session.getTable(); GameSeat winnerSeat = findSeatByUserId(table, userId); + // 自摸胡时牌墙已空,说明当前这张就是最后一张牌,按海底捞月加番。 + boolean gangShangHua = isPostGangDraw(session, winnerSeat.getSeatNo()); + boolean haiDiLaoYue = table.getWallTiles().isEmpty(); winnerSeat.declareHu(); appendAndPublish(session, GameEvent.responseActionDeclared( @@ -659,8 +675,11 @@ public class GameSessionService { appendSettlementEvents(session, settlementService.settleSelfDrawHu( table, winnerSeat.getSeatNo(), - null + null, + gangShangHua, + haiDiLaoYue )); + session.clearPostGangContext(); if (shouldFinishTable(table)) { table.setPhase(GamePhase.FINISHED); @@ -703,6 +722,7 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); continueFromResolvedActionTurn(session, winnerSeat); @@ -764,6 +784,7 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); continueFromResolvedActionTurn(session, winnerSeat); @@ -815,6 +836,21 @@ public class GameSessionService { return huEvaluator.canHu(seat.getHandTiles()); } + private void markPostGangContext(GameSession session, int seatNo, String tileDisplayName) { + // 杠后补摸只对当前这名玩家的下一次自摸/弃牌裁决有效。 + session.setPostGangContext(new PostGangContext(seatNo, tileDisplayName)); + } + + private boolean isPostGangDraw(GameSession session, int seatNo) { + PostGangContext postGangContext = session.getPostGangContext(); + return postGangContext != null && postGangContext.seatNo() == seatNo; + } + + private boolean isPostGangDiscard(GameSession session, int sourceSeatNo) { + PostGangContext postGangContext = session.getPostGangContext(); + return postGangContext != null && postGangContext.seatNo() == sourceSeatNo; + } + private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) { return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType()); } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java b/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java index 78f362a..a026712 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java @@ -14,6 +14,9 @@ public class HuEvaluator { if (tiles.size() % 3 != 2) { return false; } + if (isSevenPairs(tiles)) { + return true; + } int[] counts = buildCounts(tiles); for (int i = 0; i < counts.length; i++) { @@ -29,6 +32,45 @@ public class HuEvaluator { return false; } + public boolean isSevenPairs(List tiles) { + if (tiles.size() != 14) { + return false; + } + + int[] counts = buildCounts(tiles); + int pairCount = 0; + for (int count : counts) { + if (count == 0) { + continue; + } + if (count != 2 && count != 4) { + return false; + } + pairCount += count / 2; + } + return pairCount == 7; + } + + public boolean isPengPengHu(List tiles) { + if (tiles.size() % 3 != 2) { + return false; + } + + int[] counts = buildCounts(tiles); + for (int i = 0; i < counts.length; i++) { + if (counts[i] < 2) { + continue; + } + counts[i] -= 2; + if (canFormTripletsOnly(counts)) { + counts[i] += 2; + return true; + } + counts[i] += 2; + } + return false; + } + public boolean canHuWithClaimedTile(List handTiles, Tile claimedTile) { List tiles = new ArrayList<>(handTiles); tiles.add(claimedTile); @@ -73,6 +115,20 @@ public class HuEvaluator { return false; } + private boolean canFormTripletsOnly(int[] counts) { + int firstIndex = firstNonZeroIndex(counts); + if (firstIndex == -1) { + return true; + } + if (counts[firstIndex] < 3) { + return false; + } + counts[firstIndex] -= 3; + boolean result = canFormTripletsOnly(counts); + counts[firstIndex] += 3; + return result; + } + private int firstNonZeroIndex(int[] counts) { for (int i = 0; i < counts.length; i++) { if (counts[i] > 0) { 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 b77909e..3bfc850 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java @@ -4,6 +4,7 @@ import com.xuezhanmaster.game.domain.ActionType; import com.xuezhanmaster.game.domain.GameSeat; import com.xuezhanmaster.game.domain.GameTable; 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 org.springframework.stereotype.Service; @@ -16,18 +17,25 @@ import java.util.Map; @Service public class SettlementService { - private static final int DIAN_PAO_HU_SCORE = 1; - private static final int ZI_MO_HU_SCORE = 1; - private static final int BU_GANG_SCORE = 1; - private static final int MING_GANG_SCORE = 1; - private static final int AN_GANG_SCORE = 2; + private final BloodBattleScoringService scoringService; + + public SettlementService(BloodBattleScoringService scoringService) { + this.scoringService = scoringService; + } public SettlementResult settleDiscardHu( GameTable table, int winnerSeatNo, int sourceSeatNo, - String triggerTile + String triggerTile, + boolean gangShangPao, + boolean haiDiPao ) { + SettlementDetail settlementDetail = scoringService.buildDiscardHuDetail( + table.getSeats().get(winnerSeatNo), + gangShangPao, + haiDiPao + ); return applySettlement( table, SettlementType.DIAN_PAO_HU, @@ -35,7 +43,13 @@ public class SettlementService { winnerSeatNo, sourceSeatNo, triggerTile, - orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) + settlementDetail, + orderedDeltas( + winnerSeatNo, + settlementDetail.paymentScore(), + sourceSeatNo, + -settlementDetail.paymentScore() + ) ); } @@ -45,6 +59,7 @@ public class SettlementService { int sourceSeatNo, String triggerTile ) { + SettlementDetail settlementDetail = scoringService.buildExposedGangDetail(); return applySettlement( table, SettlementType.MING_GANG, @@ -52,23 +67,36 @@ public class SettlementService { winnerSeatNo, sourceSeatNo, triggerTile, - orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE) + settlementDetail, + orderedDeltas( + winnerSeatNo, + settlementDetail.paymentScore(), + sourceSeatNo, + -settlementDetail.paymentScore() + ) ); } public SettlementResult settleSelfDrawHu( GameTable table, int winnerSeatNo, - String triggerTile + String triggerTile, + boolean gangShangHua, + boolean haiDiLaoYue ) { + SettlementDetail settlementDetail = scoringService.buildSelfDrawHuDetail( + table.getSeats().get(winnerSeatNo), + gangShangHua, + haiDiLaoYue + ); Map scoreDeltas = new LinkedHashMap<>(); int totalWinScore = 0; for (GameSeat seat : table.getSeats()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { continue; } - scoreDeltas.put(seat.getSeatNo(), -ZI_MO_HU_SCORE); - totalWinScore += ZI_MO_HU_SCORE; + scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore()); + totalWinScore += settlementDetail.paymentScore(); } scoreDeltas.put(winnerSeatNo, totalWinScore); return applySettlement( @@ -78,6 +106,7 @@ public class SettlementService { winnerSeatNo, winnerSeatNo, triggerTile, + settlementDetail, scoreDeltas ); } @@ -88,6 +117,7 @@ public class SettlementService { int sourceSeatNo, String triggerTile ) { + SettlementDetail settlementDetail = scoringService.buildRobbingGangHuDetail(table.getSeats().get(winnerSeatNo)); return applySettlement( table, SettlementType.QIANG_GANG_HU, @@ -95,7 +125,13 @@ public class SettlementService { winnerSeatNo, sourceSeatNo, triggerTile, - orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) + settlementDetail, + orderedDeltas( + winnerSeatNo, + settlementDetail.paymentScore(), + sourceSeatNo, + -settlementDetail.paymentScore() + ) ); } @@ -104,14 +140,15 @@ public class SettlementService { int winnerSeatNo, String triggerTile ) { + SettlementDetail settlementDetail = scoringService.buildSupplementalGangDetail(); Map scoreDeltas = new LinkedHashMap<>(); int totalWinScore = 0; for (GameSeat seat : table.getSeats()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { continue; } - scoreDeltas.put(seat.getSeatNo(), -BU_GANG_SCORE); - totalWinScore += BU_GANG_SCORE; + scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore()); + totalWinScore += settlementDetail.paymentScore(); } scoreDeltas.put(winnerSeatNo, totalWinScore); return applySettlement( @@ -121,6 +158,7 @@ public class SettlementService { winnerSeatNo, winnerSeatNo, triggerTile, + settlementDetail, scoreDeltas ); } @@ -130,14 +168,15 @@ public class SettlementService { int winnerSeatNo, String triggerTile ) { + SettlementDetail settlementDetail = scoringService.buildConcealedGangDetail(); Map scoreDeltas = new LinkedHashMap<>(); int totalWinScore = 0; for (GameSeat seat : table.getSeats()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { continue; } - scoreDeltas.put(seat.getSeatNo(), -AN_GANG_SCORE); - totalWinScore += AN_GANG_SCORE; + scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore()); + totalWinScore += settlementDetail.paymentScore(); } scoreDeltas.put(winnerSeatNo, totalWinScore); return applySettlement( @@ -147,6 +186,7 @@ public class SettlementService { winnerSeatNo, winnerSeatNo, triggerTile, + settlementDetail, scoreDeltas ); } @@ -158,6 +198,7 @@ public class SettlementService { int actorSeatNo, int sourceSeatNo, String triggerTile, + SettlementDetail settlementDetail, Map scoreDeltas ) { List scoreChanges = new ArrayList<>(); @@ -176,6 +217,7 @@ public class SettlementService { actorSeatNo, sourceSeatNo, triggerTile, + settlementDetail, List.copyOf(scoreChanges) ); } diff --git a/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java index e502292..dd5f5cc 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java @@ -2,6 +2,8 @@ package com.xuezhanmaster.game.event; import com.xuezhanmaster.game.domain.ActionType; import com.xuezhanmaster.game.domain.ScoreChange; +import com.xuezhanmaster.game.domain.SettlementDetail; +import com.xuezhanmaster.game.domain.SettlementFan; import com.xuezhanmaster.game.domain.SettlementResult; import com.xuezhanmaster.game.domain.SettlementType; import org.junit.jupiter.api.Test; @@ -54,6 +56,12 @@ class GameEventTest { 2, 0, "9筒", + new SettlementDetail( + 1, + 2, + 4, + List.of(new SettlementFan("QING_YI_SE", "清一色", 2)) + ), List.of( new ScoreChange(2, 1, 3), new ScoreChange(0, -1, -2) @@ -69,7 +77,12 @@ class GameEventTest { .containsEntry("actionType", "HU") .containsEntry("sourceSeatNo", 0) .containsEntry("triggerTile", "9筒"); + assertThat(settlementApplied.payload()).containsKey("settlementDetail"); assertThat(settlementApplied.payload()).containsKey("scoreChanges"); + assertThat((java.util.Map) settlementApplied.payload().get("settlementDetail")) + .containsEntry("baseScore", 1) + .containsEntry("totalFan", 2) + .containsEntry("paymentScore", 4); assertThat(scoreChanged.eventType()).isEqualTo(GameEventType.SCORE_CHANGED); assertThat(scoreChanged.payload()) diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/BloodBattleScoringServiceTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/BloodBattleScoringServiceTest.java new file mode 100644 index 0000000..ae082ca --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/BloodBattleScoringServiceTest.java @@ -0,0 +1,128 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.GameSeat; +import com.xuezhanmaster.game.domain.SettlementDetail; +import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class BloodBattleScoringServiceTest { + + private final HuEvaluator huEvaluator = new HuEvaluator(); + private final BloodBattleScoringService scoringService = new BloodBattleScoringService(huEvaluator); + + @Test + void shouldEvaluateQingQiDuiWithGen() { + GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一"); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 5)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 5)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 6)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 6)); + + SettlementDetail settlementDetail = scoringService.buildSelfDrawHuDetail(winnerSeat, false, false); + + assertThat(settlementDetail.totalFan()).isEqualTo(5); + assertThat(settlementDetail.paymentScore()).isEqualTo(32); + assertThat(settlementDetail.fans()) + .extracting(fan -> fan.code()) + .containsExactly("QI_DUI", "QING_YI_SE", "GEN"); + } + + @Test + void shouldEvaluateJinGouDiaoAndRobbingGangHu() { + GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一"); + winnerSeat.addPengMeld(new Tile(TileSuit.WAN, 1), 1); + winnerSeat.addPengMeld(new Tile(TileSuit.WAN, 2), 1); + winnerSeat.addMingGangMeld(new Tile(TileSuit.WAN, 3), 1); + winnerSeat.addAnGangMeld(new Tile(TileSuit.WAN, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 9)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 9)); + + SettlementDetail settlementDetail = scoringService.buildRobbingGangHuDetail(winnerSeat); + + assertThat(settlementDetail.totalFan()).isEqualTo(7); + assertThat(settlementDetail.paymentScore()).isEqualTo(128); + assertThat(settlementDetail.fans()) + .extracting(fan -> fan.code()) + .containsExactly("DUI_DUI_HU", "JIN_GOU_DIAO", "QING_YI_SE", "GEN", "GEN", "QIANG_GANG_HU"); + } + + @Test + void shouldExposeFixedGangScores() { + assertThat(scoringService.buildExposedGangDetail().paymentScore()).isEqualTo(2); + assertThat(scoringService.buildSupplementalGangDetail().paymentScore()).isEqualTo(1); + assertThat(scoringService.buildConcealedGangDetail().paymentScore()).isEqualTo(2); + } + + @Test + void shouldAddGangShangHuaAndGangShangPaoFans() { + GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一"); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 5)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 6)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 7)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9)); + + SettlementDetail gangShangHua = scoringService.buildSelfDrawHuDetail(winnerSeat, true, false); + SettlementDetail gangShangPao = scoringService.buildDiscardHuDetail(winnerSeat, true, false); + + assertThat(gangShangHua.totalFan()).isEqualTo(1); + assertThat(gangShangHua.paymentScore()).isEqualTo(2); + assertThat(gangShangHua.fans()).extracting(fan -> fan.code()).containsExactly("GANG_SHANG_HUA"); + + assertThat(gangShangPao.totalFan()).isEqualTo(1); + assertThat(gangShangPao.paymentScore()).isEqualTo(2); + assertThat(gangShangPao.fans()).extracting(fan -> fan.code()).containsExactly("GANG_SHANG_PAO"); + } + + @Test + void shouldAddHaiDiFans() { + GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一"); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 2)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 3)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 4)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 5)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 6)); + winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 7)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9)); + winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9)); + + SettlementDetail haiDiLaoYue = scoringService.buildSelfDrawHuDetail(winnerSeat, false, true); + SettlementDetail haiDiPao = scoringService.buildDiscardHuDetail(winnerSeat, false, true); + + assertThat(haiDiLaoYue.totalFan()).isEqualTo(1); + assertThat(haiDiLaoYue.paymentScore()).isEqualTo(2); + assertThat(haiDiLaoYue.fans()).extracting(fan -> fan.code()).containsExactly("HAI_DI_LAO_YUE"); + + assertThat(haiDiPao.totalFan()).isEqualTo(1); + assertThat(haiDiPao.paymentScore()).isEqualTo(2); + assertThat(haiDiPao.fans()).extracting(fan -> fan.code()).containsExactly("HAI_DI_PAO"); + } +} 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 357218c..b52da8d 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -34,13 +34,14 @@ class GameSessionServiceTest { private final RoomService roomService = new RoomService(); private final HuEvaluator huEvaluator = new HuEvaluator(); + private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator); private final GameSessionService gameSessionService = new GameSessionService( roomService, new GameEngine(new DeckFactory()), new GameActionProcessor(huEvaluator), new ResponseActionWindowBuilder(huEvaluator), new ResponseActionResolver(), - new SettlementService(), + new SettlementService(bloodBattleScoringService), huEvaluator, new StrategyService(), new PlayerVisibilityService(), @@ -255,8 +256,8 @@ class GameSessionServiceTest { ); assertThat(afterGang.currentSeatNo()).isEqualTo(1); - assertThat(afterGang.selfSeat().score()).isEqualTo(1); - assertThat(afterGang.seats().get(0).score()).isEqualTo(-1); + assertThat(afterGang.selfSeat().score()).isEqualTo(2); + assertThat(afterGang.seats().get(0).score()).isEqualTo(-2); assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile); assertThat(session.getEvents()) .extracting(event -> event.eventType()) @@ -420,17 +421,56 @@ class GameSessionServiceTest { assertThat(afterGang.selfSeat().handTiles()).hasSize(11); } + @Test + void shouldScoreGangShangHuaAfterReplacementDrawHu() { + 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()); + prepareGangShangHuaHand(session.getTable().getSeats().get(0), "3万"); + setNextWallTile(session, "9筒"); + + GameStateResponse afterGang = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "3万", null) + ); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "HU", null, null) + ); + + assertThat(afterGang.currentSeatNo()).isEqualTo(0); + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(12); + assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(12, -4, -4, -4); + assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_HUA"); + } + @Test void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() { RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true)); GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1"); - gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name()); + GameStateResponse afterLack = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name()); + GameSession session = getSession(started.gameId()); + String invalidGangTile = findTileWithLessThanFourCopies(session.getTable().getSeats().get(0)); assertThatThrownBy(() -> gameSessionService.performAction( - started.gameId(), - new GameActionRequest("host-1", "GANG", "3万", null) + afterLack.gameId(), + new GameActionRequest("host-1", "GANG", invalidGangTile, null) )) .isInstanceOf(BusinessException.class) .extracting(throwable -> ((BusinessException) throwable).getCode()) @@ -504,11 +544,106 @@ class GameSessionServiceTest { assertThat(afterHu.phase()).isEqualTo("PLAYING"); assertThat(afterHu.currentSeatNo()).isEqualTo(2); assertThat(afterHu.selfSeat().won()).isTrue(); - assertThat(afterHu.selfSeat().score()).isEqualTo(1); - assertThat(afterHu.seats().get(0).score()).isEqualTo(-1); + assertThat(afterHu.selfSeat().score()).isEqualTo(2); + assertThat(afterHu.seats().get(0).score()).isEqualTo(-2); assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒"); } + @Test + void shouldScoreGangShangPaoOnDiscardAfterReplacementDraw() { + RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); + roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二")); + roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true)); + roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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()); + + GameSession session = getSession(started.gameId()); + prepareGangShangPaoHand(session.getTable().getSeats().get(0), "3万"); + prepareWinningHand(session.getTable().getSeats().get(1)); + removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1); + setNextWallTile(session, "8条"); + + GameStateResponse afterGang = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "3万", null) + ); + GameStateResponse afterHu = gameSessionService.discardTile(started.gameId(), "host-1", "9筒"); + afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", "9筒", 0) + ); + + assertThat(afterGang.currentSeatNo()).isEqualTo(0); + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(0); + assertThat(afterHu.seats().get(0).score()).isEqualTo(4); + assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_PAO"); + } + + @Test + void shouldScoreHaiDiLaoYueOnLastTileSelfDraw() { + 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()); + prepareSelfDrawWinningHand(session.getTable().getSeats().get(0)); + session.getTable().getWallTiles().clear(); + + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "HU", null, null) + ); + + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(6); + assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2); + assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_LAO_YUE"); + } + + @Test + void shouldScoreHaiDiPaoOnLastTileDiscardHu() { + RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); + roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二")); + roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true)); + roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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()); + + GameSession session = getSession(started.gameId()); + prepareWinningHand(session.getTable().getSeats().get(1)); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "9筒", 1); + removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1); + session.getTable().getWallTiles().clear(); + + gameSessionService.discardTile(started.gameId(), "host-1", "9筒"); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", "9筒", 0) + ); + + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(2); + assertThat(afterHu.seats().get(0).score()).isEqualTo(-2); + assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO"); + } + @Test void shouldRejectSelfDrawHuWhenHandIsNotWinning() { RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); @@ -575,6 +710,21 @@ class GameSessionServiceTest { }; } + private String findTileWithLessThanFourCopies(GameSeat seat) { + for (Tile tile : seat.getHandTiles()) { + int matchCount = 0; + for (Tile handTile : seat.getHandTiles()) { + if (handTile.equals(tile)) { + matchCount++; + } + } + if (matchCount < 4) { + return tile.getDisplayName(); + } + } + throw new IllegalStateException("测试手牌意外全部为四张同牌"); + } + private void prepareWinningHand(GameSeat seat) { seat.getHandTiles().clear(); seat.receiveTile(new Tile(TileSuit.WAN, 1)); @@ -628,6 +778,42 @@ class GameSessionServiceTest { seat.receiveTile(new Tile(TileSuit.WAN, 9)); } + private void prepareGangShangHuaHand(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, 1)); + seat.receiveTile(new Tile(TileSuit.WAN, 1)); + seat.receiveTile(new Tile(TileSuit.WAN, 2)); + seat.receiveTile(new Tile(TileSuit.WAN, 3)); + seat.receiveTile(new Tile(TileSuit.WAN, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 2)); + seat.receiveTile(new Tile(TileSuit.TONG, 3)); + seat.receiveTile(new Tile(TileSuit.TONG, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 9)); + } + + private void prepareGangShangPaoHand(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, 1)); + seat.receiveTile(new Tile(TileSuit.WAN, 1)); + seat.receiveTile(new Tile(TileSuit.WAN, 2)); + seat.receiveTile(new Tile(TileSuit.WAN, 3)); + seat.receiveTile(new Tile(TileSuit.WAN, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 2)); + seat.receiveTile(new Tile(TileSuit.TONG, 3)); + seat.receiveTile(new Tile(TileSuit.TONG, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 9)); + } + private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) { seat.getHandTiles().clear(); seat.getMeldGroups().clear(); @@ -645,4 +831,21 @@ class GameSessionServiceTest { seat.receiveTile(new Tile(TileSuit.TIAO, 9)); seat.receiveTile(new Tile(TileSuit.WAN, 9)); } + + private void setNextWallTile(GameSession session, String tileDisplayName) { + session.getTable().getWallTiles().set(0, parseTile(tileDisplayName)); + } + + @SuppressWarnings("unchecked") + private java.util.List latestSettlementFanCodes(GameSession session) { + return session.getEvents().stream() + .filter(event -> event.eventType() == GameEventType.SETTLEMENT_APPLIED) + .reduce((first, second) -> second) + .map(event -> (Map) event.payload().get("settlementDetail")) + .map(detail -> (java.util.List>) detail.get("fans")) + .map(fans -> fans.stream() + .map(item -> String.valueOf(item.get("code"))) + .toList()) + .orElseGet(java.util.List::of); + } } diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java index da8ba3a..3618af5 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java @@ -59,4 +59,26 @@ class HuEvaluatorTest { assertThat(result).isFalse(); } + + @Test + void shouldRecognizeSevenPairsWinningHand() { + boolean result = huEvaluator.canHu(List.of( + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 2), + new Tile(TileSuit.WAN, 2), + new Tile(TileSuit.WAN, 3), + new Tile(TileSuit.WAN, 3), + new Tile(TileSuit.TONG, 4), + new Tile(TileSuit.TONG, 4), + new Tile(TileSuit.TONG, 5), + new Tile(TileSuit.TONG, 5), + new Tile(TileSuit.TIAO, 6), + new Tile(TileSuit.TIAO, 6), + new Tile(TileSuit.TIAO, 9), + new Tile(TileSuit.TIAO, 9) + )); + + assertThat(result).isTrue(); + } } diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index b5971bf..7bceb49 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -232,6 +232,13 @@ - 统一玩家可见状态模型 - 前后端统一阶段与动作枚举语义 +### 5.3 注释与脚本约定 + +- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名猜语义。 +- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。 +- 后续数据库表结构、迁移脚本、初始化 SQL 与存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因与关键索引用途。 +- 注释要求遵守 `KISS`:只解释不直观的意图、约束和边界,不写“变量赋值”这类冗余注释。 + ### 5.2 最关键的系统约束 #### 约束一:教学必须基于玩家可见状态