feat: 实现麻将游戏结算系统与自摸胡功能
新增结算类型枚举和分数变更记录模型 补全响应裁决器与结算服务,支持点炮胡、自摸胡和明杠结算 扩展座位模型,增加已胡状态和分数字段 完善胡牌评估器,支持自摸胡判断 前端原型页增加分数显示和已胡状态 更新SPRINT文档记录当前进度
This commit is contained in:
@@ -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` 载荷。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public record ResponseActionResolution(
|
||||||
|
ActionType actionType,
|
||||||
|
int winnerSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public record ScoreChange(
|
||||||
|
int seatNo,
|
||||||
|
int delta,
|
||||||
|
int afterScore
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SettlementResult(
|
||||||
|
SettlementType settlementType,
|
||||||
|
ActionType actionType,
|
||||||
|
int actorSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile,
|
||||||
|
List<ScoreChange> scoreChanges
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public enum SettlementType {
|
||||||
|
DIAN_PAO_HU,
|
||||||
|
ZI_MO_HU,
|
||||||
|
MING_GANG
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ public record PublicSeatView(
|
|||||||
String playerId,
|
String 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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.xuezhanmaster.game.service;
|
||||||
|
|
||||||
|
import com.xuezhanmaster.game.domain.Tile;
|
||||||
|
import com.xuezhanmaster.game.domain.TileSuit;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HuEvaluator {
|
||||||
|
|
||||||
|
public boolean canHu(List<Tile> tiles) {
|
||||||
|
if (tiles.size() % 3 != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] counts = buildCounts(tiles);
|
||||||
|
for (int i = 0; i < counts.length; i++) {
|
||||||
|
if (counts[i] < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts[i] -= 2;
|
||||||
|
if (canFormMelds(counts)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
counts[i] += 2;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canHuWithClaimedTile(List<Tile> handTiles, Tile claimedTile) {
|
||||||
|
List<Tile> tiles = new ArrayList<>(handTiles);
|
||||||
|
tiles.add(claimedTile);
|
||||||
|
return canHu(tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canFormMelds(int[] counts) {
|
||||||
|
int firstIndex = firstNonZeroIndex(counts);
|
||||||
|
if (firstIndex == -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[firstIndex] >= 3) {
|
||||||
|
counts[firstIndex] -= 3;
|
||||||
|
if (canFormMelds(counts)) {
|
||||||
|
counts[firstIndex] += 3;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
counts[firstIndex] += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
int suitOffset = (firstIndex / 9) * 9;
|
||||||
|
int inSuitIndex = firstIndex % 9;
|
||||||
|
if (inSuitIndex <= 6
|
||||||
|
&& counts[firstIndex + 1] > 0
|
||||||
|
&& counts[firstIndex + 2] > 0
|
||||||
|
&& firstIndex + 2 < suitOffset + 9) {
|
||||||
|
counts[firstIndex]--;
|
||||||
|
counts[firstIndex + 1]--;
|
||||||
|
counts[firstIndex + 2]--;
|
||||||
|
if (canFormMelds(counts)) {
|
||||||
|
counts[firstIndex]++;
|
||||||
|
counts[firstIndex + 1]++;
|
||||||
|
counts[firstIndex + 2]++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
counts[firstIndex]++;
|
||||||
|
counts[firstIndex + 1]++;
|
||||||
|
counts[firstIndex + 2]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int firstNonZeroIndex(int[] counts) {
|
||||||
|
for (int i = 0; i < counts.length; i++) {
|
||||||
|
if (counts[i] > 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] buildCounts(List<Tile> tiles) {
|
||||||
|
int[] counts = new int[27];
|
||||||
|
for (Tile tile : tiles) {
|
||||||
|
counts[indexOf(tile)]++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int indexOf(Tile tile) {
|
||||||
|
int suitOffset = switch (tile.getSuit()) {
|
||||||
|
case WAN -> 0;
|
||||||
|
case TONG -> 9;
|
||||||
|
case TIAO -> 18;
|
||||||
|
};
|
||||||
|
return suitOffset + tile.getRank() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.xuezhanmaster.game.service;
|
||||||
|
|
||||||
|
import com.xuezhanmaster.game.domain.ActionType;
|
||||||
|
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
||||||
|
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ResponseActionResolver {
|
||||||
|
|
||||||
|
public Optional<ResponseActionResolution> resolve(ResponseActionWindow window, Map<Integer, ActionType> selections, int seatCount) {
|
||||||
|
return selections.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue() != ActionType.PASS)
|
||||||
|
.min(Comparator
|
||||||
|
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
|
||||||
|
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
|
||||||
|
.map(entry -> new ResponseActionResolution(
|
||||||
|
entry.getValue(),
|
||||||
|
entry.getKey(),
|
||||||
|
window.sourceSeatNo(),
|
||||||
|
window.triggerTile()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int priority(ActionType actionType) {
|
||||||
|
return switch (actionType) {
|
||||||
|
case HU -> 0;
|
||||||
|
case GANG -> 1;
|
||||||
|
case PENG -> 2;
|
||||||
|
case PASS -> 3;
|
||||||
|
default -> 99;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private int seatDistance(int sourceSeatNo, int targetSeatNo, int seatCount) {
|
||||||
|
return (targetSeatNo - sourceSeatNo + seatCount) % seatCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,12 +17,18 @@ import java.util.Optional;
|
|||||||
@Component
|
@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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.xuezhanmaster.game.service;
|
||||||
|
|
||||||
|
import com.xuezhanmaster.game.domain.ActionType;
|
||||||
|
import com.xuezhanmaster.game.domain.GameSeat;
|
||||||
|
import com.xuezhanmaster.game.domain.GameTable;
|
||||||
|
import com.xuezhanmaster.game.domain.ScoreChange;
|
||||||
|
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||||
|
import com.xuezhanmaster.game.domain.SettlementType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SettlementService {
|
||||||
|
|
||||||
|
private static final int DIAN_PAO_HU_SCORE = 1;
|
||||||
|
private static final int ZI_MO_HU_SCORE = 1;
|
||||||
|
private static final int MING_GANG_SCORE = 1;
|
||||||
|
|
||||||
|
public SettlementResult settleDiscardHu(
|
||||||
|
GameTable table,
|
||||||
|
int winnerSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile
|
||||||
|
) {
|
||||||
|
return applySettlement(
|
||||||
|
table,
|
||||||
|
SettlementType.DIAN_PAO_HU,
|
||||||
|
ActionType.HU,
|
||||||
|
winnerSeatNo,
|
||||||
|
sourceSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettlementResult settleExposedGang(
|
||||||
|
GameTable table,
|
||||||
|
int winnerSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile
|
||||||
|
) {
|
||||||
|
return applySettlement(
|
||||||
|
table,
|
||||||
|
SettlementType.MING_GANG,
|
||||||
|
ActionType.GANG,
|
||||||
|
winnerSeatNo,
|
||||||
|
sourceSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettlementResult settleSelfDrawHu(
|
||||||
|
GameTable table,
|
||||||
|
int winnerSeatNo,
|
||||||
|
String triggerTile
|
||||||
|
) {
|
||||||
|
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
|
||||||
|
int totalWinScore = 0;
|
||||||
|
for (GameSeat seat : table.getSeats()) {
|
||||||
|
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scoreDeltas.put(seat.getSeatNo(), -ZI_MO_HU_SCORE);
|
||||||
|
totalWinScore += ZI_MO_HU_SCORE;
|
||||||
|
}
|
||||||
|
scoreDeltas.put(winnerSeatNo, totalWinScore);
|
||||||
|
return applySettlement(
|
||||||
|
table,
|
||||||
|
SettlementType.ZI_MO_HU,
|
||||||
|
ActionType.HU,
|
||||||
|
winnerSeatNo,
|
||||||
|
winnerSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
scoreDeltas
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SettlementResult applySettlement(
|
||||||
|
GameTable table,
|
||||||
|
SettlementType settlementType,
|
||||||
|
ActionType actionType,
|
||||||
|
int actorSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile,
|
||||||
|
Map<Integer, Integer> scoreDeltas
|
||||||
|
) {
|
||||||
|
List<ScoreChange> scoreChanges = new ArrayList<>();
|
||||||
|
for (Map.Entry<Integer, Integer> entry : scoreDeltas.entrySet()) {
|
||||||
|
GameSeat seat = table.getSeats().get(entry.getKey());
|
||||||
|
seat.addScore(entry.getValue());
|
||||||
|
scoreChanges.add(new ScoreChange(
|
||||||
|
seat.getSeatNo(),
|
||||||
|
entry.getValue(),
|
||||||
|
seat.getScore()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return new SettlementResult(
|
||||||
|
settlementType,
|
||||||
|
actionType,
|
||||||
|
actorSeatNo,
|
||||||
|
sourceSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
List.copyOf(scoreChanges)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Integer, Integer> orderedDeltas(int winnerSeatNo, int winnerDelta, int sourceSeatNo, int sourceDelta) {
|
||||||
|
Map<Integer, Integer> deltas = new LinkedHashMap<>();
|
||||||
|
deltas.put(winnerSeatNo, winnerDelta);
|
||||||
|
deltas.put(sourceSeatNo, sourceDelta);
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
|
|||||||
import org.springframework.stereotype.Service;
|
import 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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.xuezhanmaster.game.service;
|
||||||
|
|
||||||
|
import com.xuezhanmaster.game.domain.Tile;
|
||||||
|
import com.xuezhanmaster.game.domain.TileSuit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class HuEvaluatorTest {
|
||||||
|
|
||||||
|
private final HuEvaluator huEvaluator = new HuEvaluator();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRecognizeStandardWinningHandWithClaimedTile() {
|
||||||
|
boolean result = huEvaluator.canHuWithClaimedTile(
|
||||||
|
List.of(
|
||||||
|
new Tile(TileSuit.WAN, 1),
|
||||||
|
new Tile(TileSuit.WAN, 1),
|
||||||
|
new Tile(TileSuit.WAN, 1),
|
||||||
|
new Tile(TileSuit.WAN, 2),
|
||||||
|
new Tile(TileSuit.WAN, 3),
|
||||||
|
new Tile(TileSuit.WAN, 4),
|
||||||
|
new Tile(TileSuit.TONG, 2),
|
||||||
|
new Tile(TileSuit.TONG, 3),
|
||||||
|
new Tile(TileSuit.TONG, 4),
|
||||||
|
new Tile(TileSuit.TIAO, 5),
|
||||||
|
new Tile(TileSuit.TIAO, 6),
|
||||||
|
new Tile(TileSuit.TIAO, 7),
|
||||||
|
new Tile(TileSuit.TONG, 9)
|
||||||
|
),
|
||||||
|
new Tile(TileSuit.TONG, 9)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRejectNonWinningHandWithClaimedTile() {
|
||||||
|
boolean result = huEvaluator.canHuWithClaimedTile(
|
||||||
|
List.of(
|
||||||
|
new Tile(TileSuit.WAN, 1),
|
||||||
|
new Tile(TileSuit.WAN, 2),
|
||||||
|
new Tile(TileSuit.WAN, 3),
|
||||||
|
new Tile(TileSuit.WAN, 4),
|
||||||
|
new Tile(TileSuit.WAN, 5),
|
||||||
|
new Tile(TileSuit.WAN, 6),
|
||||||
|
new Tile(TileSuit.TONG, 1),
|
||||||
|
new Tile(TileSuit.TONG, 2),
|
||||||
|
new Tile(TileSuit.TONG, 3),
|
||||||
|
new Tile(TileSuit.TIAO, 1),
|
||||||
|
new Tile(TileSuit.TIAO, 2),
|
||||||
|
new Tile(TileSuit.TIAO, 4),
|
||||||
|
new Tile(TileSuit.TONG, 9)
|
||||||
|
),
|
||||||
|
new Tile(TileSuit.TIAO, 9)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class ResponseActionWindowBuilderTest {
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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` 候选按钮参与真实响应裁决
|
||||||
- 已补样式支持候选动作标签展示
|
- 已补样式支持候选动作标签展示
|
||||||
|
|
||||||
## 验收结果
|
## 验收结果
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user