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

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.domain;
public record ResponseActionResolution(
ActionType actionType,
int winnerSeatNo,
int sourceSeatNo,
String triggerTile
) {
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public record ScoreChange(
int seatNo,
int delta,
int afterScore
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public enum SettlementType {
DIAN_PAO_HU,
ZI_MO_HU,
MING_GANG
}

View File

@@ -7,9 +7,10 @@ public record PublicSeatView(
String playerId,
String nickname,
boolean ai,
boolean won,
String lackSuit,
int score,
int handCount,
List<String> discardTiles
) {
}

View File

@@ -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
) {
}

View File

@@ -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;
}
}

View File

@@ -13,5 +13,7 @@ public enum GameEventType {
GANG_DECLARED,
HU_DECLARED,
PASS_DECLARED,
SETTLEMENT_APPLIED,
SCORE_CHANGED,
ACTION_REQUIRED
}

View File

@@ -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();
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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
)
);
}

View File

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

View File

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

View File

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

View File

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

View File

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