feat(计分): 实现血战计分V1核心逻辑

- 新增血战计分服务,支持七对、清一色等基础番型及杠上花等特殊加番
- 扩展结算结果结构,包含番型明细与支付分数计算
- 新增PostGangContext记录杠后补摸窗口,用于判断杠上花/杠上炮
- 完善胡牌判定器,新增七对和对对胡识别方法
- 更新开发计划文档,补充注释规范要求
- 添加计分相关单元测试,确保核心逻辑正确性
This commit is contained in:
hujun
2026-03-20 14:50:19 +08:00
parent d038a8732d
commit 34809fd0f3
17 changed files with 804 additions and 29 deletions

View File

@@ -0,0 +1,40 @@
# 正式血战计分 V12026-03-20
- 已从工程占位分切换到“最小可扩展正式版”计分骨架。
- 新增后端规则服务:`backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java`
- `SettlementResult` 已扩展 `settlementDetail`,结算事件 `SETTLEMENT_APPLIED` 现在会携带:
- `baseScore`
- `totalFan`
- `paymentScore`
- `fans`(番型明细)
- 当前 V1 已支持的基础番型/加番:
- `七对`2 番
- `对对胡`1 番
- `金钩钓`1 番
- `清一色`2 番
- `根`:每个 1 番
- `抢杠胡`1 番
- `杠上花`1 番
- `杠上炮`1 番
- `海底捞月`1 番
- `海底炮`1 番
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`
- 点炮胡:放炮者单独支付 `paymentScore`
- 自摸胡:所有未胡玩家各支付 `paymentScore`
- 抢杠胡:按胡牌番型 + `抢杠胡` 1 番,由补杠方单独支付
- 杠上花:在自摸胡基础上额外加 1 番
- 杠上炮:在点炮胡基础上额外加 1 番
- 海底捞月:自摸胡时若牌墙已空,额外加 1 番
- 海底炮:点炮胡时若牌墙已空,额外加 1 番
- 当前杠分口径:
- `明杠/点杠`:放杠者单独支付 2 分
- `补杠`:所有未胡玩家各支付 1 分
- `暗杠`:所有未胡玩家各支付 2 分
- `GameSession` 已新增 `PostGangContext`,只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
- 当前仍未实现:
- 自摸加番/加底地方变体
- 天胡、地胡
- 查叫、退税、过水不胡、一炮多响
- 文档约定已补到 `docs/DEVELOPMENT_PLAN.md`:后续前端、后端、数据库表结构与 SQL 脚本都需要补充必要中文注释,重点说明复杂规则、关键字段、状态切换、约束原因和索引用途。
- 最小验证:`cd backend && mvn clean test`,当前 48 个测试通过。

View File

@@ -17,6 +17,8 @@ public class GameSession {
private final GameTable table; private final GameTable table;
private final List<GameEvent> events; private final List<GameEvent> events;
private ResponseActionWindow pendingResponseActionWindow; private ResponseActionWindow pendingResponseActionWindow;
// 记录最近一次杠后补摸窗口,用于判断杠上花与杠上炮。
private PostGangContext postGangContext;
private final Map<Integer, ActionType> responseActionSelections; private final Map<Integer, ActionType> responseActionSelections;
public GameSession(String roomId, GameTable table) { public GameSession(String roomId, GameTable table) {
@@ -56,6 +58,14 @@ public class GameSession {
this.pendingResponseActionWindow = pendingResponseActionWindow; this.pendingResponseActionWindow = pendingResponseActionWindow;
} }
public PostGangContext getPostGangContext() {
return postGangContext;
}
public void setPostGangContext(PostGangContext postGangContext) {
this.postGangContext = postGangContext;
}
public Map<Integer, ActionType> getResponseActionSelections() { public Map<Integer, ActionType> getResponseActionSelections() {
return responseActionSelections; return responseActionSelections;
} }
@@ -63,4 +73,8 @@ public class GameSession {
public void clearResponseActionSelections() { public void clearResponseActionSelections() {
responseActionSelections.clear(); responseActionSelections.clear();
} }
public void clearPostGangContext() {
this.postGangContext = null;
}
} }

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public record PostGangContext(
int seatNo,
String tileDisplayName
) {
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record SettlementDetail(
int baseScore,
int totalFan,
int paymentScore,
List<SettlementFan> fans
) {
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.game.domain;
public record SettlementFan(
String code,
String label,
int fan
) {
}

View File

@@ -8,6 +8,7 @@ public record SettlementResult(
int actorSeatNo, int actorSeatNo,
int sourceSeatNo, int sourceSeatNo,
String triggerTile, String triggerTile,
SettlementDetail settlementDetail,
List<ScoreChange> scoreChanges List<ScoreChange> scoreChanges
) { ) {
} }

View File

@@ -1,6 +1,8 @@
package com.xuezhanmaster.game.event; package com.xuezhanmaster.game.event;
import com.xuezhanmaster.game.domain.ScoreChange; import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.SettlementResult; import com.xuezhanmaster.game.domain.SettlementResult;
import java.time.Instant; import java.time.Instant;
@@ -92,6 +94,7 @@ public record GameEvent(
payload.put("actorSeatNo", settlementResult.actorSeatNo()); payload.put("actorSeatNo", settlementResult.actorSeatNo());
payload.put("sourceSeatNo", settlementResult.sourceSeatNo()); payload.put("sourceSeatNo", settlementResult.sourceSeatNo());
payload.put("triggerTile", settlementResult.triggerTile()); payload.put("triggerTile", settlementResult.triggerTile());
payload.put("settlementDetail", toSettlementDetailPayload(settlementResult.settlementDetail()));
payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges())); payload.put("scoreChanges", toScoreChangePayload(settlementResult.scoreChanges()));
return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload); return of(gameId, GameEventType.SETTLEMENT_APPLIED, settlementResult.actorSeatNo(), payload);
} }
@@ -143,4 +146,21 @@ public record GameEvent(
} }
return payload; return payload;
} }
private static Map<String, Object> toSettlementDetailPayload(SettlementDetail settlementDetail) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("baseScore", settlementDetail.baseScore());
payload.put("totalFan", settlementDetail.totalFan());
payload.put("paymentScore", settlementDetail.paymentScore());
List<Map<String, Object>> fans = new ArrayList<>();
for (SettlementFan settlementFan : settlementDetail.fans()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("code", settlementFan.code());
item.put("label", settlementFan.label());
item.put("fan", settlementFan.fan());
fans.add(item);
}
payload.put("fans", fans);
return payload;
}
} }

View File

@@ -0,0 +1,167 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.MeldGroup;
import com.xuezhanmaster.game.domain.MeldType;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class BloodBattleScoringService {
private static final int BASE_SCORE = 1;
private static final int EXPOSED_GANG_SCORE = 2;
private static final int SUPPLEMENTAL_GANG_SCORE = 1;
private static final int CONCEALED_GANG_SCORE = 2;
private final HuEvaluator huEvaluator;
public BloodBattleScoringService(HuEvaluator huEvaluator) {
this.huEvaluator = huEvaluator;
}
public SettlementDetail buildDiscardHuDetail(
GameSeat winnerSeat,
boolean gangShangPao,
boolean haiDiPao
) {
return buildHuDetail(winnerSeat, false, false, gangShangPao, false, haiDiPao);
}
public SettlementDetail buildSelfDrawHuDetail(
GameSeat winnerSeat,
boolean gangShangHua,
boolean haiDiLaoYue
) {
// V1 先采用统一基础口径,不叠加地方“自摸加底/加番”变体。
return buildHuDetail(winnerSeat, false, gangShangHua, false, haiDiLaoYue, false);
}
public SettlementDetail buildRobbingGangHuDetail(GameSeat winnerSeat) {
return buildHuDetail(winnerSeat, true, false, false, false, false);
}
public SettlementDetail buildExposedGangDetail() {
return new SettlementDetail(BASE_SCORE, 0, EXPOSED_GANG_SCORE, List.of());
}
public SettlementDetail buildSupplementalGangDetail() {
return new SettlementDetail(BASE_SCORE, 0, SUPPLEMENTAL_GANG_SCORE, List.of());
}
public SettlementDetail buildConcealedGangDetail() {
return new SettlementDetail(BASE_SCORE, 0, CONCEALED_GANG_SCORE, List.of());
}
private SettlementDetail buildHuDetail(
GameSeat winnerSeat,
boolean robbingGang,
boolean gangShangHua,
boolean gangShangPao,
boolean haiDiLaoYue,
boolean haiDiPao
) {
List<SettlementFan> fans = new ArrayList<>();
if (huEvaluator.isSevenPairs(winnerSeat.getHandTiles())) {
fans.add(new SettlementFan("QI_DUI", "七对", 2));
} else {
if (huEvaluator.isPengPengHu(winnerSeat.getHandTiles())) {
fans.add(new SettlementFan("DUI_DUI_HU", "对对胡", 1));
}
if (isJinGouDiao(winnerSeat)) {
fans.add(new SettlementFan("JIN_GOU_DIAO", "金钩钓", 1));
}
}
if (isQingYiSe(winnerSeat)) {
fans.add(new SettlementFan("QING_YI_SE", "清一色", 2));
}
int genCount = countGen(winnerSeat);
for (int i = 0; i < genCount; i++) {
fans.add(new SettlementFan("GEN", "", 1));
}
if (gangShangHua) {
fans.add(new SettlementFan("GANG_SHANG_HUA", "杠上花", 1));
}
if (gangShangPao) {
fans.add(new SettlementFan("GANG_SHANG_PAO", "杠上炮", 1));
}
if (haiDiLaoYue) {
fans.add(new SettlementFan("HAI_DI_LAO_YUE", "海底捞月", 1));
}
if (haiDiPao) {
fans.add(new SettlementFan("HAI_DI_PAO", "海底炮", 1));
}
if (robbingGang) {
fans.add(new SettlementFan("QIANG_GANG_HU", "抢杠胡", 1));
}
int totalFan = fans.stream()
.mapToInt(SettlementFan::fan)
.sum();
int paymentScore = BASE_SCORE << totalFan;
return new SettlementDetail(BASE_SCORE, totalFan, paymentScore, List.copyOf(fans));
}
private boolean isJinGouDiao(GameSeat winnerSeat) {
return winnerSeat.getMeldGroups().size() == 4
&& winnerSeat.getHandTiles().size() == 2
&& winnerSeat.getHandTiles().get(0).equals(winnerSeat.getHandTiles().get(1));
}
private boolean isQingYiSe(GameSeat winnerSeat) {
TileSuit firstSuit = null;
for (Tile tile : winnerSeat.getHandTiles()) {
if (firstSuit == null) {
firstSuit = tile.getSuit();
continue;
}
if (tile.getSuit() != firstSuit) {
return false;
}
}
for (MeldGroup meldGroup : winnerSeat.getMeldGroups()) {
Tile meldTile = meldGroup.tile();
if (firstSuit == null) {
firstSuit = meldTile.getSuit();
continue;
}
if (meldTile.getSuit() != firstSuit) {
return false;
}
}
return firstSuit != null;
}
private int countGen(GameSeat winnerSeat) {
Map<Tile, Integer> tileCounts = new LinkedHashMap<>();
for (Tile tile : winnerSeat.getHandTiles()) {
tileCounts.merge(tile, 1, Integer::sum);
}
for (MeldGroup meldGroup : winnerSeat.getMeldGroups()) {
tileCounts.merge(meldGroup.tile(), tileCountForMeld(meldGroup.type()), Integer::sum);
}
int genCount = 0;
for (Integer count : tileCounts.values()) {
if (count == 4) {
genCount++;
}
}
return genCount;
}
private int tileCountForMeld(MeldType meldType) {
return switch (meldType) {
case PENG -> 3;
case MING_GANG, BU_GANG, AN_GANG -> 4;
};
}
}

View File

@@ -6,6 +6,7 @@ import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat; import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession; import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable; import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.PostGangContext;
import com.xuezhanmaster.game.domain.ResponseActionResolution; import com.xuezhanmaster.game.domain.ResponseActionResolution;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate; import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow; import com.xuezhanmaster.game.domain.ResponseActionWindow;
@@ -495,6 +496,7 @@ public class GameSessionService {
} }
private void continueAfterDiscardWithoutResponse(GameSession session) { private void continueAfterDiscardWithoutResponse(GameSession session) {
session.clearPostGangContext();
moveToNextSeat(session.getTable(), session.getGameId()); moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session); autoPlayBots(session);
notifyActionIfHumanTurn(session); notifyActionIfHumanTurn(session);
@@ -526,6 +528,7 @@ public class GameSessionService {
} }
private void executePeng(GameSession session, ResponseActionResolution resolution) { private void executePeng(GameSession session, ResponseActionResolution resolution) {
session.clearPostGangContext();
Tile claimedTile = parseTile(resolution.triggerTile()); Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
@@ -548,6 +551,7 @@ public class GameSessionService {
} }
private void executeGang(GameSession session, ResponseActionResolution resolution) { private void executeGang(GameSession session, ResponseActionResolution resolution) {
session.clearPostGangContext();
Tile claimedTile = parseTile(resolution.triggerTile()); Tile claimedTile = parseTile(resolution.triggerTile());
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo()); GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
@@ -580,6 +584,7 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat); continueFromResolvedActionTurn(session, winnerSeat);
@@ -619,6 +624,11 @@ public class GameSessionService {
sourceSeat.getSeatNo(), sourceSeat.getSeatNo(),
claimedTile.getDisplayName() claimedTile.getDisplayName()
)); ));
// 牌墙已空时,这次点炮对应“最后一张牌后的弃牌胡”,按海底炮加番。
boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow)
&& isPostGangDiscard(session, sourceSeat.getSeatNo());
boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow)
&& table.getWallTiles().isEmpty();
if (isSupplementalGangWindow(responseActionWindow)) { if (isSupplementalGangWindow(responseActionWindow)) {
appendSettlementEvents(session, settlementService.settleRobbingGangHu( appendSettlementEvents(session, settlementService.settleRobbingGangHu(
table, table,
@@ -628,12 +638,15 @@ public class GameSessionService {
)); ));
} else { } else {
appendSettlementEvents(session, settlementService.settleDiscardHu( appendSettlementEvents(session, settlementService.settleDiscardHu(
table, table,
winnerSeat.getSeatNo(), winnerSeat.getSeatNo(),
sourceSeat.getSeatNo(), sourceSeat.getSeatNo(),
claimedTile.getDisplayName() claimedTile.getDisplayName(),
gangShangPao,
haiDiPao
)); ));
} }
session.clearPostGangContext();
if (shouldFinishTable(table)) { if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED); table.setPhase(GamePhase.FINISHED);
@@ -647,6 +660,9 @@ public class GameSessionService {
private void executeSelfDrawHu(GameSession session, String userId) { private void executeSelfDrawHu(GameSession session, String userId) {
GameTable table = session.getTable(); GameTable table = session.getTable();
GameSeat winnerSeat = findSeatByUserId(table, userId); GameSeat winnerSeat = findSeatByUserId(table, userId);
// 自摸胡时牌墙已空,说明当前这张就是最后一张牌,按海底捞月加番。
boolean gangShangHua = isPostGangDraw(session, winnerSeat.getSeatNo());
boolean haiDiLaoYue = table.getWallTiles().isEmpty();
winnerSeat.declareHu(); winnerSeat.declareHu();
appendAndPublish(session, GameEvent.responseActionDeclared( appendAndPublish(session, GameEvent.responseActionDeclared(
@@ -659,8 +675,11 @@ public class GameSessionService {
appendSettlementEvents(session, settlementService.settleSelfDrawHu( appendSettlementEvents(session, settlementService.settleSelfDrawHu(
table, table,
winnerSeat.getSeatNo(), winnerSeat.getSeatNo(),
null null,
gangShangHua,
haiDiLaoYue
)); ));
session.clearPostGangContext();
if (shouldFinishTable(table)) { if (shouldFinishTable(table)) {
table.setPhase(GamePhase.FINISHED); table.setPhase(GamePhase.FINISHED);
@@ -703,6 +722,7 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat); continueFromResolvedActionTurn(session, winnerSeat);
@@ -764,6 +784,7 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat); continueFromResolvedActionTurn(session, winnerSeat);
@@ -815,6 +836,21 @@ public class GameSessionService {
return huEvaluator.canHu(seat.getHandTiles()); return huEvaluator.canHu(seat.getHandTiles());
} }
private void markPostGangContext(GameSession session, int seatNo, String tileDisplayName) {
// 杠后补摸只对当前这名玩家的下一次自摸/弃牌裁决有效。
session.setPostGangContext(new PostGangContext(seatNo, tileDisplayName));
}
private boolean isPostGangDraw(GameSession session, int seatNo) {
PostGangContext postGangContext = session.getPostGangContext();
return postGangContext != null && postGangContext.seatNo() == seatNo;
}
private boolean isPostGangDiscard(GameSession session, int sourceSeatNo) {
PostGangContext postGangContext = session.getPostGangContext();
return postGangContext != null && postGangContext.seatNo() == sourceSeatNo;
}
private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) { private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) {
return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType()); return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType());
} }

View File

@@ -14,6 +14,9 @@ public class HuEvaluator {
if (tiles.size() % 3 != 2) { if (tiles.size() % 3 != 2) {
return false; return false;
} }
if (isSevenPairs(tiles)) {
return true;
}
int[] counts = buildCounts(tiles); int[] counts = buildCounts(tiles);
for (int i = 0; i < counts.length; i++) { for (int i = 0; i < counts.length; i++) {
@@ -29,6 +32,45 @@ public class HuEvaluator {
return false; return false;
} }
public boolean isSevenPairs(List<Tile> tiles) {
if (tiles.size() != 14) {
return false;
}
int[] counts = buildCounts(tiles);
int pairCount = 0;
for (int count : counts) {
if (count == 0) {
continue;
}
if (count != 2 && count != 4) {
return false;
}
pairCount += count / 2;
}
return pairCount == 7;
}
public boolean isPengPengHu(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 (canFormTripletsOnly(counts)) {
counts[i] += 2;
return true;
}
counts[i] += 2;
}
return false;
}
public boolean canHuWithClaimedTile(List<Tile> handTiles, Tile claimedTile) { public boolean canHuWithClaimedTile(List<Tile> handTiles, Tile claimedTile) {
List<Tile> tiles = new ArrayList<>(handTiles); List<Tile> tiles = new ArrayList<>(handTiles);
tiles.add(claimedTile); tiles.add(claimedTile);
@@ -73,6 +115,20 @@ public class HuEvaluator {
return false; return false;
} }
private boolean canFormTripletsOnly(int[] counts) {
int firstIndex = firstNonZeroIndex(counts);
if (firstIndex == -1) {
return true;
}
if (counts[firstIndex] < 3) {
return false;
}
counts[firstIndex] -= 3;
boolean result = canFormTripletsOnly(counts);
counts[firstIndex] += 3;
return result;
}
private int firstNonZeroIndex(int[] counts) { private int firstNonZeroIndex(int[] counts) {
for (int i = 0; i < counts.length; i++) { for (int i = 0; i < counts.length; i++) {
if (counts[i] > 0) { if (counts[i] > 0) {

View File

@@ -4,6 +4,7 @@ import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GameSeat; import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable; import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ScoreChange; import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementResult; import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType; import com.xuezhanmaster.game.domain.SettlementType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -16,18 +17,25 @@ import java.util.Map;
@Service @Service
public class SettlementService { public class SettlementService {
private static final int DIAN_PAO_HU_SCORE = 1; private final BloodBattleScoringService scoringService;
private static final int ZI_MO_HU_SCORE = 1;
private static final int BU_GANG_SCORE = 1; public SettlementService(BloodBattleScoringService scoringService) {
private static final int MING_GANG_SCORE = 1; this.scoringService = scoringService;
private static final int AN_GANG_SCORE = 2; }
public SettlementResult settleDiscardHu( public SettlementResult settleDiscardHu(
GameTable table, GameTable table,
int winnerSeatNo, int winnerSeatNo,
int sourceSeatNo, int sourceSeatNo,
String triggerTile String triggerTile,
boolean gangShangPao,
boolean haiDiPao
) { ) {
SettlementDetail settlementDetail = scoringService.buildDiscardHuDetail(
table.getSeats().get(winnerSeatNo),
gangShangPao,
haiDiPao
);
return applySettlement( return applySettlement(
table, table,
SettlementType.DIAN_PAO_HU, SettlementType.DIAN_PAO_HU,
@@ -35,7 +43,13 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
sourceSeatNo, sourceSeatNo,
triggerTile, triggerTile,
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) settlementDetail,
orderedDeltas(
winnerSeatNo,
settlementDetail.paymentScore(),
sourceSeatNo,
-settlementDetail.paymentScore()
)
); );
} }
@@ -45,6 +59,7 @@ public class SettlementService {
int sourceSeatNo, int sourceSeatNo,
String triggerTile String triggerTile
) { ) {
SettlementDetail settlementDetail = scoringService.buildExposedGangDetail();
return applySettlement( return applySettlement(
table, table,
SettlementType.MING_GANG, SettlementType.MING_GANG,
@@ -52,23 +67,36 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
sourceSeatNo, sourceSeatNo,
triggerTile, triggerTile,
orderedDeltas(winnerSeatNo, MING_GANG_SCORE, sourceSeatNo, -MING_GANG_SCORE) settlementDetail,
orderedDeltas(
winnerSeatNo,
settlementDetail.paymentScore(),
sourceSeatNo,
-settlementDetail.paymentScore()
)
); );
} }
public SettlementResult settleSelfDrawHu( public SettlementResult settleSelfDrawHu(
GameTable table, GameTable table,
int winnerSeatNo, int winnerSeatNo,
String triggerTile String triggerTile,
boolean gangShangHua,
boolean haiDiLaoYue
) { ) {
SettlementDetail settlementDetail = scoringService.buildSelfDrawHuDetail(
table.getSeats().get(winnerSeatNo),
gangShangHua,
haiDiLaoYue
);
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>(); Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0; int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) { for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue; continue;
} }
scoreDeltas.put(seat.getSeatNo(), -ZI_MO_HU_SCORE); scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore());
totalWinScore += ZI_MO_HU_SCORE; totalWinScore += settlementDetail.paymentScore();
} }
scoreDeltas.put(winnerSeatNo, totalWinScore); scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement( return applySettlement(
@@ -78,6 +106,7 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
winnerSeatNo, winnerSeatNo,
triggerTile, triggerTile,
settlementDetail,
scoreDeltas scoreDeltas
); );
} }
@@ -88,6 +117,7 @@ public class SettlementService {
int sourceSeatNo, int sourceSeatNo,
String triggerTile String triggerTile
) { ) {
SettlementDetail settlementDetail = scoringService.buildRobbingGangHuDetail(table.getSeats().get(winnerSeatNo));
return applySettlement( return applySettlement(
table, table,
SettlementType.QIANG_GANG_HU, SettlementType.QIANG_GANG_HU,
@@ -95,7 +125,13 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
sourceSeatNo, sourceSeatNo,
triggerTile, triggerTile,
orderedDeltas(winnerSeatNo, DIAN_PAO_HU_SCORE, sourceSeatNo, -DIAN_PAO_HU_SCORE) settlementDetail,
orderedDeltas(
winnerSeatNo,
settlementDetail.paymentScore(),
sourceSeatNo,
-settlementDetail.paymentScore()
)
); );
} }
@@ -104,14 +140,15 @@ public class SettlementService {
int winnerSeatNo, int winnerSeatNo,
String triggerTile String triggerTile
) { ) {
SettlementDetail settlementDetail = scoringService.buildSupplementalGangDetail();
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>(); Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0; int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) { for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue; continue;
} }
scoreDeltas.put(seat.getSeatNo(), -BU_GANG_SCORE); scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore());
totalWinScore += BU_GANG_SCORE; totalWinScore += settlementDetail.paymentScore();
} }
scoreDeltas.put(winnerSeatNo, totalWinScore); scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement( return applySettlement(
@@ -121,6 +158,7 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
winnerSeatNo, winnerSeatNo,
triggerTile, triggerTile,
settlementDetail,
scoreDeltas scoreDeltas
); );
} }
@@ -130,14 +168,15 @@ public class SettlementService {
int winnerSeatNo, int winnerSeatNo,
String triggerTile String triggerTile
) { ) {
SettlementDetail settlementDetail = scoringService.buildConcealedGangDetail();
Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>(); Map<Integer, Integer> scoreDeltas = new LinkedHashMap<>();
int totalWinScore = 0; int totalWinScore = 0;
for (GameSeat seat : table.getSeats()) { for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) { if (seat.getSeatNo() == winnerSeatNo || seat.isWon()) {
continue; continue;
} }
scoreDeltas.put(seat.getSeatNo(), -AN_GANG_SCORE); scoreDeltas.put(seat.getSeatNo(), -settlementDetail.paymentScore());
totalWinScore += AN_GANG_SCORE; totalWinScore += settlementDetail.paymentScore();
} }
scoreDeltas.put(winnerSeatNo, totalWinScore); scoreDeltas.put(winnerSeatNo, totalWinScore);
return applySettlement( return applySettlement(
@@ -147,6 +186,7 @@ public class SettlementService {
winnerSeatNo, winnerSeatNo,
winnerSeatNo, winnerSeatNo,
triggerTile, triggerTile,
settlementDetail,
scoreDeltas scoreDeltas
); );
} }
@@ -158,6 +198,7 @@ public class SettlementService {
int actorSeatNo, int actorSeatNo,
int sourceSeatNo, int sourceSeatNo,
String triggerTile, String triggerTile,
SettlementDetail settlementDetail,
Map<Integer, Integer> scoreDeltas Map<Integer, Integer> scoreDeltas
) { ) {
List<ScoreChange> scoreChanges = new ArrayList<>(); List<ScoreChange> scoreChanges = new ArrayList<>();
@@ -176,6 +217,7 @@ public class SettlementService {
actorSeatNo, actorSeatNo,
sourceSeatNo, sourceSeatNo,
triggerTile, triggerTile,
settlementDetail,
List.copyOf(scoreChanges) List.copyOf(scoreChanges)
); );
} }

View File

@@ -2,6 +2,8 @@ package com.xuezhanmaster.game.event;
import com.xuezhanmaster.game.domain.ActionType; import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ScoreChange; import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.SettlementResult; import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType; import com.xuezhanmaster.game.domain.SettlementType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -54,6 +56,12 @@ class GameEventTest {
2, 2,
0, 0,
"9筒", "9筒",
new SettlementDetail(
1,
2,
4,
List.of(new SettlementFan("QING_YI_SE", "清一色", 2))
),
List.of( List.of(
new ScoreChange(2, 1, 3), new ScoreChange(2, 1, 3),
new ScoreChange(0, -1, -2) new ScoreChange(0, -1, -2)
@@ -69,7 +77,12 @@ class GameEventTest {
.containsEntry("actionType", "HU") .containsEntry("actionType", "HU")
.containsEntry("sourceSeatNo", 0) .containsEntry("sourceSeatNo", 0)
.containsEntry("triggerTile", "9筒"); .containsEntry("triggerTile", "9筒");
assertThat(settlementApplied.payload()).containsKey("settlementDetail");
assertThat(settlementApplied.payload()).containsKey("scoreChanges"); assertThat(settlementApplied.payload()).containsKey("scoreChanges");
assertThat((java.util.Map<String, Object>) settlementApplied.payload().get("settlementDetail"))
.containsEntry("baseScore", 1)
.containsEntry("totalFan", 2)
.containsEntry("paymentScore", 4);
assertThat(scoreChanged.eventType()).isEqualTo(GameEventType.SCORE_CHANGED); assertThat(scoreChanged.eventType()).isEqualTo(GameEventType.SCORE_CHANGED);
assertThat(scoreChanged.payload()) assertThat(scoreChanged.payload())

View File

@@ -0,0 +1,128 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BloodBattleScoringServiceTest {
private final HuEvaluator huEvaluator = new HuEvaluator();
private final BloodBattleScoringService scoringService = new BloodBattleScoringService(huEvaluator);
@Test
void shouldEvaluateQingQiDuiWithGen() {
GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一");
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 5));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 5));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 6));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 6));
SettlementDetail settlementDetail = scoringService.buildSelfDrawHuDetail(winnerSeat, false, false);
assertThat(settlementDetail.totalFan()).isEqualTo(5);
assertThat(settlementDetail.paymentScore()).isEqualTo(32);
assertThat(settlementDetail.fans())
.extracting(fan -> fan.code())
.containsExactly("QI_DUI", "QING_YI_SE", "GEN");
}
@Test
void shouldEvaluateJinGouDiaoAndRobbingGangHu() {
GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一");
winnerSeat.addPengMeld(new Tile(TileSuit.WAN, 1), 1);
winnerSeat.addPengMeld(new Tile(TileSuit.WAN, 2), 1);
winnerSeat.addMingGangMeld(new Tile(TileSuit.WAN, 3), 1);
winnerSeat.addAnGangMeld(new Tile(TileSuit.WAN, 4));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 9));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 9));
SettlementDetail settlementDetail = scoringService.buildRobbingGangHuDetail(winnerSeat);
assertThat(settlementDetail.totalFan()).isEqualTo(7);
assertThat(settlementDetail.paymentScore()).isEqualTo(128);
assertThat(settlementDetail.fans())
.extracting(fan -> fan.code())
.containsExactly("DUI_DUI_HU", "JIN_GOU_DIAO", "QING_YI_SE", "GEN", "GEN", "QIANG_GANG_HU");
}
@Test
void shouldExposeFixedGangScores() {
assertThat(scoringService.buildExposedGangDetail().paymentScore()).isEqualTo(2);
assertThat(scoringService.buildSupplementalGangDetail().paymentScore()).isEqualTo(1);
assertThat(scoringService.buildConcealedGangDetail().paymentScore()).isEqualTo(2);
}
@Test
void shouldAddGangShangHuaAndGangShangPaoFans() {
GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一");
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 2));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 3));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 4));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 5));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 6));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 7));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9));
SettlementDetail gangShangHua = scoringService.buildSelfDrawHuDetail(winnerSeat, true, false);
SettlementDetail gangShangPao = scoringService.buildDiscardHuDetail(winnerSeat, true, false);
assertThat(gangShangHua.totalFan()).isEqualTo(1);
assertThat(gangShangHua.paymentScore()).isEqualTo(2);
assertThat(gangShangHua.fans()).extracting(fan -> fan.code()).containsExactly("GANG_SHANG_HUA");
assertThat(gangShangPao.totalFan()).isEqualTo(1);
assertThat(gangShangPao.paymentScore()).isEqualTo(2);
assertThat(gangShangPao.fans()).extracting(fan -> fan.code()).containsExactly("GANG_SHANG_PAO");
}
@Test
void shouldAddHaiDiFans() {
GameSeat winnerSeat = new GameSeat(0, false, "user-1", "玩家一");
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 1));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 2));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 3));
winnerSeat.receiveTile(new Tile(TileSuit.WAN, 4));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 2));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 3));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 4));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 5));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 6));
winnerSeat.receiveTile(new Tile(TileSuit.TIAO, 7));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9));
winnerSeat.receiveTile(new Tile(TileSuit.TONG, 9));
SettlementDetail haiDiLaoYue = scoringService.buildSelfDrawHuDetail(winnerSeat, false, true);
SettlementDetail haiDiPao = scoringService.buildDiscardHuDetail(winnerSeat, false, true);
assertThat(haiDiLaoYue.totalFan()).isEqualTo(1);
assertThat(haiDiLaoYue.paymentScore()).isEqualTo(2);
assertThat(haiDiLaoYue.fans()).extracting(fan -> fan.code()).containsExactly("HAI_DI_LAO_YUE");
assertThat(haiDiPao.totalFan()).isEqualTo(1);
assertThat(haiDiPao.paymentScore()).isEqualTo(2);
assertThat(haiDiPao.fans()).extracting(fan -> fan.code()).containsExactly("HAI_DI_PAO");
}
}

View File

@@ -34,13 +34,14 @@ class GameSessionServiceTest {
private final RoomService roomService = new RoomService(); private final RoomService roomService = new RoomService();
private final HuEvaluator huEvaluator = new HuEvaluator(); private final HuEvaluator huEvaluator = new HuEvaluator();
private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator);
private final GameSessionService gameSessionService = new GameSessionService( private final GameSessionService gameSessionService = new GameSessionService(
roomService, roomService,
new GameEngine(new DeckFactory()), new GameEngine(new DeckFactory()),
new GameActionProcessor(huEvaluator), new GameActionProcessor(huEvaluator),
new ResponseActionWindowBuilder(huEvaluator), new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(), new ResponseActionResolver(),
new SettlementService(), new SettlementService(bloodBattleScoringService),
huEvaluator, huEvaluator,
new StrategyService(), new StrategyService(),
new PlayerVisibilityService(), new PlayerVisibilityService(),
@@ -255,8 +256,8 @@ class GameSessionServiceTest {
); );
assertThat(afterGang.currentSeatNo()).isEqualTo(1); assertThat(afterGang.currentSeatNo()).isEqualTo(1);
assertThat(afterGang.selfSeat().score()).isEqualTo(1); assertThat(afterGang.selfSeat().score()).isEqualTo(2);
assertThat(afterGang.seats().get(0).score()).isEqualTo(-1); assertThat(afterGang.seats().get(0).score()).isEqualTo(-2);
assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile); assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile);
assertThat(session.getEvents()) assertThat(session.getEvents())
.extracting(event -> event.eventType()) .extracting(event -> event.eventType())
@@ -420,17 +421,56 @@ class GameSessionServiceTest {
assertThat(afterGang.selfSeat().handTiles()).hasSize(11); assertThat(afterGang.selfSeat().handTiles()).hasSize(11);
} }
@Test
void shouldScoreGangShangHuaAfterReplacementDrawHu() {
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());
prepareGangShangHuaHand(session.getTable().getSeats().get(0), "3万");
setNextWallTile(session, "9筒");
GameStateResponse afterGang = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "HU", null, null)
);
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(12);
assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(12, -4, -4, -4);
assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_HUA");
}
@Test @Test
void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() { void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true)); roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1"); GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name()); GameStateResponse afterLack = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
String invalidGangTile = findTileWithLessThanFourCopies(session.getTable().getSeats().get(0));
assertThatThrownBy(() -> gameSessionService.performAction( assertThatThrownBy(() -> gameSessionService.performAction(
started.gameId(), afterLack.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null) new GameActionRequest("host-1", "GANG", invalidGangTile, null)
)) ))
.isInstanceOf(BusinessException.class) .isInstanceOf(BusinessException.class)
.extracting(throwable -> ((BusinessException) throwable).getCode()) .extracting(throwable -> ((BusinessException) throwable).getCode())
@@ -504,11 +544,106 @@ class GameSessionServiceTest {
assertThat(afterHu.phase()).isEqualTo("PLAYING"); assertThat(afterHu.phase()).isEqualTo("PLAYING");
assertThat(afterHu.currentSeatNo()).isEqualTo(2); assertThat(afterHu.currentSeatNo()).isEqualTo(2);
assertThat(afterHu.selfSeat().won()).isTrue(); assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(1); assertThat(afterHu.selfSeat().score()).isEqualTo(2);
assertThat(afterHu.seats().get(0).score()).isEqualTo(-1); assertThat(afterHu.seats().get(0).score()).isEqualTo(-2);
assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒"); assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒");
} }
@Test
void shouldScoreGangShangPaoOnDiscardAfterReplacementDraw() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameSession session = getSession(started.gameId());
prepareGangShangPaoHand(session.getTable().getSeats().get(0), "3万");
prepareWinningHand(session.getTable().getSeats().get(1));
removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1);
setNextWallTile(session, "8条");
GameStateResponse afterGang = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "GANG", "3万", null)
);
GameStateResponse afterHu = gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", "9筒", 0)
);
assertThat(afterGang.currentSeatNo()).isEqualTo(0);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(0);
assertThat(afterHu.seats().get(0).score()).isEqualTo(4);
assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_PAO");
}
@Test
void shouldScoreHaiDiLaoYueOnLastTileSelfDraw() {
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());
prepareSelfDrawWinningHand(session.getTable().getSeats().get(0));
session.getTable().getWallTiles().clear();
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "HU", null, null)
);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(6);
assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2);
assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_LAO_YUE");
}
@Test
void shouldScoreHaiDiPaoOnLastTileDiscardHu() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", 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());
GameSession session = getSession(started.gameId());
prepareWinningHand(session.getTable().getSeats().get(1));
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "9筒", 1);
removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1);
session.getTable().getWallTiles().clear();
gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", "9筒", 0)
);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(2);
assertThat(afterHu.seats().get(0).score()).isEqualTo(-2);
assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO");
}
@Test @Test
void shouldRejectSelfDrawHuWhenHandIsNotWinning() { void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
@@ -575,6 +710,21 @@ class GameSessionServiceTest {
}; };
} }
private String findTileWithLessThanFourCopies(GameSeat seat) {
for (Tile tile : seat.getHandTiles()) {
int matchCount = 0;
for (Tile handTile : seat.getHandTiles()) {
if (handTile.equals(tile)) {
matchCount++;
}
}
if (matchCount < 4) {
return tile.getDisplayName();
}
}
throw new IllegalStateException("测试手牌意外全部为四张同牌");
}
private void prepareWinningHand(GameSeat seat) { private void prepareWinningHand(GameSeat seat) {
seat.getHandTiles().clear(); seat.getHandTiles().clear();
seat.receiveTile(new Tile(TileSuit.WAN, 1)); seat.receiveTile(new Tile(TileSuit.WAN, 1));
@@ -628,6 +778,42 @@ class GameSessionServiceTest {
seat.receiveTile(new Tile(TileSuit.WAN, 9)); seat.receiveTile(new Tile(TileSuit.WAN, 9));
} }
private void prepareGangShangHuaHand(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, 1));
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.WAN, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 2));
seat.receiveTile(new Tile(TileSuit.TONG, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 9));
}
private void prepareGangShangPaoHand(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, 1));
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.WAN, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 2));
seat.receiveTile(new Tile(TileSuit.TONG, 3));
seat.receiveTile(new Tile(TileSuit.TONG, 4));
seat.receiveTile(new Tile(TileSuit.TONG, 9));
}
private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) { private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
seat.getHandTiles().clear(); seat.getHandTiles().clear();
seat.getMeldGroups().clear(); seat.getMeldGroups().clear();
@@ -645,4 +831,21 @@ class GameSessionServiceTest {
seat.receiveTile(new Tile(TileSuit.TIAO, 9)); seat.receiveTile(new Tile(TileSuit.TIAO, 9));
seat.receiveTile(new Tile(TileSuit.WAN, 9)); seat.receiveTile(new Tile(TileSuit.WAN, 9));
} }
private void setNextWallTile(GameSession session, String tileDisplayName) {
session.getTable().getWallTiles().set(0, parseTile(tileDisplayName));
}
@SuppressWarnings("unchecked")
private java.util.List<String> latestSettlementFanCodes(GameSession session) {
return session.getEvents().stream()
.filter(event -> event.eventType() == GameEventType.SETTLEMENT_APPLIED)
.reduce((first, second) -> second)
.map(event -> (Map<String, Object>) event.payload().get("settlementDetail"))
.map(detail -> (java.util.List<Map<String, Object>>) detail.get("fans"))
.map(fans -> fans.stream()
.map(item -> String.valueOf(item.get("code")))
.toList())
.orElseGet(java.util.List::of);
}
} }

View File

@@ -59,4 +59,26 @@ class HuEvaluatorTest {
assertThat(result).isFalse(); assertThat(result).isFalse();
} }
@Test
void shouldRecognizeSevenPairsWinningHand() {
boolean result = huEvaluator.canHu(List.of(
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 1),
new Tile(TileSuit.WAN, 2),
new Tile(TileSuit.WAN, 2),
new Tile(TileSuit.WAN, 3),
new Tile(TileSuit.WAN, 3),
new Tile(TileSuit.TONG, 4),
new Tile(TileSuit.TONG, 4),
new Tile(TileSuit.TONG, 5),
new Tile(TileSuit.TONG, 5),
new Tile(TileSuit.TIAO, 6),
new Tile(TileSuit.TIAO, 6),
new Tile(TileSuit.TIAO, 9),
new Tile(TileSuit.TIAO, 9)
));
assertThat(result).isTrue();
}
} }

View File

@@ -232,6 +232,13 @@
- 统一玩家可见状态模型 - 统一玩家可见状态模型
- 前后端统一阶段与动作枚举语义 - 前后端统一阶段与动作枚举语义
### 5.3 注释与脚本约定
- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名猜语义。
- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。
- 后续数据库表结构、迁移脚本、初始化 SQL 与存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因与关键索引用途。
- 注释要求遵守 `KISS`:只解释不直观的意图、约束和边界,不写“变量赋值”这类冗余注释。
### 5.2 最关键的系统约束 ### 5.2 最关键的系统约束
#### 约束一:教学必须基于玩家可见状态 #### 约束一:教学必须基于玩家可见状态