feat(计分): 实现血战计分V1核心逻辑
- 新增血战计分服务,支持七对、清一色等基础番型及杠上花等特殊加番 - 扩展结算结果结构,包含番型明细与支付分数计算 - 新增PostGangContext记录杠后补摸窗口,用于判断杠上花/杠上炮 - 完善胡牌判定器,新增七对和对对胡识别方法 - 更新开发计划文档,补充注释规范要求 - 添加计分相关单元测试,确保核心逻辑正确性
This commit is contained in:
Binary file not shown.
40
.serena/memories/blood_battle_scoring_v1.md
Normal file
40
.serena/memories/blood_battle_scoring_v1.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 正式血战计分 V1(2026-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 个测试通过。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public record PostGangContext(
|
||||||
|
int seatNo,
|
||||||
|
String tileDisplayName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.xuezhanmaster.game.domain;
|
||||||
|
|
||||||
|
public record SettlementFan(
|
||||||
|
String code,
|
||||||
|
String label,
|
||||||
|
int fan
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -631,9 +641,12 @@ public class GameSessionService {
|
|||||||
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,13 @@
|
|||||||
- 统一玩家可见状态模型
|
- 统一玩家可见状态模型
|
||||||
- 前后端统一阶段与动作枚举语义
|
- 前后端统一阶段与动作枚举语义
|
||||||
|
|
||||||
|
### 5.3 注释与脚本约定
|
||||||
|
|
||||||
|
- 后端新增或修改的业务代码,需要为复杂规则、关键字段和跨阶段状态补充简洁中文注释,避免只靠方法名猜语义。
|
||||||
|
- 前端新增或修改的页面与状态逻辑,需要为复杂交互、实时消息消费和视图状态切换补充简洁中文注释,避免后续拆页时理解断层。
|
||||||
|
- 后续数据库表结构、迁移脚本、初始化 SQL 与存储过程,也需要补充必要中文注释,重点说明业务含义、约束原因与关键索引用途。
|
||||||
|
- 注释要求遵守 `KISS`:只解释不直观的意图、约束和边界,不写“变量赋值”这类冗余注释。
|
||||||
|
|
||||||
### 5.2 最关键的系统约束
|
### 5.2 最关键的系统约束
|
||||||
|
|
||||||
#### 约束一:教学必须基于玩家可见状态
|
#### 约束一:教学必须基于玩家可见状态
|
||||||
|
|||||||
Reference in New Issue
Block a user