feat: 实现补杠和抢杠胡功能

新增 MeldType 和 MeldGroup 领域模型,支持碰、明杠、补杠、暗杠四种副露类型
GameSeat 新增副露管理方法,支持将碰升级为补杠
ResponseActionWindowBuilder 新增补杠响应窗口构建逻辑
SettlementService 新增补杠和抢杠胡结算规则
前端新增副露展示区域,支持显示各类副露标签
This commit is contained in:
hujun
2026-03-20 14:14:07 +08:00
parent 36dcfb7d31
commit d038a8732d
16 changed files with 633 additions and 29 deletions

View File

@@ -12,6 +12,7 @@ public class GameSeat {
private final String nickname;
private final List<Tile> handTiles = new ArrayList<>();
private final List<Tile> discardTiles = new ArrayList<>();
private final List<MeldGroup> meldGroups = new ArrayList<>();
private TileSuit lackSuit;
private boolean won;
private int score;
@@ -51,6 +52,10 @@ public class GameSeat {
return discardTiles;
}
public List<MeldGroup> getMeldGroups() {
return meldGroups;
}
public TileSuit getLackSuit() {
return lackSuit;
}
@@ -107,6 +112,34 @@ public class GameSeat {
throw new IllegalStateException("弃牌区不存在指定牌");
}
public void addPengMeld(Tile tile, int sourceSeatNo) {
meldGroups.add(new MeldGroup(MeldType.PENG, tile, sourceSeatNo));
}
public void addMingGangMeld(Tile tile, int sourceSeatNo) {
meldGroups.add(new MeldGroup(MeldType.MING_GANG, tile, sourceSeatNo));
}
public void upgradePengToBuGang(Tile tile) {
for (int i = 0; i < meldGroups.size(); i++) {
MeldGroup meldGroup = meldGroups.get(i);
if (meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile)) {
meldGroups.set(i, new MeldGroup(MeldType.BU_GANG, tile, meldGroup.sourceSeatNo()));
return;
}
}
throw new IllegalStateException("当前没有可升级为补杠的碰副露");
}
public boolean hasPengMeld(Tile tile) {
return meldGroups.stream()
.anyMatch(meldGroup -> meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile));
}
public void addAnGangMeld(Tile tile) {
meldGroups.add(new MeldGroup(MeldType.AN_GANG, tile, null));
}
public void declareHu() {
this.won = true;
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public record MeldGroup(
MeldType type,
Tile tile,
Integer sourceSeatNo
) {
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public enum MeldType {
PENG,
MING_GANG,
BU_GANG,
AN_GANG
}

View File

@@ -2,6 +2,9 @@ package com.xuezhanmaster.game.domain;
public enum SettlementType {
DIAN_PAO_HU,
QIANG_GANG_HU,
ZI_MO_HU,
MING_GANG
BU_GANG,
MING_GANG,
AN_GANG
}

View File

@@ -11,6 +11,7 @@ public record PublicSeatView(
String lackSuit,
int score,
int handCount,
List<String> discardTiles
List<String> discardTiles,
List<String> melds
) {
}

View File

@@ -10,6 +10,7 @@ public record SelfSeatView(
String lackSuit,
int score,
List<String> handTiles,
List<String> discardTiles
List<String> discardTiles,
List<String> melds
) {
}

View File

@@ -50,6 +50,10 @@ public class GameActionProcessor {
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
if (sourceSeatNo == null) {
validateSelfGang(session, table, seat, tileDisplayName);
return List.of();
}
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return List.of();
}
@@ -219,6 +223,33 @@ public class GameActionProcessor {
}
}
private void validateSelfGang(GameSession session, GameTable table, GameSeat actorSeat, String tileDisplayName) {
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许暗杠");
}
if (actorSeat.isWon()) {
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续杠牌");
}
if (actorSeat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的回合,不能暗杠");
}
if (session.getPendingResponseActionWindow() != null) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "响应窗口期间不能执行暗杠");
}
if (isBlank(tileDisplayName)) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", "暗杠动作缺少目标牌");
}
Tile tile = findTileInHand(actorSeat, tileDisplayName);
long sameTileCount = actorSeat.getHandTiles().stream()
.filter(handTile -> handTile.equals(tile))
.count();
boolean canConcealedGang = sameTileCount >= 4;
boolean canSupplementalGang = sameTileCount >= 1 && actorSeat.hasPengMeld(tile);
if (!canConcealedGang && !canSupplementalGang) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前手牌不满足暗杠条件");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}

View File

@@ -147,7 +147,8 @@ public class GameSessionService {
self.getLackSuit() == null ? null : self.getLackSuit().name(),
self.getScore(),
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
toMeldLabels(self)
),
buildPublicSeats(table)
);
@@ -210,6 +211,30 @@ public class GameSessionService {
continue;
}
Optional<String> selfGangCandidate = findSelfGangCandidate(currentSeat);
if (selfGangCandidate.isPresent()) {
List<GameEvent> gangEvents = gameActionProcessor.process(
session,
new GameActionRequest(
currentSeat.getPlayerId(),
"GANG",
selfGangCandidate.get(),
null
)
);
appendAndPublish(session, gangEvents);
handlePostActionEffects(
session,
ActionType.GANG,
new GameActionRequest(currentSeat.getPlayerId(), "GANG", selfGangCandidate.get(), null),
gangEvents
);
if (table.getPhase() == GamePhase.FINISHED) {
return;
}
continue;
}
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
List<GameEvent> events = gameActionProcessor.process(
@@ -345,7 +370,8 @@ public class GameSessionService {
private void handleGangPostAction(GameSession session, GameActionRequest request) {
if (request.sourceSeatNo() == null) {
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充");
handleSelfGangPostAction(session, request.userId(), request.tile());
return;
}
handleResponseDeclarationPostAction(session, ActionType.GANG, request);
}
@@ -419,6 +445,10 @@ public class GameSessionService {
}
closeResponseWindowAsPass(session, responseActionWindow);
if (isSupplementalGangWindow(responseActionWindow)) {
executeSupplementalGang(session, responseActionWindow.sourceSeatNo(), responseActionWindow.triggerTile());
return;
}
continueAfterDiscardWithoutResponse(session);
}
@@ -479,7 +509,7 @@ public class GameSessionService {
switch (resolution.actionType()) {
case PENG -> executePeng(session, resolution);
case GANG -> executeGang(session, resolution);
case HU -> executeHu(session, resolution);
case HU -> executeHu(session, responseActionWindow, resolution);
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
}
}
@@ -503,6 +533,7 @@ public class GameSessionService {
winnerSeat.removeMatchingHandTiles(claimedTile, 2);
sourceSeat.removeMatchingDiscardTile(claimedTile);
winnerSeat.addPengMeld(claimedTile, sourceSeat.getSeatNo());
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
@@ -524,6 +555,7 @@ public class GameSessionService {
winnerSeat.removeMatchingHandTiles(claimedTile, 3);
sourceSeat.removeMatchingDiscardTile(claimedTile);
winnerSeat.addMingGangMeld(claimedTile, sourceSeat.getSeatNo());
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
@@ -562,14 +594,22 @@ public class GameSessionService {
notifyActionIfHumanTurn(session);
}
private void executeHu(GameSession session, ResponseActionResolution resolution) {
private void executeHu(
GameSession session,
ResponseActionWindow responseActionWindow,
ResponseActionResolution resolution
) {
Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
winnerSeat.receiveTile(claimedTile);
sourceSeat.removeMatchingDiscardTile(claimedTile);
if (isSupplementalGangWindow(responseActionWindow)) {
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
} else {
sourceSeat.removeMatchingDiscardTile(claimedTile);
}
winnerSeat.declareHu();
appendAndPublish(session, GameEvent.responseActionDeclared(
@@ -579,12 +619,21 @@ public class GameSessionService {
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
appendSettlementEvents(session, settlementService.settleDiscardHu(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
if (isSupplementalGangWindow(responseActionWindow)) {
appendSettlementEvents(session, settlementService.settleRobbingGangHu(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
} else {
appendSettlementEvents(session, settlementService.settleDiscardHu(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
}
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
@@ -624,6 +673,102 @@ public class GameSessionService {
notifyActionIfHumanTurn(session);
}
private void executeSelfGang(GameSession session, String userId, String tileDisplayName) {
GameTable table = session.getTable();
GameSeat winnerSeat = findSeatByUserId(table, userId);
Tile gangTile = parseTile(tileDisplayName);
winnerSeat.removeMatchingHandTiles(gangTile, 4);
winnerSeat.addAnGangMeld(gangTile);
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.GANG_DECLARED,
winnerSeat.getSeatNo(),
null,
gangTile.getDisplayName()
));
appendSettlementEvents(session, settlementService.settleConcealedGang(
table,
winnerSeat.getSeatNo(),
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile);
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat);
}
private void handleSelfGangPostAction(GameSession session, String userId, String tileDisplayName) {
GameTable table = session.getTable();
GameSeat actorSeat = findSeatByUserId(table, userId);
Tile gangTile = parseTile(tileDisplayName);
if (actorSeat.hasPengMeld(gangTile)) {
Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForSupplementalGang(
session,
actorSeat.getSeatNo(),
gangTile
);
if (window.isPresent()) {
openResponseWindow(session, window.get());
autoPassAiCandidates(session);
if (session.getPendingResponseActionWindow() == null) {
executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
}
return;
}
executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
return;
}
executeSelfGang(session, userId, tileDisplayName);
}
private void executeSupplementalGang(GameSession session, int seatNo, String tileDisplayName) {
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(seatNo);
Tile gangTile = parseTile(tileDisplayName);
winnerSeat.removeMatchingHandTiles(gangTile, 1);
winnerSeat.upgradePengToBuGang(gangTile);
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.GANG_DECLARED,
winnerSeat.getSeatNo(),
null,
gangTile.getDisplayName()
));
appendSettlementEvents(session, settlementService.settleSupplementalGang(
table,
winnerSeat.getSeatNo(),
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile);
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat);
}
private ActionType parseActionType(String actionType) {
try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
@@ -643,7 +788,8 @@ public class GameSessionService {
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getScore(),
seat.getHandTiles().size(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
toMeldLabels(seat)
))
.toList();
}
@@ -669,6 +815,10 @@ public class GameSessionService {
return huEvaluator.canHu(seat.getHandTiles());
}
private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) {
return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType());
}
private long countActiveSeats(GameTable table) {
return table.getSeats().stream()
.filter(seat -> !seat.isWon())
@@ -691,7 +841,39 @@ public class GameSessionService {
if (canSelfDrawHu(currentSeat)) {
candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null));
}
for (String gangTile : findSelfGangCandidates(currentSeat)) {
candidates.add(new PrivateActionCandidate(ActionType.GANG.name(), gangTile));
}
candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null));
return List.copyOf(candidates);
}
private Optional<String> findSelfGangCandidate(GameSeat seat) {
List<String> candidates = findSelfGangCandidates(seat);
if (candidates.isEmpty()) {
return Optional.empty();
}
return Optional.of(candidates.get(0));
}
private List<String> findSelfGangCandidates(GameSeat seat) {
return seat.getHandTiles().stream()
.collect(java.util.stream.Collectors.groupingBy(Tile::getDisplayName, java.util.stream.Collectors.counting()))
.entrySet().stream()
.filter(entry -> entry.getValue() >= 4 || seat.hasPengMeld(parseTile(entry.getKey())))
.map(java.util.Map.Entry::getKey)
.sorted()
.toList();
}
private List<String> toMeldLabels(GameSeat seat) {
return seat.getMeldGroups().stream()
.map(meld -> switch (meld.type()) {
case PENG -> "碰:" + meld.tile().getDisplayName();
case MING_GANG -> "明杠:" + meld.tile().getDisplayName();
case BU_GANG -> "补杠:" + meld.tile().getDisplayName();
case AN_GANG -> "暗杠:" + meld.tile().getDisplayName();
})
.toList();
}
}

View File

@@ -55,6 +55,40 @@ public class ResponseActionWindowBuilder {
));
}
public Optional<ResponseActionWindow> buildForSupplementalGang(GameSession session, int sourceSeatNo, Tile gangTile) {
GameTable table = session.getTable();
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
continue;
}
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) {
continue;
}
seatCandidates.add(new ResponseActionSeatCandidate(
seat.getSeatNo(),
seat.getPlayerId(),
List.of(
new ResponseActionOption(ActionType.HU, gangTile.getDisplayName()),
new ResponseActionOption(ActionType.PASS, null)
)
));
}
if (seatCandidates.isEmpty()) {
return Optional.empty();
}
return Optional.of(ResponseActionWindow.create(
session.getGameId(),
"SUPPLEMENTAL_GANG_DECLARED",
sourceSeatNo,
gangTile.getDisplayName(),
seatCandidates
));
}
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile);
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);

View File

@@ -18,7 +18,9 @@ public class SettlementService {
private static final int DIAN_PAO_HU_SCORE = 1;
private static final int ZI_MO_HU_SCORE = 1;
private static final int BU_GANG_SCORE = 1;
private static final int MING_GANG_SCORE = 1;
private static final int AN_GANG_SCORE = 2;
public SettlementResult settleDiscardHu(
GameTable table,
@@ -80,6 +82,75 @@ public class SettlementService {
);
}
public SettlementResult settleRobbingGangHu(
GameTable table,
int winnerSeatNo,
int sourceSeatNo,
String triggerTile
) {
return applySettlement(
table,
SettlementType.QIANG_GANG_HU,
ActionType.HU,
winnerSeatNo,
sourceSeatNo,
triggerTile,
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE)
);
}
public SettlementResult settleSupplementalGang(
GameTable table,
int winnerSeatNo,
String triggerTile
) {
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue;
}
scoreDeltas.put(seat.getSeatNo(), -BU_GANG_SCORE);
totalWinScore += BU_GANG_SCORE;
}
scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement(
table,
SettlementType.BU_GANG,
ActionType.GANG,
winnerSeatNo,
winnerSeatNo,
triggerTile,
scoreDeltas
);
}
public SettlementResult settleConcealedGang(
GameTable table,
int winnerSeatNo,
String triggerTile
) {
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue;
}
scoreDeltas.put(seat.getSeatNo(), -AN_GANG_SCORE);
totalWinScore += AN_GANG_SCORE;
}
scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement(
table,
SettlementType.AN_GANG,
ActionType.GANG,
winnerSeatNo,
winnerSeatNo,
triggerTile,
scoreDeltas
);
}
private SettlementResult applySettlement(
GameTable table,
SettlementType settlementType,