From 48da7d4990cade6d4cb51f8156ff097da47f0865 Mon Sep 17 00:00:00 2001 From: hujun Date: Fri, 20 Mar 2026 13:04:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=80=99=E9=80=89=E6=A8=A1=E5=9E=8B=E4=B8=8E=E7=A7=81=E6=9C=89?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E6=B6=88=E6=81=AF=E7=BB=93=E6=9E=84=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括: - 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型 - 扩展 PrivateActionMessage 支持响应候选上下文 - 实现 ResponseActionWindowBuilder 构建弃牌响应候选 - 拆分 GameMessagePublisher 支持回合动作和响应动作消息 - 更新前端原型页展示结构化候选动作 - 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md --- .serena/memories/current_execution_entry.md | 21 +- README.md | 2 + .../game/domain/GameSession.java | 21 + .../game/domain/ResponseActionOption.java | 7 + .../domain/ResponseActionSeatCandidate.java | 10 + .../game/domain/ResponseActionWindow.java | 34 ++ .../game/service/GameActionProcessor.java | 74 +++- .../game/service/GameSessionService.java | 163 +++++++- .../service/ResponseActionWindowBuilder.java | 76 ++++ .../ws/dto/PrivateActionCandidate.java | 7 + .../ws/dto/PrivateActionMessage.java | 9 +- .../ws/service/GameMessagePublisher.java | 55 ++- .../game/service/GameSessionServiceTest.java | 1 + .../ResponseActionWindowBuilderTest.java | 86 ++++ .../ws/service/GameMessagePublisherTest.java | 78 ++++ docs/DEVELOPMENT_PLAN.md | 2 + docs/RESPONSE_RESOLUTION_RULES.md | 373 ++++++++++++++++++ docs/SPRINT_01_ISSUES_BOARD.md | 269 +++++-------- frontend/src/App.vue | 46 ++- frontend/src/style.css | 25 ++ 20 files changed, 1151 insertions(+), 208 deletions(-) create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionOption.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionSeatCandidate.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionWindow.java create mode 100644 backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java create mode 100644 backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionCandidate.java create mode 100644 backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java create mode 100644 backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java create mode 100644 docs/RESPONSE_RESOLUTION_RULES.md diff --git a/.serena/memories/current_execution_entry.md b/.serena/memories/current_execution_entry.md index 14d2d40..da14684 100644 --- a/.serena/memories/current_execution_entry.md +++ b/.serena/memories/current_execution_entry.md @@ -1,10 +1,15 @@ # 当前执行入口 - 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`。 -- Sprint 1 当前进度:`S1-01`、`S1-02`、`S1-03` 已完成,推荐的下一个编码任务是 `S1-04`。 -- `S1-03` 已完成内容: - - `GameEventType` 已补齐未来响应窗口和响应动作事件类型。 - - `GameEvent` 已新增统一工厂方法,统一现有公共事件和未来响应事件的载荷格式。 - - `GameSessionService` 与 `GameActionProcessor` 的现有公共事件已改为复用统一工厂方法。 - - 已新增 `GameEventTest`,验证新增动作事件与标准公共事件的载荷结构。 -- 当前推荐的后续依赖主线:`S1-04 -> S1-05 -> S1-07`。 -- 注意:目前新增事件类型仍主要是模型与约定层准备,真实响应窗口和响应动作事件还没有开始实际发出,下一步要用 `S1-04` 补候选动作模型。 \ No newline at end of file +- 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 diff --git a/README.md b/README.md index 262ac79..854383a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ npm run build - [周计划看板](/D:/WorkSpace/me/xzmaster/docs/WEEKLY_PLAN_BOARD.md) - [Issue 模板与看板](/D:/WorkSpace/me/xzmaster/docs/ISSUE_TEMPLATES_BOARD.md) - [Sprint 1 Issue 看板](/D:/WorkSpace/me/xzmaster/docs/SPRINT_01_ISSUES_BOARD.md) +- [响应优先级规则澄清](/D:/WorkSpace/me/xzmaster/docs/RESPONSE_RESOLUTION_RULES.md) 这三份文档用于把主计划进一步拆成可直接执行的落地材料: @@ -103,6 +104,7 @@ npm run build - `周计划看板`:回答“接下来每周做什么、如何验收、怎样滚动调整” - `Issue 模板与看板`:回答“单个任务如何立项、描述、拆解、验收、进入看板” - `Sprint 1 Issue 看板`:回答“当前这一轮开发具体先做哪些真实任务、按什么顺序推进” +- `响应优先级规则澄清`:回答“碰 / 杠 / 胡 / 过 的冲突怎么裁决、哪些属于项目 V1 约定” ### 推荐阅读顺序 diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java index d2b86ff..fc73f4c 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSession.java @@ -4,7 +4,9 @@ import com.xuezhanmaster.game.event.GameEvent; import java.util.ArrayList; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; public class GameSession { @@ -14,6 +16,8 @@ public class GameSession { private final Instant createdAt; private final GameTable table; private final List events; + private ResponseActionWindow pendingResponseActionWindow; + private final Map responseActionSelections; public GameSession(String roomId, GameTable table) { this.gameId = UUID.randomUUID().toString(); @@ -21,6 +25,7 @@ public class GameSession { this.createdAt = Instant.now(); this.table = table; this.events = new ArrayList<>(); + this.responseActionSelections = new LinkedHashMap<>(); } public String getGameId() { @@ -42,4 +47,20 @@ public class GameSession { public List getEvents() { return events; } + + public ResponseActionWindow getPendingResponseActionWindow() { + return pendingResponseActionWindow; + } + + public void setPendingResponseActionWindow(ResponseActionWindow pendingResponseActionWindow) { + this.pendingResponseActionWindow = pendingResponseActionWindow; + } + + public Map getResponseActionSelections() { + return responseActionSelections; + } + + public void clearResponseActionSelections() { + responseActionSelections.clear(); + } } diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionOption.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionOption.java new file mode 100644 index 0000000..69f1986 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionOption.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.game.domain; + +public record ResponseActionOption( + ActionType actionType, + String tile +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionSeatCandidate.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionSeatCandidate.java new file mode 100644 index 0000000..2d1f46d --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionSeatCandidate.java @@ -0,0 +1,10 @@ +package com.xuezhanmaster.game.domain; + +import java.util.List; + +public record ResponseActionSeatCandidate( + int seatNo, + String userId, + List options +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionWindow.java b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionWindow.java new file mode 100644 index 0000000..b302dec --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/ResponseActionWindow.java @@ -0,0 +1,34 @@ +package com.xuezhanmaster.game.domain; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record ResponseActionWindow( + String windowId, + String gameId, + String triggerEventType, + int sourceSeatNo, + String triggerTile, + List seatCandidates, + Instant createdAt +) { + + public static ResponseActionWindow create( + String gameId, + String triggerEventType, + int sourceSeatNo, + String triggerTile, + List seatCandidates + ) { + return new ResponseActionWindow( + UUID.randomUUID().toString(), + gameId, + triggerEventType, + sourceSeatNo, + triggerTile, + List.copyOf(seatCandidates), + Instant.now() + ); + } +} 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 e6ce1e7..f86b291 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java @@ -6,6 +6,8 @@ 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.ResponseActionSeatCandidate; +import com.xuezhanmaster.game.domain.ResponseActionWindow; import com.xuezhanmaster.game.domain.Tile; import com.xuezhanmaster.game.domain.TileSuit; import com.xuezhanmaster.game.dto.GameActionRequest; @@ -35,29 +37,29 @@ public class GameActionProcessor { private List peng(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); - validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "碰"); + validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "碰"); return unsupportedAction(ActionType.PENG); } private List gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); - validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "杠"); + validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "杠"); return unsupportedAction(ActionType.GANG); } private List hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); - validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "胡"); + validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "胡"); return unsupportedAction(ActionType.HU); } private List pass(GameSession session, String userId, Integer sourceSeatNo) { GameTable table = session.getTable(); GameSeat seat = findSeatByUserId(table, userId); - validateResponseAction(table, seat, sourceSeatNo, null, "过", false); - return unsupportedAction(ActionType.PASS); + validateResponseAction(session, table, seat, sourceSeatNo, null, "过", false); + return List.of(); } private List unsupportedAction(ActionType actionType) { @@ -143,16 +145,18 @@ public class GameActionProcessor { } private void validateResponseAction( + GameSession session, GameTable table, GameSeat actorSeat, Integer sourceSeatNo, String tileDisplayName, String actionName ) { - validateResponseAction(table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true); + validateResponseAction(session, table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true); } private void validateResponseAction( + GameSession session, GameTable table, GameSeat actorSeat, Integer sourceSeatNo, @@ -172,12 +176,70 @@ public class GameActionProcessor { if (sourceSeatNo == actorSeat.getSeatNo()) { throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不能是自己"); } + ResponseActionWindow responseActionWindow = sessionResponseWindow(session, sourceSeatNo, actionName); + validateSeatCandidate(responseActionWindow, actorSeat, actionName); if (requiresTile && isBlank(tileDisplayName)) { throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少目标牌"); } + if (requiresTile) { + validateCandidateAction(responseActionWindow, actorSeat, tileDisplayName, actionName); + } else { + validateCandidateAction(responseActionWindow, actorSeat, null, actionName); + } } private boolean isBlank(String value) { return value == null || value.isBlank(); } + + private ResponseActionWindow sessionResponseWindow(GameSession session, Integer sourceSeatNo, String actionName) { + ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow(); + if (responseActionWindow == null) { + throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口"); + } + if (responseActionWindow.sourceSeatNo() != sourceSeatNo) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", actionName + "动作来源窗口不匹配"); + } + return responseActionWindow; + } + + private void validateSeatCandidate(ResponseActionWindow responseActionWindow, GameSeat actorSeat, String actionName) { + if (responseActionWindow == null) { + throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口"); + } + boolean isCandidateSeat = responseActionWindow.seatCandidates().stream() + .anyMatch(candidate -> candidate.seatNo() == actorSeat.getSeatNo()); + if (!isCandidateSeat) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中"); + } + } + + private void validateCandidateAction( + ResponseActionWindow responseActionWindow, + GameSeat actorSeat, + String tileDisplayName, + String actionName + ) { + ResponseActionSeatCandidate seatCandidate = responseActionWindow.seatCandidates().stream() + .filter(candidate -> candidate.seatNo() == actorSeat.getSeatNo()) + .findFirst() + .orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中")); + + boolean matched = seatCandidate.options().stream() + .anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName)) + && (tileDisplayName == null || tileDisplayName.equals(option.tile()))); + if (!matched) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前窗口不支持该" + actionName + "动作"); + } + } + + private String toActionTypeName(String actionName) { + return switch (actionName) { + case "碰" -> ActionType.PENG.name(); + case "杠" -> ActionType.GANG.name(); + case "胡" -> ActionType.HU.name(); + case "过" -> ActionType.PASS.name(); + default -> actionName; + }; + } } 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 b4b3c99..56ab6fc 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -6,6 +6,8 @@ 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.ResponseActionSeatCandidate; +import com.xuezhanmaster.game.domain.ResponseActionWindow; import com.xuezhanmaster.game.domain.Tile; import com.xuezhanmaster.game.dto.GameActionRequest; import com.xuezhanmaster.game.dto.GameStateResponse; @@ -29,6 +31,7 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @Service @@ -37,6 +40,7 @@ public class GameSessionService { private final RoomService roomService; private final GameEngine gameEngine; private final GameActionProcessor gameActionProcessor; + private final ResponseActionWindowBuilder responseActionWindowBuilder; private final StrategyService strategyService; private final PlayerVisibilityService playerVisibilityService; private final TeachingService teachingService; @@ -47,6 +51,7 @@ public class GameSessionService { RoomService roomService, GameEngine gameEngine, GameActionProcessor gameActionProcessor, + ResponseActionWindowBuilder responseActionWindowBuilder, StrategyService strategyService, PlayerVisibilityService playerVisibilityService, TeachingService teachingService, @@ -55,6 +60,7 @@ public class GameSessionService { this.roomService = roomService; this.gameEngine = gameEngine; this.gameActionProcessor = gameActionProcessor; + this.responseActionWindowBuilder = responseActionWindowBuilder; this.strategyService = strategyService; this.playerVisibilityService = playerVisibilityService; this.teachingService = teachingService; @@ -82,7 +88,7 @@ public class GameSessionService { ActionType actionType = parseActionType(request.actionType()); List events = gameActionProcessor.process(session, request); appendAndPublish(session, events); - handlePostActionEffects(session, actionType); + handlePostActionEffects(session, actionType, request, events); return toStateResponse(session, request.userId()); } @@ -195,7 +201,7 @@ public class GameSessionService { return; } - gameMessagePublisher.publishPrivateActionRequired( + gameMessagePublisher.publishPrivateTurnActionRequired( session.getGameId(), currentSeat.getPlayerId(), List.of("DISCARD"), @@ -214,24 +220,165 @@ public class GameSessionService { ); } - private void handlePostActionEffects(GameSession session, ActionType actionType) { + private void handlePostActionEffects( + GameSession session, + ActionType actionType, + GameActionRequest request, + List events + ) { switch (actionType) { case SELECT_LACK_SUIT -> { if (session.getTable().getPhase() == GamePhase.PLAYING) { notifyActionIfHumanTurn(session); } } - case DISCARD -> { - moveToNextSeat(session.getTable(), session.getGameId()); - autoPlayBots(session); - notifyActionIfHumanTurn(session); - } + case DISCARD -> handleDiscardPostAction(session, request, events); + case PASS -> handlePassPostAction(session, request); default -> { // 其余动作在后续 Sprint 中补充对应副作用 } } } + private void notifyResponseCandidates(GameSession session, ResponseActionWindow responseActionWindow) { + for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) { + GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo()); + if (seat.isAi()) { + continue; + } + gameMessagePublisher.publishPrivateResponseActionRequired( + session.getGameId(), + seatCandidate.userId(), + session.getTable().getCurrentSeatNo(), + responseActionWindow, + seatCandidate + ); + } + } + + private void handleDiscardPostAction(GameSession session, GameActionRequest request, List events) { + GameSeat sourceSeat = findSeatByUserId(session.getTable(), request.userId()); + Tile discardedTile = resolveDiscardedTile(events, request.tile()); + Optional window = responseActionWindowBuilder.buildForDiscard( + session, + sourceSeat.getSeatNo(), + discardedTile + ); + + if (window.isPresent()) { + openResponseWindow(session, window.get()); + autoPassAiCandidates(session); + if (session.getPendingResponseActionWindow() == null) { + continueAfterDiscardWithoutResponse(session); + } + return; + } + + continueAfterDiscardWithoutResponse(session); + } + + private void handlePassPostAction(GameSession session, GameActionRequest request) { + 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); + } + } + + private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) { + session.setPendingResponseActionWindow(responseActionWindow); + session.clearResponseActionSelections(); + appendAndPublish(session, GameEvent.responseWindowOpened( + session.getGameId(), + responseActionWindow.sourceSeatNo(), + responseActionWindow.sourceSeatNo(), + responseActionWindow.triggerTile() + )); + notifyResponseCandidates(session, responseActionWindow); + } + + private void autoPassAiCandidates(GameSession session) { + ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow(); + if (responseActionWindow == null) { + return; + } + for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) { + GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo()); + if (seat.isAi()) { + session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS); + } + } + if (allResponseCandidatesResolved(session, responseActionWindow)) { + closeResponseWindowAsPass(session, responseActionWindow); + } + } + + private boolean allResponseCandidatesResolved(GameSession session, ResponseActionWindow responseActionWindow) { + return responseActionWindow.seatCandidates().stream() + .allMatch(candidate -> session.getResponseActionSelections().containsKey(candidate.seatNo())); + } + + private void closeResponseWindowAsPass(GameSession session, ResponseActionWindow responseActionWindow) { + appendAndPublish(session, GameEvent.responseWindowClosed( + session.getGameId(), + null, + responseActionWindow.sourceSeatNo(), + ActionType.PASS.name() + )); + session.setPendingResponseActionWindow(null); + session.clearResponseActionSelections(); + } + + private ResponseActionWindow requirePendingResponseWindow(GameSession session) { + ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow(); + if (responseActionWindow == null) { + throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有待处理的响应窗口"); + } + return responseActionWindow; + } + + private Tile resolveDiscardedTile(List events, String fallbackTileDisplayName) { + return events.stream() + .filter(event -> event.eventType() == GameEventType.TILE_DISCARDED) + .map(event -> event.payload().get("tile")) + .filter(String.class::isInstance) + .map(String.class::cast) + .findFirst() + .map(this::parseTile) + .orElseGet(() -> parseTile(fallbackTileDisplayName)); + } + + private Tile parseTile(String tileDisplayName) { + if (tileDisplayName == null || tileDisplayName.isBlank()) { + throw new BusinessException("GAME_TILE_INVALID", "无法解析当前动作对应的牌"); + } + + String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1); + String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1); + int rank; + try { + rank = Integer.parseInt(rankPart); + } catch (NumberFormatException exception) { + throw new BusinessException("GAME_TILE_INVALID", "牌面点数不合法"); + } + + 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); + default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法"); + }; + return tile; + } + + private void continueAfterDiscardWithoutResponse(GameSession session) { + moveToNextSeat(session.getTable(), session.getGameId()); + autoPlayBots(session); + notifyActionIfHumanTurn(session); + } + private ActionType parseActionType(String actionType) { try { return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT)); diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java new file mode 100644 index 0000000..e8b64f8 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java @@ -0,0 +1,76 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.ActionType; +import com.xuezhanmaster.game.domain.GameSeat; +import com.xuezhanmaster.game.domain.GameSession; +import com.xuezhanmaster.game.domain.GameTable; +import com.xuezhanmaster.game.domain.ResponseActionOption; +import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; +import com.xuezhanmaster.game.domain.ResponseActionWindow; +import com.xuezhanmaster.game.domain.Tile; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Component +public class ResponseActionWindowBuilder { + + 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) { + continue; + } + + List options = buildSeatOptions(seat, discardedTile); + if (!options.isEmpty()) { + seatCandidates.add(new ResponseActionSeatCandidate( + seat.getSeatNo(), + seat.getPlayerId(), + List.copyOf(options) + )); + } + } + + if (seatCandidates.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(ResponseActionWindow.create( + session.getGameId(), + "TILE_DISCARDED", + sourceSeatNo, + discardedTile.getDisplayName(), + seatCandidates + )); + } + + private List buildSeatOptions(GameSeat seat, Tile discardedTile) { + int sameTileCount = countSameTileInHand(seat, discardedTile); + if (sameTileCount < 2) { + return List.of(); + } + + List options = new ArrayList<>(); + 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)); + return options; + } + + private int countSameTileInHand(GameSeat seat, Tile discardedTile) { + int count = 0; + for (Tile tile : seat.getHandTiles()) { + if (tile.equals(discardedTile)) { + count++; + } + } + return count; + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionCandidate.java b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionCandidate.java new file mode 100644 index 0000000..bf7f4ca --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionCandidate.java @@ -0,0 +1,7 @@ +package com.xuezhanmaster.ws.dto; + +public record PrivateActionCandidate( + String actionType, + String tile +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java index 8bb4fb8..00936b3 100644 --- a/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java +++ b/backend/src/main/java/com/xuezhanmaster/ws/dto/PrivateActionMessage.java @@ -5,8 +5,13 @@ import java.util.List; public record PrivateActionMessage( String gameId, String userId, + String actionScope, List availableActions, - int currentSeatNo + int currentSeatNo, + String windowId, + String triggerEventType, + Integer sourceSeatNo, + String triggerTile, + List candidates ) { } - 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 d913b03..7bf21c4 100644 --- a/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java +++ b/backend/src/main/java/com/xuezhanmaster/ws/service/GameMessagePublisher.java @@ -1,6 +1,9 @@ package com.xuezhanmaster.ws.service; +import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; +import com.xuezhanmaster.game.domain.ResponseActionWindow; import com.xuezhanmaster.game.event.GameEvent; +import com.xuezhanmaster.ws.dto.PrivateActionCandidate; import com.xuezhanmaster.ws.dto.PrivateActionMessage; import com.xuezhanmaster.ws.dto.PrivateTeachingMessage; import com.xuezhanmaster.ws.dto.PublicGameMessage; @@ -31,10 +34,52 @@ public class GameMessagePublisher { ); } - public void publishPrivateActionRequired(String gameId, String userId, List availableActions, int currentSeatNo) { + public void publishPrivateTurnActionRequired(String gameId, String userId, List availableActions, int currentSeatNo) { messagingTemplate.convertAndSend( "/topic/users/" + userId + "/actions", - new PrivateActionMessage(gameId, userId, availableActions, currentSeatNo) + new PrivateActionMessage( + gameId, + userId, + "TURN", + availableActions, + currentSeatNo, + null, + null, + null, + null, + toCandidates(availableActions) + ) + ); + } + + public void publishPrivateResponseActionRequired( + String gameId, + String userId, + int currentSeatNo, + ResponseActionWindow window, + ResponseActionSeatCandidate seatCandidate + ) { + List availableActions = seatCandidate.options().stream() + .map(option -> option.actionType().name()) + .toList(); + List candidates = seatCandidate.options().stream() + .map(option -> new PrivateActionCandidate(option.actionType().name(), option.tile())) + .toList(); + + messagingTemplate.convertAndSend( + "/topic/users/" + userId + "/actions", + new PrivateActionMessage( + gameId, + userId, + "RESPONSE", + availableActions, + currentSeatNo, + window.windowId(), + window.triggerEventType(), + window.sourceSeatNo(), + window.triggerTile(), + candidates + ) ); } @@ -44,4 +89,10 @@ public class GameMessagePublisher { new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation) ); } + + private List toCandidates(List availableActions) { + return availableActions.stream() + .map(actionType -> new PrivateActionCandidate(actionType, null)) + .toList(); + } } 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 6795dda..6ee7334 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -30,6 +30,7 @@ class GameSessionServiceTest { roomService, new GameEngine(new DeckFactory()), new GameActionProcessor(), + new ResponseActionWindowBuilder(), new StrategyService(), new PlayerVisibilityService(), new TeachingService(), diff --git a/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java b/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java new file mode 100644 index 0000000..2cbe038 --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilderTest.java @@ -0,0 +1,86 @@ +package com.xuezhanmaster.game.service; + +import com.xuezhanmaster.game.domain.ActionType; +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.ResponseActionSeatCandidate; +import com.xuezhanmaster.game.domain.ResponseActionWindow; +import com.xuezhanmaster.game.domain.Tile; +import com.xuezhanmaster.game.domain.TileSuit; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResponseActionWindowBuilderTest { + + private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(); + + @Test + void shouldBuildResponseCandidatesForDiscardedTile() { + Tile discardedTile = new Tile(TileSuit.WAN, 3); + GameSession session = new GameSession("room-1", createPlayingTable( + seat(0, "u0"), + seat(1, "u1", discardedTile, discardedTile), + seat(2, "u2", discardedTile, discardedTile, discardedTile), + seat(3, "u3", new Tile(TileSuit.TIAO, 1)) + )); + + Optional result = builder.buildForDiscard(session, 0, discardedTile); + + assertThat(result).isPresent(); + ResponseActionWindow window = result.orElseThrow(); + assertThat(window.triggerEventType()).isEqualTo("TILE_DISCARDED"); + assertThat(window.sourceSeatNo()).isEqualTo(0); + assertThat(window.triggerTile()).isEqualTo("3万"); + assertThat(window.seatCandidates()).hasSize(2); + + ResponseActionSeatCandidate seat1 = window.seatCandidates().get(0); + assertThat(seat1.seatNo()).isEqualTo(1); + assertThat(seat1.userId()).isEqualTo("u1"); + assertThat(seat1.options()) + .extracting(option -> option.actionType()) + .containsExactly(ActionType.PENG, ActionType.PASS); + + ResponseActionSeatCandidate seat2 = window.seatCandidates().get(1); + assertThat(seat2.seatNo()).isEqualTo(2); + assertThat(seat2.options()) + .extracting(option -> option.actionType()) + .containsExactly(ActionType.PENG, ActionType.GANG, ActionType.PASS); + } + + @Test + void shouldReturnEmptyWhenNoSeatCanRespond() { + Tile discardedTile = new Tile(TileSuit.TONG, 5); + GameSession session = new GameSession("room-1", createPlayingTable( + seat(0, "u0"), + seat(1, "u1", new Tile(TileSuit.WAN, 1)), + seat(2, "u2", new Tile(TileSuit.WAN, 2)), + seat(3, "u3", new Tile(TileSuit.WAN, 3)) + )); + + Optional result = builder.buildForDiscard(session, 0, discardedTile); + + assertThat(result).isEmpty(); + } + + private GameTable createPlayingTable(GameSeat... seats) { + GameTable table = new GameTable(List.of(seats), new ArrayList<>()); + table.setPhase(GamePhase.PLAYING); + table.setCurrentSeatNo(0); + return table; + } + + private GameSeat seat(int seatNo, String userId, Tile... tiles) { + GameSeat seat = new GameSeat(seatNo, false, userId, "seat-" + seatNo); + for (Tile tile : tiles) { + seat.receiveTile(tile); + } + return seat; + } +} diff --git a/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java b/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java new file mode 100644 index 0000000..cda9b92 --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/ws/service/GameMessagePublisherTest.java @@ -0,0 +1,78 @@ +package com.xuezhanmaster.ws.service; + +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.PrivateActionMessage; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class GameMessagePublisherTest { + + private final SimpMessagingTemplate messagingTemplate = mock(SimpMessagingTemplate.class); + private final GameMessagePublisher gameMessagePublisher = new GameMessagePublisher(messagingTemplate); + + @Test + void shouldPublishStructuredTurnActionMessage() { + gameMessagePublisher.publishPrivateTurnActionRequired("game-1", "user-1", List.of("DISCARD"), 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.currentSeatNo()).isEqualTo(2); + assertThat(message.candidates()).hasSize(1); + assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD"); + assertThat(message.candidates().get(0).tile()).isNull(); + } + + @Test + void shouldPublishStructuredResponseActionMessage() { + ResponseActionWindow window = ResponseActionWindow.create( + "game-1", + "TILE_DISCARDED", + 0, + "3万", + List.of(new ResponseActionSeatCandidate( + 1, + "user-1", + List.of( + new ResponseActionOption(ActionType.PENG, "3万"), + new ResponseActionOption(ActionType.PASS, null) + ) + )) + ); + + gameMessagePublisher.publishPrivateResponseActionRequired( + "game-1", + "user-1", + 0, + window, + window.seatCandidates().get(0) + ); + + 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("RESPONSE"); + assertThat(message.windowId()).isEqualTo(window.windowId()); + assertThat(message.triggerEventType()).isEqualTo("TILE_DISCARDED"); + assertThat(message.sourceSeatNo()).isEqualTo(0); + assertThat(message.triggerTile()).isEqualTo("3万"); + assertThat(message.availableActions()).containsExactly("PENG", "PASS"); + assertThat(message.candidates()) + .extracting(candidate -> candidate.actionType()) + .containsExactly("PENG", "PASS"); + } +} diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index bd861b1..b5971bf 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -895,6 +895,7 @@ npm run build - `WEEKLY_PLAN_BOARD.md`:把近期执行拆成按周推进的工作节奏 - `ISSUE_TEMPLATES_BOARD.md`:把单个任务的立项模板和看板推进方式固化下来 - `SPRINT_01_ISSUES_BOARD.md`:把当前最优先的 Week 1 / Week 2 工作直接展开成可执行 Issue +- `RESPONSE_RESOLUTION_RULES.md`:把响应窗口、优先级、同优先级冲突处理和 V1 工程取舍写清楚 ### 15.2 建议协作节奏 @@ -953,5 +954,6 @@ npm run build - `docs/WEEKLY_PLAN_BOARD.md` - `docs/ISSUE_TEMPLATES_BOARD.md` - `docs/SPRINT_01_ISSUES_BOARD.md` +- `docs/RESPONSE_RESOLUTION_RULES.md` 这些文档用于把主计划拆成更细的执行层内容,并提供实际推进时的状态管理结构。 diff --git a/docs/RESPONSE_RESOLUTION_RULES.md b/docs/RESPONSE_RESOLUTION_RULES.md new file mode 100644 index 0000000..27c7961 --- /dev/null +++ b/docs/RESPONSE_RESOLUTION_RULES.md @@ -0,0 +1,373 @@ +# XueZhanMaster 响应优先级规则澄清 + +本文档用于澄清四川麻将血战到底在本项目中的响应窗口、优先级与裁决方式。 +它不等于“所有地区、所有平台唯一正确规则”,而是: + +1. 先吸收公开规则中的高共识部分 +2. 再明确本项目 `V1` 的工程实现边界 +3. 给后续真实裁决代码提供单一依据 + +当前状态快照日期:`2026-03-20` + +--- + +## 1. 文档结论先看 + +### 1.1 本项目 V1 采用的核心结论 + +- 只支持 `碰 / 杠 / 胡 / 过`,不支持 `吃` +- 响应窗口首先只围绕“弃牌后响应”建立 +- 优先级顺序采用: + - `HU > GANG > PENG > PASS` +- AI 与真人使用同一套优先级规则 +- 同优先级冲突时,按出牌者之后的最近顺位优先 +- `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会 +- `过水不胡` 作为后续增强规则,不在当前 `V1` 强制启用 +- `一炮多响` 在项目 `V1` 中暂不实现,采用“单窗口单胜出动作”的工程裁决 + +### 1.2 为什么这样定 + +- 这样能和当前后端结构最自然衔接 +- 可以先把响应窗口停顿与恢复流程做稳 +- 避免在 `HU` 判定、多人同时胡牌、结算分摊都未稳定时,提前引入高复杂度冲突逻辑 + +这符合: + +- `KISS`:先做单窗口单胜出 +- `YAGNI`:当前不抢做一炮多响和完整过手胡体系 +- `SOLID`:先把规则澄清文档与裁决器边界固定 +- `DRY`:所有动作冲突统一走一套优先级裁决 + +--- + +## 2. 公开规则共识与本项目采用范围 + +### 2.1 高共识部分 + +结合公开资料,以下规则共识较高: + +- 四川麻将 / 成都麻将常见规则中: + - 只有 `万 / 筒 / 条` + - `可碰、可杠,不可吃` + - 血战到底是一家胡后牌局不立即结束,其他未胡家继续 +- 四川麻将公开资料中常见“点炮”“杠分”“查叫”“花猪”等扩展规则 +- 多个公开资料都把血战到底视为成都规则或四川规则的典型变体 + +### 2.2 规则不完全统一的部分 + +公开资料中存在差异或版本不完全一致的地方: + +- 是否支持 `一炮多响` +- 是否严格执行 `过水不胡` +- `过水不胡` 的解除时机 +- 抢杠胡、补杠、绕杠与响应时机的细节 +- 比赛规则、地方线下习惯和手游平台的实现差异 + +因此本项目必须明确“工程采用规则”,不能把互相矛盾的外部说法直接混进代码。 + +--- + +## 3. 本项目 V1 规则边界 + +### 3.1 当前先实现哪些响应窗口 + +`V1` 只实现: + +- 某玩家打出一张牌后 +- 其他仍在牌局中的玩家基于这张弃牌触发响应窗口 + +`V1` 暂不实现: + +- 抢杠胡窗口 +- 补杠后二次响应窗口 +- 最后一张牌必须胡的特殊强制窗口 +- 多轮连续嵌套响应窗口 + +### 3.2 当前先支持哪些候选动作 + +当前候选结构已经准备支持: + +- `PENG` +- `GANG` +- `PASS` + +`HU` 的动作入口和事件枚举虽然已预留,但真正候选生成依赖胡牌判定完成后再接入。 + +因此要区分两个状态: + +1. 接口和模型层:`HU` 已被保留 +2. 当前实际候选生成层:以 `PENG / GANG / PASS` 为主 + +--- + +## 4. 优先级规则 + +### 4.1 基础优先级 + +本项目 `V1` 的响应优先级顺序固定为: + +1. `HU` +2. `GANG` +3. `PENG` +4. `PASS` + +解释: + +- `PASS` 不参与争夺,只表示当前窗口内放弃 +- `PENG` 与 `GANG` 都是夺取弃牌控制权的动作 +- `HU` 直接改变对局参与关系和结算路径,因此优先级最高 + +### 4.2 同优先级冲突 + +当多个玩家对同一张弃牌拥有同优先级动作时,`V1` 采用: + +- 按出牌者之后的最近顺位优先 + +具体定义: + +- 当前系统座位轮转顺序是 `seatNo` 递增后取模 +- 因此从 `sourceSeatNo + 1` 开始,沿当前行牌方向逐个查找 +- 先遇到的候选玩家优先获胜 + +示例: + +- 4 人桌,出牌者为 `seat 0` +- `seat 1` 和 `seat 3` 同时都能 `PENG` +- 则 `seat 1` 获胜 + +### 4.3 为什么不在 V1 支持一炮多响 + +这是一个项目级工程取舍,不是宣称外部规则世界只有这一种。 + +原因: + +- 一炮多响会直接放大以下复杂度: + - 多赢家结算 + - 胡牌先后次序 + - 胡后谁退出牌局、谁继续 + - 多人同时胡后下一个行动座位怎么定 +- 当前项目还未完成: + - 胡牌判定 + - 基础结算 + - 胡后继续行牌完整链路 + +所以 `V1` 先采用: + +- `单窗口单胜出动作` + +后续若要升级到一炮多响,应作为 `V2` 明确需求,不应在当前实现里偷偷混入。 + +--- + +## 5. PASS 与过手规则 + +### 5.1 PASS 在本项目中的定义 + +`PASS` 只表示: + +- 当前响应窗口内 +- 当前玩家放弃对当前触发牌的响应 + +它不表示: + +- 永久放弃此类动作 +- 放弃后续整局碰、杠、胡能力 + +### 5.2 当前窗口内的限制 + +在同一个响应窗口内: + +- 玩家一旦提交 `PASS` +- 不能再次回到同一个窗口重新声明 `PENG / GANG / HU` + +这是当前必须实现的最小一致性规则。 + +### 5.3 `过水不胡` 的项目处理 + +公开资料中可见类似“过水不胡 / 同巡振听 / 过手胡”的描述,但不同平台和地方规则细节并不完全统一。 + +因此本项目采取分阶段策略: + +#### V1 + +- 不实现完整 `过水不胡` +- 只保证“同一响应窗口内,PASS 后不能反悔” + +#### V2 + +- 评估是否加入: + - 玩家错过可胡后,直到自己下一次摸牌或动牌前不能再胡 + - 是否仅限制同张牌 + - 是否限制同一路听牌 + - 是否允许加番后破除限制 + +--- + +## 6. 响应窗口生命周期 + +### 6.1 打开条件 + +当一名玩家完成弃牌后: + +1. 系统生成 `TILE_DISCARDED` 公共事件 +2. 系统用弃牌和当前牌桌状态构建响应候选 +3. 若存在候选,则创建 `ResponseActionWindow` +4. 发出 `RESPONSE_WINDOW_OPENED` 公共事件 +5. 向有资格响应的玩家分别下发私有动作消息 + +### 6.2 关闭条件 + +响应窗口在以下任一条件满足时关闭: + +- 所有候选玩家都已响应 +- 响应超时 +- 裁决器已确定唯一胜出动作 + +关闭后应发出: + +- `RESPONSE_WINDOW_CLOSED` + +并带上至少以下信息: + +- 来源座位 +- 最终裁决动作类型 + +### 6.3 关闭后的流转 + +#### 若有人胜出 + +- 执行胜出动作 +- 广播对应公共事件: + - `PENG_DECLARED` + - `GANG_DECLARED` + - `HU_DECLARED` + - `PASS_DECLARED` 一般不作为最终胜出动作事件使用 + +#### 若所有人都放弃 + +- 广播窗口关闭事件 +- 继续原始出牌后的下家摸牌流程 + +--- + +## 7. 公共消息与私有消息边界 + +### 7.1 公共消息应该包含什么 + +公共事件只应表达“桌面上发生了什么”,例如: + +- 某张牌被打出 +- 响应窗口已打开 +- 响应窗口已关闭 +- 某玩家碰 / 杠 / 胡 + +公共事件不应广播: + +- 每个玩家分别可做哪些动作 +- 某玩家私下收到的教学建议 + +### 7.2 私有消息应该包含什么 + +私有动作消息应表达“你现在能做什么”,例如: + +- 作用域:当前回合动作 / 响应动作 +- 来源牌与来源座位 +- 当前候选动作列表 +- 当前窗口 ID + +这也是当前后端已升级为结构化 `PrivateActionMessage` 的原因。 + +--- + +## 8. 对代码实现的直接指导 + +### 8.1 裁决器建议输入 + +后续真正实现裁决器时,建议输入至少包括: + +- `ResponseActionWindow` +- 各候选玩家的最终响应 +- 当前出牌座位 +- 当前行牌顺序 + +### 8.2 裁决器建议输出 + +建议输出至少包括: + +- 是否有人胜出 +- 胜出玩家 +- 胜出动作 +- 窗口内已放弃玩家 +- 是否继续原出牌后流转 + +### 8.3 当前推荐实现顺序 + +1. 先把 `ResponseActionWindow` 真正接入弃牌后主流程 +2. 再实现窗口停顿与私有候选下发 +3. 再实现优先级裁决器 +4. 最后再接入 `HU` 判定与结算 + +不要反过来做,否则会出现: + +- 胡牌判定先写了,但没有窗口停顿 +- 前端已经能显示候选,但后端还无法裁决 +- 公共事件和私有动作消息互相打架 + +--- + +## 9. 与当前代码进度的对应关系 + +当前已经完成: + +- 新动作统一入口 +- 新动作基础校验 +- 新动作事件模型 +- 响应候选模型 +- 结构化私有动作消息 +- H5 原型页候选消息展示占位 + +当前尚未完成: + +- 真正的响应窗口停顿 +- 基于窗口的多人响应收集 +- 优先级裁决器 +- `HU` 候选生成与胡牌判定 + +因此本文件的直接用途是: + +- 让下一步裁决实现有明确规则依据 +- 降低“每轮新对话都重新讨论优先级”的沟通成本 + +--- + +## 10. 本项目 V1 的最终裁决口径 + +为了避免歧义,本项目 `V1` 最终采用如下口径: + +1. 血战到底按成都麻将大框架实现 +2. 不可吃,只处理 `碰 / 杠 / 胡 / 过` +3. 当前第一阶段只围绕“弃牌后响应”建窗口 +4. 优先级固定为 `HU > GANG > PENG > PASS` +5. 同优先级按出牌者之后最近顺位优先 +6. `PASS` 仅放弃当前窗口 +7. 当前不实现完整 `过水不胡` +8. 当前不实现 `一炮多响` + +如果后续要改这些口径,必须先更新本文件,再改代码。 + +--- + +## 11. 参考来源与说明 + +以下来源用于确认“四川麻将 / 成都麻将血战到底”的高共识规则与差异点: + +- [四川麻将 - 维基百科](https://zh.wikipedia.org/wiki/%E5%9B%9B%E5%B7%9D%E9%BA%BB%E5%B0%87) +- [成都麻将:血战到底 - 快懂百科](https://www.baike.com/wikiid/6198029954197195589) +- [四川麻将规则 - 同城游](https://www.tcy365.com/news/d30057.html) +- [成都麻将换三张规则 - 游戏茶苑](https://gametea.com/news/201910/9099.html) + +说明: + +- 上述来源之间对 `一炮多响`、`过水不胡` 等细节并不完全一致 +- 因此本文中的 `V1` 规则属于“基于公开共识后的工程裁决” +- 若未来需要做更地道或更赛事化的模式,应再增加“规则模式配置层” diff --git a/docs/SPRINT_01_ISSUES_BOARD.md b/docs/SPRINT_01_ISSUES_BOARD.md index 1b4e67b..3c86fe8 100644 --- a/docs/SPRINT_01_ISSUES_BOARD.md +++ b/docs/SPRINT_01_ISSUES_BOARD.md @@ -44,101 +44,6 @@ Sprint 目标: ## 2. 待做 -### S1-05 [功能] 扩展私有动作消息体,支持响应候选下发 - -## 背景 - -后续 `HU / GANG / PENG / PASS` 不是任何时刻都可点,必须先有“当前玩家可执行哪些动作”的私有候选列表。现在虽有私有动作主题,但消息体还不足以表达响应窗口和候选动作。 - -## 目标 - -把私有动作消息体扩展成可支持: - -- 当前可行动作列表 -- 候选动作来源 -- 响应截止上下文 -- 与当前回合/弃牌事件的关联关系 - -## 范围 - -- 定义候选动作 DTO -- 定义是否为响应窗口动作 -- 定义关联事件 ID 或动作上下文 -- 补充前端消费字段说明 - -## 非范围 - -- 不在本任务里完成最终 UI 交互 -- 不在本任务里实现多人竞争裁决 - -## 依赖 - -- `S1-01` -- `S1-02` -- `S1-04` - -## 产出物 - -- 后端私有动作消息模型 -- 消息发布说明 -- 前端订阅字段适配说明 - -## 验收标准 - -- 服务端能向指定用户发送结构化候选动作 -- 消息能区分“主动出牌动作”和“被动响应动作” -- 前端收到后无需猜测字段语义 - -## 验证方式 - -- 后端:`mvn test` -- 前端:`npm run build` -- 手工:模拟一次弃牌后,检查候选动作消息结构是否完整 - -### S1-06 [研究] 响应优先级裁决规则澄清 - -## 背景 - -在真正实现多人响应之前,必须先统一项目内部的动作优先级和冲突处理规则,否则后端、前端、教学系统会各自假设,后续很容易返工。 - -## 目标 - -形成一份明确的规则澄清结果,至少回答: - -- `HU / GANG / PENG / PASS` 的优先级顺序 -- 多人同时可响应时的裁决方式 -- 响应窗口何时打开、何时关闭 -- AI 与真人竞争时是否采用同一裁决规则 - -## 范围 - -- 梳理当前产品约束 -- 梳理实现层需要的最小规则集 -- 输出推荐裁决策略 - -## 非范围 - -- 不在本任务里直接落代码 - -## 依赖 - -- `S1-04` - -## 产出物 - -- 规则澄清文档 -- 后续开发任务拆分建议 - -## 验收标准 - -- 可以直接指导 `S1-07` -- 后续实现不再需要对优先级做二次猜测 - -## 验证方式 - -- 文档评审 -- 与主计划、阶段看板、周计划保持一致 - ### S1-08 [H5] 对局页信息架构与页面拆分方案 ## 背景 @@ -222,92 +127,6 @@ Sprint 目标: - 前端已知道如何接响应候选 - 下一轮 H5 正式页面改造可以直接开始 -### S1-04 [功能] 响应候选模型初版 - -## 背景 - -要支持 `PENG / GANG / HU / PASS`,系统不能只知道“执行了什么”,还必须知道“现在允许谁做什么”。这个模型是后续优先级裁决、前端动作面板、教学提示的共同基础。 - -## 目标 - -定义响应候选模型,能表达: - -- 当前响应来源于哪次弃牌或事件 -- 哪些玩家可以响应 -- 每个玩家可响应哪些动作 -- 响应窗口的生命周期 - -## 范围 - -- 设计候选动作数据结构 -- 设计响应窗口上下文 -- 设计与座位、玩家 ID、事件 ID 的关联 - -## 非范围 - -- 不在本任务里完成最终竞争裁决实现 - -## 依赖 - -- `S1-02` -- `S1-03` - -## 产出物 - -- 后端候选动作模型 -- 前后端字段语义说明 - -## 验收标准 - -- 候选结构可表达“谁现在能做什么” -- 后续可直接承接 `PASS` -- 前端动作面板无需再自行推导 - -## 验证方式 - -- 模型评审 -- 后端:`mvn test` - -### S1-07 [功能] H5 动作面板字段对齐与占位接入 - -## 背景 - -后端一旦开始发送响应候选,前端至少要能消费这些字段并用最小方式展示,否则会形成后端已支持、前端完全不可见的断层。 - -## 目标 - -让 H5 原型页先具备读取并展示响应候选字段的占位能力,为后续正式动作面板铺路。 - -## 范围 - -- 订阅并解析新的私有动作消息字段 -- 在原型页中增加最小占位展示 -- 区分“主动动作按钮”和“响应动作按钮” - -## 非范围 - -- 不在本任务里完成正式视觉设计 -- 不在本任务里完成多状态动画 - -## 依赖 - -- `S1-05` - -## 产出物 - -- 前端字段适配 -- H5 原型占位展示 - -## 验收标准 - -- 当前原型页能看到候选动作 -- 不会影响现有定缺和出牌功能 - -## 验证方式 - -- 前端:`npm run build` -- H5 手工:检查候选动作占位区是否出现 - --- ## 4. 已完成 @@ -397,6 +216,94 @@ Sprint 目标: - 后续 `S1-04 / S1-05` 可以直接复用事件约定 - `mvn test` 已通过 +### S1-04 [功能] 响应候选模型初版 + +## 已完成内容 + +- 新增响应候选领域模型: + - `ResponseActionOption` + - `ResponseActionSeatCandidate` + - `ResponseActionWindow` +- `GameSession` 已预留 `pendingResponseActionWindow` 字段,为后续真实响应流程挂载窗口对象做准备 +- 新增 `ResponseActionWindowBuilder`,可基于一次弃牌构建候选窗口 +- 当前候选规则支持: + - 手里 2 张同牌时生成 `PENG + PASS` + - 手里 3 张同牌时生成 `PENG + GANG + PASS` +- 已补单测验证: + - 有候选时可正确构建多座位响应窗口 + - 无候选时返回空结果 + +## 验收结果 + +- 后端已经能表达“谁现在可以响应什么” +- `PASS` 已纳入候选动作模型 +- 下一步 `S1-05` 可以直接把候选窗口映射到私有动作消息 +- `mvn test` 已通过 + +### S1-05 [功能] 扩展私有动作消息体,支持响应候选下发 + +## 已完成内容 + +- `PrivateActionMessage` 已升级为结构化消息,补充字段: + - `actionScope` + - `windowId` + - `triggerEventType` + - `sourceSeatNo` + - `triggerTile` + - `candidates` +- 新增 `PrivateActionCandidate` DTO +- `GameMessagePublisher` 已拆分: + - `publishPrivateTurnActionRequired` + - `publishPrivateResponseActionRequired` +- 当前回合动作消息与响应候选消息已可共用同一消息结构 +- 已补消息发布单测,验证 turn / response 两类消息形状 + +## 验收结果 + +- 私有动作消息已经能区分“主动回合动作”和“被动响应动作” +- 前端后续无需再猜测候选字段语义 +- `mvn test` 已通过 + +### S1-07 [功能] H5 动作面板字段对齐与占位接入 + +## 已完成内容 + +- `App.vue` 已对齐新的私有动作消息结构 +- H5 原型页已支持识别: + - `TURN` + - `RESPONSE` + 两类动作消息作用域 +- 私有动作区已增加候选动作展示占位 +- 私有动作区已增加来源座位、目标牌等上下文字段展示 +- 已补样式支持候选动作标签展示 + +## 验收结果 + +- H5 原型页已经能消费结构化私有动作消息 +- 当前定缺和出牌流程未被破坏 +- `npm run build` 已通过 + +### S1-06 [研究] 响应优先级裁决规则澄清 + +## 已完成内容 + +- 新增规则澄清文档 `docs/RESPONSE_RESOLUTION_RULES.md` +- 明确了本项目 `V1` 的响应优先级: + - `HU > GANG > PENG > PASS` +- 明确了本项目 `V1` 的同优先级裁决: + - 按出牌者之后最近顺位优先 +- 明确了本项目 `V1` 的工程取舍: + - 当前不实现完整 `过水不胡` + - 当前不实现 `一炮多响` +- 明确了公共消息与私有消息的职责边界 +- 明确了后续真实响应窗口接入主流程的推荐顺序 + +## 验收结果 + +- 后续实现不需要再重新讨论优先级口径 +- 裁决器实现已有单一依据 +- 文档已纳入 README 与主计划索引 + --- ## 5. 依赖关系图 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cfdbab4..0ca32ca 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -63,11 +63,22 @@ type PublicGameMessage = { createdAt: string } +type PrivateActionCandidate = { + actionType: string + tile: string | null +} + type PrivateActionMessage = { gameId: string userId: string + actionScope: string availableActions: string[] currentSeatNo: number + windowId: string | null + triggerEventType: string | null + sourceSeatNo: number | null + triggerTile: string | null + candidates: PrivateActionCandidate[] } type PrivateTeachingMessage = { @@ -107,11 +118,24 @@ const phaseLabelMap: Record = { LACK_SELECTION: '定缺阶段' } +const actionScopeLabelMap: Record = { + TURN: '当前回合动作', + RESPONSE: '响应候选动作' +} + 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 ) const publicSeats = computed(() => game.value?.seats ?? []) +const privateActionCandidates = computed(() => privateAction.value?.candidates ?? []) +const privateActionSummary = computed(() => { + if (!privateAction.value) { + return '' + } + const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope + return `${scopeLabel}:${privateAction.value.availableActions.join(' / ')}` +}) async function requestJson(url: string, options?: RequestInit): Promise { const response = await fetch(url, { @@ -516,7 +540,27 @@ function discard(tile: string) {
私有动作消息 - {{ privateAction.availableActions.join(' / ') }} + 尚未收到私有动作消息
diff --git a/frontend/src/style.css b/frontend/src/style.css index 5e3e098..53bece3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -333,6 +333,31 @@ h2 { word-break: break-word; } +.action-meta-row { + margin-top: 10px; +} + +.candidate-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.candidate-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid rgba(113, 82, 47, 0.18); + background: linear-gradient(180deg, rgba(255, 247, 235, 0.98) 0%, rgba(241, 224, 196, 0.92) 100%); + color: var(--text); + font-size: 13px; + font-weight: 800; +} + .compact-field { flex: 1; min-width: 110px;