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