feat: 实现最小正式版过水不胡规则并完善前端动作面板

- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡
- 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法
- 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验
- 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换
- 优化公共事件处理逻辑,自动清理失效的私有动作面板
- 更新相关文档说明当前实现的规则范围和工程取舍
- 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
hujun
2026-03-20 15:26:22 +08:00
parent b84d0e8980
commit 6bcdf26fca
13 changed files with 832 additions and 148 deletions

View File

@@ -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);
}

View File

@@ -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())));

View File

@@ -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));

View File

@@ -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<ResponseActionOption> 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()) {

View File

@@ -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));