feat: 实现最小正式版过水不胡规则并完善前端动作面板
- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡 - 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法 - 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验 - 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换 - 优化公共事件处理逻辑,自动清理失效的私有动作面板 - 更新相关文档说明当前实现的规则范围和工程取舍 - 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user