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

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

View File

@@ -1,15 +1,19 @@
# 当前执行入口 # 当前执行入口
- 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md` - 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`
- Sprint 1 当前进度:`S1-01``S1-02``S1-03``S1-04``S1-05``S1-06``S1-07` 已完成 - 当前主线已进入“统一结算服务 + 自摸胡闭环”阶段
- `S1-06` 已完成内容 - 最新已完成能力
- 已新增 `docs/RESPONSE_RESOLUTION_RULES.md` - `HU` 已同时支持两条路径:
- 已明确项目 V1 响应优先级:`HU > GANG > PENG > PASS` - 响应胡(吃别人弃牌)
- 已明确同优先级裁决按出牌者之后最近顺位优先。 - 自摸胡(当前回合主动胡)
- 已明确当前 V1 不实现完整 `过水不胡` `一炮多响` - `HuEvaluator` 已补 `canHu(List<Tile>)`,可直接判断整手自摸是否成胡
- 已明确公共消息与私有消息边界,以及后续裁决器接入顺序 - 当前回合私有动作消息已支持结构化候选下发,真人玩家在可自摸时会收到 `HU + DISCARD` 候选
- 当前推荐的下一步有两条: - AI 当前回合若满足自摸胡条件,会优先执行 `HU`,不再一律走弃牌策略。
- 文档主线:`S1-08` 对局页信息架构与页面拆分方案 - `SettlementService` 已补 `ZI_MO_HU` 结算占位规则:所有未胡玩家各 `-1`,自摸方累计获得对应分数
- 代码主线:开始下一轮“真实响应窗口停顿 + 候选下发 + 裁决器”实现,不再停留在模型和消息层 - 自摸胡后会将胡牌座位标记为 `won`,并按血战逻辑继续推进到下一有效座位;若仅剩 1 名未胡玩家则结束
- 重要现状说明 - 当前仍未完成的核心点
- 后端和前端已经具备结构化响应候选模型与私有消息结构 - `暗杠 / 补杠` 需要引入副露/杠展示状态,否则手牌数与桌面状态会失真
- 当前仍未在弃牌后真正暂停主流程等待响应,这是下一轮核心开发点。 - 正式四川血战计分规则仍未实现,当前仍是工程占位分。
- H5 正式页面拆分仍未开始。
- 当前推荐的下一步:
1. 代码主线:补 `Meld/副露` 领域模型,再接 `暗杠 / 补杠 / 杠后补牌 / 杠分事件`
2. 结算主线:把占位规则升级为正式血战分数模型,并细化 `SETTLEMENT_APPLIED` 载荷。

View File

@@ -13,6 +13,8 @@ public class GameSeat {
private final List<Tile> handTiles = new ArrayList<>(); private final List<Tile> handTiles = new ArrayList<>();
private final List<Tile> discardTiles = new ArrayList<>(); private final List<Tile> discardTiles = new ArrayList<>();
private TileSuit lackSuit; private TileSuit lackSuit;
private boolean won;
private int score;
public GameSeat(int seatNo, boolean ai, String nickname) { public GameSeat(int seatNo, boolean ai, String nickname) {
this(seatNo, ai, UUID.randomUUID().toString(), nickname); this(seatNo, ai, UUID.randomUUID().toString(), nickname);
@@ -57,6 +59,22 @@ public class GameSeat {
this.lackSuit = lackSuit; 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) { public void receiveTile(Tile tile) {
handTiles.add(tile); handTiles.add(tile);
} }
@@ -65,4 +83,31 @@ public class GameSeat {
handTiles.remove(tile); handTiles.remove(tile);
discardTiles.add(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 playerId,
String nickname, String nickname,
boolean ai, boolean ai,
boolean won,
String lackSuit, String lackSuit,
int score,
int handCount, int handCount,
List<String> discardTiles List<String> discardTiles
) { ) {
} }

View File

@@ -6,9 +6,10 @@ public record SelfSeatView(
int seatNo, int seatNo,
String playerId, String playerId,
String nickname, String nickname,
boolean won,
String lackSuit, String lackSuit,
int score,
List<String> handTiles, List<String> handTiles,
List<String> discardTiles List<String> discardTiles
) { ) {
} }

View File

@@ -1,7 +1,12 @@
package com.xuezhanmaster.game.event; package com.xuezhanmaster.game.event;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementResult;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public record GameEvent( public record GameEvent(
@@ -70,7 +75,7 @@ public record GameEvent(
String gameId, String gameId,
GameEventType eventType, GameEventType eventType,
int seatNo, int seatNo,
int sourceSeatNo, Integer sourceSeatNo,
String tile String tile
) { ) {
return of(gameId, eventType, seatNo, buildActionPayload( 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<>(); Map<String, Object> payload = new LinkedHashMap<>();
payload.put("actionType", actionType); payload.put("actionType", actionType);
if (sourceSeatNo != null) {
payload.put("sourceSeatNo", sourceSeatNo); payload.put("sourceSeatNo", sourceSeatNo);
}
if (tile != null && !tile.isBlank()) { if (tile != null && !tile.isBlank()) {
payload.put("tile", tile); payload.put("tile", tile);
} }
@@ -99,4 +131,16 @@ public record GameEvent(
default -> eventType.name(); 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, GANG_DECLARED,
HU_DECLARED, HU_DECLARED,
PASS_DECLARED, PASS_DECLARED,
SETTLEMENT_APPLIED,
SCORE_CHANGED,
ACTION_REQUIRED ACTION_REQUIRED
} }

View File

@@ -21,6 +21,12 @@ import java.util.List;
@Component @Component
public class GameActionProcessor { public class GameActionProcessor {
private final HuEvaluator huEvaluator;
public GameActionProcessor(HuEvaluator huEvaluator) {
this.huEvaluator = huEvaluator;
}
public List<GameEvent> process(GameSession session, GameActionRequest request) { public List<GameEvent> process(GameSession session, GameActionRequest request) {
ActionType actionType = parseActionType(request.actionType()); ActionType actionType = parseActionType(request.actionType());
return switch (actionType) { return switch (actionType) {
@@ -38,21 +44,25 @@ public class GameActionProcessor {
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId); GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, ""); 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) { private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId); GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, ""); 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) { private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId); GameSeat seat = findSeatByUserId(table, userId);
if (sourceSeatNo == null) {
validateSelfDrawHu(session, table, seat);
return List.of();
}
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, ""); validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU); return List.of();
} }
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) { private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
@@ -93,6 +103,9 @@ public class GameActionProcessor {
} }
GameSeat seat = findSeatByUserId(table, userId); GameSeat seat = findSeatByUserId(table, userId);
if (seat.isWon()) {
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续出牌");
}
if (seat.getSeatNo() != table.getCurrentSeatNo()) { if (seat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合"); 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) { private boolean isBlank(String value) {
return value == null || value.isBlank(); 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.GameSeat;
import com.xuezhanmaster.game.domain.GameSession; import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable; import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow; 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.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest; import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse; import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.PublicSeatView; 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.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService; import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.ws.service.GameMessagePublisher; import com.xuezhanmaster.ws.service.GameMessagePublisher;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -41,6 +46,9 @@ public class GameSessionService {
private final GameEngine gameEngine; private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor; private final GameActionProcessor gameActionProcessor;
private final ResponseActionWindowBuilder responseActionWindowBuilder; private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final ResponseActionResolver responseActionResolver;
private final SettlementService settlementService;
private final HuEvaluator huEvaluator;
private final StrategyService strategyService; private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService; private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService; private final TeachingService teachingService;
@@ -52,6 +60,9 @@ public class GameSessionService {
GameEngine gameEngine, GameEngine gameEngine,
GameActionProcessor gameActionProcessor, GameActionProcessor gameActionProcessor,
ResponseActionWindowBuilder responseActionWindowBuilder, ResponseActionWindowBuilder responseActionWindowBuilder,
ResponseActionResolver responseActionResolver,
SettlementService settlementService,
HuEvaluator huEvaluator,
StrategyService strategyService, StrategyService strategyService,
PlayerVisibilityService playerVisibilityService, PlayerVisibilityService playerVisibilityService,
TeachingService teachingService, TeachingService teachingService,
@@ -61,6 +72,9 @@ public class GameSessionService {
this.gameEngine = gameEngine; this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor; this.gameActionProcessor = gameActionProcessor;
this.responseActionWindowBuilder = responseActionWindowBuilder; this.responseActionWindowBuilder = responseActionWindowBuilder;
this.responseActionResolver = responseActionResolver;
this.settlementService = settlementService;
this.huEvaluator = huEvaluator;
this.strategyService = strategyService; this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService; this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService; this.teachingService = teachingService;
@@ -129,7 +143,9 @@ public class GameSessionService {
self.getSeatNo(), self.getSeatNo(),
self.getPlayerId(), self.getPlayerId(),
self.getNickname(), self.getNickname(),
self.isWon(),
self.getLackSuit() == null ? null : self.getLackSuit().name(), self.getLackSuit() == null ? null : self.getLackSuit().name(),
self.getScore(),
self.getHandTiles().stream().map(Tile::getDisplayName).toList(), self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
self.getDiscardTiles().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) { private void moveToNextSeat(GameTable table, String gameId) {
int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size(); if (shouldFinishTable(table)) {
GameSeat nextSeat = table.getSeats().get(nextSeatNo);
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED); table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name())); gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
return; 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); Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile); nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeatNo); table.setCurrentSeatNo(nextSeat.getSeatNo());
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size())); gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo)); gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
} }
private void autoPlayBots(GameSession session) { private void autoPlayBots(GameSession session) {
GameTable table = session.getTable(); GameTable table = session.getTable();
while (table.getPhase() == GamePhase.PLAYING) { while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo()); GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (currentSeat.isWon()) {
moveToNextSeat(table, session.getGameId());
continue;
}
if (!currentSeat.isAi()) { if (!currentSeat.isAi()) {
return; 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); PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState); StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
List<GameEvent> events = gameActionProcessor.process( List<GameEvent> events = gameActionProcessor.process(
@@ -197,14 +246,14 @@ public class GameSessionService {
} }
GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo()); GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo());
if (currentSeat.isAi()) { if (currentSeat.isAi() || currentSeat.isWon()) {
return; return;
} }
gameMessagePublisher.publishPrivateTurnActionRequired( gameMessagePublisher.publishPrivateTurnActionCandidates(
session.getGameId(), session.getGameId(),
currentSeat.getPlayerId(), currentSeat.getPlayerId(),
List.of("DISCARD"), buildTurnActionCandidates(currentSeat),
currentSeat.getSeatNo() currentSeat.getSeatNo()
); );
@@ -234,6 +283,9 @@ public class GameSessionService {
} }
case DISCARD -> handleDiscardPostAction(session, request, events); case DISCARD -> handleDiscardPostAction(session, request, events);
case PASS -> handlePassPostAction(session, request); case PASS -> handlePassPostAction(session, request);
case PENG -> handleResponseDeclarationPostAction(session, actionType, request);
case GANG -> handleGangPostAction(session, request);
case HU -> handleHuPostAction(session, request);
default -> { default -> {
// 其余动作在后续 Sprint 中补充对应副作用 // 其余动作在后续 Sprint 中补充对应副作用
} }
@@ -281,10 +333,29 @@ public class GameSessionService {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session); ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId()); GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS); session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
if (allResponseCandidatesResolved(session, responseActionWindow)) { tryResolveResponseWindow(session, responseActionWindow);
closeResponseWindowAsPass(session, responseActionWindow);
continueAfterDiscardWithoutResponse(session);
} }
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) { private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
@@ -331,6 +402,26 @@ public class GameSessionService {
session.clearResponseActionSelections(); 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) { private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow(); ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) { if (responseActionWindow == null) {
@@ -365,9 +456,9 @@ public class GameSessionService {
} }
Tile tile = switch (suitLabel) { Tile tile = switch (suitLabel) {
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.WAN, rank); case "" -> new Tile(TileSuit.WAN, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TONG, rank); case "" -> new Tile(TileSuit.TONG, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TIAO, rank); case "" -> new Tile(TileSuit.TIAO, rank);
default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法"); default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
}; };
return tile; return tile;
@@ -379,6 +470,160 @@ public class GameSessionService {
notifyActionIfHumanTurn(session); 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) { private ActionType parseActionType(String actionType) {
try { try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT)); return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
@@ -394,10 +639,59 @@ public class GameSessionService {
seat.getPlayerId(), seat.getPlayerId(),
seat.getNickname(), seat.getNickname(),
seat.isAi(), seat.isAi(),
seat.isWon(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(), seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getScore(),
seat.getHandTiles().size(), seat.getHandTiles().size(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList() seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
)) ))
.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 @Component
public class ResponseActionWindowBuilder { public class ResponseActionWindowBuilder {
private final HuEvaluator huEvaluator;
public ResponseActionWindowBuilder(HuEvaluator huEvaluator) {
this.huEvaluator = huEvaluator;
}
public Optional<ResponseActionWindow> buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) { public Optional<ResponseActionWindow> buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) {
GameTable table = session.getTable(); GameTable table = session.getTable();
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>(); List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
for (GameSeat seat : table.getSeats()) { for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == sourceSeatNo) { if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
continue; continue;
} }
@@ -51,16 +57,26 @@ public class ResponseActionWindowBuilder {
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) { private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile); int sameTileCount = countSameTileInHand(seat, discardedTile);
if (sameTileCount < 2) { boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);
if (!canHu && sameTileCount < 2) {
return List.of(); return List.of();
} }
List<ResponseActionOption> options = new ArrayList<>(); List<ResponseActionOption> options = new ArrayList<>();
if (canHu) {
options.add(new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()));
}
options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName())); options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName()));
if (sameTileCount >= 3) { if (sameTileCount >= 3) {
options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName())); options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName()));
} }
options.add(new ResponseActionOption(ActionType.PASS, null)); 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; 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 org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
@Service @Service
public class GameMessagePublisher { public class GameMessagePublisher {
@@ -35,6 +36,19 @@ public class GameMessagePublisher {
} }
public void publishPrivateTurnActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) { 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( messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions", "/topic/users/" + userId + "/actions",
new PrivateActionMessage( new PrivateActionMessage(
@@ -47,7 +61,7 @@ public class GameMessagePublisher {
null, null,
null, null,
null, null,
toCandidates(availableActions) candidates
) )
); );
} }

View File

@@ -1,7 +1,13 @@
package com.xuezhanmaster.game.event; 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 org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class GameEventTest { class GameEventTest {
@@ -39,4 +45,53 @@ class GameEventTest {
assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED); assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED);
assertThat(switched.payload()).containsEntry("currentSeatNo", 3); 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; package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException; 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.dto.GameActionRequest;
import com.xuezhanmaster.game.domain.TileSuit; import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse; import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.service.GameActionProcessor; import com.xuezhanmaster.game.service.GameActionProcessor;
import com.xuezhanmaster.game.event.GameEventType;
import com.xuezhanmaster.strategy.service.StrategyService; import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService; import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService; import com.xuezhanmaster.teaching.service.TeachingService;
@@ -18,6 +22,9 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
import com.xuezhanmaster.ws.service.GameMessagePublisher; import com.xuezhanmaster.ws.service.GameMessagePublisher;
import java.lang.reflect.Field;
import java.util.Map;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -26,11 +33,15 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameSessionServiceTest { class GameSessionServiceTest {
private final RoomService roomService = new RoomService(); private final RoomService roomService = new RoomService();
private final HuEvaluator huEvaluator = new HuEvaluator();
private final GameSessionService gameSessionService = new GameSessionService( private final GameSessionService gameSessionService = new GameSessionService(
roomService, roomService,
new GameEngine(new DeckFactory()), new GameEngine(new DeckFactory()),
new GameActionProcessor(), new GameActionProcessor(huEvaluator),
new ResponseActionWindowBuilder(), new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(),
new SettlementService(),
huEvaluator,
new StrategyService(), new StrategyService(),
new PlayerVisibilityService(), new PlayerVisibilityService(),
new TeachingService(), new TeachingService(),
@@ -65,6 +76,8 @@ class GameSessionServiceTest {
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1"); GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name()); GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
String discardTile = playing.selfSeat().handTiles().get(0); 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); GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);
@@ -88,7 +101,7 @@ class GameSessionServiceTest {
)) ))
.isInstanceOf(BusinessException.class) .isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode()) .extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_UNSUPPORTED"); .isEqualTo("GAME_ACTION_WINDOW_NOT_FOUND");
} }
@Test @Test
@@ -156,4 +169,327 @@ class GameSessionServiceTest {
.extracting(throwable -> ((BusinessException) throwable).getCode()) .extracting(throwable -> ((BusinessException) throwable).getCode())
.isEqualTo("GAME_ACTION_INVALID"); .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 { class ResponseActionWindowBuilderTest {
private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(); private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder(new HuEvaluator());
@Test @Test
void shouldBuildResponseCandidatesForDiscardedTile() { void shouldBuildResponseCandidatesForDiscardedTile() {
@@ -69,6 +69,58 @@ class ResponseActionWindowBuilderTest {
assertThat(result).isEmpty(); 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) { private GameTable createPlayingTable(GameSeat... seats) {
GameTable table = new GameTable(List.of(seats), new ArrayList<>()); GameTable table = new GameTable(List.of(seats), new ArrayList<>());
table.setPhase(GamePhase.PLAYING); 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.ResponseActionOption;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow; import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage; import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -22,17 +23,25 @@ class GameMessagePublisherTest {
@Test @Test
void shouldPublishStructuredTurnActionMessage() { 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); ArgumentCaptor<PrivateActionMessage> captor = ArgumentCaptor.forClass(PrivateActionMessage.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture()); verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture());
PrivateActionMessage message = captor.getValue(); PrivateActionMessage message = captor.getValue();
assertThat(message.actionScope()).isEqualTo("TURN"); assertThat(message.actionScope()).isEqualTo("TURN");
assertThat(message.availableActions()).containsExactly("DISCARD"); assertThat(message.availableActions()).containsExactly("HU", "DISCARD");
assertThat(message.currentSeatNo()).isEqualTo(2); assertThat(message.currentSeatNo()).isEqualTo(2);
assertThat(message.candidates()).hasSize(1); assertThat(message.candidates()).hasSize(2);
assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD"); assertThat(message.candidates().get(0).actionType()).isEqualTo("HU");
assertThat(message.candidates().get(0).tile()).isNull(); assertThat(message.candidates().get(0).tile()).isNull();
} }

View File

@@ -256,6 +256,13 @@ Sprint 目标:
- `publishPrivateTurnActionRequired` - `publishPrivateTurnActionRequired`
- `publishPrivateResponseActionRequired` - `publishPrivateResponseActionRequired`
- 当前回合动作消息与响应候选消息已可共用同一消息结构 - 当前回合动作消息与响应候选消息已可共用同一消息结构
- 弃牌后若存在候选,后端已能真正创建响应窗口并下发私有候选动作消息
- AI 候选当前会自动提交 `PASS`
- 当前已接入初版响应裁决:
- 全员响应完成后按优先级和顺位决出单一赢家
- `PENG` 已可真实执行并夺取回合
- `GANG` 已可真实执行并补摸一张牌
- `HU` 已可进入候选、参与裁决并结束当前单局
- 已补消息发布单测,验证 turn / response 两类消息形状 - 已补消息发布单测,验证 turn / response 两类消息形状
## 验收结果 ## 验收结果
@@ -275,6 +282,9 @@ Sprint 目标:
两类动作消息作用域 两类动作消息作用域
- 私有动作区已增加候选动作展示占位 - 私有动作区已增加候选动作展示占位
- 私有动作区已增加来源座位、目标牌等上下文字段展示 - 私有动作区已增加来源座位、目标牌等上下文字段展示
- H5 原型页已支持点击候选动作按钮,并提交带 `sourceSeatNo` 的动作请求
- 当前已可通过点击 `PASS` 让响应窗口恢复行牌
- 当前已可通过点击 `PENG / GANG` 候选按钮参与真实响应裁决
- 已补样式支持候选动作标签展示 - 已补样式支持候选动作标签展示
## 验收结果 ## 验收结果

View File

@@ -30,7 +30,9 @@ type SelfSeatView = {
seatNo: number seatNo: number
playerId: string playerId: string
nickname: string nickname: string
won: boolean
lackSuit: string | null lackSuit: string | null
score: number
handTiles: string[] handTiles: string[]
discardTiles: string[] discardTiles: string[]
} }
@@ -40,7 +42,9 @@ type PublicSeatView = {
playerId: string playerId: string
nickname: string nickname: string
ai: boolean ai: boolean
won: boolean
lackSuit: string | null lackSuit: string | null
score: number
handCount: number handCount: number
discardTiles: string[] discardTiles: string[]
} }
@@ -125,7 +129,11 @@ const actionScopeLabelMap: Record<string, string> = {
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION') const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed( const canDiscard = computed(
() => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo () =>
game.value?.phase === 'PLAYING' &&
!game.value.selfSeat.won &&
game.value.selfSeat.playerId === currentUserId.value &&
game.value.currentSeatNo === game.value.selfSeat.seatNo
) )
const publicSeats = computed(() => game.value?.seats ?? []) const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? []) const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
@@ -330,7 +338,7 @@ async function refreshGameState() {
}) })
} }
async function submitAction(actionType: string, tile?: string) { async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
if (!game.value) { if (!game.value) {
return return
} }
@@ -341,7 +349,8 @@ async function submitAction(actionType: string, tile?: string) {
body: JSON.stringify({ body: JSON.stringify({
userId: currentUserId.value, userId: currentUserId.value,
actionType, actionType,
tile: tile ?? null tile: tile ?? null,
sourceSeatNo: sourceSeatNo ?? null
}) })
}) })
info.value = actionType === 'DISCARD' ? `已打出 ${tile}` : `已提交动作 ${actionType}` info.value = actionType === 'DISCARD' ? `已打出 ${tile}` : `已提交动作 ${actionType}`
@@ -355,6 +364,11 @@ function selectLack() {
function discard(tile: string) { function discard(tile: string) {
return submitAction('DISCARD', tile) return submitAction('DISCARD', tile)
} }
function submitCandidateAction(actionType: string, tile: string | null) {
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
}
</script> </script>
<template> <template>
@@ -512,7 +526,11 @@ function discard(tile: string) {
<div class="self-card"> <div class="self-card">
<div class="section-title"> <div class="section-title">
<strong>我的手牌</strong> <strong>我的手牌</strong>
<div class="mini-tags">
<span class="mini-pill">当前回合 {{ game.currentSeatNo }}</span> <span class="mini-pill">当前回合 {{ game.currentSeatNo }}</span>
<span class="mini-tag">积分 {{ game.selfSeat.score }}</span>
<span class="mini-tag">{{ game.selfSeat.won ? '已胡' : '未胡' }}</span>
</div>
</div> </div>
<div class="tile-grid"> <div class="tile-grid">
<button <button
@@ -549,13 +567,15 @@ function discard(tile: string) {
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span> <span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
</div> </div>
<div class="candidate-list" v-if="privateActionCandidates.length > 0"> <div class="candidate-list" v-if="privateActionCandidates.length > 0">
<span <button
v-for="(candidate, index) in privateActionCandidates" v-for="(candidate, index) in privateActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`" :key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip" class="candidate-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
> >
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template> {{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
</span> </button>
</div> </div>
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy"> <span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
当前原型页已能识别响应候选消息后续会继续补正式动作面板和真实响应流程 当前原型页已能识别响应候选消息后续会继续补正式动作面板和真实响应流程
@@ -579,6 +599,8 @@ function discard(tile: string) {
</div> </div>
<div class="mini-tags"> <div class="mini-tags">
<span class="mini-tag">{{ seat.ai ? 'AI' : '真人' }}</span> <span class="mini-tag">{{ seat.ai ? 'AI' : '真人' }}</span>
<span class="mini-tag">{{ seat.won ? '已胡' : '在局' }}</span>
<span class="mini-tag">积分 {{ seat.score }}</span>
<span class="mini-tag">手牌 {{ seat.handCount }}</span> <span class="mini-tag">手牌 {{ seat.handCount }}</span>
<span class="mini-tag">{{ seat.lackSuit ?? '未定缺' }}</span> <span class="mini-tag">{{ seat.lackSuit ?? '未定缺' }}</span>
</div> </div>