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,

View File

@@ -14,10 +14,14 @@ public class PlayerVisibilityService {
public PlayerVisibleGameState buildVisibleState(GameTable table, GameSeat seat) {
List<String> publicDiscards = new ArrayList<>();
List<String> publicMelds = new ArrayList<>();
for (GameSeat gameSeat : table.getSeats()) {
for (Tile tile : gameSeat.getDiscardTiles()) {
publicDiscards.add(gameSeat.getSeatNo() + ":" + tile.getDisplayName());
}
gameSeat.getMeldGroups().forEach(meld ->
publicMelds.add(gameSeat.getSeatNo() + ":" + meld.type().name() + ":" + meld.tile().getDisplayName())
);
}
return new PlayerVisibleGameState(
@@ -26,8 +30,7 @@ public class PlayerVisibilityService {
seat.getLackSuit(),
List.copyOf(seat.getHandTiles()),
publicDiscards,
List.of()
publicMelds
);
}
}

View File

@@ -94,4 +94,21 @@ class GameEventTest {
.doesNotContainKey("sourceSeatNo")
.doesNotContainKey("tile");
}
@Test
void shouldAllowGangEventWithoutSourceSeatForConcealedGang() {
GameEvent concealedGang = GameEvent.responseActionDeclared(
"game-1",
GameEventType.GANG_DECLARED,
0,
null,
"3万"
);
assertThat(concealedGang.eventType()).isEqualTo(GameEventType.GANG_DECLARED);
assertThat(concealedGang.payload())
.containsEntry("actionType", "GANG")
.containsEntry("tile", "3万")
.doesNotContainKey("sourceSeatNo");
}
}

View File

@@ -228,6 +228,7 @@ class GameSessionServiceTest {
assertThat(afterPeng.currentSeatNo()).isEqualTo(1);
assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile);
assertThat(afterPeng.selfSeat().handTiles()).hasSize(13);
assertThat(afterPeng.selfSeat().melds()).containsExactly("碰:" + discardTile);
}
@Test
@@ -256,6 +257,7 @@ class GameSessionServiceTest {
assertThat(afterGang.currentSeatNo()).isEqualTo(1);
assertThat(afterGang.selfSeat().score()).isEqualTo(1);
assertThat(afterGang.seats().get(0).score()).isEqualTo(-1);
assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile);
assertThat(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
@@ -392,6 +394,121 @@ class GameSessionServiceTest {
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
}
@Test
void shouldResolveSelfConcealedGangAndKeepTurnAfterReplacementDraw() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
prepareConcealedGangHand(session.getTable().getSeats().get(0), "3万");
int beforeWallCount = session.getTable().getWallTiles().size();
GameStateResponse afterGang = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
);
assertThat(afterGang.phase()).isEqualTo("PLAYING");
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1);
assertThat(afterGang.selfSeat().melds()).containsExactly("暗杠:3万");
assertThat(afterGang.selfSeat().score()).isEqualTo(6);
assertThat(afterGang.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2);
assertThat(afterGang.selfSeat().handTiles()).hasSize(11);
}
@Test
void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_WINDOW_INVALID");
}
@Test
void shouldResolveSupplementalGangAndKeepMeldState() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
GameSeat seat = session.getTable().getSeats().get(0);
prepareSupplementalGangHand(seat, "3万");
int beforeWallCount = session.getTable().getWallTiles().size();
GameStateResponse afterGang = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
);
assertThat(afterGang.phase()).isEqualTo("PLAYING");
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1);
assertThat(afterGang.selfSeat().melds()).containsExactly("补杠:3万");
assertThat(afterGang.selfSeat().score()).isEqualTo(3);
assertThat(afterGang.seats()).extracting(seatView -> seatView.score()).contains(3, -1, -1, -1);
assertThat(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
}
@Test
void shouldResolveRobbingGangHuBeforeSupplementalGangCompletes() {
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());
prepareSupplementalGangHand(session.getTable().getSeats().get(0), "9筒");
prepareWinningHand(session.getTable().getSeats().get(1));
GameStateResponse afterGangDeclare = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "9筒", null)
);
assertThat(session.getPendingResponseActionWindow()).isNotNull();
assertThat(afterGangDeclare.currentSeatNo()).isEqualTo(0);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", "9筒", 0)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterHu.phase()).isEqualTo("PLAYING");
assertThat(afterHu.currentSeatNo()).isEqualTo(2);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
assertThat(afterHu.seats().get(0).score()).isEqualTo(-1);
assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒");
}
@Test
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
@@ -492,4 +609,40 @@ class GameSessionServiceTest {
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
}
private void prepareConcealedGangHand(GameSeat seat, String gangTileDisplayName) {
seat.getHandTiles().clear();
Tile gangTile = parseTile(gangTileDisplayName);
for (int i = 0; i < 4; i++) {
seat.receiveTile(gangTile);
}
seat.receiveTile(new Tile(TileSuit.WAN, 1));
seat.receiveTile(new Tile(TileSuit.WAN, 2));
seat.receiveTile(new Tile(TileSuit.WAN, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 5));
seat.receiveTile(new Tile(TileSuit.TONG, 6));
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.WAN, 9));
}
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
seat.getHandTiles().clear();
seat.getMeldGroups().clear();
Tile gangTile = parseTile(gangTileDisplayName);
seat.addPengMeld(gangTile, 1);
seat.receiveTile(gangTile);
seat.receiveTile(new Tile(TileSuit.WAN, 1));
seat.receiveTile(new Tile(TileSuit.WAN, 2));
seat.receiveTile(new Tile(TileSuit.WAN, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 5));
seat.receiveTile(new Tile(TileSuit.TONG, 6));
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.WAN, 9));
}
}

View File

@@ -121,6 +121,40 @@ class ResponseActionWindowBuilderTest {
.containsExactly(2);
}
@Test
void shouldBuildHuOnlyResponseWindowForSupplementalGang() {
Tile gangTile = new Tile(TileSuit.TONG, 9);
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
seat(1, "u1",
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 2),
new Tile(TileSuit.WAN, 3),
new Tile(TileSuit.WAN, 4),
new Tile(TileSuit.TONG, 2),
new Tile(TileSuit.TONG, 3),
new Tile(TileSuit.TONG, 4),
new Tile(TileSuit.TIAO, 5),
new Tile(TileSuit.TIAO, 6),
new Tile(TileSuit.TIAO, 7),
new Tile(TileSuit.TONG, 9)
),
seat(2, "u2"),
seat(3, "u3")
));
Optional<ResponseActionWindow> result = builder.buildForSupplementalGang(session, 0, gangTile);
assertThat(result).isPresent();
assertThat(result.orElseThrow().triggerEventType()).isEqualTo("SUPPLEMENTAL_GANG_DECLARED");
assertThat(result.orElseThrow().seatCandidates()).hasSize(1);
assertThat(result.orElseThrow().seatCandidates().get(0).options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.HU, ActionType.PASS);
}
private GameTable createPlayingTable(GameSeat... seats) {
GameTable table = new GameTable(List.of(seats), new ArrayList<>());
table.setPhase(GamePhase.PLAYING);