feat: 实现响应候选模型与私有动作消息结构化
新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括: - 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型 - 扩展 PrivateActionMessage 支持响应候选上下文 - 实现 ResponseActionWindowBuilder 构建弃牌响应候选 - 拆分 GameMessagePublisher 支持回合动作和响应动作消息 - 更新前端原型页展示结构化候选动作 - 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public record ResponseActionOption(
|
||||
ActionType actionType,
|
||||
String tile
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ResponseActionSeatCandidate(
|
||||
int seatNo,
|
||||
String userId,
|
||||
List<ResponseActionOption> options
|
||||
) {
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.xuezhanmaster.ws.dto;
|
||||
|
||||
public record PrivateActionCandidate(
|
||||
String actionType,
|
||||
String tile
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user