feat: 实现响应候选模型与私有动作消息结构化

新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括:

- 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型
- 扩展 PrivateActionMessage 支持响应候选上下文
- 实现 ResponseActionWindowBuilder 构建弃牌响应候选
- 拆分 GameMessagePublisher 支持回合动作和响应动作消息
- 更新前端原型页展示结构化候选动作
- 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md
This commit is contained in:
hujun
2026-03-20 13:04:59 +08:00
parent 24fce055fd
commit 48da7d4990
20 changed files with 1151 additions and 208 deletions

View File

@@ -4,7 +4,9 @@ import com.xuezhanmaster.game.event.GameEvent;
import java.util.ArrayList;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class GameSession {
@@ -14,6 +16,8 @@ public class GameSession {
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
private ResponseActionWindow pendingResponseActionWindow;
private final Map<Integer, ActionType> responseActionSelections;
public GameSession(String roomId, GameTable table) {
this.gameId = UUID.randomUUID().toString();
@@ -21,6 +25,7 @@ public class GameSession {
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
this.responseActionSelections = new LinkedHashMap<>();
}
public String getGameId() {
@@ -42,4 +47,20 @@ public class GameSession {
public List<GameEvent> getEvents() {
return events;
}
public ResponseActionWindow getPendingResponseActionWindow() {
return pendingResponseActionWindow;
}
public void setPendingResponseActionWindow(ResponseActionWindow pendingResponseActionWindow) {
this.pendingResponseActionWindow = pendingResponseActionWindow;
}
public Map<Integer, ActionType> getResponseActionSelections() {
return responseActionSelections;
}
public void clearResponseActionSelections() {
responseActionSelections.clear();
}
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public record ResponseActionOption(
ActionType actionType,
String tile
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record ResponseActionSeatCandidate(
int seatNo,
String userId,
List<ResponseActionOption> options
) {
}

View File

@@ -0,0 +1,34 @@
package com.xuezhanmaster.game.domain;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record ResponseActionWindow(
String windowId,
String gameId,
String triggerEventType,
int sourceSeatNo,
String triggerTile,
List<ResponseActionSeatCandidate> seatCandidates,
Instant createdAt
) {
public static ResponseActionWindow create(
String gameId,
String triggerEventType,
int sourceSeatNo,
String triggerTile,
List<ResponseActionSeatCandidate> seatCandidates
) {
return new ResponseActionWindow(
UUID.randomUUID().toString(),
gameId,
triggerEventType,
sourceSeatNo,
triggerTile,
List.copyOf(seatCandidates),
Instant.now()
);
}
}

View File

@@ -6,6 +6,8 @@ import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
@@ -35,29 +37,29 @@ public class GameActionProcessor {
private List<GameEvent> peng(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.PENG);
}
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.GANG);
}
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU);
}
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, null, "", false);
return unsupportedAction(ActionType.PASS);
validateResponseAction(session, table, seat, sourceSeatNo, null, "", false);
return List.of();
}
private List<GameEvent> unsupportedAction(ActionType actionType) {
@@ -143,16 +145,18 @@ public class GameActionProcessor {
}
private void validateResponseAction(
GameSession session,
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName
) {
validateResponseAction(table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
validateResponseAction(session, table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
}
private void validateResponseAction(
GameSession session,
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
@@ -172,12 +176,70 @@ public class GameActionProcessor {
if (sourceSeatNo == actorSeat.getSeatNo()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不能是自己");
}
ResponseActionWindow responseActionWindow = sessionResponseWindow(session, sourceSeatNo, actionName);
validateSeatCandidate(responseActionWindow, actorSeat, actionName);
if (requiresTile && isBlank(tileDisplayName)) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少目标牌");
}
if (requiresTile) {
validateCandidateAction(responseActionWindow, actorSeat, tileDisplayName, actionName);
} else {
validateCandidateAction(responseActionWindow, actorSeat, null, actionName);
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private ResponseActionWindow sessionResponseWindow(GameSession session, Integer sourceSeatNo, String actionName) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口");
}
if (responseActionWindow.sourceSeatNo() != sourceSeatNo) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", actionName + "动作来源窗口不匹配");
}
return responseActionWindow;
}
private void validateSeatCandidate(ResponseActionWindow responseActionWindow, GameSeat actorSeat, String actionName) {
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口");
}
boolean isCandidateSeat = responseActionWindow.seatCandidates().stream()
.anyMatch(candidate -> candidate.seatNo() == actorSeat.getSeatNo());
if (!isCandidateSeat) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中");
}
}
private void validateCandidateAction(
ResponseActionWindow responseActionWindow,
GameSeat actorSeat,
String tileDisplayName,
String actionName
) {
ResponseActionSeatCandidate seatCandidate = responseActionWindow.seatCandidates().stream()
.filter(candidate -> candidate.seatNo() == actorSeat.getSeatNo())
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中"));
boolean matched = seatCandidate.options().stream()
.anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName))
&& (tileDisplayName == null || tileDisplayName.equals(option.tile())));
if (!matched) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前窗口不支持该" + actionName + "动作");
}
}
private String toActionTypeName(String actionName) {
return switch (actionName) {
case "" -> ActionType.PENG.name();
case "" -> ActionType.GANG.name();
case "" -> ActionType.HU.name();
case "" -> ActionType.PASS.name();
default -> actionName;
};
}
}

View File

@@ -6,6 +6,8 @@ import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
@@ -29,6 +31,7 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
@@ -37,6 +40,7 @@ public class GameSessionService {
private final RoomService roomService;
private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor;
private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
@@ -47,6 +51,7 @@ public class GameSessionService {
RoomService roomService,
GameEngine gameEngine,
GameActionProcessor gameActionProcessor,
ResponseActionWindowBuilder responseActionWindowBuilder,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService,
@@ -55,6 +60,7 @@ public class GameSessionService {
this.roomService = roomService;
this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor;
this.responseActionWindowBuilder = responseActionWindowBuilder;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
@@ -82,7 +88,7 @@ public class GameSessionService {
ActionType actionType = parseActionType(request.actionType());
List<GameEvent> events = gameActionProcessor.process(session, request);
appendAndPublish(session, events);
handlePostActionEffects(session, actionType);
handlePostActionEffects(session, actionType, request, events);
return toStateResponse(session, request.userId());
}
@@ -195,7 +201,7 @@ public class GameSessionService {
return;
}
gameMessagePublisher.publishPrivateActionRequired(
gameMessagePublisher.publishPrivateTurnActionRequired(
session.getGameId(),
currentSeat.getPlayerId(),
List.of("DISCARD"),
@@ -214,24 +220,165 @@ public class GameSessionService {
);
}
private void handlePostActionEffects(GameSession session, ActionType actionType) {
private void handlePostActionEffects(
GameSession session,
ActionType actionType,
GameActionRequest request,
List<GameEvent> events
) {
switch (actionType) {
case SELECT_LACK_SUIT -> {
if (session.getTable().getPhase() == GamePhase.PLAYING) {
notifyActionIfHumanTurn(session);
}
}
case DISCARD -> {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
case DISCARD -> handleDiscardPostAction(session, request, events);
case PASS -> handlePassPostAction(session, request);
default -> {
// 其余动作在后续 Sprint 中补充对应副作用
}
}
}
private void notifyResponseCandidates(GameSession session, ResponseActionWindow responseActionWindow) {
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
if (seat.isAi()) {
continue;
}
gameMessagePublisher.publishPrivateResponseActionRequired(
session.getGameId(),
seatCandidate.userId(),
session.getTable().getCurrentSeatNo(),
responseActionWindow,
seatCandidate
);
}
}
private void handleDiscardPostAction(GameSession session, GameActionRequest request, List<GameEvent> events) {
GameSeat sourceSeat = findSeatByUserId(session.getTable(), request.userId());
Tile discardedTile = resolveDiscardedTile(events, request.tile());
Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForDiscard(
session,
sourceSeat.getSeatNo(),
discardedTile
);
if (window.isPresent()) {
openResponseWindow(session, window.get());
autoPassAiCandidates(session);
if (session.getPendingResponseActionWindow() == null) {
continueAfterDiscardWithoutResponse(session);
}
return;
}
continueAfterDiscardWithoutResponse(session);
}
private void handlePassPostAction(GameSession session, GameActionRequest request) {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
if (allResponseCandidatesResolved(session, responseActionWindow)) {
closeResponseWindowAsPass(session, responseActionWindow);
continueAfterDiscardWithoutResponse(session);
}
}
private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
session.setPendingResponseActionWindow(responseActionWindow);
session.clearResponseActionSelections();
appendAndPublish(session, GameEvent.responseWindowOpened(
session.getGameId(),
responseActionWindow.sourceSeatNo(),
responseActionWindow.sourceSeatNo(),
responseActionWindow.triggerTile()
));
notifyResponseCandidates(session, responseActionWindow);
}
private void autoPassAiCandidates(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
return;
}
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
if (seat.isAi()) {
session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS);
}
}
if (allResponseCandidatesResolved(session, responseActionWindow)) {
closeResponseWindowAsPass(session, responseActionWindow);
}
}
private boolean allResponseCandidatesResolved(GameSession session, ResponseActionWindow responseActionWindow) {
return responseActionWindow.seatCandidates().stream()
.allMatch(candidate -> session.getResponseActionSelections().containsKey(candidate.seatNo()));
}
private void closeResponseWindowAsPass(GameSession session, ResponseActionWindow responseActionWindow) {
appendAndPublish(session, GameEvent.responseWindowClosed(
session.getGameId(),
null,
responseActionWindow.sourceSeatNo(),
ActionType.PASS.name()
));
session.setPendingResponseActionWindow(null);
session.clearResponseActionSelections();
}
private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有待处理的响应窗口");
}
return responseActionWindow;
}
private Tile resolveDiscardedTile(List<GameEvent> events, String fallbackTileDisplayName) {
return events.stream()
.filter(event -> event.eventType() == GameEventType.TILE_DISCARDED)
.map(event -> event.payload().get("tile"))
.filter(String.class::isInstance)
.map(String.class::cast)
.findFirst()
.map(this::parseTile)
.orElseGet(() -> parseTile(fallbackTileDisplayName));
}
private Tile parseTile(String tileDisplayName) {
if (tileDisplayName == null || tileDisplayName.isBlank()) {
throw new BusinessException("GAME_TILE_INVALID", "无法解析当前动作对应的牌");
}
String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1);
String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1);
int rank;
try {
rank = Integer.parseInt(rankPart);
} catch (NumberFormatException exception) {
throw new BusinessException("GAME_TILE_INVALID", "牌面点数不合法");
}
Tile tile = switch (suitLabel) {
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.WAN, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TONG, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TIAO, rank);
default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
};
return tile;
}
private void continueAfterDiscardWithoutResponse(GameSession session) {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
private ActionType parseActionType(String actionType) {
try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));

View File

@@ -0,0 +1,76 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionOption;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Component
public class ResponseActionWindowBuilder {
public Optional<ResponseActionWindow> buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) {
GameTable table = session.getTable();
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == sourceSeatNo) {
continue;
}
List<ResponseActionOption> options = buildSeatOptions(seat, discardedTile);
if (!options.isEmpty()) {
seatCandidates.add(new ResponseActionSeatCandidate(
seat.getSeatNo(),
seat.getPlayerId(),
List.copyOf(options)
));
}
}
if (seatCandidates.isEmpty()) {
return Optional.empty();
}
return Optional.of(ResponseActionWindow.create(
session.getGameId(),
"TILE_DISCARDED",
sourceSeatNo,
discardedTile.getDisplayName(),
seatCandidates
));
}
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile);
if (sameTileCount < 2) {
return List.of();
}
List<ResponseActionOption> options = new ArrayList<>();
options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName()));
if (sameTileCount >= 3) {
options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName()));
}
options.add(new ResponseActionOption(ActionType.PASS, null));
return options;
}
private int countSameTileInHand(GameSeat seat, Tile discardedTile) {
int count = 0;
for (Tile tile : seat.getHandTiles()) {
if (tile.equals(discardedTile)) {
count++;
}
}
return count;
}
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.ws.dto;
public record PrivateActionCandidate(
String actionType,
String tile
) {
}

View File

@@ -5,8 +5,13 @@ import java.util.List;
public record PrivateActionMessage(
String gameId,
String userId,
String actionScope,
List<String> availableActions,
int currentSeatNo
int currentSeatNo,
String windowId,
String triggerEventType,
Integer sourceSeatNo,
String triggerTile,
List<PrivateActionCandidate> candidates
) {
}

View File

@@ -1,6 +1,9 @@
package com.xuezhanmaster.ws.service;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
import com.xuezhanmaster.ws.dto.PublicGameMessage;
@@ -31,10 +34,52 @@ public class GameMessagePublisher {
);
}
public void publishPrivateActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
public void publishPrivateTurnActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(gameId, userId, availableActions, currentSeatNo)
new PrivateActionMessage(
gameId,
userId,
"TURN",
availableActions,
currentSeatNo,
null,
null,
null,
null,
toCandidates(availableActions)
)
);
}
public void publishPrivateResponseActionRequired(
String gameId,
String userId,
int currentSeatNo,
ResponseActionWindow window,
ResponseActionSeatCandidate seatCandidate
) {
List<String> availableActions = seatCandidate.options().stream()
.map(option -> option.actionType().name())
.toList();
List<PrivateActionCandidate> candidates = seatCandidate.options().stream()
.map(option -> new PrivateActionCandidate(option.actionType().name(), option.tile()))
.toList();
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(
gameId,
userId,
"RESPONSE",
availableActions,
currentSeatNo,
window.windowId(),
window.triggerEventType(),
window.sourceSeatNo(),
window.triggerTile(),
candidates
)
);
}
@@ -44,4 +89,10 @@ public class GameMessagePublisher {
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
);
}
private List<PrivateActionCandidate> toCandidates(List<String> availableActions) {
return availableActions.stream()
.map(actionType -> new PrivateActionCandidate(actionType, null))
.toList();
}
}

View File

@@ -30,6 +30,7 @@ class GameSessionServiceTest {
roomService,
new GameEngine(new DeckFactory()),
new GameActionProcessor(),
new ResponseActionWindowBuilder(),
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService(),

View File

@@ -0,0 +1,86 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class ResponseActionWindowBuilderTest {
private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder();
@Test
void shouldBuildResponseCandidatesForDiscardedTile() {
Tile discardedTile = new Tile(TileSuit.WAN, 3);
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
seat(1, "u1", discardedTile, discardedTile),
seat(2, "u2", discardedTile, discardedTile, discardedTile),
seat(3, "u3", new Tile(TileSuit.TIAO, 1))
));
Optional<ResponseActionWindow> result = builder.buildForDiscard(session, 0, discardedTile);
assertThat(result).isPresent();
ResponseActionWindow window = result.orElseThrow();
assertThat(window.triggerEventType()).isEqualTo("TILE_DISCARDED");
assertThat(window.sourceSeatNo()).isEqualTo(0);
assertThat(window.triggerTile()).isEqualTo("3万");
assertThat(window.seatCandidates()).hasSize(2);
ResponseActionSeatCandidate seat1 = window.seatCandidates().get(0);
assertThat(seat1.seatNo()).isEqualTo(1);
assertThat(seat1.userId()).isEqualTo("u1");
assertThat(seat1.options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.PENG, ActionType.PASS);
ResponseActionSeatCandidate seat2 = window.seatCandidates().get(1);
assertThat(seat2.seatNo()).isEqualTo(2);
assertThat(seat2.options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.PENG, ActionType.GANG, ActionType.PASS);
}
@Test
void shouldReturnEmptyWhenNoSeatCanRespond() {
Tile discardedTile = new Tile(TileSuit.TONG, 5);
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
seat(1, "u1", new Tile(TileSuit.WAN, 1)),
seat(2, "u2", new Tile(TileSuit.WAN, 2)),
seat(3, "u3", new Tile(TileSuit.WAN, 3))
));
Optional<ResponseActionWindow> result = builder.buildForDiscard(session, 0, discardedTile);
assertThat(result).isEmpty();
}
private GameTable createPlayingTable(GameSeat... seats) {
GameTable table = new GameTable(List.of(seats), new ArrayList<>());
table.setPhase(GamePhase.PLAYING);
table.setCurrentSeatNo(0);
return table;
}
private GameSeat seat(int seatNo, String userId, Tile... tiles) {
GameSeat seat = new GameSeat(seatNo, false, userId, "seat-" + seatNo);
for (Tile tile : tiles) {
seat.receiveTile(tile);
}
return seat;
}
}

View File

@@ -0,0 +1,78 @@
package com.xuezhanmaster.ws.service;
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.PrivateActionMessage;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class GameMessagePublisherTest {
private final SimpMessagingTemplate messagingTemplate = mock(SimpMessagingTemplate.class);
private final GameMessagePublisher gameMessagePublisher = new GameMessagePublisher(messagingTemplate);
@Test
void shouldPublishStructuredTurnActionMessage() {
gameMessagePublisher.publishPrivateTurnActionRequired("game-1", "user-1", List.of("DISCARD"), 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.currentSeatNo()).isEqualTo(2);
assertThat(message.candidates()).hasSize(1);
assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD");
assertThat(message.candidates().get(0).tile()).isNull();
}
@Test
void shouldPublishStructuredResponseActionMessage() {
ResponseActionWindow window = ResponseActionWindow.create(
"game-1",
"TILE_DISCARDED",
0,
"3万",
List.of(new ResponseActionSeatCandidate(
1,
"user-1",
List.of(
new ResponseActionOption(ActionType.PENG, "3万"),
new ResponseActionOption(ActionType.PASS, null)
)
))
);
gameMessagePublisher.publishPrivateResponseActionRequired(
"game-1",
"user-1",
0,
window,
window.seatCandidates().get(0)
);
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("RESPONSE");
assertThat(message.windowId()).isEqualTo(window.windowId());
assertThat(message.triggerEventType()).isEqualTo("TILE_DISCARDED");
assertThat(message.sourceSeatNo()).isEqualTo(0);
assertThat(message.triggerTile()).isEqualTo("3万");
assertThat(message.availableActions()).containsExactly("PENG", "PASS");
assertThat(message.candidates())
.extracting(candidate -> candidate.actionType())
.containsExactly("PENG", "PASS");
}
}