feat: 实现补杠和抢杠胡功能

新增 MeldType 和 MeldGroup 领域模型,支持碰、明杠、补杠、暗杠四种副露类型
GameSeat 新增副露管理方法,支持将碰升级为补杠
ResponseActionWindowBuilder 新增补杠响应窗口构建逻辑
SettlementService 新增补杠和抢杠胡结算规则
前端新增副露展示区域,支持显示各类副露标签
This commit is contained in:
hujun
2026-03-20 14:14:07 +08:00
parent 36dcfb7d31
commit d038a8732d
16 changed files with 633 additions and 29 deletions

View File

@@ -1,19 +1,31 @@
# 当前执行入口
- 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`
- 当前主线已进入“统一结算服务 + 自摸胡闭环”阶段。
- 当前主线已进入“补杠 + 抢杠胡闭环”阶段。
- 最新已完成能力:
- `HU` 已同时支持两条路径:
- 响应胡(吃别人弃牌)
- 自摸胡(当前回合主动胡
- `HuEvaluator` 已补 `canHu(List<Tile>)`,可直接判断整手自摸是否成胡。
- 当前回合私有动作消息已支持结构化候选下发,真人玩家在可自摸时会收到 `HU + DISCARD` 候选
- AI 当前回合若满足自摸胡条件,会优先执行 `HU`,不再一律走弃牌策略
- `SettlementService` 已补 `ZI_MO_HU` 结算占位规则:所有未胡玩家各 `-1`,自摸方累计获得对应分数。
- 自摸胡后会将胡牌座位标记为 `won`,并按血战逻辑继续推进到下一有效座位;若仅剩 1 名未胡玩家则结束。
- `GANG` 主动动作已区分三条路径:
- 暗杠4 张同牌)
- 补杠(已有 `PENG` 副露 + 手牌 1 张同牌
- 响应明杠(来自他人弃牌)
- 新增 `MeldType.BU_GANG``GameSeat` 已支持把 `PENG` 升级为 `BU_GANG`
- `ResponseActionWindowBuilder` 已新增 `buildForSupplementalGang`,补杠时只会为其他玩家生成 `HU + PASS` 响应窗口
- `GameSessionService` 已支持:
- 补杠宣告时打开抢杠胡响应窗口
- 全员 `PASS` 后真正执行补杠、结算、补摸一张、继续本家回合
- 有人 `HU` 时按 `抢杠胡` 结算并终止补杠完成
- `SettlementService` 已新增:
- `BU_GANG` 占位结算
- `QIANG_GANG_HU` 占位结算
- H5 原型页当前已可看到 `碰 / 明杠 / 补杠 / 暗杠` 副露标签。
- 当前占位分规则:
- `BU_GANG`:所有未胡对手各 `-1`,补杠方累计加分
- `QIANG_GANG_HU`:暂按点炮胡占位分处理(胡牌方 `+1`,补杠方 `-1`
- 已完成验证:
- `mvn test` 通过,当前共 38 个测试。
- `npm run build` 通过。
- 当前仍未完成的核心点:
- `暗杠 / 补杠` 需要引入副露/杠展示状态,否则手牌数与桌面状态会失真。
- 正式四川血战计分规则仍未实现,当前仍是工程占位分。
- `过水不胡``一炮多响``查叫 / 退税` 尚未接入。
- H5 正式页面拆分仍未开始。
- 当前推荐的下一步:
1. 代码主线:`Meld/副露` 领域模型,再接 `暗杠 / 补杠 / 杠后补牌 / 杠分事件`
2. 结算主线:把占位规则升级为正式血战分数模型,并细化 `SETTLEMENT_APPLIED` 载荷
1. 规则主线:把占位结算升级为正式四川血战计分规则
2. 产品主线:开始 `S1-08`,拆分正式 H5 页面与对局组件

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public record MeldGroup(
MeldType type,
Tile tile,
Integer sourceSeatNo
) {
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public enum MeldType {
PENG,
MING_GANG,
BU_GANG,
AN_GANG
}

View File

@@ -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
}

View File

@@ -11,6 +11,7 @@ public record PublicSeatView(
String lackSuit,
int score,
int handCount,
List<String> discardTiles
List<String> discardTiles,
List<String> melds
) {
}

View File

@@ -10,6 +10,7 @@ public record SelfSeatView(
String lackSuit,
int score,
List<String> handTiles,
List<String> discardTiles
List<String> discardTiles,
List<String> melds
) {
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
);
}
}

View File

@@ -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");
}
}

View File

@@ -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));
}
}

View File

@@ -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);

View File

@@ -35,6 +35,7 @@ type SelfSeatView = {
score: number
handTiles: string[]
discardTiles: string[]
melds: string[]
}
type PublicSeatView = {
@@ -47,6 +48,7 @@ type PublicSeatView = {
score: number
handCount: number
discardTiles: string[]
melds: string[]
}
type GameStateResponse = {
@@ -543,6 +545,11 @@ function submitCandidateAction(actionType: string, tile: string | null) {
{{ tile }}
</button>
</div>
<div class="discard-row" v-if="game.selfSeat.melds.length > 0">
<span v-for="(meld, index) in game.selfSeat.melds" :key="`self-meld-${meld}-${index}`" class="discard-chip">
{{ meld }}
</span>
</div>
</div>
</article>
@@ -610,6 +617,12 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</span>
<span v-if="seat.discardTiles.length === 0" class="empty-copy">暂无弃牌</span>
</div>
<div class="discard-row">
<span v-for="(meld, index) in seat.melds" :key="`${seat.seatNo}-meld-${meld}-${index}`" class="discard-chip">
{{ meld }}
</span>
<span v-if="seat.melds.length === 0" class="empty-copy">暂无副露</span>
</div>
</article>
</div>