feat: 实现补杠和抢杠胡功能
新增 MeldType 和 MeldGroup 领域模型,支持碰、明杠、补杠、暗杠四种副露类型 GameSeat 新增副露管理方法,支持将碰升级为补杠 ResponseActionWindowBuilder 新增补杠响应窗口构建逻辑 SettlementService 新增补杠和抢杠胡结算规则 前端新增副露展示区域,支持显示各类副露标签
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user