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

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