feat: 实现补杠和抢杠胡功能
新增 MeldType 和 MeldGroup 领域模型,支持碰、明杠、补杠、暗杠四种副露类型 GameSeat 新增副露管理方法,支持将碰升级为补杠 ResponseActionWindowBuilder 新增补杠响应窗口构建逻辑 SettlementService 新增补杠和抢杠胡结算规则 前端新增副露展示区域,支持显示各类副露标签
This commit is contained in:
@@ -12,6 +12,7 @@ public class GameSeat {
|
||||
private final String nickname;
|
||||
private final List<Tile> handTiles = new ArrayList<>();
|
||||
private final List<Tile> discardTiles = new ArrayList<>();
|
||||
private final List<MeldGroup> meldGroups = new ArrayList<>();
|
||||
private TileSuit lackSuit;
|
||||
private boolean won;
|
||||
private int score;
|
||||
@@ -51,6 +52,10 @@ public class GameSeat {
|
||||
return discardTiles;
|
||||
}
|
||||
|
||||
public List<MeldGroup> getMeldGroups() {
|
||||
return meldGroups;
|
||||
}
|
||||
|
||||
public TileSuit getLackSuit() {
|
||||
return lackSuit;
|
||||
}
|
||||
@@ -107,6 +112,34 @@ public class GameSeat {
|
||||
throw new IllegalStateException("弃牌区不存在指定牌");
|
||||
}
|
||||
|
||||
public void addPengMeld(Tile tile, int sourceSeatNo) {
|
||||
meldGroups.add(new MeldGroup(MeldType.PENG, tile, sourceSeatNo));
|
||||
}
|
||||
|
||||
public void addMingGangMeld(Tile tile, int sourceSeatNo) {
|
||||
meldGroups.add(new MeldGroup(MeldType.MING_GANG, tile, sourceSeatNo));
|
||||
}
|
||||
|
||||
public void upgradePengToBuGang(Tile tile) {
|
||||
for (int i = 0; i < meldGroups.size(); i++) {
|
||||
MeldGroup meldGroup = meldGroups.get(i);
|
||||
if (meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile)) {
|
||||
meldGroups.set(i, new MeldGroup(MeldType.BU_GANG, tile, meldGroup.sourceSeatNo()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("当前没有可升级为补杠的碰副露");
|
||||
}
|
||||
|
||||
public boolean hasPengMeld(Tile tile) {
|
||||
return meldGroups.stream()
|
||||
.anyMatch(meldGroup -> meldGroup.type() == MeldType.PENG && meldGroup.tile().equals(tile));
|
||||
}
|
||||
|
||||
public void addAnGangMeld(Tile tile) {
|
||||
meldGroups.add(new MeldGroup(MeldType.AN_GANG, tile, null));
|
||||
}
|
||||
|
||||
public void declareHu() {
|
||||
this.won = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public record MeldGroup(
|
||||
MeldType type,
|
||||
Tile tile,
|
||||
Integer sourceSeatNo
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public enum MeldType {
|
||||
PENG,
|
||||
MING_GANG,
|
||||
BU_GANG,
|
||||
AN_GANG
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.xuezhanmaster.game.domain;
|
||||
|
||||
public enum SettlementType {
|
||||
DIAN_PAO_HU,
|
||||
QIANG_GANG_HU,
|
||||
ZI_MO_HU,
|
||||
MING_GANG
|
||||
BU_GANG,
|
||||
MING_GANG,
|
||||
AN_GANG
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public record PublicSeatView(
|
||||
String lackSuit,
|
||||
int score,
|
||||
int handCount,
|
||||
List<String> discardTiles
|
||||
List<String> discardTiles,
|
||||
List<String> melds
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public record SelfSeatView(
|
||||
String lackSuit,
|
||||
int score,
|
||||
List<String> handTiles,
|
||||
List<String> discardTiles
|
||||
List<String> discardTiles,
|
||||
List<String> melds
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ public class GameActionProcessor {
|
||||
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat seat = findSeatByUserId(table, userId);
|
||||
if (sourceSeatNo == null) {
|
||||
validateSelfGang(session, table, seat, tileDisplayName);
|
||||
return List.of();
|
||||
}
|
||||
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "杠");
|
||||
return List.of();
|
||||
}
|
||||
@@ -219,6 +223,33 @@ public class GameActionProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSelfGang(GameSession session, GameTable table, GameSeat actorSeat, String tileDisplayName) {
|
||||
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 (isBlank(tileDisplayName)) {
|
||||
throw new BusinessException("GAME_ACTION_PARAM_INVALID", "暗杠动作缺少目标牌");
|
||||
}
|
||||
Tile tile = findTileInHand(actorSeat, tileDisplayName);
|
||||
long sameTileCount = actorSeat.getHandTiles().stream()
|
||||
.filter(handTile -> handTile.equals(tile))
|
||||
.count();
|
||||
boolean canConcealedGang = sameTileCount >= 4;
|
||||
boolean canSupplementalGang = sameTileCount >= 1 && actorSeat.hasPengMeld(tile);
|
||||
if (!canConcealedGang && !canSupplementalGang) {
|
||||
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前手牌不满足暗杠条件");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
@@ -147,7 +147,8 @@ public class GameSessionService {
|
||||
self.getLackSuit() == null ? null : self.getLackSuit().name(),
|
||||
self.getScore(),
|
||||
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
|
||||
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
|
||||
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
|
||||
toMeldLabels(self)
|
||||
),
|
||||
buildPublicSeats(table)
|
||||
);
|
||||
@@ -210,6 +211,30 @@ public class GameSessionService {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<String> selfGangCandidate = findSelfGangCandidate(currentSeat);
|
||||
if (selfGangCandidate.isPresent()) {
|
||||
List<GameEvent> gangEvents = gameActionProcessor.process(
|
||||
session,
|
||||
new GameActionRequest(
|
||||
currentSeat.getPlayerId(),
|
||||
"GANG",
|
||||
selfGangCandidate.get(),
|
||||
null
|
||||
)
|
||||
);
|
||||
appendAndPublish(session, gangEvents);
|
||||
handlePostActionEffects(
|
||||
session,
|
||||
ActionType.GANG,
|
||||
new GameActionRequest(currentSeat.getPlayerId(), "GANG", selfGangCandidate.get(), null),
|
||||
gangEvents
|
||||
);
|
||||
if (table.getPhase() == GamePhase.FINISHED) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
|
||||
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
|
||||
List<GameEvent> events = gameActionProcessor.process(
|
||||
@@ -345,7 +370,8 @@ public class GameSessionService {
|
||||
|
||||
private void handleGangPostAction(GameSession session, GameActionRequest request) {
|
||||
if (request.sourceSeatNo() == null) {
|
||||
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前仅支持响应明杠,主动杠将在后续迭代补充");
|
||||
handleSelfGangPostAction(session, request.userId(), request.tile());
|
||||
return;
|
||||
}
|
||||
handleResponseDeclarationPostAction(session, ActionType.GANG, request);
|
||||
}
|
||||
@@ -419,6 +445,10 @@ public class GameSessionService {
|
||||
}
|
||||
|
||||
closeResponseWindowAsPass(session, responseActionWindow);
|
||||
if (isSupplementalGangWindow(responseActionWindow)) {
|
||||
executeSupplementalGang(session, responseActionWindow.sourceSeatNo(), responseActionWindow.triggerTile());
|
||||
return;
|
||||
}
|
||||
continueAfterDiscardWithoutResponse(session);
|
||||
}
|
||||
|
||||
@@ -479,7 +509,7 @@ public class GameSessionService {
|
||||
switch (resolution.actionType()) {
|
||||
case PENG -> executePeng(session, resolution);
|
||||
case GANG -> executeGang(session, resolution);
|
||||
case HU -> executeHu(session, resolution);
|
||||
case HU -> executeHu(session, responseActionWindow, resolution);
|
||||
default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
|
||||
}
|
||||
}
|
||||
@@ -503,6 +533,7 @@ public class GameSessionService {
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(claimedTile, 2);
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
winnerSeat.addPengMeld(claimedTile, sourceSeat.getSeatNo());
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
@@ -524,6 +555,7 @@ public class GameSessionService {
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(claimedTile, 3);
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
winnerSeat.addMingGangMeld(claimedTile, sourceSeat.getSeatNo());
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
@@ -562,14 +594,22 @@ public class GameSessionService {
|
||||
notifyActionIfHumanTurn(session);
|
||||
}
|
||||
|
||||
private void executeHu(GameSession session, ResponseActionResolution resolution) {
|
||||
private void executeHu(
|
||||
GameSession session,
|
||||
ResponseActionWindow responseActionWindow,
|
||||
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);
|
||||
if (isSupplementalGangWindow(responseActionWindow)) {
|
||||
sourceSeat.removeMatchingHandTiles(claimedTile, 1);
|
||||
} else {
|
||||
sourceSeat.removeMatchingDiscardTile(claimedTile);
|
||||
}
|
||||
winnerSeat.declareHu();
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
@@ -579,12 +619,21 @@ public class GameSessionService {
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleDiscardHu(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
if (isSupplementalGangWindow(responseActionWindow)) {
|
||||
appendSettlementEvents(session, settlementService.settleRobbingGangHu(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
} else {
|
||||
appendSettlementEvents(session, settlementService.settleDiscardHu(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
sourceSeat.getSeatNo(),
|
||||
claimedTile.getDisplayName()
|
||||
));
|
||||
}
|
||||
|
||||
if (shouldFinishTable(table)) {
|
||||
table.setPhase(GamePhase.FINISHED);
|
||||
@@ -624,6 +673,102 @@ public class GameSessionService {
|
||||
notifyActionIfHumanTurn(session);
|
||||
}
|
||||
|
||||
private void executeSelfGang(GameSession session, String userId, String tileDisplayName) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = findSeatByUserId(table, userId);
|
||||
Tile gangTile = parseTile(tileDisplayName);
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(gangTile, 4);
|
||||
winnerSeat.addAnGangMeld(gangTile);
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.GANG_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
null,
|
||||
gangTile.getDisplayName()
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleConcealedGang(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
gangTile.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 handleSelfGangPostAction(GameSession session, String userId, String tileDisplayName) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat actorSeat = findSeatByUserId(table, userId);
|
||||
Tile gangTile = parseTile(tileDisplayName);
|
||||
|
||||
if (actorSeat.hasPengMeld(gangTile)) {
|
||||
Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForSupplementalGang(
|
||||
session,
|
||||
actorSeat.getSeatNo(),
|
||||
gangTile
|
||||
);
|
||||
if (window.isPresent()) {
|
||||
openResponseWindow(session, window.get());
|
||||
autoPassAiCandidates(session);
|
||||
if (session.getPendingResponseActionWindow() == null) {
|
||||
executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
|
||||
return;
|
||||
}
|
||||
|
||||
executeSelfGang(session, userId, tileDisplayName);
|
||||
}
|
||||
|
||||
private void executeSupplementalGang(GameSession session, int seatNo, String tileDisplayName) {
|
||||
GameTable table = session.getTable();
|
||||
GameSeat winnerSeat = table.getSeats().get(seatNo);
|
||||
Tile gangTile = parseTile(tileDisplayName);
|
||||
|
||||
winnerSeat.removeMatchingHandTiles(gangTile, 1);
|
||||
winnerSeat.upgradePengToBuGang(gangTile);
|
||||
table.setCurrentSeatNo(winnerSeat.getSeatNo());
|
||||
|
||||
appendAndPublish(session, GameEvent.responseActionDeclared(
|
||||
session.getGameId(),
|
||||
GameEventType.GANG_DECLARED,
|
||||
winnerSeat.getSeatNo(),
|
||||
null,
|
||||
gangTile.getDisplayName()
|
||||
));
|
||||
appendSettlementEvents(session, settlementService.settleSupplementalGang(
|
||||
table,
|
||||
winnerSeat.getSeatNo(),
|
||||
gangTile.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 ActionType parseActionType(String actionType) {
|
||||
try {
|
||||
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
|
||||
@@ -643,7 +788,8 @@ public class GameSessionService {
|
||||
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
|
||||
seat.getScore(),
|
||||
seat.getHandTiles().size(),
|
||||
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
|
||||
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
|
||||
toMeldLabels(seat)
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
@@ -669,6 +815,10 @@ public class GameSessionService {
|
||||
return huEvaluator.canHu(seat.getHandTiles());
|
||||
}
|
||||
|
||||
private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) {
|
||||
return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType());
|
||||
}
|
||||
|
||||
private long countActiveSeats(GameTable table) {
|
||||
return table.getSeats().stream()
|
||||
.filter(seat -> !seat.isWon())
|
||||
@@ -691,7 +841,39 @@ public class GameSessionService {
|
||||
if (canSelfDrawHu(currentSeat)) {
|
||||
candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null));
|
||||
}
|
||||
for (String gangTile : findSelfGangCandidates(currentSeat)) {
|
||||
candidates.add(new PrivateActionCandidate(ActionType.GANG.name(), gangTile));
|
||||
}
|
||||
candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null));
|
||||
return List.copyOf(candidates);
|
||||
}
|
||||
|
||||
private Optional<String> findSelfGangCandidate(GameSeat seat) {
|
||||
List<String> candidates = findSelfGangCandidates(seat);
|
||||
if (candidates.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(candidates.get(0));
|
||||
}
|
||||
|
||||
private List<String> findSelfGangCandidates(GameSeat seat) {
|
||||
return seat.getHandTiles().stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(Tile::getDisplayName, java.util.stream.Collectors.counting()))
|
||||
.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() >= 4 || seat.hasPengMeld(parseTile(entry.getKey())))
|
||||
.map(java.util.Map.Entry::getKey)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<String> toMeldLabels(GameSeat seat) {
|
||||
return seat.getMeldGroups().stream()
|
||||
.map(meld -> switch (meld.type()) {
|
||||
case PENG -> "碰:" + meld.tile().getDisplayName();
|
||||
case MING_GANG -> "明杠:" + meld.tile().getDisplayName();
|
||||
case BU_GANG -> "补杠:" + meld.tile().getDisplayName();
|
||||
case AN_GANG -> "暗杠:" + meld.tile().getDisplayName();
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,40 @@ public class ResponseActionWindowBuilder {
|
||||
));
|
||||
}
|
||||
|
||||
public Optional<ResponseActionWindow> buildForSupplementalGang(GameSession session, int sourceSeatNo, Tile gangTile) {
|
||||
GameTable table = session.getTable();
|
||||
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
|
||||
|
||||
for (GameSeat seat : table.getSeats()) {
|
||||
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
|
||||
continue;
|
||||
}
|
||||
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) {
|
||||
continue;
|
||||
}
|
||||
seatCandidates.add(new ResponseActionSeatCandidate(
|
||||
seat.getSeatNo(),
|
||||
seat.getPlayerId(),
|
||||
List.of(
|
||||
new ResponseActionOption(ActionType.HU, gangTile.getDisplayName()),
|
||||
new ResponseActionOption(ActionType.PASS, null)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
if (seatCandidates.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(ResponseActionWindow.create(
|
||||
session.getGameId(),
|
||||
"SUPPLEMENTAL_GANG_DECLARED",
|
||||
sourceSeatNo,
|
||||
gangTile.getDisplayName(),
|
||||
seatCandidates
|
||||
));
|
||||
}
|
||||
|
||||
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
|
||||
int sameTileCount = countSameTileInHand(seat, discardedTile);
|
||||
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);
|
||||
|
||||
@@ -18,7 +18,9 @@ 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 BU_GANG_SCORE = 1;
|
||||
private static final int MING_GANG_SCORE = 1;
|
||||
private static final int AN_GANG_SCORE = 2;
|
||||
|
||||
public SettlementResult settleDiscardHu(
|
||||
GameTable table,
|
||||
@@ -80,6 +82,75 @@ public class SettlementService {
|
||||
);
|
||||
}
|
||||
|
||||
public SettlementResult settleRobbingGangHu(
|
||||
GameTable table,
|
||||
int winnerSeatNo,
|
||||
int sourceSeatNo,
|
||||
String triggerTile
|
||||
) {
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.QIANG_GANG_HU,
|
||||
ActionType.HU,
|
||||
winnerSeatNo,
|
||||
sourceSeatNo,
|
||||
triggerTile,
|
||||
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE)
|
||||
);
|
||||
}
|
||||
|
||||
public SettlementResult settleSupplementalGang(
|
||||
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(), -BU_GANG_SCORE);
|
||||
totalWinScore += BU_GANG_SCORE;
|
||||
}
|
||||
scoreDeltas.put(winnerSeatNo, totalWinScore);
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.BU_GANG,
|
||||
ActionType.GANG,
|
||||
winnerSeatNo,
|
||||
winnerSeatNo,
|
||||
triggerTile,
|
||||
scoreDeltas
|
||||
);
|
||||
}
|
||||
|
||||
public SettlementResult settleConcealedGang(
|
||||
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(), -AN_GANG_SCORE);
|
||||
totalWinScore += AN_GANG_SCORE;
|
||||
}
|
||||
scoreDeltas.put(winnerSeatNo, totalWinScore);
|
||||
return applySettlement(
|
||||
table,
|
||||
SettlementType.AN_GANG,
|
||||
ActionType.GANG,
|
||||
winnerSeatNo,
|
||||
winnerSeatNo,
|
||||
triggerTile,
|
||||
scoreDeltas
|
||||
);
|
||||
}
|
||||
|
||||
private SettlementResult applySettlement(
|
||||
GameTable table,
|
||||
SettlementType settlementType,
|
||||
|
||||
@@ -14,10 +14,14 @@ public class PlayerVisibilityService {
|
||||
|
||||
public PlayerVisibleGameState buildVisibleState(GameTable table, GameSeat seat) {
|
||||
List<String> publicDiscards = new ArrayList<>();
|
||||
List<String> publicMelds = new ArrayList<>();
|
||||
for (GameSeat gameSeat : table.getSeats()) {
|
||||
for (Tile tile : gameSeat.getDiscardTiles()) {
|
||||
publicDiscards.add(gameSeat.getSeatNo() + ":" + tile.getDisplayName());
|
||||
}
|
||||
gameSeat.getMeldGroups().forEach(meld ->
|
||||
publicMelds.add(gameSeat.getSeatNo() + ":" + meld.type().name() + ":" + meld.tile().getDisplayName())
|
||||
);
|
||||
}
|
||||
|
||||
return new PlayerVisibleGameState(
|
||||
@@ -26,8 +30,7 @@ public class PlayerVisibilityService {
|
||||
seat.getLackSuit(),
|
||||
List.copyOf(seat.getHandTiles()),
|
||||
publicDiscards,
|
||||
List.of()
|
||||
publicMelds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,4 +94,21 @@ class GameEventTest {
|
||||
.doesNotContainKey("sourceSeatNo")
|
||||
.doesNotContainKey("tile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowGangEventWithoutSourceSeatForConcealedGang() {
|
||||
GameEvent concealedGang = GameEvent.responseActionDeclared(
|
||||
"game-1",
|
||||
GameEventType.GANG_DECLARED,
|
||||
0,
|
||||
null,
|
||||
"3万"
|
||||
);
|
||||
|
||||
assertThat(concealedGang.eventType()).isEqualTo(GameEventType.GANG_DECLARED);
|
||||
assertThat(concealedGang.payload())
|
||||
.containsEntry("actionType", "GANG")
|
||||
.containsEntry("tile", "3万")
|
||||
.doesNotContainKey("sourceSeatNo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ class GameSessionServiceTest {
|
||||
assertThat(afterPeng.currentSeatNo()).isEqualTo(1);
|
||||
assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile);
|
||||
assertThat(afterPeng.selfSeat().handTiles()).hasSize(13);
|
||||
assertThat(afterPeng.selfSeat().melds()).containsExactly("碰:" + discardTile);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,6 +257,7 @@ class GameSessionServiceTest {
|
||||
assertThat(afterGang.currentSeatNo()).isEqualTo(1);
|
||||
assertThat(afterGang.selfSeat().score()).isEqualTo(1);
|
||||
assertThat(afterGang.seats().get(0).score()).isEqualTo(-1);
|
||||
assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile);
|
||||
assertThat(session.getEvents())
|
||||
.extracting(event -> event.eventType())
|
||||
.contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
|
||||
@@ -392,6 +394,121 @@ class GameSessionServiceTest {
|
||||
.contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSelfConcealedGangAndKeepTurnAfterReplacementDraw() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
GameSession session = getSession(started.gameId());
|
||||
prepareConcealedGangHand(session.getTable().getSeats().get(0), "3万");
|
||||
int beforeWallCount = session.getTable().getWallTiles().size();
|
||||
|
||||
GameStateResponse afterGang = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "GANG", "3万", null)
|
||||
);
|
||||
|
||||
assertThat(afterGang.phase()).isEqualTo("PLAYING");
|
||||
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
|
||||
assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1);
|
||||
assertThat(afterGang.selfSeat().melds()).containsExactly("暗杠:3万");
|
||||
assertThat(afterGang.selfSeat().score()).isEqualTo(6);
|
||||
assertThat(afterGang.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2);
|
||||
assertThat(afterGang.selfSeat().handTiles()).hasSize(11);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "GANG", "3万", null)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_ACTION_WINDOW_INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSupplementalGangAndKeepMeldState() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
GameSession session = getSession(started.gameId());
|
||||
GameSeat seat = session.getTable().getSeats().get(0);
|
||||
prepareSupplementalGangHand(seat, "3万");
|
||||
int beforeWallCount = session.getTable().getWallTiles().size();
|
||||
|
||||
GameStateResponse afterGang = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "GANG", "3万", null)
|
||||
);
|
||||
|
||||
assertThat(afterGang.phase()).isEqualTo("PLAYING");
|
||||
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
|
||||
assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1);
|
||||
assertThat(afterGang.selfSeat().melds()).containsExactly("补杠:3万");
|
||||
assertThat(afterGang.selfSeat().score()).isEqualTo(3);
|
||||
assertThat(afterGang.seats()).extracting(seatView -> seatView.score()).contains(3, -1, -1, -1);
|
||||
assertThat(session.getEvents())
|
||||
.extracting(event -> event.eventType())
|
||||
.contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveRobbingGangHuBeforeSupplementalGangCompletes() {
|
||||
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());
|
||||
prepareSupplementalGangHand(session.getTable().getSeats().get(0), "9筒");
|
||||
prepareWinningHand(session.getTable().getSeats().get(1));
|
||||
|
||||
GameStateResponse afterGangDeclare = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "GANG", "9筒", null)
|
||||
);
|
||||
|
||||
assertThat(session.getPendingResponseActionWindow()).isNotNull();
|
||||
assertThat(afterGangDeclare.currentSeatNo()).isEqualTo(0);
|
||||
|
||||
GameStateResponse afterHu = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-2", "HU", "9筒", 0)
|
||||
);
|
||||
|
||||
assertThat(session.getPendingResponseActionWindow()).isNull();
|
||||
assertThat(afterHu.phase()).isEqualTo("PLAYING");
|
||||
assertThat(afterHu.currentSeatNo()).isEqualTo(2);
|
||||
assertThat(afterHu.selfSeat().won()).isTrue();
|
||||
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
|
||||
assertThat(afterHu.seats().get(0).score()).isEqualTo(-1);
|
||||
assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
@@ -492,4 +609,40 @@ class GameSessionServiceTest {
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
|
||||
}
|
||||
|
||||
private void prepareConcealedGangHand(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, 2));
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 3));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 4));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 5));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 6));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 9));
|
||||
}
|
||||
|
||||
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
|
||||
seat.getHandTiles().clear();
|
||||
seat.getMeldGroups().clear();
|
||||
Tile gangTile = parseTile(gangTileDisplayName);
|
||||
seat.addPengMeld(gangTile, 1);
|
||||
seat.receiveTile(gangTile);
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 1));
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 2));
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 3));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 4));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 5));
|
||||
seat.receiveTile(new Tile(TileSuit.TONG, 6));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 7));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 8));
|
||||
seat.receiveTile(new Tile(TileSuit.TIAO, 9));
|
||||
seat.receiveTile(new Tile(TileSuit.WAN, 9));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,40 @@ class ResponseActionWindowBuilderTest {
|
||||
.containsExactly(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildHuOnlyResponseWindowForSupplementalGang() {
|
||||
Tile gangTile = new Tile(TileSuit.TONG, 9);
|
||||
GameSession session = new GameSession("room-1", createPlayingTable(
|
||||
seat(0, "u0"),
|
||||
seat(1, "u1",
|
||||
new Tile(TileSuit.WAN, 1),
|
||||
new Tile(TileSuit.WAN, 1),
|
||||
new Tile(TileSuit.WAN, 1),
|
||||
new Tile(TileSuit.WAN, 2),
|
||||
new Tile(TileSuit.WAN, 3),
|
||||
new Tile(TileSuit.WAN, 4),
|
||||
new Tile(TileSuit.TONG, 2),
|
||||
new Tile(TileSuit.TONG, 3),
|
||||
new Tile(TileSuit.TONG, 4),
|
||||
new Tile(TileSuit.TIAO, 5),
|
||||
new Tile(TileSuit.TIAO, 6),
|
||||
new Tile(TileSuit.TIAO, 7),
|
||||
new Tile(TileSuit.TONG, 9)
|
||||
),
|
||||
seat(2, "u2"),
|
||||
seat(3, "u3")
|
||||
));
|
||||
|
||||
Optional<ResponseActionWindow> result = builder.buildForSupplementalGang(session, 0, gangTile);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.orElseThrow().triggerEventType()).isEqualTo("SUPPLEMENTAL_GANG_DECLARED");
|
||||
assertThat(result.orElseThrow().seatCandidates()).hasSize(1);
|
||||
assertThat(result.orElseThrow().seatCandidates().get(0).options())
|
||||
.extracting(option -> option.actionType())
|
||||
.containsExactly(ActionType.HU, ActionType.PASS);
|
||||
}
|
||||
|
||||
private GameTable createPlayingTable(GameSeat... seats) {
|
||||
GameTable table = new GameTable(List.of(seats), new ArrayList<>());
|
||||
table.setPhase(GamePhase.PLAYING);
|
||||
|
||||
Reference in New Issue
Block a user