From 6bcdf26fca31dccffc94851c05025b6921c74923 Mon Sep 17 00:00:00 2001 From: hujun Date: Fri, 20 Mar 2026 15:26:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E7=89=88=E8=BF=87=E6=B0=B4=E4=B8=8D=E8=83=A1?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=B9=B6=E5=AE=8C=E5=96=84=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡 - 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法 - 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验 - 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换 - 优化公共事件处理逻辑,自动清理失效的私有动作面板 - 更新相关文档说明当前实现的规则范围和工程取舍 - 补充测试用例验证过水不胡规则的正确性 --- .../java/document_symbols_cache_v23-06-25.pkl | Bin 102264 -> 125967 bytes .serena/memories/blood_battle_scoring_v1.md | 70 +-- .../xuezhanmaster/game/domain/GameSeat.java | 14 + .../game/service/GameActionProcessor.java | 8 + .../game/service/GameSessionService.java | 15 + .../service/ResponseActionWindowBuilder.java | 13 +- .../game/service/GameSessionServiceTest.java | 119 +++++ docs/DEVELOPMENT_PLAN.md | 18 +- docs/PHASE_TASK_BOARD.md | 11 +- docs/RESPONSE_RESOLUTION_RULES.md | 92 ++-- docs/SPRINT_01_ISSUES_BOARD.md | 4 +- frontend/src/App.vue | 462 ++++++++++++++++-- frontend/src/style.css | 154 +++++- 13 files changed, 832 insertions(+), 148 deletions(-) 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 bd81720b0a93b2080d66908fd7bdc281ebe8721f..6e3cf279738cd87d2cf32addc86e54675b7d2994 100644 GIT binary patch literal 125967 zcmdU&dz_p_neP*l3>k7G_sb+?av_-{lj*r%ashMC5GEOB0%({J-tK;Tx;s7HV_$|O zf{TcF_|S+*dsz<3a-tv?(c=+0i0&Rgx~#_|vZ(8Q5&gL8>aNEtDx&8+Pt{Xz_gksA z>rEKut>llI^z-&JZ_V#pRc~FMx9XOu@0~Ph@+ACsbE%>D#;_GRZl|N6Ts9IKu@dP} z(i*cuk<3WwM8Q54x6&h4E^lW;!`6rm=5#)rNu}WPKKMLn=W>ZmdY5BuLg&qUtyIn~ z73*W&ojtLr6=`j?qIS5myQ|0Qigv~9W~-;C)#|oOM@tQuhpB1!U8UkGxM`E?rcHR$ zrt(co#i_>=>1gS|)Z(IoY+AbZKRIH7}t*jM+jKilwaSHr} z_=Wh)1C!#j4s0wGinDWe%8ukA`vc-P#V;y7f zFT3lMLN-w<=3Igteu8}Q>+=a;D7nj z!2)8MR$Qnokfr#A2d2fR<5vjoJm6o3y9+)|E)?PySA1gu*k(ES@zZMf#)9}lR|c@; z!foea5v6)CXlKU~5gYG0-F44J@H?hDzY#xmOZPHj*k?>;`I0qi**W@N5uEa zBjVWjjEMC)kOsqj<64ADRmZ`17w4C|AWqpU#qWVp@OzLOpO z%0jTgFqmlLCma@iNHq+;yEr3W*fVCO3RXUo#k);+-EA>A!}yne6@G#V71zzs21CAz zZ*1T%rng7p3^=_*3x@7bmLR#2<=3T!?>^M!k0z>lGv$_Lp^r4*xwc z*sa96)FpHse8RzQB~|Ry@1<=Gf_<>P!Pqv~vi^L42sVz|>0!72=Pwd1WRJAm?k?r1ThzaQwjw5 zjWPoKw8m5f8-qo#7}l9{G^dYk56xIAnxLCps^6+m9lx>I>4xH`bxieHxbDSP4e$;D zj{}lj!3w@61|c4Uww2#`@@klC>5a0-@L?;QvPJ8o5dSup_;)D8$8Rij#K%vUGVy1C z_`G^q`JkoW9jx?oSy``GGEoS9r%UL26hh-S%0lBO-o-?raeg0vf6zi-6|B%JtyBt& zc#f|~*->1RDwlJG?Dx84zfU1Meq(`CzVTB7E8iD^?BPr%Wn1Z>Rlh!1)$6fk!4lY; z-fyLOyMh#|G%NSJWPea0JAPxXBRhUt$z;EfN_)_n9}iaZt6)|*5v>T}pw7IjLxUfv zffp@tV?B+t1RU{GQ8!MzAU&>tgx@Gb!cVZ&0XSV%|48&acaaTK(k zaM5~BK?}c8riGu(qVD3R{ItrbHtd05Y0OFFcBZWKaqscjt1gNE zs*o7JQSL(Fr#jQRP>%s5HzcglF!M?NSt1<%)m#j_-z zDbKqdgi(m+)1z;LTkjT?-t0T`oi>Yoef&MDSx(9Y*m_6P)_4DtX*cd(+}90|8}T2^PYCERSaM>`;R zG1e8cnyv1(_Aa|?f`b*4ufm^wU{dlg_`@S)G5HEygs3sscPAB+e^r?AS7?G5Q?E}D zqgr<-gfaOi$gLd37#-gzMz1VLF^mVsv0PP+U`+lV|K_=8WUG|?16(*TE%`?x=mI>4 z7X}&dXSo?787I8Czr{KA+yQ3m`tO;_CoksoY=yfti=qhz~myl z`^1#jv5ad4lXYP3L&h|KW*ni>&Zml2T}A`qyun^cWD3_>)guNV0Eg|tu&iOld)jv5kO zmm!h4(MU);41zQZ9!P!>Em9*Qvu8b`>oOuTn^}+g3_{eHW4 f?0*Y4t0ShQ&yh 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 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 + }) +}