feat(结算): 实现血战麻将查叫与退税功能

新增局终处理逻辑,当牌墙耗尽且有多家未胡时:
1. 退税:未听牌玩家需退还此前杠分收入
2. 查叫:未听牌玩家向听牌玩家赔付最大理论点炮值
新增 SettlementType.TUI_SHUI 和 SettlementType.CHA_JIAO 结算类型
新增 ReadyHandOption 记录最优听牌选项

支持一炮多响裁决,新增 ResponseActionResolutionBatch 承载多赢家结果
在 GameSession 中新增 settlementHistory 保留结算记录供复用
更新开发文档要求加强关键区域的中文注释
This commit is contained in:
hujun
2026-03-20 15:05:00 +08:00
parent 34809fd0f3
commit b84d0e8980
11 changed files with 501 additions and 78 deletions

View File

@@ -16,6 +16,8 @@ public class GameSession {
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
// 保留已应用结算结果,供局终退税与查叫直接复用,避免从事件载荷反推。
private final List<SettlementResult> settlementHistory;
private ResponseActionWindow pendingResponseActionWindow;
// 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。
private PostGangContext postGangContext;
@@ -27,6 +29,7 @@ public class GameSession {
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
this.settlementHistory = new ArrayList<>();
this.responseActionSelections = new LinkedHashMap<>();
}
@@ -50,6 +53,10 @@ public class GameSession {
return events;
}
public List<SettlementResult> getSettlementHistory() {
return settlementHistory;
}
public ResponseActionWindow getPendingResponseActionWindow() {
return pendingResponseActionWindow;
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public record ReadyHandOption(
String triggerTile,
SettlementDetail settlementDetail
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record ResponseActionResolutionBatch(
ActionType actionType,
List<ResponseActionResolution> resolutions
) {
}

View File

@@ -6,5 +6,7 @@ public enum SettlementType {
ZI_MO_HU,
BU_GANG,
MING_GANG,
AN_GANG
AN_GANG,
TUI_SHUI,
CHA_JIAO
}

View File

@@ -3,6 +3,7 @@ package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.MeldGroup;
import com.xuezhanmaster.game.domain.MeldType;
import com.xuezhanmaster.game.domain.ReadyHandOption;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.Tile;
@@ -13,6 +14,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Component
public class BloodBattleScoringService {
@@ -61,6 +63,22 @@ public class BloodBattleScoringService {
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(
GameSeat winnerSeat,
boolean robbingGang,
@@ -164,4 +182,35 @@ public class BloodBattleScoringService {
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;
}
}

View File

@@ -7,11 +7,15 @@ import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.PostGangContext;
import com.xuezhanmaster.game.domain.ReadyHandOption;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
@@ -35,9 +39,11 @@ import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Service
@@ -49,6 +55,7 @@ public class GameSessionService {
private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final ResponseActionResolver responseActionResolver;
private final SettlementService settlementService;
private final BloodBattleScoringService scoringService;
private final HuEvaluator huEvaluator;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
@@ -63,6 +70,7 @@ public class GameSessionService {
ResponseActionWindowBuilder responseActionWindowBuilder,
ResponseActionResolver responseActionResolver,
SettlementService settlementService,
BloodBattleScoringService scoringService,
HuEvaluator huEvaluator,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
@@ -75,6 +83,7 @@ public class GameSessionService {
this.responseActionWindowBuilder = responseActionWindowBuilder;
this.responseActionResolver = responseActionResolver;
this.settlementService = settlementService;
this.scoringService = scoringService;
this.huEvaluator = huEvaluator;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
@@ -155,17 +164,16 @@ public class GameSessionService {
);
}
private void moveToNextSeat(GameTable table, String gameId) {
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
private void moveToNextSeat(GameSession session) {
GameTable table = session.getTable();
if (finishTableIfNeeded(session)) {
return;
}
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
if (nextSeatOptional.isEmpty()) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
@@ -173,8 +181,8 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeat.getSeatNo());
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
}
private void autoPlayBots(GameSession session) {
@@ -182,7 +190,7 @@ public class GameSessionService {
while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (currentSeat.isWon()) {
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
continue;
}
if (!currentSeat.isAi()) {
@@ -248,7 +256,7 @@ public class GameSessionService {
)
);
appendAndPublish(session, events);
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
if (table.getPhase() == GamePhase.FINISHED) {
return;
}
@@ -434,14 +442,14 @@ public class GameSessionService {
return;
}
Optional<ResponseActionResolution> resolution = responseActionResolver.resolve(
Optional<ResponseActionResolutionBatch> resolutionBatch = responseActionResolver.resolve(
responseActionWindow,
session.getResponseActionSelections(),
session.getTable().getSeats().size()
);
if (resolution.isPresent()) {
executeResolvedResponseAction(session, responseActionWindow, resolution.get());
if (resolutionBatch.isPresent()) {
executeResolvedResponseAction(session, responseActionWindow, resolutionBatch.get());
return;
}
@@ -497,7 +505,7 @@ public class GameSessionService {
private void continueAfterDiscardWithoutResponse(GameSession session) {
session.clearPostGangContext();
moveToNextSeat(session.getTable(), session.getGameId());
moveToNextSeat(session);
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
@@ -505,13 +513,13 @@ public class GameSessionService {
private void executeResolvedResponseAction(
GameSession session,
ResponseActionWindow responseActionWindow,
ResponseActionResolution resolution
ResponseActionResolutionBatch resolutionBatch
) {
closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType());
switch (resolution.actionType()) {
case PENG -> executePeng(session, resolution);
case GANG -> executeGang(session, resolution);
case HU -> executeHu(session, responseActionWindow, resolution);
closeResponseWindowWithResolution(session, responseActionWindow, resolutionBatch.actionType());
switch (resolutionBatch.actionType()) {
case PENG -> executePeng(session, resolutionBatch.resolutions().get(0));
case GANG -> executeGang(session, resolutionBatch.resolutions().get(0));
case HU -> executeHu(session, responseActionWindow, resolutionBatch.resolutions());
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
}
}
@@ -576,9 +584,7 @@ public class GameSessionService {
claimedTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -602,55 +608,57 @@ public class GameSessionService {
private void executeHu(
GameSession session,
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();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
GameSeat sourceSeat = table.getSeats().get(primaryResolution.sourceSeatNo());
winnerSeat.receiveTile(claimedTile);
if (isSupplementalGangWindow(responseActionWindow)) {
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
} else {
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(
session.getGameId(),
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,
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.HU_DECLARED,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
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(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName(),
gangShangPao,
haiDiPao
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName(),
gangShangPao,
haiDiPao
));
}
session.clearPostGangContext();
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -681,13 +689,11 @@ public class GameSessionService {
));
session.clearPostGangContext();
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
moveToNextSeat(table, session.getGameId());
moveToNextSeat(session);
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
@@ -714,9 +720,7 @@ public class GameSessionService {
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -776,9 +780,7 @@ public class GameSessionService {
gangTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
if (finishTableIfNeeded(session)) {
return;
}
@@ -816,6 +818,7 @@ public class GameSessionService {
}
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
session.getSettlementHistory().add(settlementResult);
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
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) {
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
}

View File

@@ -2,27 +2,52 @@ package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionResolutionBatch;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
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)))
public Optional<ResponseActionResolutionBatch> resolve(
ResponseActionWindow window,
Map<Integer, ActionType> selections,
int 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(
entry.getValue(),
entry.getKey(),
window.sourceSeatNo(),
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()
))
));
}

View File

@@ -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(
GameTable table,
SettlementType settlementType,
@@ -228,4 +271,8 @@ public class SettlementService {
deltas.put(sourceSeatNo, sourceDelta);
return deltas;
}
private SettlementDetail buildFlatDetail(int paymentScore) {
return new SettlementDetail(1, 0, paymentScore, List.of());
}
}

View File

@@ -5,6 +5,7 @@ import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.domain.SettlementType;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.service.GameActionProcessor;
@@ -42,6 +43,7 @@ class GameSessionServiceTest {
new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(),
new SettlementService(bloodBattleScoringService),
bloodBattleScoringService,
huEvaluator,
new StrategyService(),
new PlayerVisibilityService(),
@@ -644,6 +646,124 @@ class GameSessionServiceTest {
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
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
@@ -814,6 +934,41 @@ class GameSessionServiceTest {
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) {
seat.getHandTiles().clear();
seat.getMeldGroups().clear();
@@ -836,6 +991,13 @@ class GameSessionServiceTest {
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")
private java.util.List<String> latestSettlementFanCodes(GameSession session) {
return session.getEvents().stream()