diff --git a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl index bd81720..6e3cf27 100644 Binary files a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl and b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl differ diff --git a/.serena/memories/blood_battle_scoring_v1.md b/.serena/memories/blood_battle_scoring_v1.md index c1e9dea..9b403b0 100644 --- a/.serena/memories/blood_battle_scoring_v1.md +++ b/.serena/memories/blood_battle_scoring_v1.md @@ -1,58 +1,16 @@ # 正式血战计分 V1(2026-03-20) - 已从工程占位分切换到“最小可扩展正式版”计分骨架。 -- 新增后端规则服务:`backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java` -- `SettlementResult` 已扩展 `settlementDetail`,结算事件 `SETTLEMENT_APPLIED` 现在会携带: - - `baseScore` - - `totalFan` - - `paymentScore` - - `fans`(番型明细) -- 当前 V1 已支持的基础番型/加番: - - `七对`:2 番 - - `对对胡`:1 番 - - `金钩钓`:1 番 - - `清一色`:2 番 - - `根`:每个 1 番 - - `抢杠胡`:1 番 - - `杠上花`:1 番 - - `杠上炮`:1 番 - - `海底捞月`:1 番 - - `海底炮`:1 番 -- 当前胡牌计分口径:`paymentScore = 1 << totalFan` - - 点炮胡:放炮者单独支付 `paymentScore` - - 自摸胡:所有未胡玩家各支付 `paymentScore` - - 抢杠胡:按胡牌番型 + `抢杠胡` 1 番,由补杠方单独支付 - - 杠上花:在自摸胡基础上额外加 1 番 - - 杠上炮:在点炮胡基础上额外加 1 番 - - 海底捞月:自摸胡时若牌墙已空,额外加 1 番 - - 海底炮:点炮胡时若牌墙已空,额外加 1 番 -- 当前杠分口径: - - `明杠/点杠`:放杠者单独支付 2 分 - - `补杠`:所有未胡玩家各支付 1 分 - - `暗杠`:所有未胡玩家各支付 2 分 -- `GameSession` 已新增: - - `PostGangContext`:只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮` - - `settlementHistory`:记录已应用结算结果,供局终 `退税/查叫` 直接复用 -- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`。 -- 查叫/退税已接入局终处理: - - 触发时机:牌墙耗尽且仍有多家未胡时 - - 处理顺序:先 `退税`,再 `查叫` - - `退税`:未听牌玩家若此前有杠分收入,需要按原支付关系逐笔退还 - - `查叫`:未听牌玩家向仍在场且已听牌玩家,按对方理论最大可胡牌型的点炮赔分支付 - - 已新增 `SettlementType.TUI_SHUI` 与 `SettlementType.CHA_JIAO` - - 已新增 `ReadyHandOption`,用于查叫时记录最优听牌目标牌与对应结算明细 -- 一炮多响已接入响应裁决: - - `HU` 现在支持多赢家同窗裁决,只要响应窗口内有多个 `HU` 声明,就会一起结算 - - `PENG/GANG` 仍保持单赢家,顺位规则不变 - - 抢杠胡窗口也会复用这套多赢家 `HU` 裁决路径 - - 已新增 `ResponseActionResolutionBatch` 承载多赢家响应结果 -- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。 -- 当前仍未实现: - - 自摸加番/加底地方变体 - - 天胡、地胡 - - 过水不胡 -- 文档约定已加强到 `docs/DEVELOPMENT_PLAN.md`: - - 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释 - - 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释 - - 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,必须补中文注释说明业务含义、约束原因、字段口径和回滚要点 - - 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域 -- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。 \ No newline at end of file +- 当前已支持的规则主线包括:七对、对对胡、金钩钓、清一色、根、抢杠胡、杠上花、杠上炮、海底捞月、海底炮、明杠/补杠/暗杠、退税、查叫、一炮多响、最小正式版过水不胡。 +- 当前胡牌计分口径:`paymentScore = 1 << totalFan`。 +- 一炮多响:只对 `HU` 开放多赢家同窗裁决,`PENG/GANG` 仍单赢家。 +- 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。 +- 后端最小验证:`cd backend && mvn test` 通过,当前 53 个测试通过。 +- 前端 H5 对局页已经完成两轮联调: + - 已区分“当前回合动作”和“响应动作”两种动作面板。 + - `DISCARD` 继续通过点击手牌执行,`GANG/HU` 等额外动作走统一面板。 + - 响应动作面板会展示 `sourceSeatNo`、`triggerTile`、`triggerEventType`、`windowId`。 + - 已支持在对局页切换玩家视角,并自动刷新对应 `GameStateResponse` 与重连该玩家私有 WebSocket 主题。 + - 公共事件接收已改为统一入口处理,会在 `RESPONSE_WINDOW_CLOSED`、`TURN_SWITCHED`、`TILE_DISCARDED`、`GAME_PHASE_CHANGED` 等场景下清理已失效的私有动作面板,避免旧窗口残留。 + - 公共事件时间线已支持中文摘要文案、时间展示和原始载荷折叠查看,便于联调时同时看“业务含义”和“真实 payload”。 +- 注释约定继续有效:后端复杂规则、前端复杂交互和后续数据库脚本都要补适当偏多的中文注释。 +- 前端验证:`cd frontend && npm run build` 通过。 \ 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 25c722d..0232b30 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java +++ b/backend/src/main/java/com/xuezhanmaster/game/domain/GameSeat.java @@ -16,6 +16,8 @@ public class GameSeat { private TileSuit lackSuit; private boolean won; private int score; + // 过水不胡最小版:只限制“响应胡”,直到该玩家下一次摸牌后解除;不影响自摸胡。 + private boolean passedHuBlocked; public GameSeat(int seatNo, boolean ai, String nickname) { this(seatNo, ai, UUID.randomUUID().toString(), nickname); @@ -76,10 +78,22 @@ public class GameSeat { return score; } + public boolean isPassedHuBlocked() { + return passedHuBlocked; + } + public void addScore(int delta) { this.score += delta; } + public void markPassedHuBlocked() { + this.passedHuBlocked = true; + } + + public void clearPassedHuBlocked() { + this.passedHuBlocked = false; + } + public void receiveTile(Tile tile) { handTiles.add(tile); } 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 58766e5..4a27618 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameActionProcessor.java @@ -287,6 +287,14 @@ public class GameActionProcessor { .findFirst() .orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中")); + if ("胡".equals(actionName) && actorSeat.isPassedHuBlocked()) { + boolean hasHuOption = seatCandidate.options().stream() + .anyMatch(option -> option.actionType() == ActionType.HU); + if (!hasHuOption) { + throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前处于过水不胡限制中,需待下次摸牌后才能再胡"); + } + } + boolean matched = seatCandidate.options().stream() .anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName)) && (tileDisplayName == null || tileDisplayName.equals(option.tile()))); 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 494f288..5523a40 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -180,6 +180,8 @@ public class GameSessionService { GameSeat nextSeat = nextSeatOptional.get(); Tile drawnTile = table.getWallTiles().remove(0); nextSeat.receiveTile(drawnTile); + // 玩家真正完成下一次摸牌后,才解除此前由“过水不胡”带来的响应胡限制。 + nextSeat.clearPassedHuBlocked(); table.setCurrentSeatNo(nextSeat.getSeatNo()); gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size())); gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo())); @@ -366,6 +368,7 @@ public class GameSessionService { private void handlePassPostAction(GameSession session, GameActionRequest request) { ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session); GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId()); + markPassedHuBlockedIfNeeded(responseActionWindow, actorSeat); session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS); tryResolveResponseWindow(session, responseActionWindow); } @@ -413,6 +416,7 @@ public class GameSessionService { for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) { GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo()); if (seat.isAi()) { + markPassedHuBlockedIfNeeded(responseActionWindow, seat); session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS); } } @@ -590,6 +594,7 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + winnerSeat.clearPassedHuBlocked(); markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); @@ -726,6 +731,7 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + winnerSeat.clearPassedHuBlocked(); markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); @@ -786,12 +792,21 @@ public class GameSessionService { Tile drawnTile = table.getWallTiles().remove(0); winnerSeat.receiveTile(drawnTile); + winnerSeat.clearPassedHuBlocked(); markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); continueFromResolvedActionTurn(session, winnerSeat); } + private void markPassedHuBlockedIfNeeded(ResponseActionWindow responseActionWindow, GameSeat seat) { + responseActionWindow.seatCandidates().stream() + .filter(candidate -> candidate.seatNo() == seat.getSeatNo()) + .findFirst() + .filter(candidate -> candidate.options().stream().anyMatch(option -> option.actionType() == ActionType.HU)) + .ifPresent(candidate -> seat.markPassedHuBlocked()); + } + 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 index c043734..f0a97f9 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/ResponseActionWindowBuilder.java @@ -63,7 +63,8 @@ public class ResponseActionWindowBuilder { if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) { continue; } - if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) { + // 抢杠胡也属于“响应胡”,因此同样受过水不胡限制。 + if (!canHuByResponse(seat, gangTile)) { continue; } seatCandidates.add(new ResponseActionSeatCandidate( @@ -91,7 +92,8 @@ public class ResponseActionWindowBuilder { private List buildSeatOptions(GameSeat seat, Tile discardedTile) { int sameTileCount = countSameTileInHand(seat, discardedTile); - boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile); + // 过水不胡只屏蔽响应胡候选,不影响同窗口内仍可用的碰、杠。 + boolean canHu = canHuByResponse(seat, discardedTile); if (!canHu && sameTileCount < 2) { return List.of(); } @@ -114,6 +116,13 @@ public class ResponseActionWindowBuilder { return options; } + private boolean canHuByResponse(GameSeat seat, Tile triggerTile) { + if (seat.isPassedHuBlocked()) { + return false; + } + return huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), triggerTile); + } + private int countSameTileInHand(GameSeat seat, Tile discardedTile) { int count = 0; for (Tile tile : seat.getHandTiles()) { 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 c8e6505..abdf656 100644 --- a/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java +++ b/backend/src/test/java/com/xuezhanmaster/game/service/GameSessionServiceTest.java @@ -764,6 +764,125 @@ class GameSessionServiceTest { assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2); } + @Test + void shouldBlockResponseHuBeforeSeatDrawsAgainAfterPassingWinningWindow() { + 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()); + GameSeat winnerSeat = session.getTable().getSeats().get(1); + prepareWinningHand(winnerSeat); + session.getTable().setCurrentSeatNo(2); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(3), "9筒", 1); + removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2, 3); + + gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-3", "DISCARD", "9筒", null) + ); + GameStateResponse afterPass = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "PASS", null, 2) + ); + + assertThat(winnerSeat.isPassedHuBlocked()).isTrue(); + assertThat(afterPass.currentSeatNo()).isEqualTo(3); + + GameStateResponse afterSecondDiscard = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-4", "DISCARD", "9筒", null) + ); + + assertThat(session.getPendingResponseActionWindow()).isNull(); + assertThat(afterSecondDiscard.currentSeatNo()).isEqualTo(0); + assertThat(winnerSeat.isPassedHuBlocked()).isTrue(); + } + + @Test + void shouldClearPassedHuRestrictionAfterSeatDrawsAgain() { + 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()); + GameSeat winnerSeat = session.getTable().getSeats().get(1); + prepareWinningHand(winnerSeat); + session.getTable().setCurrentSeatNo(2); + ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 2); + removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2); + + gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-3", "DISCARD", "9筒", null) + ); + gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "PASS", null, 2) + ); + + assertThat(winnerSeat.isPassedHuBlocked()).isTrue(); + + String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName(); + removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3); + gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null) + ); + + String seatZeroSafeDiscard = session.getTable().getSeats().get(0).getHandTiles().get(0).getDisplayName(); + removeMatchingTilesFromOtherSeats(session, seatZeroSafeDiscard, 0); + setNextWallTile(session, "8万"); + GameStateResponse afterSeatZeroDiscard = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("host-1", "DISCARD", seatZeroSafeDiscard, null) + ); + + assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1); + assertThat(winnerSeat.isPassedHuBlocked()).isFalse(); + removeMatchingTilesFromOtherSeats(session, "8万", 1); + + gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "DISCARD", "8万", null) + ); + GameStateResponse afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-3", "DISCARD", "9筒", null) + ); + afterHu = gameSessionService.performAction( + started.gameId(), + new GameActionRequest("player-2", "HU", "9筒", 2) + ); + + assertThat(afterHu.selfSeat().won()).isTrue(); + assertThat(afterHu.selfSeat().score()).isEqualTo(1); + assertThat(afterHu.currentSeatNo()).isEqualTo(3); + } + @Test void shouldRejectSelfDrawHuWhenHandIsNotWinning() { RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 5be4e32..059e9e6 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -350,12 +350,10 @@ ws WebSocket 配置与消息发布 ### 7.5 当前尚未完成 -- `PENG` -- `GANG` -- `HU` -- `PASS` -- 响应动作优先级裁决 -- 胡牌判定与结算 +- 自摸加番 / 加底等地方变体 +- 天胡、地胡 +- 更完整的地方化 `过水不胡` +- 前端正式动作面板与规则联调 - 教学开关接口 - 局后个人复盘 - 数据库持久化 @@ -393,6 +391,10 @@ ws WebSocket 配置与消息发布 - 当前支持: - `SELECT_LACK_SUIT` - `DISCARD` + - `PENG` + - `GANG` + - `HU` + - `PASS` - `POST /api/games/{gameId}/lack` - 兼容接口 @@ -645,7 +647,7 @@ AI 不是单一模块,而是三层能力: 当前状态: -- 进行中 +- 已基本完成 ### M3 规则与结算 @@ -668,7 +670,7 @@ AI 不是单一模块,而是三层能力: 当前状态: -- 待做 +- 进行中 ### M4 H5 正式对局体验 diff --git a/docs/PHASE_TASK_BOARD.md b/docs/PHASE_TASK_BOARD.md index 25dc276..132e33e 100644 --- a/docs/PHASE_TASK_BOARD.md +++ b/docs/PHASE_TASK_BOARD.md @@ -1,9 +1,9 @@ # XueZhanMaster 阶段任务看板 -本文档把主计划拆成可以直接推进的阶段任务卡。 +本文档把主计划拆成可以直接推进的阶段任务卡。\ 当前状态快照日期:`2026-03-20` ---- +*** ## 1. 使用说明 @@ -33,7 +33,7 @@ - 验收标准 - 风险提示 ---- +*** ## 2. 待做 @@ -205,7 +205,7 @@ - 每局结束后可获得个人复盘 - 可沉淀到错题本并二次查看 ---- +*** ## 3. 进行中 @@ -252,7 +252,7 @@ - 验收标准: - 页面拆分方案可直接指导下一轮前端编码 ---- +*** ## 4. 已完成 @@ -359,3 +359,4 @@ - `vite.config.ts` 已代理 `/api` 和 `/ws` - 验收结果: - H5 原型页面可走通当前最小链路 + diff --git a/docs/RESPONSE_RESOLUTION_RULES.md b/docs/RESPONSE_RESOLUTION_RULES.md index 27c7961..44bfb08 100644 --- a/docs/RESPONSE_RESOLUTION_RULES.md +++ b/docs/RESPONSE_RESOLUTION_RULES.md @@ -22,19 +22,22 @@ - AI 与真人使用同一套优先级规则 - 同优先级冲突时,按出牌者之后的最近顺位优先 - `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会 -- `过水不胡` 作为后续增强规则,不在当前 `V1` 强制启用 -- `一炮多响` 在项目 `V1` 中暂不实现,采用“单窗口单胜出动作”的工程裁决 +- `过水不胡` 已在项目 `V1` 中启用“最小正式版” + - 玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次摸牌前,不能再做响应胡 + - 该限制只作用于响应胡,不影响 `PENG / GANG`,也不影响自摸胡 +- `一炮多响` 已在项目 `V1` 中实现,但只对 `HU` 开放多赢家同窗裁决 + - `PENG / GANG` 仍保持单赢家裁决,顺位规则不变 ### 1.2 为什么这样定 - 这样能和当前后端结构最自然衔接 -- 可以先把响应窗口停顿与恢复流程做稳 -- 避免在 `HU` 判定、多人同时胡牌、结算分摊都未稳定时,提前引入高复杂度冲突逻辑 +- 可以先把响应窗口停顿、恢复和结算主链做稳 +- 即使已支持一炮多响与最小版过水不胡,也仍把复杂度限制在当前统一动作入口和统一响应窗口内 这符合: -- `KISS`:先做单窗口单胜出 -- `YAGNI`:当前不抢做一炮多响和完整过手胡体系 +- `KISS`:一炮多响只对 `HU` 放开,多赢家共用一张牌源,避免把 `PENG / GANG` 也做成多胜出分支 +- `YAGNI`:当前不抢做完整地方化 `过水不胡` 体系,也不引入可配置规则模式层 - `SOLID`:先把规则澄清文档与裁决器边界固定 - `DRY`:所有动作冲突统一走一套优先级裁决 @@ -75,11 +78,10 @@ - 某玩家打出一张牌后 - 其他仍在牌局中的玩家基于这张弃牌触发响应窗口 +- 补杠声明后的抢杠胡响应窗口 `V1` 暂不实现: -- 抢杠胡窗口 -- 补杠后二次响应窗口 - 最后一张牌必须胡的特殊强制窗口 - 多轮连续嵌套响应窗口 @@ -89,14 +91,13 @@ - `PENG` - `GANG` +- `HU` - `PASS` -`HU` 的动作入口和事件枚举虽然已预留,但真正候选生成依赖胡牌判定完成后再接入。 +其中: -因此要区分两个状态: - -1. 接口和模型层:`HU` 已被保留 -2. 当前实际候选生成层:以 `PENG / GANG / PASS` 为主 +1. 弃牌响应窗口支持 `HU / PENG / GANG / PASS` +2. 补杠抢杠胡窗口支持 `HU / PASS` --- @@ -135,27 +136,30 @@ - `seat 1` 和 `seat 3` 同时都能 `PENG` - 则 `seat 1` 获胜 -### 4.3 为什么不在 V1 支持一炮多响 +### 4.3 为什么在 V1 只对 `HU` 支持一炮多响 -这是一个项目级工程取舍,不是宣称外部规则世界只有这一种。 +这也是一个项目级工程取舍,不是宣称外部规则世界只有这一种。 原因: -- 一炮多响会直接放大以下复杂度: - - 多赢家结算 - - 胡牌先后次序 - - 胡后谁退出牌局、谁继续 - - 多人同时胡后下一个行动座位怎么定 -- 当前项目还未完成: +- `HU` 多赢家是血战到底中较常见、且用户感知较强的规则 +- 当前后端已经具备: - 胡牌判定 - 基础结算 - - 胡后继续行牌完整链路 + - 胡后继续行牌主链 + - 结算历史沉淀 +- 但如果把 `PENG / GANG` 也做成多赢家分支,会明显抬高后续行牌复杂度 -所以 `V1` 先采用: +所以 `V1` 采用: -- `单窗口单胜出动作` +- `HU` 可多赢家同窗裁决 +- `PENG / GANG` 仍然单赢家裁决 -后续若要升级到一炮多响,应作为 `V2` 明确需求,不应在当前实现里偷偷混入。 +这样做的好处是: + +- 保住高价值规则一致性 +- 又不破坏现有统一动作入口、统一响应窗口和统一结算出口 +- 仍符合 `KISS` --- @@ -190,16 +194,21 @@ #### V1 -- 不实现完整 `过水不胡` -- 只保证“同一响应窗口内,PASS 后不能反悔” +- 不实现完整地方化 `过水不胡` +- 当前只实现“最小正式版”: + - 玩家在响应窗口里本可 `HU` 但选择 `PASS` 时,记录一次响应禁胡状态 + - 在该玩家下一次真正摸牌前,后续弃牌胡与抢杠胡都不再给出 `HU` 候选 + - 下一次真正摸牌后解除限制 + - 不影响 `PENG / GANG` + - 不影响自摸胡 #### V2 - 评估是否加入: - - 玩家错过可胡后,直到自己下一次摸牌或动牌前不能再胡 - 是否仅限制同张牌 - 是否限制同一路听牌 - 是否允许加番后破除限制 + - 是否需要把“解除时机”细化为摸牌、碰杠、换巡或加番后解除 --- @@ -323,20 +332,25 @@ - 新动作基础校验 - 新动作事件模型 - 响应候选模型 +- 响应窗口停顿与关闭 +- 基于窗口的多人响应收集 +- `HU` 候选生成与胡牌判定 +- `HU` 的一炮多响裁决 +- 抢杠胡窗口 +- 最小正式版 `过水不胡` - 结构化私有动作消息 - H5 原型页候选消息展示占位 当前尚未完成: -- 真正的响应窗口停顿 -- 基于窗口的多人响应收集 -- 优先级裁决器 -- `HU` 候选生成与胡牌判定 +- 前端正式动作面板联调 +- 更完整的地方化 `过水不胡` +- 规则模式配置层 -因此本文件的直接用途是: +因此本文件当前的直接用途是: -- 让下一步裁决实现有明确规则依据 -- 降低“每轮新对话都重新讨论优先级”的沟通成本 +- 让后续前后端联调和规则扩展有明确规则依据 +- 降低“每轮新对话都重新讨论响应口径”的沟通成本 --- @@ -346,12 +360,12 @@ 1. 血战到底按成都麻将大框架实现 2. 不可吃,只处理 `碰 / 杠 / 胡 / 过` -3. 当前第一阶段只围绕“弃牌后响应”建窗口 +3. 当前响应窗口覆盖“弃牌后响应”与“补杠后的抢杠胡响应” 4. 优先级固定为 `HU > GANG > PENG > PASS` -5. 同优先级按出牌者之后最近顺位优先 +5. 同优先级按出牌者之后最近顺位优先,且 `HU` 支持一炮多响 6. `PASS` 仅放弃当前窗口 -7. 当前不实现完整 `过水不胡` -8. 当前不实现 `一炮多响` +7. 当前实现最小正式版 `过水不胡`,解除时机为该玩家下一次真正摸牌 +8. 当前只对 `HU` 实现一炮多响,`PENG / GANG` 仍保持单赢家裁决 如果后续要改这些口径,必须先更新本文件,再改代码。 diff --git a/docs/SPRINT_01_ISSUES_BOARD.md b/docs/SPRINT_01_ISSUES_BOARD.md index a520262..c8f3a1a 100644 --- a/docs/SPRINT_01_ISSUES_BOARD.md +++ b/docs/SPRINT_01_ISSUES_BOARD.md @@ -303,8 +303,8 @@ Sprint 目标: - 明确了本项目 `V1` 的同优先级裁决: - 按出牌者之后最近顺位优先 - 明确了本项目 `V1` 的工程取舍: - - 当前不实现完整 `过水不胡` - - 当前不实现 `一炮多响` + - 当前只实现最小正式版 `过水不胡` + - 当前只对 `HU` 实现 `一炮多响` - 明确了公共消息与私有消息的职责边界 - 明确了后续真实响应窗口接入主流程的推荐顺序 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index de6c844..3e4a048 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -95,9 +95,15 @@ type PrivateTeachingMessage = { explanation: string } +type ViewUserOption = { + userId: string + label: string + seatNo: number +} + const busy = ref(false) const error = ref('') -const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。') +const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。') const ownerId = ref('host-1') const ownerName = ref('房主') @@ -129,6 +135,38 @@ const actionScopeLabelMap: Record = { RESPONSE: '响应候选动作' } +const actionTypeLabelMap: Record = { + SELECT_LACK_SUIT: '定缺', + DISCARD: '出牌', + PENG: '碰', + GANG: '杠', + HU: '胡', + PASS: '过' +} + +const triggerEventTypeLabelMap: Record = { + TILE_DISCARDED: '弃牌后响应', + SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡' +} + +const publicEventLabelMap: Record = { + GAME_STARTED: '对局开始', + LACK_SELECTED: '定缺完成', + GAME_PHASE_CHANGED: '阶段切换', + TILE_DISCARDED: '出牌', + TILE_DRAWN: '摸牌', + TURN_SWITCHED: '轮转', + RESPONSE_WINDOW_OPENED: '响应窗口开启', + RESPONSE_WINDOW_CLOSED: '响应窗口关闭', + PENG_DECLARED: '碰牌宣告', + GANG_DECLARED: '杠牌宣告', + HU_DECLARED: '胡牌宣告', + PASS_DECLARED: '过牌宣告', + SETTLEMENT_APPLIED: '结算应用', + SCORE_CHANGED: '分数变化', + ACTION_REQUIRED: '动作提醒' +} + const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION') const canDiscard = computed( () => @@ -137,14 +175,117 @@ const canDiscard = computed( 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(' / ')}` + const readableActions = privateAction.value.availableActions.map(toActionLabel) + return `${scopeLabel}:${readableActions.join(' / ')}` +}) + +const turnActionCandidates = computed(() => { + if (privateAction.value?.actionScope !== 'TURN') { + return [] + } + // 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。 + return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD') +}) + +const responseActionCandidates = computed(() => { + if (privateAction.value?.actionScope !== 'RESPONSE') { + return [] + } + return privateAction.value.candidates +}) + +const responseContextSummary = computed(() => { + if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') { + return '' + } + const triggerLabel = privateAction.value.triggerEventType + ? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType + : '响应窗口' + const sourceSeatLabel = + privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知' + const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌' + return `${triggerLabel},${sourceSeatLabel},${triggerTileLabel}` +}) + +const actionPanelHint = computed(() => { + if (!privateAction.value) { + return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。' + } + if (privateAction.value.actionScope === 'TURN') { + return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。' + } + return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。` +}) + +const currentViewLabel = computed(() => { + if (!game.value) { + return currentUserId.value + } + return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}` +}) + +const viewUserOptions = computed(() => { + if (game.value) { + const options = new Map() + options.set(game.value.selfSeat.playerId, { + userId: game.value.selfSeat.playerId, + label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`, + seatNo: game.value.selfSeat.seatNo + }) + for (const seat of game.value.seats) { + options.set(seat.playerId, { + userId: seat.playerId, + label: `${seat.nickname} · 座位 ${seat.seatNo}`, + seatNo: seat.seatNo + }) + } + return [...options.values()].sort((left, right) => left.seatNo - right.seatNo) + } + + return [ + { userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 }, + { userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 } + ] +}) + +const actionDiagnosticItems = computed(() => { + if (!privateAction.value) { + return [] + } + return [ + { + key: 'scope', + label: '作用域', + value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope + }, + { + key: 'window', + label: '窗口 ID', + value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合' + }, + { + key: 'event', + label: '触发来源', + value: privateAction.value.triggerEventType + ? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType + : '当前回合' + }, + { + key: 'source', + label: '来源座位', + value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无' + } + ] }) async function requestJson(url: string, options?: RequestInit): Promise { @@ -208,9 +349,10 @@ function connectWs(gameId: string, userId: string) { wsStatus.value = 'connected' client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => { const payload = JSON.parse(message.body) as PublicGameMessage - publicEvents.value = [payload, ...publicEvents.value].slice(0, 16) + handlePublicEvent(payload) }) client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => { + // 私有动作消息是前端动作面板的唯一事实来源,后续所有按钮启用状态都以这里为准。 privateAction.value = JSON.parse(message.body) as PrivateActionMessage }) client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => { @@ -236,6 +378,7 @@ function connectWs(gameId: string, userId: string) { watch( () => [game.value?.gameId, currentUserId.value] as const, ([gameId, userId]) => { + // 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。 if (gameId) { connectWs(gameId, userId) } else { @@ -340,6 +483,27 @@ async function refreshGameState() { }) } +async function switchUserView(userId: string) { + if (currentUserId.value === userId) { + return + } + + currentUserId.value = userId + + if (!game.value) { + info.value = `已切换到 ${userId} 视角。` + return + } + + const targetGameId = game.value.gameId + await runTask(async () => { + game.value = await requestJson( + `/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}` + ) + info.value = `已切换到 ${userId} 视角,并同步最新对局状态。` + }) +} + async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) { if (!game.value) { return @@ -355,7 +519,17 @@ async function submitAction(actionType: string, tile?: string, sourceSeatNo?: nu sourceSeatNo: sourceSeatNo ?? null }) }) - info.value = actionType === 'DISCARD' ? `已打出 ${tile}。` : `已提交动作 ${actionType}。` + + const readableAction = toActionLabel(actionType) + if (actionType === 'DISCARD' && tile) { + info.value = `已打出 ${tile}。` + return + } + if (tile) { + info.value = `已提交 ${readableAction} ${tile}。` + return + } + info.value = `已提交动作 ${readableAction}。` }) } @@ -371,6 +545,153 @@ function submitCandidateAction(actionType: string, tile: string | null) { const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null return submitAction(actionType, tile ?? undefined, sourceSeatNo) } + +function toActionLabel(actionType: string) { + return actionTypeLabelMap[actionType] ?? actionType +} + +function formatCandidateLabel(candidate: PrivateActionCandidate) { + const actionLabel = toActionLabel(candidate.actionType) + if (!candidate.tile) { + return actionLabel + } + return `${actionLabel} · ${candidate.tile}` +} + +function handlePublicEvent(event: PublicGameMessage) { + // 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。 + if (shouldClearPrivateActionByEvent(event)) { + privateAction.value = null + } + publicEvents.value = [event, ...publicEvents.value].slice(0, 16) +} + +function shouldClearPrivateActionByEvent(event: PublicGameMessage) { + if (!privateAction.value) { + return false + } + + const currentAction = privateAction.value + const selfSeatNo = game.value?.selfSeat.seatNo ?? null + + if (event.eventType === 'RESPONSE_WINDOW_CLOSED') { + const sourceSeatNo = readNumber(event.payload.sourceSeatNo) + return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo + } + + if (event.eventType === 'GAME_PHASE_CHANGED') { + return readString(event.payload.phase) !== 'PLAYING' + } + + if (event.eventType === 'TILE_DISCARDED') { + return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo + } + + if (event.eventType === 'TURN_SWITCHED') { + const nextSeatNo = readNumber(event.payload.currentSeatNo) + return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo + } + + return false +} + +function readString(value: unknown) { + return typeof value === 'string' ? value : null +} + +function readNumber(value: unknown) { + return typeof value === 'number' ? value : null +} + +function formatSeatLabel(seatNo: number | null | undefined) { + if (seatNo === null || seatNo === undefined) { + return '未知座位' + } + return `座位 ${seatNo}` +} + +function formatPhaseLabel(phase: string | null) { + if (!phase) { + return '未知阶段' + } + return phaseLabelMap[phase] ?? phase +} + +function formatPublicEventTitle(event: PublicGameMessage) { + return publicEventLabelMap[event.eventType] ?? event.eventType +} + +function formatPublicEventSummary(event: PublicGameMessage) { + switch (event.eventType) { + case 'GAME_STARTED': + return `房间 ${readString(event.payload.roomId) ?? '-'} 已开局。` + case 'LACK_SELECTED': + return `${formatSeatLabel(event.seatNo)} 已完成定缺 ${readString(event.payload.lackSuit) ?? '-' }。` + case 'GAME_PHASE_CHANGED': + return `阶段切换为 ${formatPhaseLabel(readString(event.payload.phase))}。` + case 'TILE_DISCARDED': + return `${formatSeatLabel(event.seatNo)} 打出 ${readString(event.payload.tile) ?? '-'}。` + case 'TILE_DRAWN': + return `${formatSeatLabel(event.seatNo)} 完成摸牌,牌墙剩余 ${readNumber(event.payload.remainingWallCount) ?? '-'} 张。` + case 'TURN_SWITCHED': + return `当前轮到 ${formatSeatLabel(readNumber(event.payload.currentSeatNo) ?? event.seatNo)}。` + case 'RESPONSE_WINDOW_OPENED': + return `因 ${formatSeatLabel(readNumber(event.payload.sourceSeatNo))} 的 ${readString(event.payload.tile) ?? '-'} 打开响应窗口。` + case 'RESPONSE_WINDOW_CLOSED': + return `响应窗口已关闭,最终裁决 ${toActionLabel(readString(event.payload.resolvedActionType) ?? '-')}。` + case 'PENG_DECLARED': + case 'GANG_DECLARED': + case 'HU_DECLARED': + case 'PASS_DECLARED': + return `${formatSeatLabel(event.seatNo)} 执行 ${toActionLabel(readString(event.payload.actionType) ?? event.eventType.replace('_DECLARED', ''))}${readString(event.payload.tile) ? ` · ${readString(event.payload.tile)}` : ''}。` + case 'SETTLEMENT_APPLIED': + return formatSettlementSummary(event) + case 'SCORE_CHANGED': + return `${formatSeatLabel(event.seatNo)} 分数变化 ${formatScoreDelta(readNumber(event.payload.delta))},当前 ${readNumber(event.payload.score) ?? '-'}。` + default: + return JSON.stringify(event.payload) + } +} + +function formatSettlementSummary(event: PublicGameMessage) { + const settlementType = readString(event.payload.settlementType) ?? '未知结算' + const triggerTile = readString(event.payload.triggerTile) + const detail = asRecord(event.payload.settlementDetail) + const paymentScore = readNumber(detail?.paymentScore) + const totalFan = readNumber(detail?.totalFan) + return `${settlementType}${triggerTile ? ` · ${triggerTile}` : ''},${paymentScore ?? '-'} 分,${totalFan ?? 0} 番。` +} + +function formatScoreDelta(delta: number | null) { + if (delta === null) { + return '-' + } + return delta > 0 ? `+${delta}` : `${delta}` +} + +function asRecord(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + return value as Record +} + +function formatEventPayload(event: PublicGameMessage) { + return JSON.stringify(event.payload, null, 2) +} + +function formatEventTime(createdAt: string) { + const date = new Date(createdAt) + if (Number.isNaN(date.getTime())) { + return createdAt + } + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) +}