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

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

View File

@@ -13,6 +13,8 @@ public class GameSeat {
private final List<Tile> handTiles = new ArrayList<>();
private final List<Tile> discardTiles = new ArrayList<>();
private TileSuit lackSuit;
private boolean won;
private int score;
public GameSeat(int seatNo, boolean ai, String nickname) {
this(seatNo, ai, UUID.randomUUID().toString(), nickname);
@@ -57,6 +59,22 @@ public class GameSeat {
this.lackSuit = lackSuit;
}
public boolean isWon() {
return won;
}
public void setWon(boolean won) {
this.won = won;
}
public int getScore() {
return score;
}
public void addScore(int delta) {
this.score += delta;
}
public void receiveTile(Tile tile) {
handTiles.add(tile);
}
@@ -65,4 +83,31 @@ public class GameSeat {
handTiles.remove(tile);
discardTiles.add(tile);
}
public void removeMatchingHandTiles(Tile tile, int count) {
int remaining = count;
for (int i = handTiles.size() - 1; i >= 0 && remaining > 0; i--) {
if (handTiles.get(i).equals(tile)) {
handTiles.remove(i);
remaining--;
}
}
if (remaining > 0) {
throw new IllegalStateException("手牌中可移除的匹配牌数量不足");
}
}
public void removeMatchingDiscardTile(Tile tile) {
for (int i = discardTiles.size() - 1; i >= 0; i--) {
if (discardTiles.get(i).equals(tile)) {
discardTiles.remove(i);
return;
}
}
throw new IllegalStateException("弃牌区不存在指定牌");
}
public void declareHu() {
this.won = true;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record SettlementResult(
SettlementType settlementType,
ActionType actionType,
int actorSeatNo,
int sourceSeatNo,
String triggerTile,
List<ScoreChange> scoreChanges
) {
}

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
package com.xuezhanmaster.game.event;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementResult;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record GameEvent(
@@ -70,7 +75,7 @@ public record GameEvent(
String gameId,
GameEventType eventType,
int seatNo,
int sourceSeatNo,
Integer sourceSeatNo,
String tile
) {
return of(gameId, eventType, seatNo, buildActionPayload(
@@ -80,10 +85,37 @@ public record GameEvent(
));
}
private static Map<String, Object> buildActionPayload(String actionType, int sourceSeatNo, String tile) {
public static GameEvent settlementApplied(String gameId, SettlementResult settlementResult) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("settlementType", settlementResult.settlementType().name());
payload.put("actionType", settlementResult.actionType().name());
payload.put("actorSeatNo", settlementResult.actorSeatNo());
payload.put("sourceSeatNo", settlementResult.sourceSeatNo());
payload.put("triggerTile", settlementResult.triggerTile());
payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges()));
return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload);
}
public static GameEvent scoreChanged(
String gameId,
int seatNo,
String settlementType,
int delta,
int score
) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("settlementType", settlementType);
payload.put("delta", delta);
payload.put("score", score);
return of(gameId, GameEventType.SCORE_CHANGED, seatNo, payload);
}
private static Map<String, Object> buildActionPayload(String actionType, Integer sourceSeatNo, String tile) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("actionType", actionType);
payload.put("sourceSeatNo", sourceSeatNo);
if (sourceSeatNo != null) {
payload.put("sourceSeatNo", sourceSeatNo);
}
if (tile != null && !tile.isBlank()) {
payload.put("tile", tile);
}
@@ -99,4 +131,16 @@ public record GameEvent(
default -> eventType.name();
};
}
private static List<Map<String, Object>> toScoreChangePayload(List<ScoreChange> scoreChanges) {
List<Map<String, Object>> payload = new ArrayList<>();
for (ScoreChange scoreChange : scoreChanges) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("seatNo", scoreChange.seatNo());
item.put("delta", scoreChange.delta());
item.put("afterScore", scoreChange.afterScore());
payload.add(item);
}
return payload;
}
}

View File

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

View File

@@ -21,6 +21,12 @@ import java.util.List;
@Component
public class GameActionProcessor {
private final HuEvaluator huEvaluator;
public GameActionProcessor(HuEvaluator huEvaluator) {
this.huEvaluator = huEvaluator;
}
public List<GameEvent> process(GameSession session, GameActionRequest request) {
ActionType actionType = parseActionType(request.actionType());
return switch (actionType) {
@@ -38,21 +44,25 @@ public class GameActionProcessor {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.PENG);
return List.of();
}
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.GANG);
return List.of();
}
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
if (sourceSeatNo == null) {
validateSelfDrawHu(session, table, seat);
return List.of();
}
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU);
return List.of();
}
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
@@ -93,6 +103,9 @@ public class GameActionProcessor {
}
GameSeat seat = findSeatByUserId(table, userId);
if (seat.isWon()) {
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能继续出牌");
}
if (seat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合");
}
@@ -188,6 +201,24 @@ public class GameActionProcessor {
}
}
private void validateSelfDrawHu(GameSession session, GameTable table, GameSeat actorSeat) {
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许自摸胡");
}
if (actorSeat.isWon()) {
throw new BusinessException("GAME_SEAT_ALREADY_WON", "已胡玩家不能再次胡牌");
}
if (actorSeat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的回合,不能自摸胡");
}
if (session.getPendingResponseActionWindow() != null) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "响应窗口期间不能执行自摸胡");
}
if (!huEvaluator.canHu(actorSeat.getHandTiles())) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前手牌不满足自摸胡条件");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}

View File

@@ -6,9 +6,13 @@ import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.PublicSeatView;
@@ -26,6 +30,7 @@ import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -41,6 +46,9 @@ public class GameSessionService {
private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor;
private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final ResponseActionResolver responseActionResolver;
private final SettlementService settlementService;
private final HuEvaluator huEvaluator;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
@@ -52,6 +60,9 @@ public class GameSessionService {
GameEngine gameEngine,
GameActionProcessor gameActionProcessor,
ResponseActionWindowBuilder responseActionWindowBuilder,
ResponseActionResolver responseActionResolver,
SettlementService settlementService,
HuEvaluator huEvaluator,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService,
@@ -61,6 +72,9 @@ public class GameSessionService {
this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor;
this.responseActionWindowBuilder = responseActionWindowBuilder;
this.responseActionResolver = responseActionResolver;
this.settlementService = settlementService;
this.huEvaluator = huEvaluator;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
@@ -129,7 +143,9 @@ public class GameSessionService {
self.getSeatNo(),
self.getPlayerId(),
self.getNickname(),
self.isWon(),
self.getLackSuit() == null ? null : self.getLackSuit().name(),
self.getScore(),
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
),
@@ -138,29 +154,62 @@ public class GameSessionService {
}
private void moveToNextSeat(GameTable table, String gameId) {
int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size();
GameSeat nextSeat = table.getSeats().get(nextSeatNo);
if (table.getWallTiles().isEmpty()) {
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
return;
}
Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
if (nextSeatOptional.isEmpty()) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
return;
}
GameSeat nextSeat = nextSeatOptional.get();
Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeatNo);
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo));
table.setCurrentSeatNo(nextSeat.getSeatNo());
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeat.getSeatNo()));
}
private void autoPlayBots(GameSession session) {
GameTable table = session.getTable();
while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (currentSeat.isWon()) {
moveToNextSeat(table, session.getGameId());
continue;
}
if (!currentSeat.isAi()) {
return;
}
if (canSelfDrawHu(currentSeat)) {
List<GameEvent> huEvents = gameActionProcessor.process(
session,
new GameActionRequest(
currentSeat.getPlayerId(),
"HU",
null,
null
)
);
appendAndPublish(session, huEvents);
handlePostActionEffects(
session,
ActionType.HU,
new GameActionRequest(currentSeat.getPlayerId(), "HU", null, null),
huEvents
);
if (table.getPhase() == GamePhase.FINISHED) {
return;
}
continue;
}
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
List<GameEvent> events = gameActionProcessor.process(
@@ -197,14 +246,14 @@ public class GameSessionService {
}
GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo());
if (currentSeat.isAi()) {
if (currentSeat.isAi() || currentSeat.isWon()) {
return;
}
gameMessagePublisher.publishPrivateTurnActionRequired(
gameMessagePublisher.publishPrivateTurnActionCandidates(
session.getGameId(),
currentSeat.getPlayerId(),
List.of("DISCARD"),
buildTurnActionCandidates(currentSeat),
currentSeat.getSeatNo()
);
@@ -234,6 +283,9 @@ public class GameSessionService {
}
case DISCARD -> handleDiscardPostAction(session, request, events);
case PASS -> handlePassPostAction(session, request);
case PENG -> handleResponseDeclarationPostAction(session, actionType, request);
case GANG -> handleGangPostAction(session, request);
case HU -> handleHuPostAction(session, request);
default -> {
// 其余动作在后续 Sprint 中补充对应副作用
}
@@ -281,10 +333,29 @@ public class GameSessionService {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
if (allResponseCandidatesResolved(session, responseActionWindow)) {
closeResponseWindowAsPass(session, responseActionWindow);
continueAfterDiscardWithoutResponse(session);
tryResolveResponseWindow(session, responseActionWindow);
}
private void handleResponseDeclarationPostAction(GameSession session, ActionType actionType, GameActionRequest request) {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
session.getResponseActionSelections().put(actorSeat.getSeatNo(), actionType);
tryResolveResponseWindow(session, responseActionWindow);
}
private void handleGangPostAction(GameSession session, GameActionRequest request) {
if (request.sourceSeatNo() == null) {
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充");
}
handleResponseDeclarationPostAction(session, ActionType.GANG, request);
}
private void handleHuPostAction(GameSession session, GameActionRequest request) {
if (request.sourceSeatNo() == null) {
executeSelfDrawHu(session, request.userId());
return;
}
handleResponseDeclarationPostAction(session, ActionType.HU, request);
}
private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
@@ -331,6 +402,26 @@ public class GameSessionService {
session.clearResponseActionSelections();
}
private void tryResolveResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
if (!allResponseCandidatesResolved(session, responseActionWindow)) {
return;
}
Optional<ResponseActionResolution> resolution = responseActionResolver.resolve(
responseActionWindow,
session.getResponseActionSelections(),
session.getTable().getSeats().size()
);
if (resolution.isPresent()) {
executeResolvedResponseAction(session, responseActionWindow, resolution.get());
return;
}
closeResponseWindowAsPass(session, responseActionWindow);
continueAfterDiscardWithoutResponse(session);
}
private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
@@ -365,9 +456,9 @@ public class GameSessionService {
}
Tile tile = switch (suitLabel) {
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.WAN, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TONG, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TIAO, rank);
case "" -> new Tile(TileSuit.WAN, rank);
case "" -> new Tile(TileSuit.TONG, rank);
case "" -> new Tile(TileSuit.TIAO, rank);
default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
};
return tile;
@@ -379,6 +470,160 @@ public class GameSessionService {
notifyActionIfHumanTurn(session);
}
private void executeResolvedResponseAction(
GameSession session,
ResponseActionWindow responseActionWindow,
ResponseActionResolution resolution
) {
closeResponseWindowWithResolution(session, responseActionWindow, resolution.actionType());
switch (resolution.actionType()) {
case PENG -> executePeng(session, resolution);
case GANG -> executeGang(session, resolution);
case HU -> executeHu(session, resolution);
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
}
}
private void closeResponseWindowWithResolution(GameSession session, ResponseActionWindow responseActionWindow, ActionType actionType) {
appendAndPublish(session, GameEvent.responseWindowClosed(
session.getGameId(),
null,
responseActionWindow.sourceSeatNo(),
actionType.name()
));
session.setPendingResponseActionWindow(null);
session.clearResponseActionSelections();
}
private void executePeng(GameSession session, ResponseActionResolution resolution) {
Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
winnerSeat.removeMatchingHandTiles(claimedTile, 2);
sourceSeat.removeMatchingDiscardTile(claimedTile);
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.PENG_DECLARED,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat);
}
private void executeGang(GameSession session, ResponseActionResolution resolution) {
Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
winnerSeat.removeMatchingHandTiles(claimedTile, 3);
sourceSeat.removeMatchingDiscardTile(claimedTile);
table.setCurrentSeatNo(winnerSeat.getSeatNo());
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.GANG_DECLARED,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
appendSettlementEvents(session, settlementService.settleExposedGang(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile);
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat);
}
private void continueFromResolvedActionTurn(GameSession session, GameSeat currentSeat) {
if (currentSeat.isAi()) {
autoPlayBots(session);
notifyActionIfHumanTurn(session);
return;
}
notifyActionIfHumanTurn(session);
}
private void executeHu(GameSession session, ResponseActionResolution resolution) {
Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());
winnerSeat.receiveTile(claimedTile);
sourceSeat.removeMatchingDiscardTile(claimedTile);
winnerSeat.declareHu();
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.HU_DECLARED,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
appendSettlementEvents(session, settlementService.settleDiscardHu(
table,
winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(),
claimedTile.getDisplayName()
));
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
continueAfterDiscardWithoutResponse(session);
}
private void executeSelfDrawHu(GameSession session, String userId) {
GameTable table = session.getTable();
GameSeat winnerSeat = findSeatByUserId(table, userId);
winnerSeat.declareHu();
appendAndPublish(session, GameEvent.responseActionDeclared(
session.getGameId(),
GameEventType.HU_DECLARED,
winnerSeat.getSeatNo(),
null,
null
));
appendSettlementEvents(session, settlementService.settleSelfDrawHu(
table,
winnerSeat.getSeatNo(),
null
));
if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED);
appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
return;
}
moveToNextSeat(table, session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
private ActionType parseActionType(String actionType) {
try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
@@ -394,10 +639,59 @@ public class GameSessionService {
seat.getPlayerId(),
seat.getNickname(),
seat.isAi(),
seat.isWon(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getScore(),
seat.getHandTiles().size(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
))
.toList();
}
private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
appendAndPublish(session, GameEvent.scoreChanged(
session.getGameId(),
scoreChange.seatNo(),
settlementResult.settlementType().name(),
scoreChange.delta(),
scoreChange.afterScore()
));
}
}
private boolean shouldFinishTable(GameTable table) {
return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
}
private boolean canSelfDrawHu(GameSeat seat) {
return huEvaluator.canHu(seat.getHandTiles());
}
private long countActiveSeats(GameTable table) {
return table.getSeats().stream()
.filter(seat -> !seat.isWon())
.count();
}
private Optional<GameSeat> findNextActiveSeat(GameTable table, int fromSeatNo) {
int seatCount = table.getSeats().size();
for (int offset = 1; offset <= seatCount; offset++) {
GameSeat candidate = table.getSeats().get((fromSeatNo + offset) % seatCount);
if (!candidate.isWon()) {
return Optional.of(candidate);
}
}
return Optional.empty();
}
private List<PrivateActionCandidate> buildTurnActionCandidates(GameSeat currentSeat) {
List<PrivateActionCandidate> candidates = new java.util.ArrayList<>();
if (canSelfDrawHu(currentSeat)) {
candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null));
}
candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null));
return List.copyOf(candidates);
}
}

View File

@@ -0,0 +1,101 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class HuEvaluator {
public boolean canHu(List<Tile> tiles) {
if (tiles.size() % 3 != 2) {
return false;
}
int[] counts = buildCounts(tiles);
for (int i = 0; i < counts.length; i++) {
if (counts[i] < 2) {
continue;
}
counts[i] -= 2;
if (canFormMelds(counts)) {
return true;
}
counts[i] += 2;
}
return false;
}
public boolean canHuWithClaimedTile(List<Tile> handTiles, Tile claimedTile) {
List<Tile> tiles = new ArrayList<>(handTiles);
tiles.add(claimedTile);
return canHu(tiles);
}
private boolean canFormMelds(int[] counts) {
int firstIndex = firstNonZeroIndex(counts);
if (firstIndex == -1) {
return true;
}
if (counts[firstIndex] >= 3) {
counts[firstIndex] -= 3;
if (canFormMelds(counts)) {
counts[firstIndex] += 3;
return true;
}
counts[firstIndex] += 3;
}
int suitOffset = (firstIndex / 9) * 9;
int inSuitIndex = firstIndex % 9;
if (inSuitIndex <= 6
&& counts[firstIndex + 1] > 0
&& counts[firstIndex + 2] > 0
&& firstIndex + 2 < suitOffset + 9) {
counts[firstIndex]--;
counts[firstIndex + 1]--;
counts[firstIndex + 2]--;
if (canFormMelds(counts)) {
counts[firstIndex]++;
counts[firstIndex + 1]++;
counts[firstIndex + 2]++;
return true;
}
counts[firstIndex]++;
counts[firstIndex + 1]++;
counts[firstIndex + 2]++;
}
return false;
}
private int firstNonZeroIndex(int[] counts) {
for (int i = 0; i < counts.length; i++) {
if (counts[i] > 0) {
return i;
}
}
return -1;
}
private int[] buildCounts(List<Tile> tiles) {
int[] counts = new int[27];
for (Tile tile : tiles) {
counts[indexOf(tile)]++;
}
return counts;
}
private int indexOf(Tile tile) {
int suitOffset = switch (tile.getSuit()) {
case WAN -> 0;
case TONG -> 9;
case TIAO -> 18;
};
return suitOffset + tile.getRank() - 1;
}
}

View File

@@ -0,0 +1,42 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.Map;
import java.util.Optional;
@Component
public class ResponseActionResolver {
public Optional<ResponseActionResolution> resolve(ResponseActionWindow window, Map<Integer, ActionType> selections, int seatCount) {
return selections.entrySet().stream()
.filter(entry -> entry.getValue() != ActionType.PASS)
.min(Comparator
.comparingInt((Map.Entry<Integer, ActionType> entry) -> priority(entry.getValue()))
.thenComparingInt(entry -> seatDistance(window.sourceSeatNo(), entry.getKey(), seatCount)))
.map(entry -> new ResponseActionResolution(
entry.getValue(),
entry.getKey(),
window.sourceSeatNo(),
window.triggerTile()
));
}
private int priority(ActionType actionType) {
return switch (actionType) {
case HU -> 0;
case GANG -> 1;
case PENG -> 2;
case PASS -> 3;
default -> 99;
};
}
private int seatDistance(int sourceSeatNo, int targetSeatNo, int seatCount) {
return (targetSeatNo - sourceSeatNo + seatCount) % seatCount;
}
}

View File

@@ -17,12 +17,18 @@ import java.util.Optional;
@Component
public class ResponseActionWindowBuilder {
private final HuEvaluator huEvaluator;
public ResponseActionWindowBuilder(HuEvaluator huEvaluator) {
this.huEvaluator = huEvaluator;
}
public Optional<ResponseActionWindow> buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) {
GameTable table = session.getTable();
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == sourceSeatNo) {
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
continue;
}
@@ -51,16 +57,26 @@ public class ResponseActionWindowBuilder {
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile);
if (sameTileCount < 2) {
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);
if (!canHu && sameTileCount < 2) {
return List.of();
}
List<ResponseActionOption> options = new ArrayList<>();
if (canHu) {
options.add(new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()));
}
options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName()));
if (sameTileCount >= 3) {
options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName()));
}
options.add(new ResponseActionOption(ActionType.PASS, null));
if (sameTileCount < 2) {
return List.of(
new ResponseActionOption(ActionType.HU, discardedTile.getDisplayName()),
new ResponseActionOption(ActionType.PASS, null)
);
}
return options;
}

View File

@@ -0,0 +1,118 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class SettlementService {
private static final int DIAN_PAO_HU_SCORE = 1;
private static final int ZI_MO_HU_SCORE = 1;
private static final int MING_GANG_SCORE = 1;
public SettlementResult settleDiscardHu(
GameTable table,
int winnerSeatNo,
int sourceSeatNo,
String triggerTile
) {
return applySettlement(
table,
SettlementType.DIAN_PAO_HU,
ActionType.HU,
winnerSeatNo,
sourceSeatNo,
triggerTile,
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE)
);
}
public SettlementResult settleExposedGang(
GameTable table,
int winnerSeatNo,
int sourceSeatNo,
String triggerTile
) {
return applySettlement(
table,
SettlementType.MING_GANG,
ActionType.GANG,
winnerSeatNo,
sourceSeatNo,
triggerTile,
orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE)
);
}
public SettlementResult settleSelfDrawHu(
GameTable table,
int winnerSeatNo,
String triggerTile
) {
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue;
}
scoreDeltas.put(seat.getSeatNo(), -ZI_MO_HU_SCORE);
totalWinScore += ZI_MO_HU_SCORE;
}
scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement(
table,
SettlementType.ZI_MO_HU,
ActionType.HU,
winnerSeatNo,
winnerSeatNo,
triggerTile,
scoreDeltas
);
}
private SettlementResult applySettlement(
GameTable table,
SettlementType settlementType,
ActionType actionType,
int actorSeatNo,
int sourceSeatNo,
String triggerTile,
Map<Integer, Integer> scoreDeltas
) {
List<ScoreChange> scoreChanges = new ArrayList<>();
for (Map.Entry<Integer, Integer> entry : scoreDeltas.entrySet()) {
GameSeat seat = table.getSeats().get(entry.getKey());
seat.addScore(entry.getValue());
scoreChanges.add(new ScoreChange(
seat.getSeatNo(),
entry.getValue(),
seat.getScore()
));
}
return new SettlementResult(
settlementType,
actionType,
actorSeatNo,
sourceSeatNo,
triggerTile,
List.copyOf(scoreChanges)
);
}
private Map<Integer, Integer> orderedDeltas(int winnerSeatNo, int winnerDelta, int sourceSeatNo, int sourceDelta) {
Map<Integer, Integer> deltas = new LinkedHashMap<>();
deltas.put(winnerSeatNo, winnerDelta);
deltas.put(sourceSeatNo, sourceDelta);
return deltas;
}
}