feat: 实现麻将游戏结算系统与自摸胡功能

新增结算类型枚举和分数变更记录模型
补全响应裁决器与结算服务,支持点炮胡、自摸胡和明杠结算
扩展座位模型,增加已胡状态和分数字段
完善胡牌评估器,支持自摸胡判断
前端原型页增加分数显示和已胡状态
更新SPRINT文档记录当前进度
This commit is contained in:
hujun
2026-03-20 13:58:16 +08:00
parent 48da7d4990
commit 36dcfb7d31
24 changed files with 1349 additions and 53 deletions

View File

@@ -1,7 +1,13 @@
package com.xuezhanmaster.game.event;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameEventTest {
@@ -39,4 +45,53 @@ class GameEventTest {
assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED);
assertThat(switched.payload()).containsEntry("currentSeatNo", 3);
}
@Test
void shouldBuildSettlementEvents() {
SettlementResult settlementResult = new SettlementResult(
SettlementType.DIAN_PAO_HU,
ActionType.HU,
2,
0,
"9筒",
List.of(
new ScoreChange(2, 1, 3),
new ScoreChange(0, -1, -2)
)
);
GameEvent settlementApplied = GameEvent.settlementApplied("game-1", settlementResult);
GameEvent scoreChanged = GameEvent.scoreChanged("game-1", 2, SettlementType.DIAN_PAO_HU.name(), 1, 3);
assertThat(settlementApplied.eventType()).isEqualTo(GameEventType.SETTLEMENT_APPLIED);
assertThat(settlementApplied.payload())
.containsEntry("settlementType", "DIAN_PAO_HU")
.containsEntry("actionType", "HU")
.containsEntry("sourceSeatNo", 0)
.containsEntry("triggerTile", "9筒");
assertThat(settlementApplied.payload()).containsKey("scoreChanges");
assertThat(scoreChanged.eventType()).isEqualTo(GameEventType.SCORE_CHANGED);
assertThat(scoreChanged.payload())
.containsEntry("settlementType", "DIAN_PAO_HU")
.containsEntry("delta", 1)
.containsEntry("score", 3);
}
@Test
void shouldAllowHuEventWithoutSourceSeatForSelfDraw() {
GameEvent selfDrawHu = GameEvent.responseActionDeclared(
"game-1",
GameEventType.HU_DECLARED,
0,
null,
null
);
assertThat(selfDrawHu.eventType()).isEqualTo(GameEventType.HU_DECLARED);
assertThat(selfDrawHu.payload())
.containsEntry("actionType", "HU")
.doesNotContainKey("sourceSeatNo")
.doesNotContainKey("tile");
}
}

View File

@@ -1,10 +1,14 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.service.GameActionProcessor;
import com.xuezhanmaster.game.event.GameEventType;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
@@ -18,6 +22,9 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import java.lang.reflect.Field;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.assertj.core.api.Assertions.assertThat;
@@ -26,11 +33,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameSessionServiceTest {
private final RoomService roomService = new RoomService();
private final HuEvaluator huEvaluator = new HuEvaluator();
private final GameSessionService gameSessionService = new GameSessionService(
roomService,
new GameEngine(new DeckFactory()),
new GameActionProcessor(),
new ResponseActionWindowBuilder(),
new GameActionProcessor(huEvaluator),
new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(),
new SettlementService(),
huEvaluator,
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService(),
@@ -65,6 +76,8 @@ class GameSessionServiceTest {
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
String discardTile = playing.selfSeat().handTiles().get(0);
GameSession session = getSession(playing.gameId());
removeMatchingTilesFromOtherSeats(session, discardTile, 0);
GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);
@@ -88,7 +101,7 @@ class GameSessionServiceTest {
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_UNSUPPORTED");
.isEqualTo("GAME_ACTION_WINDOW_NOT_FOUND");
}
@Test
@@ -156,4 +169,327 @@ class GameSessionServiceTest {
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_INVALID");
}
@Test
void shouldPauseDiscardFlowUntilHumanPassesResponseWindow() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameStateResponse playing = gameSessionService.getState(started.gameId(), "host-1");
String discardTile = playing.selfSeat().handTiles().get(0);
GameSession session = getSession(playing.gameId());
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 2);
removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);
GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);
assertThat(session.getPendingResponseActionWindow()).isNotNull();
assertThat(afterDiscard.currentSeatNo()).isEqualTo(0);
GameStateResponse afterPass = gameSessionService.performAction(
playing.gameId(),
new GameActionRequest("player-2", "PASS", null, 0)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterPass.currentSeatNo()).isEqualTo(1);
assertThat(afterPass.phase()).isEqualTo("PLAYING");
}
@Test
void shouldResolvePengAndTransferTurnToWinner() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameStateResponse hostView = gameSessionService.getState(started.gameId(), "host-1");
String discardTile = hostView.selfSeat().handTiles().get(0);
GameSession session = getSession(hostView.gameId());
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 2);
removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);
gameSessionService.discardTile(hostView.gameId(), "host-1", discardTile);
GameStateResponse afterPeng = gameSessionService.performAction(
hostView.gameId(),
new GameActionRequest("player-2", "PENG", discardTile, 0)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterPeng.currentSeatNo()).isEqualTo(1);
assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile);
assertThat(afterPeng.selfSeat().handTiles()).hasSize(13);
}
@Test
void shouldResolveGangAndApplySettlementScore() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameStateResponse hostView = gameSessionService.getState(started.gameId(), "host-1");
String discardTile = hostView.selfSeat().handTiles().get(0);
GameSession session = getSession(hostView.gameId());
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 3);
removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);
gameSessionService.discardTile(hostView.gameId(), "host-1", discardTile);
GameStateResponse afterGang = gameSessionService.performAction(
hostView.gameId(),
new GameActionRequest("player-2", "GANG", discardTile, 0)
);
assertThat(afterGang.currentSeatNo()).isEqualTo(1);
assertThat(afterGang.selfSeat().score()).isEqualTo(1);
assertThat(afterGang.seats().get(0).score()).isEqualTo(-1);
assertThat(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
}
@Test
void shouldResolveHuAndContinueBloodBattleWhenMultipleActiveSeatsRemain() {
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());
prepareWinningHand(session.getTable().getSeats().get(1));
String discardTile = "9筒";
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), discardTile, 1);
removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);
gameSessionService.discardTile(started.gameId(), "host-1", discardTile);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", discardTile, 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(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
}
@Test
void shouldFinishBloodBattleWhenOnlyOneActiveSeatRemainsAfterHu() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameSession session = getSession(started.gameId());
prepareWinningHand(session.getTable().getSeats().get(1));
session.getTable().getSeats().get(2).declareHu();
session.getTable().getSeats().get(3).declareHu();
String discardTile = "9筒";
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), discardTile, 1);
removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);
gameSessionService.discardTile(started.gameId(), "host-1", discardTile);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", discardTile, 0)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterHu.phase()).isEqualTo("FINISHED");
assertThat(afterHu.selfSeat().won()).isTrue();
}
@Test
void shouldSkipWonSeatWhenAdvancingToNextTurn() {
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());
session.getTable().getSeats().get(1).declareHu();
String discardTile = started.selfSeat().handTiles().get(0);
removeMatchingTilesFromOtherSeats(session, discardTile, 0);
GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", discardTile);
assertThat(afterDiscard.currentSeatNo()).isEqualTo(2);
}
@Test
void shouldResolveSelfDrawHuAndContinueToNextActiveSeat() {
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());
prepareSelfDrawWinningHand(session.getTable().getSeats().get(0));
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "HU", null, null)
);
assertThat(afterHu.phase()).isEqualTo("PLAYING");
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(3);
assertThat(afterHu.currentSeatNo()).isEqualTo(1);
assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(3, -1, -1, -1);
assertThat(session.getEvents())
.extracting(event -> event.eventType())
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
}
@Test
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
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", "HU", null, null)
))
.isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_WINDOW_INVALID");
}
@SuppressWarnings("unchecked")
private GameSession getSession(String gameId) {
try {
Field field = GameSessionService.class.getDeclaredField("sessions");
field.setAccessible(true);
Map<String, GameSession> sessions = (Map<String, GameSession>) field.get(gameSessionService);
return sessions.get(gameId);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("无法读取测试用对局会话", exception);
}
}
private void ensureSeatHasMatchingTiles(GameSeat seat, String tileDisplayName, int count) {
Tile tile = parseTile(tileDisplayName);
for (int i = 0; i < count; i++) {
seat.receiveTile(tile);
}
}
private void removeMatchingTilesFromOtherSeats(GameSession session, String tileDisplayName, int... keepSeatNos) {
for (GameSeat seat : session.getTable().getSeats()) {
if (shouldKeepSeat(seat.getSeatNo(), keepSeatNos)) {
continue;
}
seat.getHandTiles().removeIf(tile -> tile.getDisplayName().equals(tileDisplayName));
}
}
private boolean shouldKeepSeat(int seatNo, int... keepSeatNos) {
for (int keepSeatNo : keepSeatNos) {
if (seatNo == keepSeatNo) {
return true;
}
}
return false;
}
private Tile parseTile(String tileDisplayName) {
String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1);
String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1);
int rank = Integer.parseInt(rankPart);
return switch (suitLabel) {
case "" -> new Tile(TileSuit.WAN, rank);
case "" -> new Tile(TileSuit.TONG, rank);
case "" -> new Tile(TileSuit.TIAO, rank);
default -> throw new IllegalArgumentException("未知花色");
};
}
private void prepareWinningHand(GameSeat seat) {
seat.getHandTiles().clear();
seat.receiveTile(new Tile(TileSuit.WAN, 1));
seat.receiveTile(new Tile(TileSuit.WAN, 1));
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.WAN, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 2));
seat.receiveTile(new Tile(TileSuit.TONG, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TIAO, 5));
seat.receiveTile(new Tile(TileSuit.TIAO, 6));
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
seat.receiveTile(new Tile(TileSuit.TONG, 9));
}
private void prepareSelfDrawWinningHand(GameSeat seat) {
seat.getHandTiles().clear();
seat.receiveTile(new Tile(TileSuit.WAN, 1));
seat.receiveTile(new Tile(TileSuit.WAN, 1));
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.WAN, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 2));
seat.receiveTile(new Tile(TileSuit.TONG, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TIAO, 5));
seat.receiveTile(new Tile(TileSuit.TIAO, 6));
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
}
}

View File

@@ -0,0 +1,62 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class HuEvaluatorTest {
private final HuEvaluator huEvaluator = new HuEvaluator();
@Test
void shouldRecognizeStandardWinningHandWithClaimedTile() {
boolean result = huEvaluator.canHuWithClaimedTile(
List.of(
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)
),
new Tile(TileSuit.TONG, 9)
);
assertThat(result).isTrue();
}
@Test
void shouldRejectNonWinningHandWithClaimedTile() {
boolean result = huEvaluator.canHuWithClaimedTile(
List.of(
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 2),
new Tile(TileSuit.WAN, 3),
new Tile(TileSuit.WAN, 4),
new Tile(TileSuit.WAN, 5),
new Tile(TileSuit.WAN, 6),
new Tile(TileSuit.TONG, 1),
new Tile(TileSuit.TONG, 2),
new Tile(TileSuit.TONG, 3),
new Tile(TileSuit.TIAO, 1),
new Tile(TileSuit.TIAO, 2),
new Tile(TileSuit.TIAO, 4),
new Tile(TileSuit.TONG, 9)
),
new Tile(TileSuit.TIAO, 9)
);
assertThat(result).isFalse();
}
}

View File

@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class ResponseActionWindowBuilderTest {
private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder();
private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(new HuEvaluator());
@Test
void shouldBuildResponseCandidatesForDiscardedTile() {
@@ -69,6 +69,58 @@ class ResponseActionWindowBuilderTest {
assertThat(result).isEmpty();
}
@Test
void shouldIncludeHuWhenClaimedTileCompletesWinningHand() {
Tile discardedTile = 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.buildForDiscard(session, 0, discardedTile);
assertThat(result).isPresent();
assertThat(result.orElseThrow().seatCandidates().get(0).options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.HU, ActionType.PASS);
}
@Test
void shouldSkipSeatsThatAlreadyWonWhenBuildingResponseWindow() {
Tile discardedTile = new Tile(TileSuit.WAN, 3);
GameSeat wonSeat = seat(1, "u1", discardedTile, discardedTile, discardedTile);
wonSeat.declareHu();
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
wonSeat,
seat(2, "u2", discardedTile, discardedTile),
seat(3, "u3")
));
Optional<ResponseActionWindow> result = builder.buildForDiscard(session, 0, discardedTile);
assertThat(result).isPresent();
assertThat(result.orElseThrow().seatCandidates())
.extracting(ResponseActionSeatCandidate::seatNo)
.containsExactly(2);
}
private GameTable createPlayingTable(GameSeat... seats) {
GameTable table = new GameTable(List.of(seats), new ArrayList<>());
table.setPhase(GamePhase.PLAYING);

View File

@@ -4,6 +4,7 @@ import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ResponseActionOption;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -22,17 +23,25 @@ class GameMessagePublisherTest {
@Test
void shouldPublishStructuredTurnActionMessage() {
gameMessagePublisher.publishPrivateTurnActionRequired("game-1", "user-1", List.of("DISCARD"), 2);
gameMessagePublisher.publishPrivateTurnActionCandidates(
"game-1",
"user-1",
List.of(
new PrivateActionCandidate("HU", null),
new PrivateActionCandidate("DISCARD", null)
),
2
);
ArgumentCaptor<PrivateActionMessage> captor = ArgumentCaptor.forClass(PrivateActionMessage.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture());
PrivateActionMessage message = captor.getValue();
assertThat(message.actionScope()).isEqualTo("TURN");
assertThat(message.availableActions()).containsExactly("DISCARD");
assertThat(message.availableActions()).containsExactly("HU", "DISCARD");
assertThat(message.currentSeatNo()).isEqualTo(2);
assertThat(message.candidates()).hasSize(1);
assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD");
assertThat(message.candidates()).hasSize(2);
assertThat(message.candidates().get(0).actionType()).isEqualTo("HU");
assertThat(message.candidates().get(0).tile()).isNull();
}