diff --git a/.serena/memories/current_execution_entry.md b/.serena/memories/current_execution_entry.md index da14684..8854eac 100644 --- a/.serena/memories/current_execution_entry.md +++ b/.serena/memories/current_execution_entry.md @@ -1,15 +1,19 @@ # 当前执行入口 - 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`。 -- Sprint 1 当前进度:`S1-01`、`S1-02`、`S1-03`、`S1-04`、`S1-05`、`S1-06`、`S1-07` 已完成。 -- `S1-06` 已完成内容: - - 已新增 `docs/RESPONSE_RESOLUTION_RULES.md`。 - - 已明确项目 V1 响应优先级:`HU > GANG > PENG > PASS`。 - - 已明确同优先级裁决按出牌者之后最近顺位优先。 - - 已明确当前 V1 不实现完整 `过水不胡` 和 `一炮多响`。 - - 已明确公共消息与私有消息边界,以及后续裁决器接入顺序。 -- 当前推荐的下一步有两条: - - 文档主线:`S1-08` 对局页信息架构与页面拆分方案。 - - 代码主线:开始下一轮“真实响应窗口停顿 + 候选下发 + 裁决器”实现,不再停留在模型和消息层。 -- 重要现状说明: - - 后端和前端已经具备结构化响应候选模型与私有消息结构。 - - 当前仍未在弃牌后真正暂停主流程等待响应,这是下一轮核心开发点。 \ No newline at end of file +- 当前主线已进入“统一结算服务 + 自摸胡闭环”阶段。 +- 最新已完成能力: + - `HU` 已同时支持两条路径: + - 响应胡(吃别人弃牌) + - 自摸胡(当前回合主动胡) + - `HuEvaluator` 已补 `canHu(List)`,可直接判断整手自摸是否成胡。 + - 当前回合私有动作消息已支持结构化候选下发,真人玩家在可自摸时会收到 `HU + DISCARD` 候选。 + - AI 当前回合若满足自摸胡条件,会优先执行 `HU`,不再一律走弃牌策略。 + - `SettlementService` 已补 `ZI_MO_HU` 结算占位规则:所有未胡玩家各 `-1`,自摸方累计获得对应分数。 + - 自摸胡后会将胡牌座位标记为 `won`,并按血战逻辑继续推进到下一有效座位;若仅剩 1 名未胡玩家则结束。 +- 当前仍未完成的核心点: + - `暗杠 / 补杠` 需要引入副露/杠展示状态,否则手牌数与桌面状态会失真。 + - 正式四川血战计分规则仍未实现,当前仍是工程占位分。 + - H5 正式页面拆分仍未开始。 +- 当前推荐的下一步: + 1. 代码主线:补 `Meld/副露` 领域模型,再接 `暗杠 / 补杠 / 杠后补牌 / 杠分事件`。 + 2. 结算主线:把占位规则升级为正式血战分数模型,并细化 `SETTLEMENT_APPLIED` 载荷。 \ 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 3d87fd9..71394a2 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java @@ -13,6 +13,8 @@ public class GameSeat { private final List handTiles = new ArrayList<>(); private final List discardTiles = new ArrayList<>(); private TileSuit lackSuit; + private boolean won; + private int score; public GameSeat(int seatNo, boolean ai, String nickname) { this(seatNo, ai, UUID.randomUUID().toString(), nickname); @@ -57,6 +59,22 @@ public class GameSeat { this.lackSuit = lackSuit; } + public boolean isWon() { + return won; + } + + public void setWon(boolean won) { + this.won = won; + } + + public int getScore() { + return score; + } + + public void addScore(int delta) { + this.score += delta; + } + public void receiveTile(Tile tile) { handTiles.add(tile); } @@ -65,4 +83,31 @@ public class GameSeat { handTiles.remove(tile); discardTiles.add(tile); } + + public void removeMatchingHandTiles(Tile tile, int count) { + int remaining = count; + for (int i = handTiles.size() - 1; i >= 0 && remaining > 0; i--) { + if (handTiles.get(i).equals(tile)) { + handTiles.remove(i); + remaining--; + } + } + if (remaining > 0) { + throw new IllegalStateException("手牌中可移除的匹配牌数量不足"); + } + } + + public void removeMatchingDiscardTile(Tile tile) { + for (int i = discardTiles.size() - 1; i >= 0; i--) { + if (discardTiles.get(i).equals(tile)) { + discardTiles.remove(i); + return; + } + } + throw new IllegalStateException("弃牌区不存在指定牌"); + } + + public void declareHu() { + this.won = true; + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolution.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolution.java new file mode 100644 index 0000000..ca93430 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionResolution.java @@ -0,0 +1,9 @@ +package com.xuezhanmaster.game.domain; + +public record ResponseActionResolution( + ActionType actionType, + int winnerSeatNo, + int sourceSeatNo, + String triggerTile +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ScoreChange.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ScoreChange.java new file mode 100644 index 0000000..e08c4bc --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ScoreChange.java @@ -0,0 +1,8 @@ +package com.xuezhanmaster.game.domain; + +public record ScoreChange( + int seatNo, + int delta, + int afterScore +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java new file mode 100644 index 0000000..e121fd6 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementResult.java @@ -0,0 +1,13 @@ +package com.xuezhanmaster.game.domain; + +import java.util.List; + +public record SettlementResult( + SettlementType settlementType, + ActionType actionType, + int actorSeatNo, + int sourceSeatNo, + String triggerTile, + List scoreChanges +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java new file mode 100644 index 0000000..0f7bc94 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/SettlementType.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.game.domain; + +public enum SettlementType { + DIAN_PAO_HU, + ZI_MO_HU, + MING_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 482c6a0..3ac82d2 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/PublicSeatView.java @@ -7,9 +7,10 @@ public record PublicSeatView( String playerId, String nickname, boolean ai, + boolean won, String lackSuit, + int score, int handCount, List discardTiles ) { } - 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 6746760..1de9785 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java +++ b/backend/src/main/java/com/xuezhanmaster/game/dto/SelfSeatView.java @@ -6,9 +6,10 @@ public record SelfSeatView( int seatNo, String playerId, String nickname, + boolean won, String lackSuit, + int score, List handTiles, List discardTiles ) { } - 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 e5e2b3d..99900a3 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java +++ b/backend/src/main/java/com/xuezhanmaster/game/event/GameEvent.java @@ -1,7 +1,12 @@ package com.xuezhanmaster.game.event; +import com.xuezhanmaster.game.domain.ScoreChange; +import com.xuezhanmaster.game.domain.SettlementResult; + import java.time.Instant; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; public record GameEvent( @@ -70,7 +75,7 @@ public record GameEvent( String gameId, GameEventType eventType, int seatNo, - int sourceSeatNo, + Integer sourceSeatNo, String tile ) { return of(gameId, eventType, seatNo, buildActionPayload( @@ -80,10 +85,37 @@ public record GameEvent( )); } - private static Map buildActionPayload(String actionType, int sourceSeatNo, String tile) { + public static GameEvent settlementApplied(String gameId, SettlementResult settlementResult) { + Map payload = new LinkedHashMap<>(); + payload.put("settlementType", settlementResult.settlementType().name()); + payload.put("actionType", settlementResult.actionType().name()); + payload.put("actorSeatNo", settlementResult.actorSeatNo()); + payload.put("sourceSeatNo", settlementResult.sourceSeatNo()); + payload.put("triggerTile", settlementResult.triggerTile()); + payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges())); + return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload); + } + + public static GameEvent scoreChanged( + String gameId, + int seatNo, + String settlementType, + int delta, + int score + ) { + Map payload = new LinkedHashMap<>(); + payload.put("settlementType", settlementType); + payload.put("delta", delta); + payload.put("score", score); + return of(gameId, GameEventType.SCORE_CHANGED, seatNo, payload); + } + + private static Map buildActionPayload(String actionType, Integer sourceSeatNo, String tile) { Map payload = new LinkedHashMap<>(); payload.put("actionType", actionType); - payload.put("sourceSeatNo", sourceSeatNo); + if (sourceSeatNo != null) { + payload.put("sourceSeatNo", sourceSeatNo); + } if (tile != null && !tile.isBlank()) { payload.put("tile", tile); } @@ -99,4 +131,16 @@ public record GameEvent( default -> eventType.name(); }; } + + private static List> toScoreChangePayload(List scoreChanges) { + List> payload = new ArrayList<>(); + for (ScoreChange scoreChange : scoreChanges) { + Map item = new LinkedHashMap<>(); + item.put("seatNo", scoreChange.seatNo()); + item.put("delta", scoreChange.delta()); + item.put("afterScore", scoreChange.afterScore()); + payload.add(item); + } + return payload; + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java b/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java index 691644c..c230d7a 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java +++ b/backend/src/main/java/com/xuezhanmaster/game/event/GameEventType.java @@ -13,5 +13,7 @@ public enum GameEventType { GANG_DECLARED, HU_DECLARED, PASS_DECLARED, + SETTLEMENT_APPLIED, + SCORE_CHANGED, ACTION_REQUIRED } 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 f86b291..faade21 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java @@ -21,6 +21,12 @@ import java.util.List; @Component public class GameActionProcessor { + private final HuEvaluator huEvaluator; + + public GameActionProcessor(HuEvaluator huEvaluator) { + this.huEvaluator = huEvaluator; + } + public List process(GameSession session, GameActionRequest request) { ActionType actionType = parseActionType(request.actionType()); return switch (actionType) { @@ -38,21 +44,25 @@ public class GameActionProcessor { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "碰"); - return unsupportedAction(ActionType.PENG); + return List.of(); } private List gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "杠"); - return unsupportedAction(ActionType.GANG); + return List.of(); } private List hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); + if (sourceSeatNo == null) { + validateSelfDrawHu(session, table, seat); + return List.of(); + } validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "胡"); - return unsupportedAction(ActionType.HU); + return List.of(); } private List pass(GameSession session, String userId, Integer sourceSeatNo) { @@ -93,6 +103,9 @@ public class GameActionProcessor { } GameSeat seat = findSeatByUserId(table, userId); + if (seat.isWon()) { + throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续出牌"); + } if (seat.getSeatNo() != table.getCurrentSeatNo()) { throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合"); } @@ -188,6 +201,24 @@ public class GameActionProcessor { } } + private void validateSelfDrawHu(GameSession session, GameTable table, GameSeat actorSeat) { + 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 (!huEvaluator.canHu(actorSeat.getHandTiles())) { + 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 56ab6fc..2c83c93 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -6,9 +6,13 @@ 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.ResponseActionResolution; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionWindow; +import com.xuezhanmaster.game.domain.ScoreChange; +import com.xuezhanmaster.game.domain.SettlementResult; import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; import com.xuezhanmaster.game.dto.GameActionRequest; import com.xuezhanmaster.game.dto.GameStateResponse; import com.xuezhanmaster.game.dto.PublicSeatView; @@ -26,6 +30,7 @@ import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse; import com.xuezhanmaster.teaching.service.PlayerVisibilityService; import com.xuezhanmaster.teaching.service.TeachingService; import com.xuezhanmaster.ws.service.GameMessagePublisher; +import com.xuezhanmaster.ws.dto.PrivateActionCandidate; import org.springframework.stereotype.Service; import java.util.List; @@ -41,6 +46,9 @@ public class GameSessionService { private final GameEngine gameEngine; private final GameActionProcessor gameActionProcessor; private final ResponseActionWindowBuilder responseActionWindowBuilder; + private final ResponseActionResolver responseActionResolver; + private final SettlementService settlementService; + private final HuEvaluator huEvaluator; private final StrategyService strategyService; private final PlayerVisibilityService playerVisibilityService; private final TeachingService teachingService; @@ -52,6 +60,9 @@ public class GameSessionService { GameEngine gameEngine, GameActionProcessor gameActionProcessor, ResponseActionWindowBuilder responseActionWindowBuilder, + ResponseActionResolver responseActionResolver, + SettlementService settlementService, + HuEvaluator huEvaluator, StrategyService strategyService, PlayerVisibilityService playerVisibilityService, TeachingService teachingService, @@ -61,6 +72,9 @@ public class GameSessionService { this.gameEngine = gameEngine; this.gameActionProcessor = gameActionProcessor; this.responseActionWindowBuilder = responseActionWindowBuilder; + this.responseActionResolver = responseActionResolver; + this.settlementService = settlementService; + this.huEvaluator = huEvaluator; this.strategyService = strategyService; this.playerVisibilityService = playerVisibilityService; this.teachingService = teachingService; @@ -129,7 +143,9 @@ public class GameSessionService { self.getSeatNo(), self.getPlayerId(), self.getNickname(), + self.isWon(), self.getLackSuit() == null ? null : self.getLackSuit().name(), + self.getScore(), self.getHandTiles().stream().map(Tile::getDisplayName).toList(), self.getDiscardTiles().stream().map(Tile::getDisplayName).toList() ), @@ -138,29 +154,62 @@ public class GameSessionService { } private void moveToNextSeat(GameTable table, String gameId) { - int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size(); - GameSeat nextSeat = table.getSeats().get(nextSeatNo); - if (table.getWallTiles().isEmpty()) { + if (shouldFinishTable(table)) { table.setPhase(GamePhase.FINISHED); gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name())); return; } + Optional nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo()); + if (nextSeatOptional.isEmpty()) { + table.setPhase(GamePhase.FINISHED); + gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name())); + return; + } + + GameSeat nextSeat = nextSeatOptional.get(); Tile drawnTile = table.getWallTiles().remove(0); nextSeat.receiveTile(drawnTile); - table.setCurrentSeatNo(nextSeatNo); - gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size())); - gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo)); + table.setCurrentSeatNo(nextSeat.getSeatNo()); + gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size())); + gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo())); } private void autoPlayBots(GameSession session) { GameTable table = session.getTable(); while (table.getPhase() == GamePhase.PLAYING) { GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo()); + if (currentSeat.isWon()) { + moveToNextSeat(table, session.getGameId()); + continue; + } if (!currentSeat.isAi()) { return; } + if (canSelfDrawHu(currentSeat)) { + List huEvents = gameActionProcessor.process( + session, + new GameActionRequest( + currentSeat.getPlayerId(), + "HU", + null, + null + ) + ); + appendAndPublish(session, huEvents); + handlePostActionEffects( + session, + ActionType.HU, + new GameActionRequest(currentSeat.getPlayerId(), "HU", null, null), + huEvents + ); + if (table.getPhase() == GamePhase.FINISHED) { + return; + } + continue; + } + PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat); StrategyDecision decision = strategyService.evaluateDiscard(visibleState); List events = gameActionProcessor.process( @@ -197,14 +246,14 @@ public class GameSessionService { } GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo()); - if (currentSeat.isAi()) { + if (currentSeat.isAi() || currentSeat.isWon()) { return; } - gameMessagePublisher.publishPrivateTurnActionRequired( + gameMessagePublisher.publishPrivateTurnActionCandidates( session.getGameId(), currentSeat.getPlayerId(), - List.of("DISCARD"), + buildTurnActionCandidates(currentSeat), currentSeat.getSeatNo() ); @@ -234,6 +283,9 @@ public class GameSessionService { } case DISCARD -> handleDiscardPostAction(session, request, events); case PASS -> handlePassPostAction(session, request); + case PENG -> handleResponseDeclarationPostAction(session, actionType, request); + case GANG -> handleGangPostAction(session, request); + case HU -> handleHuPostAction(session, request); default -> { // 其余动作在后续 Sprint 中补充对应副作用 } @@ -281,10 +333,29 @@ public class GameSessionService { ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session); GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId()); session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS); - if (allResponseCandidatesResolved(session, responseActionWindow)) { - closeResponseWindowAsPass(session, responseActionWindow); - continueAfterDiscardWithoutResponse(session); + tryResolveResponseWindow(session, responseActionWindow); + } + + private void handleResponseDeclarationPostAction(GameSession session, ActionType actionType, GameActionRequest request) { + ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session); + GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId()); + session.getResponseActionSelections().put(actorSeat.getSeatNo(), actionType); + tryResolveResponseWindow(session, responseActionWindow); + } + + private void handleGangPostAction(GameSession session, GameActionRequest request) { + if (request.sourceSeatNo() == null) { + throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充"); } + handleResponseDeclarationPostAction(session, ActionType.GANG, request); + } + + private void handleHuPostAction(GameSession session, GameActionRequest request) { + if (request.sourceSeatNo() == null) { + executeSelfDrawHu(session, request.userId()); + return; + } + handleResponseDeclarationPostAction(session, ActionType.HU, request); } private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) { @@ -331,6 +402,26 @@ public class GameSessionService { session.clearResponseActionSelections(); } + private void tryResolveResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) { + if (!allResponseCandidatesResolved(session, responseActionWindow)) { + return; + } + + Optional resolution = responseActionResolver.resolve( + responseActionWindow, + session.getResponseActionSelections(), + session.getTable().getSeats().size() + ); + + if (resolution.isPresent()) { + executeResolvedResponseAction(session, responseActionWindow, resolution.get()); + return; + } + + closeResponseWindowAsPass(session, responseActionWindow); + continueAfterDiscardWithoutResponse(session); + } + private ResponseActionWindow requirePendingResponseWindow(GameSession session) { ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow(); if (responseActionWindow == null) { @@ -365,9 +456,9 @@ public class GameSessionService { } Tile tile = switch (suitLabel) { - case "万" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.WAN, rank); - case "筒" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TONG, rank); - case "条" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TIAO, rank); + case "万" -> new Tile(TileSuit.WAN, rank); + case "筒" -> new Tile(TileSuit.TONG, rank); + case "条" -> new Tile(TileSuit.TIAO, rank); default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法"); }; return tile; @@ -379,6 +470,160 @@ public class GameSessionService { notifyActionIfHumanTurn(session); } + private void executeResolvedResponseAction( + GameSession session, + ResponseActionWindow responseActionWindow, + ResponseActionResolution resolution + ) { + closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType()); + switch (resolution.actionType()) { + case PENG -> executePeng(session, resolution); + case GANG -> executeGang(session, resolution); + case HU -> executeHu(session, resolution); + default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果"); + } + } + + private void closeResponseWindowWithResolution(GameSession session, ResponseActionWindow responseActionWindow, ActionType actionType) { + appendAndPublish(session, GameEvent.responseWindowClosed( + session.getGameId(), + null, + responseActionWindow.sourceSeatNo(), + actionType.name() + )); + session.setPendingResponseActionWindow(null); + session.clearResponseActionSelections(); + } + + private void executePeng(GameSession session, 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.removeMatchingHandTiles(claimedTile, 2); + sourceSeat.removeMatchingDiscardTile(claimedTile); + table.setCurrentSeatNo(winnerSeat.getSeatNo()); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.PENG_DECLARED, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); + continueFromResolvedActionTurn(session, winnerSeat); + } + + private void executeGang(GameSession session, 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.removeMatchingHandTiles(claimedTile, 3); + sourceSeat.removeMatchingDiscardTile(claimedTile); + table.setCurrentSeatNo(winnerSeat.getSeatNo()); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.GANG_DECLARED, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + appendSettlementEvents(session, settlementService.settleExposedGang( + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.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 continueFromResolvedActionTurn(GameSession session, GameSeat currentSeat) { + if (currentSeat.isAi()) { + autoPlayBots(session); + notifyActionIfHumanTurn(session); + return; + } + notifyActionIfHumanTurn(session); + } + + private void executeHu(GameSession session, 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); + winnerSeat.declareHu(); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.HU_DECLARED, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + appendSettlementEvents(session, settlementService.settleDiscardHu( + table, + winnerSeat.getSeatNo(), + sourceSeat.getSeatNo(), + claimedTile.getDisplayName() + )); + + if (shouldFinishTable(table)) { + table.setPhase(GamePhase.FINISHED); + appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name())); + return; + } + + continueAfterDiscardWithoutResponse(session); + } + + private void executeSelfDrawHu(GameSession session, String userId) { + GameTable table = session.getTable(); + GameSeat winnerSeat = findSeatByUserId(table, userId); + winnerSeat.declareHu(); + + appendAndPublish(session, GameEvent.responseActionDeclared( + session.getGameId(), + GameEventType.HU_DECLARED, + winnerSeat.getSeatNo(), + null, + null + )); + appendSettlementEvents(session, settlementService.settleSelfDrawHu( + table, + winnerSeat.getSeatNo(), + null + )); + + if (shouldFinishTable(table)) { + table.setPhase(GamePhase.FINISHED); + appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name())); + return; + } + + moveToNextSeat(table, session.getGameId()); + autoPlayBots(session); + notifyActionIfHumanTurn(session); + } + private ActionType parseActionType(String actionType) { try { return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT)); @@ -394,10 +639,59 @@ public class GameSessionService { seat.getPlayerId(), seat.getNickname(), seat.isAi(), + seat.isWon(), seat.getLackSuit() == null ? null : seat.getLackSuit().name(), + seat.getScore(), seat.getHandTiles().size(), seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList() )) .toList(); } + + private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) { + appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult)); + for (ScoreChange scoreChange : settlementResult.scoreChanges()) { + appendAndPublish(session, GameEvent.scoreChanged( + session.getGameId(), + scoreChange.seatNo(), + settlementResult.settlementType().name(), + scoreChange.delta(), + scoreChange.afterScore() + )); + } + } + + private boolean shouldFinishTable(GameTable table) { + return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1; + } + + private boolean canSelfDrawHu(GameSeat seat) { + return huEvaluator.canHu(seat.getHandTiles()); + } + + private long countActiveSeats(GameTable table) { + return table.getSeats().stream() + .filter(seat -> !seat.isWon()) + .count(); + } + + private Optional findNextActiveSeat(GameTable table, int fromSeatNo) { + int seatCount = table.getSeats().size(); + for (int offset = 1; offset <= seatCount; offset++) { + GameSeat candidate = table.getSeats().get((fromSeatNo + offset) % seatCount); + if (!candidate.isWon()) { + return Optional.of(candidate); + } + } + return Optional.empty(); + } + + private List buildTurnActionCandidates(GameSeat currentSeat) { + List candidates = new java.util.ArrayList<>(); + if (canSelfDrawHu(currentSeat)) { + candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null)); + } + candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null)); + return List.copyOf(candidates); + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java b/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java new file mode 100644 index 0000000..78f362a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/HuEvaluator.java @@ -0,0 +1,101 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class HuEvaluator { + + public boolean canHu(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 (canFormMelds(counts)) { + return true; + } + counts[i] += 2; + } + return false; + } + + public boolean canHuWithClaimedTile(List handTiles, Tile claimedTile) { + List tiles = new ArrayList<>(handTiles); + tiles.add(claimedTile); + return canHu(tiles); + } + + private boolean canFormMelds(int[] counts) { + int firstIndex = firstNonZeroIndex(counts); + if (firstIndex == -1) { + return true; + } + + if (counts[firstIndex] >= 3) { + counts[firstIndex] -= 3; + if (canFormMelds(counts)) { + counts[firstIndex] += 3; + return true; + } + counts[firstIndex] += 3; + } + + int suitOffset = (firstIndex / 9) * 9; + int inSuitIndex = firstIndex % 9; + if (inSuitIndex <= 6 + && counts[firstIndex + 1] > 0 + && counts[firstIndex + 2] > 0 + && firstIndex + 2 < suitOffset + 9) { + counts[firstIndex]--; + counts[firstIndex + 1]--; + counts[firstIndex + 2]--; + if (canFormMelds(counts)) { + counts[firstIndex]++; + counts[firstIndex + 1]++; + counts[firstIndex + 2]++; + return true; + } + counts[firstIndex]++; + counts[firstIndex + 1]++; + counts[firstIndex + 2]++; + } + + return false; + } + + private int firstNonZeroIndex(int[] counts) { + for (int i = 0; i < counts.length; i++) { + if (counts[i] > 0) { + return i; + } + } + return -1; + } + + private int[] buildCounts(List tiles) { + int[] counts = new int[27]; + for (Tile tile : tiles) { + counts[indexOf(tile)]++; + } + return counts; + } + + private int indexOf(Tile tile) { + int suitOffset = switch (tile.getSuit()) { + case WAN -> 0; + case TONG -> 9; + case TIAO -> 18; + }; + return suitOffset + tile.getRank() - 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 new file mode 100644 index 0000000..d68f8f9 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionResolver.java @@ -0,0 +1,42 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.ActionType; +import com.xuezhanmaster.game.domain.ResponseActionResolution; +import com.xuezhanmaster.game.domain.ResponseActionWindow; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +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))) + .map(entry -> new ResponseActionResolution( + entry.getValue(), + entry.getKey(), + window.sourceSeatNo(), + window.triggerTile() + )); + } + + private int priority(ActionType actionType) { + return switch (actionType) { + case HU -> 0; + case GANG -> 1; + case PENG -> 2; + case PASS -> 3; + default -> 99; + }; + } + + private int seatDistance(int sourceSeatNo, int targetSeatNo, int seatCount) { + return (targetSeatNo - sourceSeatNo + seatCount) % seatCount; + } +} 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 e8b64f8..5af0f30 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java @@ -17,12 +17,18 @@ import java.util.Optional; @Component public class ResponseActionWindowBuilder { + private final HuEvaluator huEvaluator; + + public ResponseActionWindowBuilder(HuEvaluator huEvaluator) { + this.huEvaluator = huEvaluator; + } + public Optional buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) { GameTable table = session.getTable(); List seatCandidates = new ArrayList<>(); for (GameSeat seat : table.getSeats()) { - if (seat.getSeatNo() == sourceSeatNo) { + if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) { continue; } @@ -51,16 +57,26 @@ public class ResponseActionWindowBuilder { private List buildSeatOptions(GameSeat seat, Tile discardedTile) { int sameTileCount = countSameTileInHand(seat, discardedTile); - if (sameTileCount < 2) { + boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile); + if (!canHu && sameTileCount < 2) { return List.of(); } List options = new ArrayList<>(); + if (canHu) { + options.add(new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName())); + } options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName())); if (sameTileCount >= 3) { options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName())); } options.add(new ResponseActionOption(ActionType.PASS, null)); + if (sameTileCount < 2) { + return List.of( + new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()), + new ResponseActionOption(ActionType.PASS, null) + ); + } return options; } diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java new file mode 100644 index 0000000..8e364d6 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/SettlementService.java @@ -0,0 +1,118 @@ +package com.xuezhanmaster.game.service; + +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.SettlementResult; +import com.xuezhanmaster.game.domain.SettlementType; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +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 MING_GANG_SCORE = 1; + + public SettlementResult settleDiscardHu( + GameTable table, + int winnerSeatNo, + int sourceSeatNo, + String triggerTile + ) { + return applySettlement( + table, + SettlementType.DIAN_PAO_HU, + ActionType.HU, + winnerSeatNo, + sourceSeatNo, + triggerTile, + orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) + ); + } + + public SettlementResult settleExposedGang( + GameTable table, + int winnerSeatNo, + int sourceSeatNo, + String triggerTile + ) { + return applySettlement( + table, + SettlementType.MING_GANG, + ActionType.GANG, + winnerSeatNo, + sourceSeatNo, + triggerTile, + orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE) + ); + } + + public SettlementResult settleSelfDrawHu( + 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(), -ZI_MO_HU_SCORE); + totalWinScore += ZI_MO_HU_SCORE; + } + scoreDeltas.put(winnerSeatNo, totalWinScore); + return applySettlement( + table, + SettlementType.ZI_MO_HU, + ActionType.HU, + winnerSeatNo, + winnerSeatNo, + triggerTile, + scoreDeltas + ); + } + + private SettlementResult applySettlement( + GameTable table, + SettlementType settlementType, + ActionType actionType, + int actorSeatNo, + int sourceSeatNo, + String triggerTile, + Map scoreDeltas + ) { + List scoreChanges = new ArrayList<>(); + for (Map.Entry entry : scoreDeltas.entrySet()) { + GameSeat seat = table.getSeats().get(entry.getKey()); + seat.addScore(entry.getValue()); + scoreChanges.add(new ScoreChange( + seat.getSeatNo(), + entry.getValue(), + seat.getScore() + )); + } + return new SettlementResult( + settlementType, + actionType, + actorSeatNo, + sourceSeatNo, + triggerTile, + List.copyOf(scoreChanges) + ); + } + + private Map orderedDeltas(int winnerSeatNo, int winnerDelta, int sourceSeatNo, int sourceDelta) { + Map deltas = new LinkedHashMap<>(); + deltas.put(winnerSeatNo, winnerDelta); + deltas.put(sourceSeatNo, sourceDelta); + return deltas; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java b/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java index 7bf21c4..5cbffc0 100644 --- a/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java +++ b/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java @@ -11,6 +11,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import java.util.List; +import java.util.stream.Collectors; @Service public class GameMessagePublisher { @@ -35,6 +36,19 @@ public class GameMessagePublisher { } public void publishPrivateTurnActionRequired(String gameId, String userId, List availableActions, int currentSeatNo) { + publishPrivateTurnActionCandidates(gameId, userId, toCandidates(availableActions), currentSeatNo); + } + + public void publishPrivateTurnActionCandidates( + String gameId, + String userId, + List candidates, + int currentSeatNo + ) { + List availableActions = candidates.stream() + .map(PrivateActionCandidate::actionType) + .distinct() + .collect(Collectors.toList()); messagingTemplate.convertAndSend( "/topic/users/" + userId + "/actions", new PrivateActionMessage( @@ -47,7 +61,7 @@ public class GameMessagePublisher { null, null, null, - toCandidates(availableActions) + candidates ) ); } 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 1a46fd1..622cda0 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/event/GameEventTest.java @@ -1,7 +1,13 @@ package com.xuezhanmaster.game.event; +import com.xuezhanmaster.game.domain.ActionType; +import com.xuezhanmaster.game.domain.ScoreChange; +import com.xuezhanmaster.game.domain.SettlementResult; +import com.xuezhanmaster.game.domain.SettlementType; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; class GameEventTest { @@ -39,4 +45,53 @@ class GameEventTest { assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED); assertThat(switched.payload()).containsEntry("currentSeatNo", 3); } + + @Test + void shouldBuildSettlementEvents() { + SettlementResult settlementResult = new SettlementResult( + SettlementType.DIAN_PAO_HU, + ActionType.HU, + 2, + 0, + "9筒", + List.of( + new ScoreChange(2, 1, 3), + new ScoreChange(0, -1, -2) + ) + ); + + GameEvent settlementApplied = GameEvent.settlementApplied("game-1", settlementResult); + GameEvent scoreChanged = GameEvent.scoreChanged("game-1", 2, SettlementType.DIAN_PAO_HU.name(), 1, 3); + + assertThat(settlementApplied.eventType()).isEqualTo(GameEventType.SETTLEMENT_APPLIED); + assertThat(settlementApplied.payload()) + .containsEntry("settlementType", "DIAN_PAO_HU") + .containsEntry("actionType", "HU") + .containsEntry("sourceSeatNo", 0) + .containsEntry("triggerTile", "9筒"); + assertThat(settlementApplied.payload()).containsKey("scoreChanges"); + + assertThat(scoreChanged.eventType()).isEqualTo(GameEventType.SCORE_CHANGED); + assertThat(scoreChanged.payload()) + .containsEntry("settlementType", "DIAN_PAO_HU") + .containsEntry("delta", 1) + .containsEntry("score", 3); + } + + @Test + void shouldAllowHuEventWithoutSourceSeatForSelfDraw() { + GameEvent selfDrawHu = GameEvent.responseActionDeclared( + "game-1", + GameEventType.HU_DECLARED, + 0, + null, + null + ); + + assertThat(selfDrawHu.eventType()).isEqualTo(GameEventType.HU_DECLARED); + assertThat(selfDrawHu.payload()) + .containsEntry("actionType", "HU") + .doesNotContainKey("sourceSeatNo") + .doesNotContainKey("tile"); + } } 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 6ee7334..d953cb5 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -1,10 +1,14 @@ package com.xuezhanmaster.game.service; import com.xuezhanmaster.common.exception.BusinessException; +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.TileSuit; import com.xuezhanmaster.game.dto.GameStateResponse; import com.xuezhanmaster.game.service.GameActionProcessor; +import com.xuezhanmaster.game.event.GameEventType; import com.xuezhanmaster.strategy.service.StrategyService; import com.xuezhanmaster.teaching.service.PlayerVisibilityService; import com.xuezhanmaster.teaching.service.TeachingService; @@ -18,6 +22,9 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import com.xuezhanmaster.ws.service.GameMessagePublisher; +import java.lang.reflect.Field; +import java.util.Map; + import static org.mockito.Mockito.mock; import static org.assertj.core.api.Assertions.assertThat; @@ -26,11 +33,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class GameSessionServiceTest { private final RoomService roomService = new RoomService(); + private final HuEvaluator huEvaluator = new HuEvaluator(); private final GameSessionService gameSessionService = new GameSessionService( roomService, new GameEngine(new DeckFactory()), - new GameActionProcessor(), - new ResponseActionWindowBuilder(), + new GameActionProcessor(huEvaluator), + new ResponseActionWindowBuilder(huEvaluator), + new ResponseActionResolver(), + new SettlementService(), + huEvaluator, new StrategyService(), new PlayerVisibilityService(), new TeachingService(), @@ -65,6 +76,8 @@ class GameSessionServiceTest { GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1"); GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name()); String discardTile = playing.selfSeat().handTiles().get(0); + GameSession session = getSession(playing.gameId()); + removeMatchingTilesFromOtherSeats(session, discardTile, 0); GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile); @@ -88,7 +101,7 @@ class GameSessionServiceTest { )) .isInstanceOf(BusinessException.class) .extracting(throwable -> ((BusinessException) throwable).getCode()) - .isEqualTo("GAME_ACTION_UNSUPPORTED"); + .isEqualTo("GAME_ACTION_WINDOW_NOT_FOUND"); } @Test @@ -156,4 +169,327 @@ class GameSessionServiceTest { .extracting(throwable -> ((BusinessException) throwable).getCode()) .isEqualTo("GAME_ACTION_INVALID"); } + + @Test + void shouldPauseDiscardFlowUntilHumanPassesResponseWindow() { + 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()); + GameStateResponse playing = gameSessionService.getState(started.gameId(), "host-1"); + String discardTile = playing.selfSeat().handTiles().get(0); + GameSession session = getSession(playing.gameId()); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 2); + removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1); + + GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile); + + assertThat(session.getPendingResponseActionWindow()).isNotNull(); + assertThat(afterDiscard.currentSeatNo()).isEqualTo(0); + + GameStateResponse afterPass = gameSessionService.performAction( + playing.gameId(), + new GameActionRequest("player-2", "PASS", null, 0) + ); + + assertThat(session.getPendingResponseActionWindow()).isNull(); + assertThat(afterPass.currentSeatNo()).isEqualTo(1); + assertThat(afterPass.phase()).isEqualTo("PLAYING"); + } + + @Test + void shouldResolvePengAndTransferTurnToWinner() { + 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()); + + GameStateResponse hostView = gameSessionService.getState(started.gameId(), "host-1"); + String discardTile = hostView.selfSeat().handTiles().get(0); + GameSession session = getSession(hostView.gameId()); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 2); + removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1); + + gameSessionService.discardTile(hostView.gameId(), "host-1", discardTile); + GameStateResponse afterPeng = gameSessionService.performAction( + hostView.gameId(), + new GameActionRequest("player-2", "PENG", discardTile, 0) + ); + + assertThat(session.getPendingResponseActionWindow()).isNull(); + assertThat(afterPeng.currentSeatNo()).isEqualTo(1); + assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile); + assertThat(afterPeng.selfSeat().handTiles()).hasSize(13); + } + + @Test + void shouldResolveGangAndApplySettlementScore() { + 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()); + + GameStateResponse hostView = gameSessionService.getState(started.gameId(), "host-1"); + String discardTile = hostView.selfSeat().handTiles().get(0); + GameSession session = getSession(hostView.gameId()); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 3); + removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1); + + gameSessionService.discardTile(hostView.gameId(), "host-1", discardTile); + GameStateResponse afterGang = gameSessionService.performAction( + hostView.gameId(), + new GameActionRequest("player-2", "GANG", discardTile, 0) + ); + + assertThat(afterGang.currentSeatNo()).isEqualTo(1); + assertThat(afterGang.selfSeat().score()).isEqualTo(1); + assertThat(afterGang.seats().get(0).score()).isEqualTo(-1); + assertThat(session.getEvents()) + .extracting(event -> event.eventType()) + .contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); + } + + @Test + void shouldResolveHuAndContinueBloodBattleWhenMultipleActiveSeatsRemain() { + 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)); + String discardTile = "9筒"; + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), discardTile, 1); + removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1); + + gameSessionService.discardTile(started.gameId(), "host-1", discardTile); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", discardTile, 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(session.getEvents()) + .extracting(event -> event.eventType()) + .contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); + } + + @Test + void shouldFinishBloodBattleWhenOnlyOneActiveSeatRemainsAfterHu() { + 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)); + session.getTable().getSeats().get(2).declareHu(); + session.getTable().getSeats().get(3).declareHu(); + String discardTile = "9筒"; + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), discardTile, 1); + removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1); + + gameSessionService.discardTile(started.gameId(), "host-1", discardTile); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", discardTile, 0) + ); + + assertThat(session.getPendingResponseActionWindow()).isNull(); + assertThat(afterHu.phase()).isEqualTo("FINISHED"); + assertThat(afterHu.selfSeat().won()).isTrue(); + } + + @Test + void shouldSkipWonSeatWhenAdvancingToNextTurn() { + 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()); + session.getTable().getSeats().get(1).declareHu(); + String discardTile = started.selfSeat().handTiles().get(0); + removeMatchingTilesFromOtherSeats(session, discardTile, 0); + + GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", discardTile); + + assertThat(afterDiscard.currentSeatNo()).isEqualTo(2); + } + + @Test + void shouldResolveSelfDrawHuAndContinueToNextActiveSeat() { + 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)); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "HU", null, null) + ); + + assertThat(afterHu.phase()).isEqualTo("PLAYING"); + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(3); + assertThat(afterHu.currentSeatNo()).isEqualTo(1); + assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(3, -1, -1, -1); + assertThat(session.getEvents()) + .extracting(event -> event.eventType()) + .contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED); + } + + @Test + void shouldRejectSelfDrawHuWhenHandIsNotWinning() { + 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", "HU", null, null) + )) + .isInstanceOf(BusinessException.class) + .extracting(throwable -> ((BusinessException) throwable).getCode()) + .isEqualTo("GAME_ACTION_WINDOW_INVALID"); + } + + @SuppressWarnings("unchecked") + private GameSession getSession(String gameId) { + try { + Field field = GameSessionService.class.getDeclaredField("sessions"); + field.setAccessible(true); + Map sessions = (Map) field.get(gameSessionService); + return sessions.get(gameId); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("无法读取测试用对局会话", exception); + } + } + + private void ensureSeatHasMatchingTiles(GameSeat seat, String tileDisplayName, int count) { + Tile tile = parseTile(tileDisplayName); + for (int i = 0; i < count; i++) { + seat.receiveTile(tile); + } + } + + private void removeMatchingTilesFromOtherSeats(GameSession session, String tileDisplayName, int... keepSeatNos) { + for (GameSeat seat : session.getTable().getSeats()) { + if (shouldKeepSeat(seat.getSeatNo(), keepSeatNos)) { + continue; + } + seat.getHandTiles().removeIf(tile -> tile.getDisplayName().equals(tileDisplayName)); + } + } + + private boolean shouldKeepSeat(int seatNo, int... keepSeatNos) { + for (int keepSeatNo : keepSeatNos) { + if (seatNo == keepSeatNo) { + return true; + } + } + return false; + } + + private Tile parseTile(String tileDisplayName) { + String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1); + String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1); + int rank = Integer.parseInt(rankPart); + return switch (suitLabel) { + case "万" -> new Tile(TileSuit.WAN, rank); + case "筒" -> new Tile(TileSuit.TONG, rank); + case "条" -> new Tile(TileSuit.TIAO, rank); + default -> throw new IllegalArgumentException("未知花色"); + }; + } + + private void prepareWinningHand(GameSeat seat) { + seat.getHandTiles().clear(); + 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.TIAO, 5)); + seat.receiveTile(new Tile(TileSuit.TIAO, 6)); + seat.receiveTile(new Tile(TileSuit.TIAO, 7)); + seat.receiveTile(new Tile(TileSuit.TONG, 9)); + } + + private void prepareSelfDrawWinningHand(GameSeat seat) { + seat.getHandTiles().clear(); + 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.TIAO, 5)); + seat.receiveTile(new Tile(TileSuit.TIAO, 6)); + seat.receiveTile(new Tile(TileSuit.TIAO, 7)); + seat.receiveTile(new Tile(TileSuit.TIAO, 9)); + seat.receiveTile(new Tile(TileSuit.TIAO, 9)); + } } diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java new file mode 100644 index 0000000..da8ba3a --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/HuEvaluatorTest.java @@ -0,0 +1,62 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class HuEvaluatorTest { + + private final HuEvaluator huEvaluator = new HuEvaluator(); + + @Test + void shouldRecognizeStandardWinningHandWithClaimedTile() { + boolean result = huEvaluator.canHuWithClaimedTile( + List.of( + 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) + ), + new Tile(TileSuit.TONG, 9) + ); + + assertThat(result).isTrue(); + } + + @Test + void shouldRejectNonWinningHandWithClaimedTile() { + boolean result = huEvaluator.canHuWithClaimedTile( + List.of( + new Tile(TileSuit.WAN, 1), + new Tile(TileSuit.WAN, 2), + new Tile(TileSuit.WAN, 3), + new Tile(TileSuit.WAN, 4), + new Tile(TileSuit.WAN, 5), + new Tile(TileSuit.WAN, 6), + new Tile(TileSuit.TONG, 1), + new Tile(TileSuit.TONG, 2), + new Tile(TileSuit.TONG, 3), + new Tile(TileSuit.TIAO, 1), + new Tile(TileSuit.TIAO, 2), + new Tile(TileSuit.TIAO, 4), + new Tile(TileSuit.TONG, 9) + ), + new Tile(TileSuit.TIAO, 9) + ); + + assertThat(result).isFalse(); + } +} 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 2cbe038..f5bfc56 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java @@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat; class ResponseActionWindowBuilderTest { - private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(); + private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(new HuEvaluator()); @Test void shouldBuildResponseCandidatesForDiscardedTile() { @@ -69,6 +69,58 @@ class ResponseActionWindowBuilderTest { assertThat(result).isEmpty(); } + @Test + void shouldIncludeHuWhenClaimedTileCompletesWinningHand() { + Tile discardedTile = 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.buildForDiscard(session, 0, discardedTile); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().seatCandidates().get(0).options()) + .extracting(option -> option.actionType()) + .containsExactly(ActionType.HU, ActionType.PASS); + } + + @Test + void shouldSkipSeatsThatAlreadyWonWhenBuildingResponseWindow() { + Tile discardedTile = new Tile(TileSuit.WAN, 3); + GameSeat wonSeat = seat(1, "u1", discardedTile, discardedTile, discardedTile); + wonSeat.declareHu(); + GameSession session = new GameSession("room-1", createPlayingTable( + seat(0, "u0"), + wonSeat, + seat(2, "u2", discardedTile, discardedTile), + seat(3, "u3") + )); + + Optional result = builder.buildForDiscard(session, 0, discardedTile); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().seatCandidates()) + .extracting(ResponseActionSeatCandidate::seatNo) + .containsExactly(2); + } + private GameTable createPlayingTable(GameSeat... seats) { GameTable table = new GameTable(List.of(seats), new ArrayList<>()); table.setPhase(GamePhase.PLAYING); diff --git a/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java b/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java index cda9b92..aacf6f0 100644 --- a/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java +++ b/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java @@ -4,6 +4,7 @@ import com.xuezhanmaster.game.domain.ActionType; import com.xuezhanmaster.game.domain.ResponseActionOption; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionWindow; +import com.xuezhanmaster.ws.dto.PrivateActionCandidate; import com.xuezhanmaster.ws.dto.PrivateActionMessage; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -22,17 +23,25 @@ class GameMessagePublisherTest { @Test void shouldPublishStructuredTurnActionMessage() { - gameMessagePublisher.publishPrivateTurnActionRequired("game-1", "user-1", List.of("DISCARD"), 2); + gameMessagePublisher.publishPrivateTurnActionCandidates( + "game-1", + "user-1", + List.of( + new PrivateActionCandidate("HU", null), + new PrivateActionCandidate("DISCARD", null) + ), + 2 + ); ArgumentCaptor captor = ArgumentCaptor.forClass(PrivateActionMessage.class); verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture()); PrivateActionMessage message = captor.getValue(); assertThat(message.actionScope()).isEqualTo("TURN"); - assertThat(message.availableActions()).containsExactly("DISCARD"); + assertThat(message.availableActions()).containsExactly("HU", "DISCARD"); assertThat(message.currentSeatNo()).isEqualTo(2); - assertThat(message.candidates()).hasSize(1); - assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD"); + assertThat(message.candidates()).hasSize(2); + assertThat(message.candidates().get(0).actionType()).isEqualTo("HU"); assertThat(message.candidates().get(0).tile()).isNull(); } diff --git a/docs/SPRINT_01_ISSUES_BOARD.md b/docs/SPRINT_01_ISSUES_BOARD.md index 3c86fe8..a520262 100644 --- a/docs/SPRINT_01_ISSUES_BOARD.md +++ b/docs/SPRINT_01_ISSUES_BOARD.md @@ -256,6 +256,13 @@ Sprint 目标: - `publishPrivateTurnActionRequired` - `publishPrivateResponseActionRequired` - 当前回合动作消息与响应候选消息已可共用同一消息结构 +- 弃牌后若存在候选,后端已能真正创建响应窗口并下发私有候选动作消息 +- AI 候选当前会自动提交 `PASS` +- 当前已接入初版响应裁决: + - 全员响应完成后按优先级和顺位决出单一赢家 + - `PENG` 已可真实执行并夺取回合 + - `GANG` 已可真实执行并补摸一张牌 + - `HU` 已可进入候选、参与裁决并结束当前单局 - 已补消息发布单测,验证 turn / response 两类消息形状 ## 验收结果 @@ -275,6 +282,9 @@ Sprint 目标: 两类动作消息作用域 - 私有动作区已增加候选动作展示占位 - 私有动作区已增加来源座位、目标牌等上下文字段展示 +- H5 原型页已支持点击候选动作按钮,并提交带 `sourceSeatNo` 的动作请求 +- 当前已可通过点击 `PASS` 让响应窗口恢复行牌 +- 当前已可通过点击 `PENG / GANG` 候选按钮参与真实响应裁决 - 已补样式支持候选动作标签展示 ## 验收结果 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0ca32ca..d933ce4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,7 +30,9 @@ type SelfSeatView = { seatNo: number playerId: string nickname: string + won: boolean lackSuit: string | null + score: number handTiles: string[] discardTiles: string[] } @@ -40,7 +42,9 @@ type PublicSeatView = { playerId: string nickname: string ai: boolean + won: boolean lackSuit: string | null + score: number handCount: number discardTiles: string[] } @@ -125,7 +129,11 @@ const actionScopeLabelMap: Record = { const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION') const canDiscard = computed( - () => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo + () => + game.value?.phase === 'PLAYING' && + !game.value.selfSeat.won && + game.value.selfSeat.playerId === currentUserId.value && + game.value.currentSeatNo === game.value.selfSeat.seatNo ) const publicSeats = computed(() => game.value?.seats ?? []) const privateActionCandidates = computed(() => privateAction.value?.candidates ?? []) @@ -330,7 +338,7 @@ async function refreshGameState() { }) } -async function submitAction(actionType: string, tile?: string) { +async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) { if (!game.value) { return } @@ -341,7 +349,8 @@ async function submitAction(actionType: string, tile?: string) { body: JSON.stringify({ userId: currentUserId.value, actionType, - tile: tile ?? null + tile: tile ?? null, + sourceSeatNo: sourceSeatNo ?? null }) }) info.value = actionType === 'DISCARD' ? `已打出 ${tile}。` : `已提交动作 ${actionType}。` @@ -355,6 +364,11 @@ function selectLack() { function discard(tile: string) { return submitAction('DISCARD', tile) } + +function submitCandidateAction(actionType: string, tile: string | null) { + const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null + return submitAction(actionType, tile ?? undefined, sourceSeatNo) +}