feat(结算): 实现血战麻将查叫与退税功能
新增局终处理逻辑,当牌墙耗尽且有多家未胡时: 1. 退税:未听牌玩家需退还此前杠分收入 2. 查叫:未听牌玩家向听牌玩家赔付最大理论点炮值 新增 SettlementType.TUI_SHUI 和 SettlementType.CHA_JIAO 结算类型 新增 ReadyHandOption 记录最优听牌选项 支持一炮多响裁决,新增 ResponseActionResolutionBatch 承载多赢家结果 在 GameSession 中新增 settlementHistory 保留结算记录供复用 更新开发文档要求加强关键区域的中文注释
This commit is contained in:
@@ -29,12 +29,30 @@
|
|||||||
- `明杠/点杠`:放杠者单独支付 2 分
|
- `明杠/点杠`:放杠者单独支付 2 分
|
||||||
- `补杠`:所有未胡玩家各支付 1 分
|
- `补杠`:所有未胡玩家各支付 1 分
|
||||||
- `暗杠`:所有未胡玩家各支付 2 分
|
- `暗杠`:所有未胡玩家各支付 2 分
|
||||||
- `GameSession` 已新增 `PostGangContext`,只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`。
|
- `GameSession` 已新增:
|
||||||
|
- `PostGangContext`:只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
|
||||||
|
- `settlementHistory`:记录已应用结算结果,供局终 `退税/查叫` 直接复用
|
||||||
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`。
|
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`。
|
||||||
|
- 查叫/退税已接入局终处理:
|
||||||
|
- 触发时机:牌墙耗尽且仍有多家未胡时
|
||||||
|
- 处理顺序:先 `退税`,再 `查叫`
|
||||||
|
- `退税`:未听牌玩家若此前有杠分收入,需要按原支付关系逐笔退还
|
||||||
|
- `查叫`:未听牌玩家向仍在场且已听牌玩家,按对方理论最大可胡牌型的点炮赔分支付
|
||||||
|
- 已新增 `SettlementType.TUI_SHUI` 与 `SettlementType.CHA_JIAO`
|
||||||
|
- 已新增 `ReadyHandOption`,用于查叫时记录最优听牌目标牌与对应结算明细
|
||||||
|
- 一炮多响已接入响应裁决:
|
||||||
|
- `HU` 现在支持多赢家同窗裁决,只要响应窗口内有多个 `HU` 声明,就会一起结算
|
||||||
|
- `PENG/GANG` 仍保持单赢家,顺位规则不变
|
||||||
|
- 抢杠胡窗口也会复用这套多赢家 `HU` 裁决路径
|
||||||
|
- 已新增 `ResponseActionResolutionBatch` 承载多赢家响应结果
|
||||||
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
|
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
|
||||||
- 当前仍未实现:
|
- 当前仍未实现:
|
||||||
- 自摸加番/加底地方变体
|
- 自摸加番/加底地方变体
|
||||||
- 天胡、地胡
|
- 天胡、地胡
|
||||||
- 查叫、退税、过水不胡、一炮多响
|
- 过水不胡
|
||||||
- 文档约定已补到 `docs/DEVELOPMENT_PLAN.md`:后续前端、后端、数据库表结构与 SQL 脚本都需要补充必要中文注释,重点说明复杂规则、关键字段、状态切换、约束原因和索引用途。
|
- 文档约定已加强到 `docs/DEVELOPMENT_PLAN.md`:
|
||||||
- 最小验证:`cd backend && mvn clean test`,当前 48 个测试通过。
|
- 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释
|
||||||
|
- 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释
|
||||||
|
- 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,必须补中文注释说明业务含义、约束原因、字段口径和回滚要点
|
||||||
|
- 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域
|
||||||
|
- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。
|
||||||
@@ -16,6 +16,8 @@ public class GameSession {
|
|||||||
private final Instant createdAt;
|
private final Instant createdAt;
|
||||||
private final GameTable table;
|
private final GameTable table;
|
||||||
private final List<GameEvent> events;
|
private final List<GameEvent> events;
|
||||||
|
// 保留已应用结算结果,供局终退税与查叫直接复用,避免从事件载荷反推。
|
||||||
|
private final List<SettlementResult> settlementHistory;
|
||||||
private ResponseActionWindow pendingResponseActionWindow;
|
private ResponseActionWindow pendingResponseActionWindow;
|
||||||
// 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。
|
// 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。
|
||||||
private PostGangContext postGangContext;
|
private PostGangContext postGangContext;
|
||||||
@@ -27,6 +29,7 @@ public class GameSession {
|
|||||||
this.createdAt = Instant.now();
|
this.createdAt = Instant.now();
|
||||||
this.table = table;
|
this.table = table;
|
||||||
this.events = new ArrayList<>();
|
this.events = new ArrayList<>();
|
||||||
|
this.settlementHistory = new ArrayList<>();
|
||||||
this.responseActionSelections = new LinkedHashMap<>();
|
this.responseActionSelections = new LinkedHashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,10 @@ public class GameSession {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<SettlementResult> getSettlementHistory() {
|
||||||
|
return settlementHistory;
|
||||||
|
}
|
||||||
|
|
||||||
public ResponseActionWindow getPendingResponseActionWindow() {
|
public ResponseActionWindow getPendingResponseActionWindow() {
|
||||||
return pendingResponseActionWindow;
|
return pendingResponseActionWindow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public record ReadyHandOption(
|
||||||
|
String triggerTile,
|
||||||
|
SettlementDetail settlementDetail
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ResponseActionResolutionBatch(
|
||||||
|
ActionType actionType,
|
||||||
|
List<ResponseActionResolution> resolutions
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -6,5 +6,7 @@ public enum SettlementType {
|
|||||||
ZI_MO_HU,
|
ZI_MO_HU,
|
||||||
BU_GANG,
|
BU_GANG,
|
||||||
MING_GANG,
|
MING_GANG,
|
||||||
AN_GANG
|
AN_GANG,
|
||||||
|
TUI_SHUI,
|
||||||
|
CHA_JIAO
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.xuezhanmaster.game.service;
|
|||||||
import com.xuezhanmaster.game.domain.GameSeat;
|
import com.xuezhanmaster.game.domain.GameSeat;
|
||||||
import com.xuezhanmaster.game.domain.MeldGroup;
|
import com.xuezhanmaster.game.domain.MeldGroup;
|
||||||
import com.xuezhanmaster.game.domain.MeldType;
|
import com.xuezhanmaster.game.domain.MeldType;
|
||||||
|
import com.xuezhanmaster.game.domain.ReadyHandOption;
|
||||||
import com.xuezhanmaster.game.domain.SettlementDetail;
|
import com.xuezhanmaster.game.domain.SettlementDetail;
|
||||||
import com.xuezhanmaster.game.domain.SettlementFan;
|
import com.xuezhanmaster.game.domain.SettlementFan;
|
||||||
import com.xuezhanmaster.game.domain.Tile;
|
import com.xuezhanmaster.game.domain.Tile;
|
||||||
@@ -13,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class BloodBattleScoringService {
|
public class BloodBattleScoringService {
|
||||||
@@ -61,6 +63,22 @@ public class BloodBattleScoringService {
|
|||||||
return new SettlementDetail(BASE_SCORE, 0, CONCEALED_GANG_SCORE, List.of());
|
return new SettlementDetail(BASE_SCORE, 0, CONCEALED_GANG_SCORE, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<ReadyHandOption> findBestReadyHandOption(GameSeat seat) {
|
||||||
|
ReadyHandOption bestOption = null;
|
||||||
|
for (Tile candidateTile : buildAllTileCandidates()) {
|
||||||
|
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), candidateTile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
GameSeat virtualSeat = copySeatWithClaimedTile(seat, candidateTile);
|
||||||
|
SettlementDetail settlementDetail = buildDiscardHuDetail(virtualSeat, false, false);
|
||||||
|
ReadyHandOption candidateOption = new ReadyHandOption(candidateTile.getDisplayName(), settlementDetail);
|
||||||
|
if (bestOption == null || isBetterReadyHandOption(candidateOption, bestOption)) {
|
||||||
|
bestOption = candidateOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(bestOption);
|
||||||
|
}
|
||||||
|
|
||||||
private SettlementDetail buildHuDetail(
|
private SettlementDetail buildHuDetail(
|
||||||
GameSeat winnerSeat,
|
GameSeat winnerSeat,
|
||||||
boolean robbingGang,
|
boolean robbingGang,
|
||||||
@@ -164,4 +182,35 @@ public class BloodBattleScoringService {
|
|||||||
case MING_GANG, BU_GANG, AN_GANG -> 4;
|
case MING_GANG, BU_GANG, AN_GANG -> 4;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Tile> buildAllTileCandidates() {
|
||||||
|
List<Tile> candidates = new ArrayList<>();
|
||||||
|
for (TileSuit tileSuit : TileSuit.values()) {
|
||||||
|
for (int rank = 1; rank <= 9; rank++) {
|
||||||
|
candidates.add(new Tile(tileSuit, rank));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameSeat copySeatWithClaimedTile(GameSeat seat, Tile claimedTile) {
|
||||||
|
GameSeat virtualSeat = new GameSeat(seat.getSeatNo(), seat.isAi(), seat.getPlayerId(), seat.getNickname());
|
||||||
|
virtualSeat.setLackSuit(seat.getLackSuit());
|
||||||
|
virtualSeat.setWon(seat.isWon());
|
||||||
|
virtualSeat.addScore(seat.getScore());
|
||||||
|
virtualSeat.getHandTiles().addAll(seat.getHandTiles());
|
||||||
|
virtualSeat.getMeldGroups().addAll(seat.getMeldGroups());
|
||||||
|
virtualSeat.receiveTile(claimedTile);
|
||||||
|
return virtualSeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBetterReadyHandOption(ReadyHandOption candidate, ReadyHandOption currentBest) {
|
||||||
|
if (candidate.settlementDetail().paymentScore() != currentBest.settlementDetail().paymentScore()) {
|
||||||
|
return candidate.settlementDetail().paymentScore() > currentBest.settlementDetail().paymentScore();
|
||||||
|
}
|
||||||
|
if (candidate.settlementDetail().totalFan() != currentBest.settlementDetail().totalFan()) {
|
||||||
|
return candidate.settlementDetail().totalFan() > currentBest.settlementDetail().totalFan();
|
||||||
|
}
|
||||||
|
return candidate.triggerTile().compareTo(currentBest.triggerTile()) < 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ 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.PostGangContext;
|
import com.xuezhanmaster.game.domain.PostGangContext;
|
||||||
|
import com.xuezhanmaster.game.domain.ReadyHandOption;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
||||||
|
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
|
||||||
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.ScoreChange;
|
||||||
|
import com.xuezhanmaster.game.domain.SettlementDetail;
|
||||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||||
|
import com.xuezhanmaster.game.domain.SettlementType;
|
||||||
import com.xuezhanmaster.game.domain.Tile;
|
import com.xuezhanmaster.game.domain.Tile;
|
||||||
import com.xuezhanmaster.game.domain.TileSuit;
|
import com.xuezhanmaster.game.domain.TileSuit;
|
||||||
import com.xuezhanmaster.game.dto.GameActionRequest;
|
import com.xuezhanmaster.game.dto.GameActionRequest;
|
||||||
@@ -35,9 +39,11 @@ import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,6 +55,7 @@ public class GameSessionService {
|
|||||||
private final ResponseActionWindowBuilder responseActionWindowBuilder;
|
private final ResponseActionWindowBuilder responseActionWindowBuilder;
|
||||||
private final ResponseActionResolver responseActionResolver;
|
private final ResponseActionResolver responseActionResolver;
|
||||||
private final SettlementService settlementService;
|
private final SettlementService settlementService;
|
||||||
|
private final BloodBattleScoringService scoringService;
|
||||||
private final HuEvaluator huEvaluator;
|
private final HuEvaluator huEvaluator;
|
||||||
private final StrategyService strategyService;
|
private final StrategyService strategyService;
|
||||||
private final PlayerVisibilityService playerVisibilityService;
|
private final PlayerVisibilityService playerVisibilityService;
|
||||||
@@ -63,6 +70,7 @@ public class GameSessionService {
|
|||||||
ResponseActionWindowBuilder responseActionWindowBuilder,
|
ResponseActionWindowBuilder responseActionWindowBuilder,
|
||||||
ResponseActionResolver responseActionResolver,
|
ResponseActionResolver responseActionResolver,
|
||||||
SettlementService settlementService,
|
SettlementService settlementService,
|
||||||
|
BloodBattleScoringService scoringService,
|
||||||
HuEvaluator huEvaluator,
|
HuEvaluator huEvaluator,
|
||||||
StrategyService strategyService,
|
StrategyService strategyService,
|
||||||
PlayerVisibilityService playerVisibilityService,
|
PlayerVisibilityService playerVisibilityService,
|
||||||
@@ -75,6 +83,7 @@ public class GameSessionService {
|
|||||||
this.responseActionWindowBuilder = responseActionWindowBuilder;
|
this.responseActionWindowBuilder = responseActionWindowBuilder;
|
||||||
this.responseActionResolver = responseActionResolver;
|
this.responseActionResolver = responseActionResolver;
|
||||||
this.settlementService = settlementService;
|
this.settlementService = settlementService;
|
||||||
|
this.scoringService = scoringService;
|
||||||
this.huEvaluator = huEvaluator;
|
this.huEvaluator = huEvaluator;
|
||||||
this.strategyService = strategyService;
|
this.strategyService = strategyService;
|
||||||
this.playerVisibilityService = playerVisibilityService;
|
this.playerVisibilityService = playerVisibilityService;
|
||||||
@@ -155,17 +164,16 @@ public class GameSessionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void moveToNextSeat(GameTable table, String gameId) {
|
private void moveToNextSeat(GameSession session) {
|
||||||
if (shouldFinishTable(table)) {
|
GameTable table = session.getTable();
|
||||||
table.setPhase(GamePhase.FINISHED);
|
if (finishTableIfNeeded(session)) {
|
||||||
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
|
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
|
||||||
if (nextSeatOptional.isEmpty()) {
|
if (nextSeatOptional.isEmpty()) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
table.setPhase(GamePhase.FINISHED);
|
||||||
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
|
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +181,8 @@ public class GameSessionService {
|
|||||||
Tile drawnTile = table.getWallTiles().remove(0);
|
Tile drawnTile = table.getWallTiles().remove(0);
|
||||||
nextSeat.receiveTile(drawnTile);
|
nextSeat.receiveTile(drawnTile);
|
||||||
table.setCurrentSeatNo(nextSeat.getSeatNo());
|
table.setCurrentSeatNo(nextSeat.getSeatNo());
|
||||||
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
|
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
|
||||||
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
|
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void autoPlayBots(GameSession session) {
|
private void autoPlayBots(GameSession session) {
|
||||||
@@ -182,7 +190,7 @@ public class GameSessionService {
|
|||||||
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()) {
|
if (currentSeat.isWon()) {
|
||||||
moveToNextSeat(table, session.getGameId());
|
moveToNextSeat(session);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!currentSeat.isAi()) {
|
if (!currentSeat.isAi()) {
|
||||||
@@ -248,7 +256,7 @@ public class GameSessionService {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
appendAndPublish(session, events);
|
appendAndPublish(session, events);
|
||||||
moveToNextSeat(table, session.getGameId());
|
moveToNextSeat(session);
|
||||||
if (table.getPhase() == GamePhase.FINISHED) {
|
if (table.getPhase() == GamePhase.FINISHED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -434,14 +442,14 @@ public class GameSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<ResponseActionResolution> resolution = responseActionResolver.resolve(
|
Optional<ResponseActionResolutionBatch> resolutionBatch = responseActionResolver.resolve(
|
||||||
responseActionWindow,
|
responseActionWindow,
|
||||||
session.getResponseActionSelections(),
|
session.getResponseActionSelections(),
|
||||||
session.getTable().getSeats().size()
|
session.getTable().getSeats().size()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resolution.isPresent()) {
|
if (resolutionBatch.isPresent()) {
|
||||||
executeResolvedResponseAction(session, responseActionWindow, resolution.get());
|
executeResolvedResponseAction(session, responseActionWindow, resolutionBatch.get());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +505,7 @@ public class GameSessionService {
|
|||||||
|
|
||||||
private void continueAfterDiscardWithoutResponse(GameSession session) {
|
private void continueAfterDiscardWithoutResponse(GameSession session) {
|
||||||
session.clearPostGangContext();
|
session.clearPostGangContext();
|
||||||
moveToNextSeat(session.getTable(), session.getGameId());
|
moveToNextSeat(session);
|
||||||
autoPlayBots(session);
|
autoPlayBots(session);
|
||||||
notifyActionIfHumanTurn(session);
|
notifyActionIfHumanTurn(session);
|
||||||
}
|
}
|
||||||
@@ -505,13 +513,13 @@ public class GameSessionService {
|
|||||||
private void executeResolvedResponseAction(
|
private void executeResolvedResponseAction(
|
||||||
GameSession session,
|
GameSession session,
|
||||||
ResponseActionWindow responseActionWindow,
|
ResponseActionWindow responseActionWindow,
|
||||||
ResponseActionResolution resolution
|
ResponseActionResolutionBatch resolutionBatch
|
||||||
) {
|
) {
|
||||||
closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType());
|
closeResponseWindowWithResolution(session, responseActionWindow, resolutionBatch.actionType());
|
||||||
switch (resolution.actionType()) {
|
switch (resolutionBatch.actionType()) {
|
||||||
case PENG -> executePeng(session, resolution);
|
case PENG -> executePeng(session, resolutionBatch.resolutions().get(0));
|
||||||
case GANG -> executeGang(session, resolution);
|
case GANG -> executeGang(session, resolutionBatch.resolutions().get(0));
|
||||||
case HU -> executeHu(session, responseActionWindow, resolution);
|
case HU -> executeHu(session, responseActionWindow, resolutionBatch.resolutions());
|
||||||
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
|
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,9 +584,7 @@ public class GameSessionService {
|
|||||||
claimedTile.getDisplayName()
|
claimedTile.getDisplayName()
|
||||||
));
|
));
|
||||||
|
|
||||||
if (table.getWallTiles().isEmpty()) {
|
if (finishTableIfNeeded(session)) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
|
||||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,55 +608,57 @@ public class GameSessionService {
|
|||||||
private void executeHu(
|
private void executeHu(
|
||||||
GameSession session,
|
GameSession session,
|
||||||
ResponseActionWindow responseActionWindow,
|
ResponseActionWindow responseActionWindow,
|
||||||
ResponseActionResolution resolution
|
List<ResponseActionResolution> resolutions
|
||||||
) {
|
) {
|
||||||
Tile claimedTile = parseTile(resolution.triggerTile());
|
ResponseActionResolution primaryResolution = resolutions.get(0);
|
||||||
|
Tile claimedTile = parseTile(primaryResolution.triggerTile());
|
||||||
GameTable table = session.getTable();
|
GameTable table = session.getTable();
|
||||||
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
|
GameSeat sourceSeat = table.getSeats().get(primaryResolution.sourceSeatNo());
|
||||||
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
|
|
||||||
|
|
||||||
winnerSeat.receiveTile(claimedTile);
|
|
||||||
if (isSupplementalGangWindow(responseActionWindow)) {
|
if (isSupplementalGangWindow(responseActionWindow)) {
|
||||||
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
|
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
|
||||||
} else {
|
} else {
|
||||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||||
}
|
}
|
||||||
winnerSeat.declareHu();
|
// 一炮多响时多个赢家共用同一张弃牌或补杠牌,牌源只移除一次,赢家逐个补牌并独立结算。
|
||||||
|
for (ResponseActionResolution resolution : resolutions) {
|
||||||
|
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
|
||||||
|
winnerSeat.receiveTile(claimedTile);
|
||||||
|
winnerSeat.declareHu();
|
||||||
|
|
||||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||||
session.getGameId(),
|
session.getGameId(),
|
||||||
GameEventType.HU_DECLARED,
|
GameEventType.HU_DECLARED,
|
||||||
winnerSeat.getSeatNo(),
|
|
||||||
sourceSeat.getSeatNo(),
|
|
||||||
claimedTile.getDisplayName()
|
|
||||||
));
|
|
||||||
// 牌墙已空时,这次点炮对应“最后一张牌后的弃牌胡”,按海底炮加番。
|
|
||||||
boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow)
|
|
||||||
&& isPostGangDiscard(session, sourceSeat.getSeatNo());
|
|
||||||
boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow)
|
|
||||||
&& table.getWallTiles().isEmpty();
|
|
||||||
if (isSupplementalGangWindow(responseActionWindow)) {
|
|
||||||
appendSettlementEvents(session, settlementService.settleRobbingGangHu(
|
|
||||||
table,
|
|
||||||
winnerSeat.getSeatNo(),
|
winnerSeat.getSeatNo(),
|
||||||
sourceSeat.getSeatNo(),
|
sourceSeat.getSeatNo(),
|
||||||
claimedTile.getDisplayName()
|
claimedTile.getDisplayName()
|
||||||
));
|
));
|
||||||
} else {
|
// 牌墙已空时,这次点炮对应“最后一张牌后的弃牌胡”,按海底炮加番。
|
||||||
|
boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow)
|
||||||
|
&& isPostGangDiscard(session, sourceSeat.getSeatNo());
|
||||||
|
boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow)
|
||||||
|
&& table.getWallTiles().isEmpty();
|
||||||
|
if (isSupplementalGangWindow(responseActionWindow)) {
|
||||||
|
appendSettlementEvents(session, settlementService.settleRobbingGangHu(
|
||||||
|
table,
|
||||||
|
winnerSeat.getSeatNo(),
|
||||||
|
sourceSeat.getSeatNo(),
|
||||||
|
claimedTile.getDisplayName()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
appendSettlementEvents(session, settlementService.settleDiscardHu(
|
appendSettlementEvents(session, settlementService.settleDiscardHu(
|
||||||
table,
|
table,
|
||||||
winnerSeat.getSeatNo(),
|
winnerSeat.getSeatNo(),
|
||||||
sourceSeat.getSeatNo(),
|
sourceSeat.getSeatNo(),
|
||||||
claimedTile.getDisplayName(),
|
claimedTile.getDisplayName(),
|
||||||
gangShangPao,
|
gangShangPao,
|
||||||
haiDiPao
|
haiDiPao
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
session.clearPostGangContext();
|
session.clearPostGangContext();
|
||||||
|
|
||||||
if (shouldFinishTable(table)) {
|
if (finishTableIfNeeded(session)) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
|
||||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,13 +689,11 @@ public class GameSessionService {
|
|||||||
));
|
));
|
||||||
session.clearPostGangContext();
|
session.clearPostGangContext();
|
||||||
|
|
||||||
if (shouldFinishTable(table)) {
|
if (finishTableIfNeeded(session)) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
|
||||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveToNextSeat(table, session.getGameId());
|
moveToNextSeat(session);
|
||||||
autoPlayBots(session);
|
autoPlayBots(session);
|
||||||
notifyActionIfHumanTurn(session);
|
notifyActionIfHumanTurn(session);
|
||||||
}
|
}
|
||||||
@@ -714,9 +720,7 @@ public class GameSessionService {
|
|||||||
gangTile.getDisplayName()
|
gangTile.getDisplayName()
|
||||||
));
|
));
|
||||||
|
|
||||||
if (table.getWallTiles().isEmpty()) {
|
if (finishTableIfNeeded(session)) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
|
||||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,9 +780,7 @@ public class GameSessionService {
|
|||||||
gangTile.getDisplayName()
|
gangTile.getDisplayName()
|
||||||
));
|
));
|
||||||
|
|
||||||
if (table.getWallTiles().isEmpty()) {
|
if (finishTableIfNeeded(session)) {
|
||||||
table.setPhase(GamePhase.FINISHED);
|
|
||||||
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +818,7 @@ public class GameSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
|
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
|
||||||
|
session.getSettlementHistory().add(settlementResult);
|
||||||
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
|
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
|
||||||
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
|
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
|
||||||
appendAndPublish(session, GameEvent.scoreChanged(
|
appendAndPublish(session, GameEvent.scoreChanged(
|
||||||
@@ -828,6 +831,99 @@ public class GameSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean finishTableIfNeeded(GameSession session) {
|
||||||
|
GameTable table = session.getTable();
|
||||||
|
if (!shouldFinishTable(table)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 血战流局场景下,牌墙耗尽但仍有多人未胡,需要先做退税和查叫,再真正结束牌局。
|
||||||
|
if (table.getWallTiles().isEmpty() && countActiveSeats(table) > 1) {
|
||||||
|
applyDrawEndSettlements(session);
|
||||||
|
}
|
||||||
|
session.clearPostGangContext();
|
||||||
|
table.setPhase(GamePhase.FINISHED);
|
||||||
|
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyDrawEndSettlements(GameSession session) {
|
||||||
|
Map<Integer, ReadyHandOption> readyHandOptions = buildReadyHandOptions(session.getTable());
|
||||||
|
Set<Integer> notReadySeatNos = session.getTable().getSeats().stream()
|
||||||
|
.filter(seat -> !seat.isWon())
|
||||||
|
.map(GameSeat::getSeatNo)
|
||||||
|
.filter(seatNo -> !readyHandOptions.containsKey(seatNo))
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|
||||||
|
applyTaxRefunds(session, notReadySeatNos);
|
||||||
|
applyChaJiao(session, readyHandOptions, notReadySeatNos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Integer, ReadyHandOption> buildReadyHandOptions(GameTable table) {
|
||||||
|
Map<Integer, ReadyHandOption> readyHandOptions = new LinkedHashMap<>();
|
||||||
|
for (GameSeat seat : table.getSeats()) {
|
||||||
|
if (seat.isWon()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scoringService.findBestReadyHandOption(seat)
|
||||||
|
.ifPresent(option -> readyHandOptions.put(seat.getSeatNo(), option));
|
||||||
|
}
|
||||||
|
return readyHandOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyTaxRefunds(GameSession session, Set<Integer> notReadySeatNos) {
|
||||||
|
if (notReadySeatNos.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 退税只针对未听牌玩家此前“已经拿到手”的杠分收入,按原支付关系逐笔退回。
|
||||||
|
for (SettlementResult settlementResult : List.copyOf(session.getSettlementHistory())) {
|
||||||
|
if (!isGangSettlement(settlementResult) || !notReadySeatNos.contains(settlementResult.actorSeatNo())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
|
||||||
|
if (scoreChange.delta() >= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendSettlementEvents(session, settlementService.settleTaxRefund(
|
||||||
|
session.getTable(),
|
||||||
|
scoreChange.seatNo(),
|
||||||
|
settlementResult.actorSeatNo(),
|
||||||
|
-scoreChange.delta(),
|
||||||
|
settlementResult.triggerTile()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyChaJiao(
|
||||||
|
GameSession session,
|
||||||
|
Map<Integer, ReadyHandOption> readyHandOptions,
|
||||||
|
Set<Integer> notReadySeatNos
|
||||||
|
) {
|
||||||
|
if (notReadySeatNos.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 查叫按“未听牌向所有听牌玩家分别赔付其最大理论点炮值”处理,先不做花猪等额外分支。
|
||||||
|
for (Integer notReadySeatNo : notReadySeatNos) {
|
||||||
|
for (Map.Entry<Integer, ReadyHandOption> entry : readyHandOptions.entrySet()) {
|
||||||
|
ReadyHandOption readyHandOption = entry.getValue();
|
||||||
|
SettlementDetail settlementDetail = readyHandOption.settlementDetail();
|
||||||
|
appendSettlementEvents(session, settlementService.settleChaJiao(
|
||||||
|
session.getTable(),
|
||||||
|
entry.getKey(),
|
||||||
|
notReadySeatNo,
|
||||||
|
readyHandOption.triggerTile(),
|
||||||
|
settlementDetail
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGangSettlement(SettlementResult settlementResult) {
|
||||||
|
return settlementResult.settlementType() == SettlementType.MING_GANG
|
||||||
|
|| settlementResult.settlementType() == SettlementType.BU_GANG
|
||||||
|
|| settlementResult.settlementType() == SettlementType.AN_GANG;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldFinishTable(GameTable table) {
|
private boolean shouldFinishTable(GameTable table) {
|
||||||
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
|
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,52 @@ package com.xuezhanmaster.game.service;
|
|||||||
|
|
||||||
import com.xuezhanmaster.game.domain.ActionType;
|
import com.xuezhanmaster.game.domain.ActionType;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
import com.xuezhanmaster.game.domain.ResponseActionResolution;
|
||||||
|
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ResponseActionResolver {
|
public class ResponseActionResolver {
|
||||||
|
|
||||||
public Optional<ResponseActionResolution> resolve(ResponseActionWindow window, Map<Integer, ActionType> selections, int seatCount) {
|
public Optional<ResponseActionResolutionBatch> resolve(
|
||||||
return selections.entrySet().stream()
|
ResponseActionWindow window,
|
||||||
.filter(entry -> entry.getValue() != ActionType.PASS)
|
Map<Integer, ActionType> selections,
|
||||||
.min(Comparator
|
int seatCount
|
||||||
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
|
) {
|
||||||
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
|
// 只要有胡牌声明,就按一炮多响返回全部胡牌赢家,其它动作全部失效。
|
||||||
|
List<ResponseActionResolution> huResolutions = selections.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue() == ActionType.HU)
|
||||||
|
.sorted(Comparator.comparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
|
||||||
.map(entry -> new ResponseActionResolution(
|
.map(entry -> new ResponseActionResolution(
|
||||||
entry.getValue(),
|
entry.getValue(),
|
||||||
entry.getKey(),
|
entry.getKey(),
|
||||||
window.sourceSeatNo(),
|
window.sourceSeatNo(),
|
||||||
window.triggerTile()
|
window.triggerTile()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
if (!huResolutions.isEmpty()) {
|
||||||
|
// 一炮多响时保留所有胡牌赢家,后续由执行层逐个结算。
|
||||||
|
return Optional.of(new ResponseActionResolutionBatch(ActionType.HU, huResolutions));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ResponseActionResolutionBatch(
|
||||||
|
entry.getValue(),
|
||||||
|
List.of(new ResponseActionResolution(
|
||||||
|
entry.getValue(),
|
||||||
|
entry.getKey(),
|
||||||
|
window.sourceSeatNo(),
|
||||||
|
window.triggerTile()
|
||||||
|
))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,49 @@ public class SettlementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SettlementResult settleTaxRefund(
|
||||||
|
GameTable table,
|
||||||
|
int recipientSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
int refundAmount,
|
||||||
|
String triggerTile
|
||||||
|
) {
|
||||||
|
return applySettlement(
|
||||||
|
table,
|
||||||
|
SettlementType.TUI_SHUI,
|
||||||
|
ActionType.GANG,
|
||||||
|
recipientSeatNo,
|
||||||
|
sourceSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
buildFlatDetail(refundAmount),
|
||||||
|
orderedDeltas(recipientSeatNo, refundAmount, sourceSeatNo, -refundAmount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettlementResult settleChaJiao(
|
||||||
|
GameTable table,
|
||||||
|
int winnerSeatNo,
|
||||||
|
int sourceSeatNo,
|
||||||
|
String triggerTile,
|
||||||
|
SettlementDetail settlementDetail
|
||||||
|
) {
|
||||||
|
return applySettlement(
|
||||||
|
table,
|
||||||
|
SettlementType.CHA_JIAO,
|
||||||
|
ActionType.HU,
|
||||||
|
winnerSeatNo,
|
||||||
|
sourceSeatNo,
|
||||||
|
triggerTile,
|
||||||
|
settlementDetail,
|
||||||
|
orderedDeltas(
|
||||||
|
winnerSeatNo,
|
||||||
|
settlementDetail.paymentScore(),
|
||||||
|
sourceSeatNo,
|
||||||
|
-settlementDetail.paymentScore()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private SettlementResult applySettlement(
|
private SettlementResult applySettlement(
|
||||||
GameTable table,
|
GameTable table,
|
||||||
SettlementType settlementType,
|
SettlementType settlementType,
|
||||||
@@ -228,4 +271,8 @@ public class SettlementService {
|
|||||||
deltas.put(sourceSeatNo, sourceDelta);
|
deltas.put(sourceSeatNo, sourceDelta);
|
||||||
return deltas;
|
return deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SettlementDetail buildFlatDetail(int paymentScore) {
|
||||||
|
return new SettlementDetail(1, 0, paymentScore, List.of());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.xuezhanmaster.game.domain.GameSeat;
|
|||||||
import com.xuezhanmaster.game.domain.GameSession;
|
import com.xuezhanmaster.game.domain.GameSession;
|
||||||
import com.xuezhanmaster.game.domain.Tile;
|
import com.xuezhanmaster.game.domain.Tile;
|
||||||
import com.xuezhanmaster.game.dto.GameActionRequest;
|
import com.xuezhanmaster.game.dto.GameActionRequest;
|
||||||
|
import com.xuezhanmaster.game.domain.SettlementType;
|
||||||
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;
|
||||||
@@ -42,6 +43,7 @@ class GameSessionServiceTest {
|
|||||||
new ResponseActionWindowBuilder(huEvaluator),
|
new ResponseActionWindowBuilder(huEvaluator),
|
||||||
new ResponseActionResolver(),
|
new ResponseActionResolver(),
|
||||||
new SettlementService(bloodBattleScoringService),
|
new SettlementService(bloodBattleScoringService),
|
||||||
|
bloodBattleScoringService,
|
||||||
huEvaluator,
|
huEvaluator,
|
||||||
new StrategyService(),
|
new StrategyService(),
|
||||||
new PlayerVisibilityService(),
|
new PlayerVisibilityService(),
|
||||||
@@ -644,6 +646,124 @@ class GameSessionServiceTest {
|
|||||||
assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO");
|
assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRefundGangScoresForNotReadySeatWhenWallExhausted() {
|
||||||
|
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());
|
||||||
|
prepareTaxRefundGangHand(session.getTable().getSeats().get(0), "3万");
|
||||||
|
setNextWallTile(session, "9万");
|
||||||
|
|
||||||
|
GameStateResponse afterGang = gameSessionService.performAction(
|
||||||
|
started.gameId(),
|
||||||
|
new GameActionRequest("host-1", "GANG", "3万", null)
|
||||||
|
);
|
||||||
|
session.getTable().getWallTiles().clear();
|
||||||
|
removeMatchingTilesFromOtherSeats(session, "9万", 0);
|
||||||
|
|
||||||
|
GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "9万");
|
||||||
|
|
||||||
|
assertThat(afterGang.selfSeat().score()).isEqualTo(6);
|
||||||
|
assertThat(afterDiscard.phase()).isEqualTo("FINISHED");
|
||||||
|
assertThat(settlementResultsOfType(session, SettlementType.TUI_SHUI)).hasSize(3);
|
||||||
|
assertThat(afterDiscard.seats()).extracting(seat -> seat.score()).contains(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldApplyChaJiaoFromNotReadySeatToReadySeatWhenWallExhausted() {
|
||||||
|
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());
|
||||||
|
prepareNotReadyHand(session.getTable().getSeats().get(0));
|
||||||
|
prepareWinningHand(session.getTable().getSeats().get(1));
|
||||||
|
session.getTable().getSeats().get(2).declareHu();
|
||||||
|
session.getTable().getSeats().get(3).declareHu();
|
||||||
|
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "1万", 1);
|
||||||
|
removeMatchingTilesFromOtherSeats(session, "1万", 0);
|
||||||
|
session.getTable().getWallTiles().clear();
|
||||||
|
|
||||||
|
GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "1万");
|
||||||
|
|
||||||
|
assertThat(afterDiscard.phase()).isEqualTo("FINISHED");
|
||||||
|
assertThat(afterDiscard.seats().get(0).score()).isEqualTo(-1);
|
||||||
|
assertThat(afterDiscard.seats().get(1).score()).isEqualTo(1);
|
||||||
|
assertThat(settlementResultsOfType(session, SettlementType.CHA_JIAO)).hasSize(1);
|
||||||
|
assertThat(latestSettlementFanCodes(session)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAllowMultipleHuWinnersOnSingleDiscard() {
|
||||||
|
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));
|
||||||
|
prepareWinningHand(session.getTable().getSeats().get(2));
|
||||||
|
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "9筒", 1);
|
||||||
|
removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1, 2);
|
||||||
|
|
||||||
|
gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
|
||||||
|
GameStateResponse afterHu = gameSessionService.performAction(
|
||||||
|
started.gameId(),
|
||||||
|
new GameActionRequest("player-2", "HU", "9筒", 0)
|
||||||
|
);
|
||||||
|
afterHu = gameSessionService.performAction(
|
||||||
|
started.gameId(),
|
||||||
|
new GameActionRequest("player-3", "HU", "9筒", 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(session.getPendingResponseActionWindow()).isNull();
|
||||||
|
assertThat(afterHu.phase()).isEqualTo("PLAYING");
|
||||||
|
assertThat(afterHu.currentSeatNo()).isEqualTo(3);
|
||||||
|
assertThat(afterHu.seats().get(0).score()).isEqualTo(-2);
|
||||||
|
assertThat(afterHu.seats().get(1).won()).isTrue();
|
||||||
|
assertThat(afterHu.seats().get(2).won()).isTrue();
|
||||||
|
assertThat(afterHu.seats().get(1).score()).isEqualTo(1);
|
||||||
|
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
|
||||||
|
assertThat(session.getEvents())
|
||||||
|
.extracting(event -> event.eventType())
|
||||||
|
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
|
||||||
|
assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
|
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
|
||||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||||
@@ -814,6 +934,41 @@ class GameSessionServiceTest {
|
|||||||
seat.receiveTile(new Tile(TileSuit.TONG, 9));
|
seat.receiveTile(new Tile(TileSuit.TONG, 9));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void prepareTaxRefundGangHand(GameSeat seat, String gangTileDisplayName) {
|
||||||
|
seat.getHandTiles().clear();
|
||||||
|
Tile gangTile = parseTile(gangTileDisplayName);
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
seat.receiveTile(gangTile);
|
||||||
|
}
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 1));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 4));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 7));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 2));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 5));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 8));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 1));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 4));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void prepareNotReadyHand(GameSeat seat) {
|
||||||
|
seat.getHandTiles().clear();
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 2));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 5));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 8));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 1));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 4));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 7));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 2));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 5));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 9));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TONG, 9));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
|
||||||
|
seat.receiveTile(new Tile(TileSuit.WAN, 3));
|
||||||
|
}
|
||||||
|
|
||||||
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
|
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
|
||||||
seat.getHandTiles().clear();
|
seat.getHandTiles().clear();
|
||||||
seat.getMeldGroups().clear();
|
seat.getMeldGroups().clear();
|
||||||
@@ -836,6 +991,13 @@ class GameSessionServiceTest {
|
|||||||
session.getTable().getWallTiles().set(0, parseTile(tileDisplayName));
|
session.getTable().getWallTiles().set(0, parseTile(tileDisplayName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private java.util.List<SettlementType> settlementResultsOfType(GameSession session, SettlementType settlementType) {
|
||||||
|
return session.getSettlementHistory().stream()
|
||||||
|
.map(settlementResult -> settlementResult.settlementType())
|
||||||
|
.filter(type -> type == settlementType)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private java.util.List<String> latestSettlementFanCodes(GameSession session) {
|
private java.util.List<String> latestSettlementFanCodes(GameSession session) {
|
||||||
return session.getEvents().stream()
|
return session.getEvents().stream()
|
||||||
|
|||||||
@@ -234,10 +234,11 @@
|
|||||||
|
|
||||||
### 5.3 注释与脚本约定
|
### 5.3 注释与脚本约定
|
||||||
|
|
||||||
- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名猜语义。
|
- 后端新增或修改的业务代码,必须为复杂规则、关键字段、状态切换、结算口径和跨阶段流程补充适量中文注释,不能只保留方法名级别语义。
|
||||||
- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。
|
- 前端新增或修改的页面、组件与状态逻辑,必须为复杂交互、实时消息消费、动作面板联动和视图状态切换补充适量中文注释,避免后续拆页或联调时理解断层。
|
||||||
- 后续数据库表结构、迁移脚本、初始化 SQL 与存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因与关键索引用途。
|
- 后续数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,也必须补充必要中文注释,重点说明业务含义、约束原因、字段口径、回填策略和关键索引用途。
|
||||||
- 注释要求遵守 `KISS`:只解释不直观的意图、约束和边界,不写“变量赋值”这类冗余注释。
|
- 注释要求遵守 `KISS`:可以适当多写,但只解释不直观的意图、约束、边界和取舍,不写“变量赋值”这类冗余注释。
|
||||||
|
- 对以下高风险区域,中文注释默认视为必需项:麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本。
|
||||||
|
|
||||||
### 5.2 最关键的系统约束
|
### 5.2 最关键的系统约束
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user