From d038a8732df58ac94499c668be5512999f19f0a3 Mon Sep 17 00:00:00 2001 From: hujun Date: Fri, 20 Mar 2026 14:14:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=A1=A5=E6=9D=A0?= =?UTF-8?q?=E5=92=8C=E6=8A=A2=E6=9D=A0=E8=83=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 MeldType 和 MeldGroup 领域模型,支持碰、明杠、补杠、暗杠四种副露类型 GameSeat 新增副露管理方法,支持将碰升级为补杠 ResponseActionWindowBuilder 新增补杠响应窗口构建逻辑 SettlementService 新增补杠和抢杠胡结算规则 前端新增副露展示区域,支持显示各类副露标签 --- .serena/memories/current_execution_entry.md | 36 ++- .../xuezhanmaster/game/domain/GameSeat.java | 33 +++ .../xuezhanmaster/game/domain/MeldGroup.java | 8 + .../xuezhanmaster/game/domain/MeldType.java | 8 + .../game/domain/SettlementType.java | 5 +- .../game/dto/PublicSeatView.java | 3 +- .../xuezhanmaster/game/dto/SelfSeatView.java | 3 +- .../game/service/GameActionProcessor.java | 31 +++ .../game/service/GameSessionService.java | 206 +++++++++++++++++- .../service/ResponseActionWindowBuilder.java | 34 +++ .../game/service/SettlementService.java | 71 ++++++ .../service/PlayerVisibilityService.java | 7 +- .../game/event/GameEventTest.java | 17 ++ .../game/service/GameSessionServiceTest.java | 153 +++++++++++++ .../ResponseActionWindowBuilderTest.java | 34 +++ frontend/src/App.vue | 13 ++ 16 files changed, 633 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/MeldGroup.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/MeldType.java diff --git a/.serena/memories/current_execution_entry.md b/.serena/memories/current_execution_entry.md index 8854eac..1ddc7b0 100644 --- a/.serena/memories/current_execution_entry.md +++ b/.serena/memories/current_execution_entry.md @@ -1,19 +1,31 @@ # 当前执行入口 - 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`。 -- 当前主线已进入“统一结算服务 + 自摸胡闭环”阶段。 +- 当前主线已进入“补杠 + 抢杠胡闭环”阶段。 - 最新已完成能力: - - `HU` 已同时支持两条路径: - - 响应胡(吃别人弃牌) - - 自摸胡(当前回合主动胡) - - `HuEvaluator` 已补 `canHu(List)`,可直接判断整手自摸是否成胡。 - - 当前回合私有动作消息已支持结构化候选下发,真人玩家在可自摸时会收到 `HU + DISCARD` 候选。 - - AI 当前回合若满足自摸胡条件,会优先执行 `HU`,不再一律走弃牌策略。 - - `SettlementService` 已补 `ZI_MO_HU` 结算占位规则:所有未胡玩家各 `-1`,自摸方累计获得对应分数。 - - 自摸胡后会将胡牌座位标记为 `won`,并按血战逻辑继续推进到下一有效座位;若仅剩 1 名未胡玩家则结束。 + - `GANG` 主动动作已区分三条路径: + - 暗杠(4 张同牌) + - 补杠(已有 `PENG` 副露 + 手牌 1 张同牌) + - 响应明杠(来自他人弃牌) + - 新增 `MeldType.BU_GANG`,`GameSeat` 已支持把 `PENG` 升级为 `BU_GANG`。 + - `ResponseActionWindowBuilder` 已新增 `buildForSupplementalGang`,补杠时只会为其他玩家生成 `HU + PASS` 响应窗口。 + - `GameSessionService` 已支持: + - 补杠宣告时打开抢杠胡响应窗口 + - 全员 `PASS` 后真正执行补杠、结算、补摸一张、继续本家回合 + - 有人 `HU` 时按 `抢杠胡` 结算并终止补杠完成 + - `SettlementService` 已新增: + - `BU_GANG` 占位结算 + - `QIANG_GANG_HU` 占位结算 + - H5 原型页当前已可看到 `碰 / 明杠 / 补杠 / 暗杠` 副露标签。 +- 当前占位分规则: + - `BU_GANG`:所有未胡对手各 `-1`,补杠方累计加分 + - `QIANG_GANG_HU`:暂按点炮胡占位分处理(胡牌方 `+1`,补杠方 `-1`) +- 已完成验证: + - `mvn test` 通过,当前共 38 个测试。 + - `npm run build` 通过。 - 当前仍未完成的核心点: - - `暗杠 / 补杠` 需要引入副露/杠展示状态,否则手牌数与桌面状态会失真。 - 正式四川血战计分规则仍未实现,当前仍是工程占位分。 + - `过水不胡`、`一炮多响`、`查叫 / 退税` 尚未接入。 - H5 正式页面拆分仍未开始。 - 当前推荐的下一步: - 1. 代码主线:补 `Meld/副露` 领域模型,再接 `暗杠 / 补杠 / 杠后补牌 / 杠分事件`。 - 2. 结算主线:把占位规则升级为正式血战分数模型,并细化 `SETTLEMENT_APPLIED` 载荷。 \ No newline at end of file + 1. 规则主线:把占位结算升级为正式四川血战计分规则。 + 2. 产品主线:开始 `S1-08`,拆分正式 H5 页面与对局组件。 \ No newline at end of file diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java index 71394a2..25c722d 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java @@ -12,6 +12,7 @@ public class GameSeat { private final String nickname; private final List handTiles = new ArrayList<>(); private final List discardTiles = new ArrayList<>(); + private final List meldGroups = new ArrayList<>(); private TileSuit lackSuit; private boolean won; private int score; @@ -51,6 +52,10 @@ public class GameSeat { return discardTiles; } + public List getMeldGroups() { + return meldGroups; + } + public TileSuit getLackSuit() { return lackSuit; } @@ -107,6 +112,34 @@ public class GameSeat { throw new IllegalStateException("弃牌区不存在指定牌"); } + public void addPengMeld(Tile tile, int sourceSeatNo) { + meldGroups.add(new MeldGroup(MeldType.PENG, tile, sourceSeatNo)); + } + + public void addMingGangMeld(Tile tile, int sourceSeatNo) { + meldGroups.add(new MeldGroup(MeldType.MING_GANG, tile, sourceSeatNo)); + } + + public void upgradePengToBuGang(Tile tile) { + for (int i = 0; i < meldGroups.size(); i++) { + MeldGroup meldGroup = meldGroups.get(i); + if (meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile)) { + meldGroups.set(i, new MeldGroup(MeldType.BU_GANG, tile, meldGroup.sourceSeatNo())); + return; + } + } + throw new IllegalStateException("当前没有可升级为补杠的碰副露"); + } + + public boolean hasPengMeld(Tile tile) { + return meldGroups.stream() + .anyMatch(meldGroup -> meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile)); + } + + public void addAnGangMeld(Tile tile) { + meldGroups.add(new MeldGroup(MeldType.AN_GANG, tile, null)); + } + public void declareHu() { this.won = true; } diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/MeldGroup.java b/backend/src/main/java/com/xuezhanmaster/game/domain/MeldGroup.java new file mode 100644 index 0000000..fdd18ec --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/MeldGroup.java @@ -0,0 +1,8 @@ +package com.xuezhanmaster.game.domain; + +public record MeldGroup( + MeldType type, + Tile tile, + Integer sourceSeatNo +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/MeldType.java b/backend/src/main/java/com/xuezhanmaster/game/domain/MeldType.java new file mode 100644 index 0000000..9909493 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/MeldType.java @@ -0,0 +1,8 @@ +package com.xuezhanmaster.game.domain; + +public enum MeldType { + PENG, + MING_GANG, + BU_GANG, + AN_GANG +} 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 0f7bc94..ec0ee4d 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java @@ -2,6 +2,9 @@ package com.xuezhanmaster.game.domain; public enum SettlementType { DIAN_PAO_HU, + QIANG_GANG_HU, ZI_MO_HU, - MING_GANG + BU_GANG, + MING_GANG, + AN_GANG } diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java b/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java index 3ac82d2..b9644c7 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java @@ -11,6 +11,7 @@ public record PublicSeatView( String lackSuit, int score, int handCount, - List discardTiles + List discardTiles, + List melds ) { } diff --git a/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java b/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java index 1de9785..a2964cf 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java @@ -10,6 +10,7 @@ public record SelfSeatView( String lackSuit, int score, List handTiles, - List discardTiles + List discardTiles, + List melds ) { } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java index faade21..58766e5 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java @@ -50,6 +50,10 @@ public class GameActionProcessor { private List gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); + if (sourceSeatNo == null) { + validateSelfGang(session, table, seat, tileDisplayName); + return List.of(); + } validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "杠"); return List.of(); } @@ -219,6 +223,33 @@ public class GameActionProcessor { } } + private void validateSelfGang(GameSession session, GameTable table, GameSeat actorSeat, String tileDisplayName) { + if (table.getPhase() != GamePhase.PLAYING) { + throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许暗杠"); + } + if (actorSeat.isWon()) { + throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续杠牌"); + } + if (actorSeat.getSeatNo() != table.getCurrentSeatNo()) { + throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的回合,不能暗杠"); + } + if (session.getPendingResponseActionWindow() != null) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "响应窗口期间不能执行暗杠"); + } + if (isBlank(tileDisplayName)) { + throw new BusinessException("GAME_ACTION_PARAM_INVALID", "暗杠动作缺少目标牌"); + } + Tile tile = findTileInHand(actorSeat, tileDisplayName); + long sameTileCount = actorSeat.getHandTiles().stream() + .filter(handTile -> handTile.equals(tile)) + .count(); + boolean canConcealedGang = sameTileCount >= 4; + boolean canSupplementalGang = sameTileCount >= 1 && actorSeat.hasPengMeld(tile); + if (!canConcealedGang && !canSupplementalGang) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前手牌不满足暗杠条件"); + } + } + private boolean isBlank(String value) { return value == null || value.isBlank(); } 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 2c83c93..a29712e 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -147,7 +147,8 @@ public class GameSessionService { self.getLackSuit() == null ? null : self.getLackSuit().name(), self.getScore(), self.getHandTiles().stream().map(Tile::getDisplayName).toList(), - self.getDiscardTiles().stream().map(Tile::getDisplayName).toList() + self.getDiscardTiles().stream().map(Tile::getDisplayName).toList(), + toMeldLabels(self) ), buildPublicSeats(table) ); @@ -210,6 +211,30 @@ public class GameSessionService { continue; } + Optional selfGangCandidate = findSelfGangCandidate(currentSeat); + if (selfGangCandidate.isPresent()) { + List gangEvents = gameActionProcessor.process( + session, + new GameActionRequest( + currentSeat.getPlayerId(), + "GANG", + selfGangCandidate.get(), + null + ) + ); + appendAndPublish(session, gangEvents); + handlePostActionEffects( + session, + ActionType.GANG, + new GameActionRequest(currentSeat.getPlayerId(), "GANG", selfGangCandidate.get(), null), + gangEvents + ); + if (table.getPhase() == GamePhase.FINISHED) { + return; + } + continue; + } + PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat); StrategyDecision decision = strategyService.evaluateDiscard(visibleState); List events = gameActionProcessor.process( @@ -345,7 +370,8 @@ public class GameSessionService { private void handleGangPostAction(GameSession session, GameActionRequest request) { if (request.sourceSeatNo() == null) { - throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充"); + handleSelfGangPostAction(session, request.userId(), request.tile()); + return; } handleResponseDeclarationPostAction(session, ActionType.GANG, request); } @@ -419,6 +445,10 @@ public class GameSessionService { } closeResponseWindowAsPass(session, responseActionWindow); + if (isSupplementalGangWindow(responseActionWindow)) { + executeSupplementalGang(session, responseActionWindow.sourceSeatNo(), responseActionWindow.triggerTile()); + return; + } continueAfterDiscardWithoutResponse(session); } @@ -479,7 +509,7 @@ public class GameSessionService { switch (resolution.actionType()) { case PENG -> executePeng(session, resolution); case GANG -> executeGang(session, resolution); - case HU -> executeHu(session, resolution); + case HU -> executeHu(session, responseActionWindow, resolution); default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果"); } } @@ -503,6 +533,7 @@ public class GameSessionService { winnerSeat.removeMatchingHandTiles(claimedTile, 2); sourceSeat.removeMatchingDiscardTile(claimedTile); + winnerSeat.addPengMeld(claimedTile, sourceSeat.getSeatNo()); table.setCurrentSeatNo(winnerSeat.getSeatNo()); appendAndPublish(session, GameEvent.responseActionDeclared( @@ -524,6 +555,7 @@ public class GameSessionService { winnerSeat.removeMatchingHandTiles(claimedTile, 3); sourceSeat.removeMatchingDiscardTile(claimedTile); + winnerSeat.addMingGangMeld(claimedTile, sourceSeat.getSeatNo()); table.setCurrentSeatNo(winnerSeat.getSeatNo()); appendAndPublish(session, GameEvent.responseActionDeclared( @@ -562,14 +594,22 @@ public class GameSessionService { notifyActionIfHumanTurn(session); } - private void executeHu(GameSession session, ResponseActionResolution resolution) { + private void executeHu( + GameSession session, + ResponseActionWindow responseActionWindow, + ResponseActionResolution resolution + ) { Tile claimedTile = parseTile(resolution.triggerTile()); GameTable table = session.getTable(); GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo()); winnerSeat.receiveTile(claimedTile); - sourceSeat.removeMatchingDiscardTile(claimedTile); + if (isSupplementalGangWindow(responseActionWindow)) { + sourceSeat.removeMatchingHandTiles(claimedTile, 1); + } else { + sourceSeat.removeMatchingDiscardTile(claimedTile); + } winnerSeat.declareHu(); appendAndPublish(session, GameEvent.responseActionDeclared( @@ -579,12 +619,21 @@ public class GameSessionService { sourceSeat.getSeatNo(), claimedTile.getDisplayName() )); - appendSettlementEvents(session, settlementService.settleDiscardHu( - table, - winnerSeat.getSeatNo(), - sourceSeat.getSeatNo(), - claimedTile.getDisplayName() - )); + if (isSupplementalGangWindow(responseActionWindow)) { + appendSettlementEvents(session, settlementService.settleRobbingGangHu( + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + } else { + appendSettlementEvents(session, settlementService.settleDiscardHu( + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + } if (shouldFinishTable(table)) { table.setPhase(GamePhase.FINISHED); @@ -624,6 +673,102 @@ public class GameSessionService { notifyActionIfHumanTurn(session); } + private void executeSelfGang(GameSession session, String userId, String tileDisplayName) { + GameTable table = session.getTable(); + GameSeat winnerSeat = findSeatByUserId(table, userId); + Tile gangTile = parseTile(tileDisplayName); + + winnerSeat.removeMatchingHandTiles(gangTile, 4); + winnerSeat.addAnGangMeld(gangTile); + table.setCurrentSeatNo(winnerSeat.getSeatNo()); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.GANG_DECLARED, + winnerSeat.getSeatNo(), + null, + gangTile.getDisplayName() + )); + appendSettlementEvents(session, settlementService.settleConcealedGang( + table, + winnerSeat.getSeatNo(), + gangTile.getDisplayName() + )); + + if (table.getWallTiles().isEmpty()) { + table.setPhase(GamePhase.FINISHED); + appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name())); + return; + } + + Tile drawnTile = table.getWallTiles().remove(0); + winnerSeat.receiveTile(drawnTile); + appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); + appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); + continueFromResolvedActionTurn(session, winnerSeat); + } + + private void handleSelfGangPostAction(GameSession session, String userId, String tileDisplayName) { + GameTable table = session.getTable(); + GameSeat actorSeat = findSeatByUserId(table, userId); + Tile gangTile = parseTile(tileDisplayName); + + if (actorSeat.hasPengMeld(gangTile)) { + Optional window = responseActionWindowBuilder.buildForSupplementalGang( + session, + actorSeat.getSeatNo(), + gangTile + ); + if (window.isPresent()) { + openResponseWindow(session, window.get()); + autoPassAiCandidates(session); + if (session.getPendingResponseActionWindow() == null) { + executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName); + } + return; + } + executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName); + return; + } + + executeSelfGang(session, userId, tileDisplayName); + } + + private void executeSupplementalGang(GameSession session, int seatNo, String tileDisplayName) { + GameTable table = session.getTable(); + GameSeat winnerSeat = table.getSeats().get(seatNo); + Tile gangTile = parseTile(tileDisplayName); + + winnerSeat.removeMatchingHandTiles(gangTile, 1); + winnerSeat.upgradePengToBuGang(gangTile); + table.setCurrentSeatNo(winnerSeat.getSeatNo()); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.GANG_DECLARED, + winnerSeat.getSeatNo(), + null, + gangTile.getDisplayName() + )); + appendSettlementEvents(session, settlementService.settleSupplementalGang( + table, + winnerSeat.getSeatNo(), + gangTile.getDisplayName() + )); + + if (table.getWallTiles().isEmpty()) { + table.setPhase(GamePhase.FINISHED); + appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name())); + return; + } + + Tile drawnTile = table.getWallTiles().remove(0); + winnerSeat.receiveTile(drawnTile); + appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); + appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); + continueFromResolvedActionTurn(session, winnerSeat); + } + private ActionType parseActionType(String actionType) { try { return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT)); @@ -643,7 +788,8 @@ public class GameSessionService { seat.getLackSuit() == null ? null : seat.getLackSuit().name(), seat.getScore(), seat.getHandTiles().size(), - seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList() + seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList(), + toMeldLabels(seat) )) .toList(); } @@ -669,6 +815,10 @@ public class GameSessionService { return huEvaluator.canHu(seat.getHandTiles()); } + private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) { + return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType()); + } + private long countActiveSeats(GameTable table) { return table.getSeats().stream() .filter(seat -> !seat.isWon()) @@ -691,7 +841,39 @@ public class GameSessionService { if (canSelfDrawHu(currentSeat)) { candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null)); } + for (String gangTile : findSelfGangCandidates(currentSeat)) { + candidates.add(new PrivateActionCandidate(ActionType.GANG.name(), gangTile)); + } candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null)); return List.copyOf(candidates); } + + private Optional findSelfGangCandidate(GameSeat seat) { + List candidates = findSelfGangCandidates(seat); + if (candidates.isEmpty()) { + return Optional.empty(); + } + return Optional.of(candidates.get(0)); + } + + private List findSelfGangCandidates(GameSeat seat) { + return seat.getHandTiles().stream() + .collect(java.util.stream.Collectors.groupingBy(Tile::getDisplayName, java.util.stream.Collectors.counting())) + .entrySet().stream() + .filter(entry -> entry.getValue() >= 4 || seat.hasPengMeld(parseTile(entry.getKey()))) + .map(java.util.Map.Entry::getKey) + .sorted() + .toList(); + } + + private List toMeldLabels(GameSeat seat) { + return seat.getMeldGroups().stream() + .map(meld -> switch (meld.type()) { + case PENG -> "碰:" + meld.tile().getDisplayName(); + case MING_GANG -> "明杠:" + meld.tile().getDisplayName(); + case BU_GANG -> "补杠:" + meld.tile().getDisplayName(); + case AN_GANG -> "暗杠:" + meld.tile().getDisplayName(); + }) + .toList(); + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java index 5af0f30..c043734 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java @@ -55,6 +55,40 @@ public class ResponseActionWindowBuilder { )); } + public Optional buildForSupplementalGang(GameSession session, int sourceSeatNo, Tile gangTile) { + GameTable table = session.getTable(); + List seatCandidates = new ArrayList<>(); + + for (GameSeat seat : table.getSeats()) { + if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) { + continue; + } + if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) { + continue; + } + seatCandidates.add(new ResponseActionSeatCandidate( + seat.getSeatNo(), + seat.getPlayerId(), + List.of( + new ResponseActionOption(ActionType.HU, gangTile.getDisplayName()), + new ResponseActionOption(ActionType.PASS, null) + ) + )); + } + + if (seatCandidates.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(ResponseActionWindow.create( + session.getGameId(), + "SUPPLEMENTAL_GANG_DECLARED", + sourceSeatNo, + gangTile.getDisplayName(), + seatCandidates + )); + } + private List buildSeatOptions(GameSeat seat, Tile discardedTile) { int sameTileCount = countSameTileInHand(seat, discardedTile); boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile); 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 8e364d6..b77909e 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java @@ -18,7 +18,9 @@ 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; public SettlementResult settleDiscardHu( GameTable table, @@ -80,6 +82,75 @@ public class SettlementService { ); } + public SettlementResult settleRobbingGangHu( + GameTable table, + int winnerSeatNo, + int sourceSeatNo, + String triggerTile + ) { + return applySettlement( + table, + SettlementType.QIANG_GANG_HU, + ActionType.HU, + winnerSeatNo, + sourceSeatNo, + triggerTile, + orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) + ); + } + + public SettlementResult settleSupplementalGang( + GameTable table, + int winnerSeatNo, + String triggerTile + ) { + 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(winnerSeatNo, totalWinScore); + return applySettlement( + table, + SettlementType.BU_GANG, + ActionType.GANG, + winnerSeatNo, + winnerSeatNo, + triggerTile, + scoreDeltas + ); + } + + public SettlementResult settleConcealedGang( + GameTable table, + int winnerSeatNo, + String triggerTile + ) { + 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(winnerSeatNo, totalWinScore); + return applySettlement( + table, + SettlementType.AN_GANG, + ActionType.GANG, + winnerSeatNo, + winnerSeatNo, + triggerTile, + scoreDeltas + ); + } + private SettlementResult applySettlement( GameTable table, SettlementType settlementType, diff --git a/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java b/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java index 2530698..8ddff0b 100644 --- a/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java +++ b/backend/src/main/java/com/xuezhanmaster/teaching/service/PlayerVisibilityService.java @@ -14,10 +14,14 @@ public class PlayerVisibilityService { public PlayerVisibleGameState buildVisibleState(GameTable table, GameSeat seat) { List publicDiscards = new ArrayList<>(); + List publicMelds = new ArrayList<>(); for (GameSeat gameSeat : table.getSeats()) { for (Tile tile : gameSeat.getDiscardTiles()) { publicDiscards.add(gameSeat.getSeatNo() + ":" + tile.getDisplayName()); } + gameSeat.getMeldGroups().forEach(meld -> + publicMelds.add(gameSeat.getSeatNo() + ":" + meld.type().name() + ":" + meld.tile().getDisplayName()) + ); } return new PlayerVisibleGameState( @@ -26,8 +30,7 @@ public class PlayerVisibilityService { seat.getLackSuit(), List.copyOf(seat.getHandTiles()), publicDiscards, - List.of() + publicMelds ); } } - 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 622cda0..e502292 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java @@ -94,4 +94,21 @@ class GameEventTest { .doesNotContainKey("sourceSeatNo") .doesNotContainKey("tile"); } + + @Test + void shouldAllowGangEventWithoutSourceSeatForConcealedGang() { + GameEvent concealedGang = GameEvent.responseActionDeclared( + "game-1", + GameEventType.GANG_DECLARED, + 0, + null, + "3万" + ); + + assertThat(concealedGang.eventType()).isEqualTo(GameEventType.GANG_DECLARED); + assertThat(concealedGang.payload()) + .containsEntry("actionType", "GANG") + .containsEntry("tile", "3万") + .doesNotContainKey("sourceSeatNo"); + } } 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 d953cb5..357218c 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -228,6 +228,7 @@ class GameSessionServiceTest { assertThat(afterPeng.currentSeatNo()).isEqualTo(1); assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile); assertThat(afterPeng.selfSeat().handTiles()).hasSize(13); + assertThat(afterPeng.selfSeat().melds()).containsExactly("碰:" + discardTile); } @Test @@ -256,6 +257,7 @@ class GameSessionServiceTest { assertThat(afterGang.currentSeatNo()).isEqualTo(1); assertThat(afterGang.selfSeat().score()).isEqualTo(1); assertThat(afterGang.seats().get(0).score()).isEqualTo(-1); + assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile); assertThat(session.getEvents()) .extracting(event -> event.eventType()) .contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); @@ -392,6 +394,121 @@ class GameSessionServiceTest { .contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); } + @Test + void shouldResolveSelfConcealedGangAndKeepTurnAfterReplacementDraw() { + 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()); + + GameSession session = getSession(started.gameId()); + prepareConcealedGangHand(session.getTable().getSeats().get(0), "3万"); + int beforeWallCount = session.getTable().getWallTiles().size(); + + GameStateResponse afterGang = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "3万", null) + ); + + assertThat(afterGang.phase()).isEqualTo("PLAYING"); + assertThat(afterGang.currentSeatNo()).isEqualTo(0); + assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1); + assertThat(afterGang.selfSeat().melds()).containsExactly("暗杠:3万"); + assertThat(afterGang.selfSeat().score()).isEqualTo(6); + assertThat(afterGang.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2); + assertThat(afterGang.selfSeat().handTiles()).hasSize(11); + } + + @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()); + + assertThatThrownBy(() -> gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "3万", null) + )) + .isInstanceOf(BusinessException.class) + .extracting(throwable -> ((BusinessException) throwable).getCode()) + .isEqualTo("GAME_ACTION_WINDOW_INVALID"); + } + + @Test + void shouldResolveSupplementalGangAndKeepMeldState() { + 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()); + + GameSession session = getSession(started.gameId()); + GameSeat seat = session.getTable().getSeats().get(0); + prepareSupplementalGangHand(seat, "3万"); + int beforeWallCount = session.getTable().getWallTiles().size(); + + GameStateResponse afterGang = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "3万", null) + ); + + assertThat(afterGang.phase()).isEqualTo("PLAYING"); + assertThat(afterGang.currentSeatNo()).isEqualTo(0); + assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1); + assertThat(afterGang.selfSeat().melds()).containsExactly("补杠:3万"); + assertThat(afterGang.selfSeat().score()).isEqualTo(3); + assertThat(afterGang.seats()).extracting(seatView -> seatView.score()).contains(3, -1, -1, -1); + assertThat(session.getEvents()) + .extracting(event -> event.eventType()) + .contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); + } + + @Test + void shouldResolveRobbingGangHuBeforeSupplementalGangCompletes() { + 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()); + prepareSupplementalGangHand(session.getTable().getSeats().get(0), "9筒"); + prepareWinningHand(session.getTable().getSeats().get(1)); + + GameStateResponse afterGangDeclare = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "GANG", "9筒", null) + ); + + assertThat(session.getPendingResponseActionWindow()).isNotNull(); + assertThat(afterGangDeclare.currentSeatNo()).isEqualTo(0); + + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", "9筒", 0) + ); + + assertThat(session.getPendingResponseActionWindow()).isNull(); + 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.seats().get(0).melds()).containsExactly("碰:9筒"); + } + @Test void shouldRejectSelfDrawHuWhenHandIsNotWinning() { RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); @@ -492,4 +609,40 @@ class GameSessionServiceTest { seat.receiveTile(new Tile(TileSuit.TIAO, 9)); seat.receiveTile(new Tile(TileSuit.TIAO, 9)); } + + private void prepareConcealedGangHand(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, 2)); + seat.receiveTile(new Tile(TileSuit.WAN, 3)); + seat.receiveTile(new Tile(TileSuit.TONG, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 5)); + seat.receiveTile(new Tile(TileSuit.TONG, 6)); + seat.receiveTile(new Tile(TileSuit.TIAO, 7)); + seat.receiveTile(new Tile(TileSuit.TIAO, 8)); + seat.receiveTile(new Tile(TileSuit.TIAO, 9)); + seat.receiveTile(new Tile(TileSuit.WAN, 9)); + } + + private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) { + seat.getHandTiles().clear(); + seat.getMeldGroups().clear(); + Tile gangTile = parseTile(gangTileDisplayName); + seat.addPengMeld(gangTile, 1); + seat.receiveTile(gangTile); + 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.TONG, 4)); + seat.receiveTile(new Tile(TileSuit.TONG, 5)); + seat.receiveTile(new Tile(TileSuit.TONG, 6)); + seat.receiveTile(new Tile(TileSuit.TIAO, 7)); + seat.receiveTile(new Tile(TileSuit.TIAO, 8)); + seat.receiveTile(new Tile(TileSuit.TIAO, 9)); + seat.receiveTile(new Tile(TileSuit.WAN, 9)); + } } diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java index f5bfc56..851de53 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java @@ -121,6 +121,40 @@ class ResponseActionWindowBuilderTest { .containsExactly(2); } + @Test + void shouldBuildHuOnlyResponseWindowForSupplementalGang() { + Tile gangTile = new Tile(TileSuit.TONG, 9); + GameSession session = new GameSession("room-1", createPlayingTable( + seat(0, "u0"), + seat(1, "u1", + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 2), + new Tile(TileSuit.WAN, 3), + new Tile(TileSuit.WAN, 4), + new Tile(TileSuit.TONG, 2), + new Tile(TileSuit.TONG, 3), + new Tile(TileSuit.TONG, 4), + new Tile(TileSuit.TIAO, 5), + new Tile(TileSuit.TIAO, 6), + new Tile(TileSuit.TIAO, 7), + new Tile(TileSuit.TONG, 9) + ), + seat(2, "u2"), + seat(3, "u3") + )); + + Optional result = builder.buildForSupplementalGang(session, 0, gangTile); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().triggerEventType()).isEqualTo("SUPPLEMENTAL_GANG_DECLARED"); + assertThat(result.orElseThrow().seatCandidates()).hasSize(1); + assertThat(result.orElseThrow().seatCandidates().get(0).options()) + .extracting(option -> option.actionType()) + .containsExactly(ActionType.HU, ActionType.PASS); + } + private GameTable createPlayingTable(GameSeat... seats) { GameTable table = new GameTable(List.of(seats), new ArrayList<>()); table.setPhase(GamePhase.PLAYING); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d933ce4..de6c844 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -35,6 +35,7 @@ type SelfSeatView = { score: number handTiles: string[] discardTiles: string[] + melds: string[] } type PublicSeatView = { @@ -47,6 +48,7 @@ type PublicSeatView = { score: number handCount: number discardTiles: string[] + melds: string[] } type GameStateResponse = { @@ -543,6 +545,11 @@ function submitCandidateAction(actionType: string, tile: string | null) { {{ tile }} +
+ + {{ meld }} + +
@@ -610,6 +617,12 @@ function submitCandidateAction(actionType: string, tile: string | null) { 暂无弃牌 +
+ + {{ meld }} + + 暂无副露 +