feat: 实现麻将游戏结算系统与自摸胡功能
新增结算类型枚举和分数变更记录模型 补全响应裁决器与结算服务,支持点炮胡、自摸胡和明杠结算 扩展座位模型,增加已胡状态和分数字段 完善胡牌评估器,支持自摸胡判断 前端原型页增加分数显示和已胡状态 更新SPRINT文档记录当前进度
This commit is contained in:
@@ -13,6 +13,8 @@ public class GameSeat {
|
||||
private final List<Tile> handTiles = new ArrayList<>();
|
||||
private final List<Tile> discardTiles = new ArrayList<>();
|
||||
private TileSuit lackSuit;
|
||||
private boolean won;
|
||||
private int score;
|
||||
|
||||
public GameSeat(int seatNo, boolean ai, String nickname) {
|
||||
this(seatNo, ai, UUID.randomUUID().toString(), nickname);
|
||||
@@ -57,6 +59,22 @@ public class GameSeat {
|
||||
this.lackSuit = lackSuit;
|
||||
}
|
||||
|
||||
public boolean isWon() {
|
||||
return won;
|
||||
}
|
||||
|
||||
public void setWon(boolean won) {
|
||||
this.won = won;
|
||||
}
|
||||
|
||||
public int getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
public void addScore(int delta) {
|
||||
this.score += delta;
|
||||
}
|
||||
|
||||
public void receiveTile(Tile tile) {
|
||||
handTiles.add(tile);
|
||||
}
|
||||
@@ -65,4 +83,31 @@ public class GameSeat {
|
||||
handTiles.remove(tile);
|
||||
discardTiles.add(tile);
|
||||
}
|
||||
|
||||
public void removeMatchingHandTiles(Tile tile, int count) {
|
||||
int remaining = count;
|
||||
for (int i = handTiles.size() - 1; i >= 0 && remaining > 0; i--) {
|
||||
if (handTiles.get(i).equals(tile)) {
|
||||
handTiles.remove(i);
|
||||
remaining--;
|
||||
}
|
||||
}
|
||||
if (remaining > 0) {
|
||||
throw new IllegalStateException("手牌中可移除的匹配牌数量不足");
|
||||
}
|
||||
}
|
||||
|
||||
public void removeMatchingDiscardTile(Tile tile) {
|
||||
for (int i = discardTiles.size() - 1; i >= 0; i--) {
|
||||
if (discardTiles.get(i).equals(tile)) {
|
||||
discardTiles.remove(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("弃牌区不存在指定牌");
|
||||
}
|
||||
|
||||
public void declareHu() {
|
||||
this.won = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public record ResponseActionResolution(
|
||||
ActionType actionType,
|
||||
int winnerSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public record ScoreChange(
|
||||
int seatNo,
|
||||
int delta,
|
||||
int afterScore
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SettlementResult(
|
||||
SettlementType settlementType,
|
||||
ActionType actionType,
|
||||
int actorSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile,
|
||||
List<ScoreChange> scoreChanges
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public enum SettlementType {
|
||||
DIAN_PAO_HU,
|
||||
ZI_MO_HU,
|
||||
MING_GANG
|
||||
}
|
||||
@@ -7,9 +7,10 @@ public record PublicSeatView(
|
||||
String playerId,
|
||||
String nickname,
|
||||
boolean ai,
|
||||
boolean won,
|
||||
String lackSuit,
|
||||
int score,
|
||||
int handCount,
|
||||
List<String> discardTiles
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ public record SelfSeatView(
|
||||
int seatNo,
|
||||
String playerId,
|
||||
String nickname,
|
||||
boolean won,
|
||||
String lackSuit,
|
||||
int score,
|
||||
List<String> handTiles,
|
||||
List<String> discardTiles
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.xuezhanmaster.game.event;
|
||||
|
||||
import com.xuezhanmaster.game.domain.ScoreChange;
|
||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record GameEvent(
|
||||
@@ -70,7 +75,7 @@ public record GameEvent(
|
||||
String gameId,
|
||||
GameEventType eventType,
|
||||
int seatNo,
|
||||
int sourceSeatNo,
|
||||
Integer sourceSeatNo,
|
||||
String tile
|
||||
) {
|
||||
return of(gameId, eventType, seatNo, buildActionPayload(
|
||||
@@ -80,10 +85,37 @@ public record GameEvent(
|
||||
));
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildActionPayload(String actionType, int sourceSeatNo, String tile) {
|
||||
public static GameEvent settlementApplied(String gameId, SettlementResult settlementResult) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("settlementType", settlementResult.settlementType().name());
|
||||
payload.put("actionType", settlementResult.actionType().name());
|
||||
payload.put("actorSeatNo", settlementResult.actorSeatNo());
|
||||
payload.put("sourceSeatNo", settlementResult.sourceSeatNo());
|
||||
payload.put("triggerTile", settlementResult.triggerTile());
|
||||
payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges()));
|
||||
return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload);
|
||||
}
|
||||
|
||||
public static GameEvent scoreChanged(
|
||||
String gameId,
|
||||
int seatNo,
|
||||
String settlementType,
|
||||
int delta,
|
||||
int score
|
||||
) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("settlementType", settlementType);
|
||||
payload.put("delta", delta);
|
||||
payload.put("score", score);
|
||||
return of(gameId, GameEventType.SCORE_CHANGED, seatNo, payload);
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildActionPayload(String actionType, Integer sourceSeatNo, String tile) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("actionType", actionType);
|
||||
payload.put("sourceSeatNo", sourceSeatNo);
|
||||
if (sourceSeatNo != null) {
|
||||
payload.put("sourceSeatNo", sourceSeatNo);
|
||||
}
|
||||
if (tile != null && !tile.isBlank()) {
|
||||
payload.put("tile", tile);
|
||||
}
|
||||
@@ -99,4 +131,16 @@ public record GameEvent(
|
||||
default -> eventType.name();
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> toScoreChangePayload(List<ScoreChange> scoreChanges) {
|
||||
List<Map<String, Object>> payload = new ArrayList<>();
|
||||
for (ScoreChange scoreChange : scoreChanges) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("seatNo", scoreChange.seatNo());
|
||||
item.put("delta", scoreChange.delta());
|
||||
item.put("afterScore", scoreChange.afterScore());
|
||||
payload.add(item);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,7 @@ public enum GameEventType {
|
||||
GANG_DECLARED,
|
||||
HU_DECLARED,
|
||||
PASS_DECLARED,
|
||||
SETTLEMENT_APPLIED,
|
||||
SCORE_CHANGED,
|
||||
ACTION_REQUIRED
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ import java.util.List;
|
||||
@Component
|
||||
public class GameActionProcessor {
|
||||
|
||||
private final HuEvaluator huEvaluator;
|
||||
|
||||
public GameActionProcessor(HuEvaluator huEvaluator) {
|
||||
this.huEvaluator = huEvaluator;
|
||||
}
|
||||
|
||||
public List<GameEvent> process(GameSession session, GameActionRequest request) {
|
||||
ActionType actionType = parseActionType(request.actionType());
|
||||
return switch (actionType) {
|
||||
@@ -38,21 +44,25 @@ public class GameActionProcessor {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat seat = findSeatByUserId(table, userId);
|
||||
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "碰");
|
||||
return unsupportedAction(ActionType.PENG);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat seat = findSeatByUserId(table, userId);
|
||||
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "杠");
|
||||
return unsupportedAction(ActionType.GANG);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat seat = findSeatByUserId(table, userId);
|
||||
if (sourceSeatNo == null) {
|
||||
validateSelfDrawHu(session, table, seat);
|
||||
return List.of();
|
||||
}
|
||||
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "胡");
|
||||
return unsupportedAction(ActionType.HU);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
|
||||
@@ -93,6 +103,9 @@ public class GameActionProcessor {
|
||||
}
|
||||
|
||||
GameSeat seat = findSeatByUserId(table, userId);
|
||||
if (seat.isWon()) {
|
||||
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续出牌");
|
||||
}
|
||||
if (seat.getSeatNo() != table.getCurrentSeatNo()) {
|
||||
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合");
|
||||
}
|
||||
@@ -188,6 +201,24 @@ public class GameActionProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSelfDrawHu(GameSession session, GameTable table, GameSeat actorSeat) {
|
||||
if (table.getPhase() != GamePhase.PLAYING) {
|
||||
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许自摸胡");
|
||||
}
|
||||
if (actorSeat.isWon()) {
|
||||
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能再次胡牌");
|
||||
}
|
||||
if (actorSeat.getSeatNo() != table.getCurrentSeatNo()) {
|
||||
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的回合,不能自摸胡");
|
||||
}
|
||||
if (session.getPendingResponseActionWindow() != null) {
|
||||
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "响应窗口期间不能执行自摸胡");
|
||||
}
|
||||
if (!huEvaluator.canHu(actorSeat.getHandTiles())) {
|
||||
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前手牌不满足自摸胡条件");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ 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.ResponseActionResolution;
|
||||
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
|
||||
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||
import com.xuezhanmaster.game.domain.ScoreChange;
|
||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||
import com.xuezhanmaster.game.domain.Tile;
|
||||
import com.xuezhanmaster.game.domain.TileSuit;
|
||||
import com.xuezhanmaster.game.dto.GameActionRequest;
|
||||
import com.xuezhanmaster.game.dto.GameStateResponse;
|
||||
import com.xuezhanmaster.game.dto.PublicSeatView;
|
||||
@@ -26,6 +30,7 @@ import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
|
||||
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
|
||||
import com.xuezhanmaster.teaching.service.TeachingService;
|
||||
import com.xuezhanmaster.ws.service.GameMessagePublisher;
|
||||
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -41,6 +46,9 @@ public class GameSessionService {
|
||||
private final GameEngine gameEngine;
|
||||
private final GameActionProcessor gameActionProcessor;
|
||||
private final ResponseActionWindowBuilder responseActionWindowBuilder;
|
||||
private final ResponseActionResolver responseActionResolver;
|
||||
private final SettlementService settlementService;
|
||||
private final HuEvaluator huEvaluator;
|
||||
private final StrategyService strategyService;
|
||||
private final PlayerVisibilityService playerVisibilityService;
|
||||
private final TeachingService teachingService;
|
||||
@@ -52,6 +60,9 @@ public class GameSessionService {
|
||||
GameEngine gameEngine,
|
||||
GameActionProcessor gameActionProcessor,
|
||||
ResponseActionWindowBuilder responseActionWindowBuilder,
|
||||
ResponseActionResolver responseActionResolver,
|
||||
SettlementService settlementService,
|
||||
HuEvaluator huEvaluator,
|
||||
StrategyService strategyService,
|
||||
PlayerVisibilityService playerVisibilityService,
|
||||
TeachingService teachingService,
|
||||
@@ -61,6 +72,9 @@ public class GameSessionService {
|
||||
this.gameEngine = gameEngine;
|
||||
this.gameActionProcessor = gameActionProcessor;
|
||||
this.responseActionWindowBuilder = responseActionWindowBuilder;
|
||||
this.responseActionResolver = responseActionResolver;
|
||||
this.settlementService = settlementService;
|
||||
this.huEvaluator = huEvaluator;
|
||||
this.strategyService = strategyService;
|
||||
this.playerVisibilityService = playerVisibilityService;
|
||||
this.teachingService = teachingService;
|
||||
@@ -129,7 +143,9 @@ public class GameSessionService {
|
||||
self.getSeatNo(),
|
||||
self.getPlayerId(),
|
||||
self.getNickname(),
|
||||
self.isWon(),
|
||||
self.getLackSuit() == null ? null : self.getLackSuit().name(),
|
||||
self.getScore(),
|
||||
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
|
||||
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
|
||||
),
|
||||
@@ -138,29 +154,62 @@ public class GameSessionService {
|
||||
}
|
||||
|
||||
private void moveToNextSeat(GameTable table, String gameId) {
|
||||
int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size();
|
||||
GameSeat nextSeat = table.getSeats().get(nextSeatNo);
|
||||
if (table.getWallTiles().isEmpty()) {
|
||||
if (shouldFinishTable(table)) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
|
||||
if (nextSeatOptional.isEmpty()) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
GameSeat nextSeat = nextSeatOptional.get();
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
nextSeat.receiveTile(drawnTile);
|
||||
table.setCurrentSeatNo(nextSeatNo);
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size()));
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo));
|
||||
table.setCurrentSeatNo(nextSeat.getSeatNo());
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
|
||||
}
|
||||
|
||||
private void autoPlayBots(GameSession session) {
|
||||
GameTable table = session.getTable();
|
||||
while (table.getPhase() == GamePhase.PLAYING) {
|
||||
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
|
||||
if (currentSeat.isWon()) {
|
||||
moveToNextSeat(table, session.getGameId());
|
||||
continue;
|
||||
}
|
||||
if (!currentSeat.isAi()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canSelfDrawHu(currentSeat)) {
|
||||
List<GameEvent> huEvents = gameActionProcessor.process(
|
||||
session,
|
||||
new GameActionRequest(
|
||||
currentSeat.getPlayerId(),
|
||||
"HU",
|
||||
null,
|
||||
null
|
||||
)
|
||||
);
|
||||
appendAndPublish(session, huEvents);
|
||||
handlePostActionEffects(
|
||||
session,
|
||||
ActionType.HU,
|
||||
new GameActionRequest(currentSeat.getPlayerId(), "HU", null, null),
|
||||
huEvents
|
||||
);
|
||||
if (table.getPhase() == GamePhase.FINISHED) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
|
||||
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
|
||||
List<GameEvent> events = gameActionProcessor.process(
|
||||
@@ -197,14 +246,14 @@ public class GameSessionService {
|
||||
}
|
||||
|
||||
GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo());
|
||||
if (currentSeat.isAi()) {
|
||||
if (currentSeat.isAi() || currentSeat.isWon()) {
|
||||
return;
|
||||
}
|
||||
|
||||
gameMessagePublisher.publishPrivateTurnActionRequired(
|
||||
gameMessagePublisher.publishPrivateTurnActionCandidates(
|
||||
session.getGameId(),
|
||||
currentSeat.getPlayerId(),
|
||||
List.of("DISCARD"),
|
||||
buildTurnActionCandidates(currentSeat),
|
||||
currentSeat.getSeatNo()
|
||||
);
|
||||
|
||||
@@ -234,6 +283,9 @@ public class GameSessionService {
|
||||
}
|
||||
case DISCARD -> handleDiscardPostAction(session, request, events);
|
||||
case PASS -> handlePassPostAction(session, request);
|
||||
case PENG -> handleResponseDeclarationPostAction(session, actionType, request);
|
||||
case GANG -> handleGangPostAction(session, request);
|
||||
case HU -> handleHuPostAction(session, request);
|
||||
default -> {
|
||||
// 其余动作在后续 Sprint 中补充对应副作用
|
||||
}
|
||||
@@ -281,10 +333,29 @@ public class GameSessionService {
|
||||
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);
|
||||
tryResolveResponseWindow(session, responseActionWindow);
|
||||
}
|
||||
|
||||
private void handleResponseDeclarationPostAction(GameSession session, ActionType actionType, GameActionRequest request) {
|
||||
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
|
||||
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
|
||||
session.getResponseActionSelections().put(actorSeat.getSeatNo(), actionType);
|
||||
tryResolveResponseWindow(session, responseActionWindow);
|
||||
}
|
||||
|
||||
private void handleGangPostAction(GameSession session, GameActionRequest request) {
|
||||
if (request.sourceSeatNo() == null) {
|
||||
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充");
|
||||
}
|
||||
handleResponseDeclarationPostAction(session, ActionType.GANG, request);
|
||||
}
|
||||
|
||||
private void handleHuPostAction(GameSession session, GameActionRequest request) {
|
||||
if (request.sourceSeatNo() == null) {
|
||||
executeSelfDrawHu(session, request.userId());
|
||||
return;
|
||||
}
|
||||
handleResponseDeclarationPostAction(session, ActionType.HU, request);
|
||||
}
|
||||
|
||||
private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
|
||||
@@ -331,6 +402,26 @@ public class GameSessionService {
|
||||
session.clearResponseActionSelections();
|
||||
}
|
||||
|
||||
private void tryResolveResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
|
||||
if (!allResponseCandidatesResolved(session, responseActionWindow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ResponseActionResolution> resolution = responseActionResolver.resolve(
|
||||
responseActionWindow,
|
||||
session.getResponseActionSelections(),
|
||||
session.getTable().getSeats().size()
|
||||
);
|
||||
|
||||
if (resolution.isPresent()) {
|
||||
executeResolvedResponseAction(session, responseActionWindow, resolution.get());
|
||||
return;
|
||||
}
|
||||
|
||||
closeResponseWindowAsPass(session, responseActionWindow);
|
||||
continueAfterDiscardWithoutResponse(session);
|
||||
}
|
||||
|
||||
private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
|
||||
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
|
||||
if (responseActionWindow == null) {
|
||||
@@ -365,9 +456,9 @@ public class GameSessionService {
|
||||
}
|
||||
|
||||
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);
|
||||
case "万" -> new Tile(TileSuit.WAN, rank);
|
||||
case "筒" -> new Tile(TileSuit.TONG, rank);
|
||||
case "条" -> new Tile(TileSuit.TIAO, rank);
|
||||
default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
|
||||
};
|
||||
return tile;
|
||||
@@ -379,6 +470,160 @@ public class GameSessionService {
|
||||
notifyActionIfHumanTurn(session);
|
||||
}
|
||||
|
||||
private void executeResolvedResponseAction(
|
||||
GameSession session,
|
||||
ResponseActionWindow responseActionWindow,
|
||||
ResponseActionResolution resolution
|
||||
) {
|
||||
closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType());
|
||||
switch (resolution.actionType()) {
|
||||
case PENG -> executePeng(session, resolution);
|
||||
case GANG -> executeGang(session, resolution);
|
||||
case HU -> executeHu(session, resolution);
|
||||
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
|
||||
}
|
||||
}
|
||||
|
||||
private void closeResponseWindowWithResolution(GameSession session, ResponseActionWindow responseActionWindow, ActionType actionType) {
|
||||
appendAndPublish(session, GameEvent.responseWindowClosed(
|
||||
session.getGameId(),
|
||||
null,
|
||||
responseActionWindow.sourceSeatNo(),
|
||||
actionType.name()
|
||||
));
|
||||
session.setPendingResponseActionWindow(null);
|
||||
session.clearResponseActionSelections();
|
||||
}
|
||||
|
||||
private void executePeng(GameSession session, ResponseActionResolution resolution) {
|
||||
Tile claimedTile = parseTile(resolution.triggerTile());
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
|
||||
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(claimedTile, 2);
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.PENG_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
|
||||
continueFromResolvedActionTurn(session, winnerSeat);
|
||||
}
|
||||
|
||||
private void executeGang(GameSession session, ResponseActionResolution resolution) {
|
||||
Tile claimedTile = parseTile(resolution.triggerTile());
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
|
||||
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(claimedTile, 3);
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.GANG_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleExposedGang(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
|
||||
if (table.getWallTiles().isEmpty()) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
winnerSeat.receiveTile(drawnTile);
|
||||
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
|
||||
continueFromResolvedActionTurn(session, winnerSeat);
|
||||
}
|
||||
|
||||
private void continueFromResolvedActionTurn(GameSession session, GameSeat currentSeat) {
|
||||
if (currentSeat.isAi()) {
|
||||
autoPlayBots(session);
|
||||
notifyActionIfHumanTurn(session);
|
||||
return;
|
||||
}
|
||||
notifyActionIfHumanTurn(session);
|
||||
}
|
||||
|
||||
private void executeHu(GameSession session, ResponseActionResolution resolution) {
|
||||
Tile claimedTile = parseTile(resolution.triggerTile());
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
|
||||
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
|
||||
|
||||
winnerSeat.receiveTile(claimedTile);
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
winnerSeat.declareHu();
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.HU_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleDiscardHu(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
|
||||
if (shouldFinishTable(table)) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
continueAfterDiscardWithoutResponse(session);
|
||||
}
|
||||
|
||||
private void executeSelfDrawHu(GameSession session, String userId) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = findSeatByUserId(table, userId);
|
||||
winnerSeat.declareHu();
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.HU_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleSelfDrawHu(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
null
|
||||
));
|
||||
|
||||
if (shouldFinishTable(table)) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
moveToNextSeat(table, session.getGameId());
|
||||
autoPlayBots(session);
|
||||
notifyActionIfHumanTurn(session);
|
||||
}
|
||||
|
||||
private ActionType parseActionType(String actionType) {
|
||||
try {
|
||||
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
|
||||
@@ -394,10 +639,59 @@ public class GameSessionService {
|
||||
seat.getPlayerId(),
|
||||
seat.getNickname(),
|
||||
seat.isAi(),
|
||||
seat.isWon(),
|
||||
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
|
||||
seat.getScore(),
|
||||
seat.getHandTiles().size(),
|
||||
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
|
||||
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
|
||||
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
|
||||
appendAndPublish(session, GameEvent.scoreChanged(
|
||||
session.getGameId(),
|
||||
scoreChange.seatNo(),
|
||||
settlementResult.settlementType().name(),
|
||||
scoreChange.delta(),
|
||||
scoreChange.afterScore()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldFinishTable(GameTable table) {
|
||||
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
|
||||
}
|
||||
|
||||
private boolean canSelfDrawHu(GameSeat seat) {
|
||||
return huEvaluator.canHu(seat.getHandTiles());
|
||||
}
|
||||
|
||||
private long countActiveSeats(GameTable table) {
|
||||
return table.getSeats().stream()
|
||||
.filter(seat -> !seat.isWon())
|
||||
.count();
|
||||
}
|
||||
|
||||
private Optional<GameSeat> findNextActiveSeat(GameTable table, int fromSeatNo) {
|
||||
int seatCount = table.getSeats().size();
|
||||
for (int offset = 1; offset <= seatCount; offset++) {
|
||||
GameSeat candidate = table.getSeats().get((fromSeatNo + offset) % seatCount);
|
||||
if (!candidate.isWon()) {
|
||||
return Optional.of(candidate);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private List<PrivateActionCandidate> buildTurnActionCandidates(GameSeat currentSeat) {
|
||||
List<PrivateActionCandidate> candidates = new java.util.ArrayList<>();
|
||||
if (canSelfDrawHu(currentSeat)) {
|
||||
candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null));
|
||||
}
|
||||
candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null));
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.game.domain.Tile;
|
||||
import com.xuezhanmaster.game.domain.TileSuit;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class HuEvaluator {
|
||||
|
||||
public boolean canHu(List<Tile> tiles) {
|
||||
if (tiles.size() % 3 != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int[] counts = buildCounts(tiles);
|
||||
for (int i = 0; i < counts.length; i++) {
|
||||
if (counts[i] < 2) {
|
||||
continue;
|
||||
}
|
||||
counts[i] -= 2;
|
||||
if (canFormMelds(counts)) {
|
||||
return true;
|
||||
}
|
||||
counts[i] += 2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean canHuWithClaimedTile(List<Tile> handTiles, Tile claimedTile) {
|
||||
List<Tile> tiles = new ArrayList<>(handTiles);
|
||||
tiles.add(claimedTile);
|
||||
return canHu(tiles);
|
||||
}
|
||||
|
||||
private boolean canFormMelds(int[] counts) {
|
||||
int firstIndex = firstNonZeroIndex(counts);
|
||||
if (firstIndex == -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (counts[firstIndex] >= 3) {
|
||||
counts[firstIndex] -= 3;
|
||||
if (canFormMelds(counts)) {
|
||||
counts[firstIndex] += 3;
|
||||
return true;
|
||||
}
|
||||
counts[firstIndex] += 3;
|
||||
}
|
||||
|
||||
int suitOffset = (firstIndex / 9) * 9;
|
||||
int inSuitIndex = firstIndex % 9;
|
||||
if (inSuitIndex <= 6
|
||||
&& counts[firstIndex + 1] > 0
|
||||
&& counts[firstIndex + 2] > 0
|
||||
&& firstIndex + 2 < suitOffset + 9) {
|
||||
counts[firstIndex]--;
|
||||
counts[firstIndex + 1]--;
|
||||
counts[firstIndex + 2]--;
|
||||
if (canFormMelds(counts)) {
|
||||
counts[firstIndex]++;
|
||||
counts[firstIndex + 1]++;
|
||||
counts[firstIndex + 2]++;
|
||||
return true;
|
||||
}
|
||||
counts[firstIndex]++;
|
||||
counts[firstIndex + 1]++;
|
||||
counts[firstIndex + 2]++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private int firstNonZeroIndex(int[] counts) {
|
||||
for (int i = 0; i < counts.length; i++) {
|
||||
if (counts[i] > 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int[] buildCounts(List<Tile> tiles) {
|
||||
int[] counts = new int[27];
|
||||
for (Tile tile : tiles) {
|
||||
counts[indexOf(tile)]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private int indexOf(Tile tile) {
|
||||
int suitOffset = switch (tile.getSuit()) {
|
||||
case WAN -> 0;
|
||||
case TONG -> 9;
|
||||
case TIAO -> 18;
|
||||
};
|
||||
return suitOffset + tile.getRank() - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.game.domain.ActionType;
|
||||
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
||||
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class ResponseActionResolver {
|
||||
|
||||
public Optional<ResponseActionResolution> resolve(ResponseActionWindow window, Map<Integer, ActionType> selections, int seatCount) {
|
||||
return selections.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() != ActionType.PASS)
|
||||
.min(Comparator
|
||||
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
|
||||
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
|
||||
.map(entry -> new ResponseActionResolution(
|
||||
entry.getValue(),
|
||||
entry.getKey(),
|
||||
window.sourceSeatNo(),
|
||||
window.triggerTile()
|
||||
));
|
||||
}
|
||||
|
||||
private int priority(ActionType actionType) {
|
||||
return switch (actionType) {
|
||||
case HU -> 0;
|
||||
case GANG -> 1;
|
||||
case PENG -> 2;
|
||||
case PASS -> 3;
|
||||
default -> 99;
|
||||
};
|
||||
}
|
||||
|
||||
private int seatDistance(int sourceSeatNo, int targetSeatNo, int seatCount) {
|
||||
return (targetSeatNo - sourceSeatNo + seatCount) % seatCount;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,18 @@ import java.util.Optional;
|
||||
@Component
|
||||
public class ResponseActionWindowBuilder {
|
||||
|
||||
private final HuEvaluator huEvaluator;
|
||||
|
||||
public ResponseActionWindowBuilder(HuEvaluator huEvaluator) {
|
||||
this.huEvaluator = huEvaluator;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,16 +57,26 @@ public class ResponseActionWindowBuilder {
|
||||
|
||||
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
|
||||
int sameTileCount = countSameTileInHand(seat, discardedTile);
|
||||
if (sameTileCount < 2) {
|
||||
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);
|
||||
if (!canHu && sameTileCount < 2) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<ResponseActionOption> options = new ArrayList<>();
|
||||
if (canHu) {
|
||||
options.add(new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()));
|
||||
}
|
||||
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));
|
||||
if (sameTileCount < 2) {
|
||||
return List.of(
|
||||
new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()),
|
||||
new ResponseActionOption(ActionType.PASS, null)
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.game.domain.ActionType;
|
||||
import com.xuezhanmaster.game.domain.GameSeat;
|
||||
import com.xuezhanmaster.game.domain.GameTable;
|
||||
import com.xuezhanmaster.game.domain.ScoreChange;
|
||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||
import com.xuezhanmaster.game.domain.SettlementType;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class SettlementService {
|
||||
|
||||
private static final int DIAN_PAO_HU_SCORE = 1;
|
||||
private static final int ZI_MO_HU_SCORE = 1;
|
||||
private static final int MING_GANG_SCORE = 1;
|
||||
|
||||
public SettlementResult settleDiscardHu(
|
||||
GameTable table,
|
||||
int winnerSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile
|
||||
) {
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.DIAN_PAO_HU,
|
||||
ActionType.HU,
|
||||
winnerSeatNo,
|
||||
sourceSeatNo,
|
||||
triggerTile,
|
||||
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE)
|
||||
);
|
||||
}
|
||||
|
||||
public SettlementResult settleExposedGang(
|
||||
GameTable table,
|
||||
int winnerSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile
|
||||
) {
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.MING_GANG,
|
||||
ActionType.GANG,
|
||||
winnerSeatNo,
|
||||
sourceSeatNo,
|
||||
triggerTile,
|
||||
orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE)
|
||||
);
|
||||
}
|
||||
|
||||
public SettlementResult settleSelfDrawHu(
|
||||
GameTable table,
|
||||
int winnerSeatNo,
|
||||
String triggerTile
|
||||
) {
|
||||
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
|
||||
int totalWinScore = 0;
|
||||
for (GameSeat seat : table.getSeats()) {
|
||||
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
|
||||
continue;
|
||||
}
|
||||
scoreDeltas.put(seat.getSeatNo(), -ZI_MO_HU_SCORE);
|
||||
totalWinScore += ZI_MO_HU_SCORE;
|
||||
}
|
||||
scoreDeltas.put(winnerSeatNo, totalWinScore);
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.ZI_MO_HU,
|
||||
ActionType.HU,
|
||||
winnerSeatNo,
|
||||
winnerSeatNo,
|
||||
triggerTile,
|
||||
scoreDeltas
|
||||
);
|
||||
}
|
||||
|
||||
private SettlementResult applySettlement(
|
||||
GameTable table,
|
||||
SettlementType settlementType,
|
||||
ActionType actionType,
|
||||
int actorSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile,
|
||||
Map<Integer, Integer> scoreDeltas
|
||||
) {
|
||||
List<ScoreChange> scoreChanges = new ArrayList<>();
|
||||
for (Map.Entry<Integer, Integer> entry : scoreDeltas.entrySet()) {
|
||||
GameSeat seat = table.getSeats().get(entry.getKey());
|
||||
seat.addScore(entry.getValue());
|
||||
scoreChanges.add(new ScoreChange(
|
||||
seat.getSeatNo(),
|
||||
entry.getValue(),
|
||||
seat.getScore()
|
||||
));
|
||||
}
|
||||
return new SettlementResult(
|
||||
settlementType,
|
||||
actionType,
|
||||
actorSeatNo,
|
||||
sourceSeatNo,
|
||||
triggerTile,
|
||||
List.copyOf(scoreChanges)
|
||||
);
|
||||
}
|
||||
|
||||
private Map<Integer, Integer> orderedDeltas(int winnerSeatNo, int winnerDelta, int sourceSeatNo, int sourceDelta) {
|
||||
Map<Integer, Integer> deltas = new LinkedHashMap<>();
|
||||
deltas.put(winnerSeatNo, winnerDelta);
|
||||
deltas.put(sourceSeatNo, sourceDelta);
|
||||
return deltas;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class GameMessagePublisher {
|
||||
@@ -35,6 +36,19 @@ public class GameMessagePublisher {
|
||||
}
|
||||
|
||||
public void publishPrivateTurnActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
|
||||
publishPrivateTurnActionCandidates(gameId, userId, toCandidates(availableActions), currentSeatNo);
|
||||
}
|
||||
|
||||
public void publishPrivateTurnActionCandidates(
|
||||
String gameId,
|
||||
String userId,
|
||||
List<PrivateActionCandidate> candidates,
|
||||
int currentSeatNo
|
||||
) {
|
||||
List<String> availableActions = candidates.stream()
|
||||
.map(PrivateActionCandidate::actionType)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
messagingTemplate.convertAndSend(
|
||||
"/topic/users/" + userId + "/actions",
|
||||
new PrivateActionMessage(
|
||||
@@ -47,7 +61,7 @@ public class GameMessagePublisher {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
toCandidates(availableActions)
|
||||
candidates
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user