feat: 添加局后复盘服务与页面容器组件

新增复盘服务相关DTO、Controller和Service
实现复盘页面容器组件ReviewPageContainer
更新前端页面架构文档与开发计划
移除DemoGameController中的演示复盘接口
补充复盘服务单元测试
This commit is contained in:
hujun
2026-03-20 16:50:49 +08:00
parent 905565e7c4
commit faf87fe3d6
27 changed files with 1639 additions and 280 deletions

View File

@@ -28,4 +28,3 @@ public class DemoGameController {
return ApiResponse.success(demoGameService.createDemoTeachingAdvice());
}
}

View File

@@ -107,6 +107,14 @@ public class GameSessionService {
return toStateResponse(session, userId);
}
/**
* 复盘服务当前仍直接消费内存态对局与结算历史,
* 这里先提供只读查询入口,避免 review 包直接接触会话仓储细节。
*/
public GameSession getSessionForReview(String gameId) {
return getRequiredSession(gameId);
}
public GameStateResponse performAction(String gameId, GameActionRequest request) {
GameSession session = getRequiredSession(gameId);
ActionType actionType = parseActionType(request.actionType());

View File

@@ -0,0 +1,29 @@
package com.xuezhanmaster.review.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
import com.xuezhanmaster.review.service.ReviewService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class ReviewController {
private final ReviewService reviewService;
public ReviewController(ReviewService reviewService) {
this.reviewService = reviewService;
}
@GetMapping("/games/{gameId}/review")
public ApiResponse<ReviewSummaryResponse> review(
@PathVariable String gameId,
@RequestParam String userId
) {
return ApiResponse.success(reviewService.createGameReviewSummary(gameId, userId));
}
}

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.review.dto;
/**
* 复盘页里的“关键失误”条目。
* 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。
*/
public record ReviewMistakeItem(
String severity,
String title,
String issue,
String suggestion
) {
}

View File

@@ -0,0 +1,15 @@
package com.xuezhanmaster.review.dto;
import java.util.List;
/**
* 复盘页里的“关键结算节点”条目。
* 当前先用扁平结构表达,后续接真实局后数据时再决定是否拆更细的事件层级。
*/
public record ReviewSettlementItem(
String title,
String summary,
int scoreDelta,
List<String> fanLabels
) {
}

View File

@@ -0,0 +1,21 @@
package com.xuezhanmaster.review.dto;
import java.util.List;
/**
* 局后复盘页的最小协议骨架。
* 当前先覆盖“总览、关键失误、训练方向”三块正式页面必需信息,后续再按真实复盘算法逐步扩展。
*/
public record ReviewSummaryResponse(
String gameId,
String userId,
String playerNickname,
int seatNo,
int finalScore,
String resultLabel,
String conclusion,
List<ReviewSettlementItem> settlementTimeline,
List<ReviewMistakeItem> mistakeInsights,
List<ReviewTrainingFocusItem> trainingFocuses
) {
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.review.dto;
/**
* 复盘页里的“后续训练方向”条目。
* 当前先保留最小训练类型标签,避免过早引入大而全训练体系。
*/
public record ReviewTrainingFocusItem(
String drillType,
String title,
String description
) {
}

View File

@@ -0,0 +1,400 @@
package com.xuezhanmaster.review.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.SettlementType;
import com.xuezhanmaster.game.service.GameSessionService;
import com.xuezhanmaster.review.dto.ReviewMistakeItem;
import com.xuezhanmaster.review.dto.ReviewSettlementItem;
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
import com.xuezhanmaster.review.dto.ReviewTrainingFocusItem;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class ReviewService {
private final GameSessionService gameSessionService;
public ReviewService(GameSessionService gameSessionService) {
this.gameSessionService = gameSessionService;
}
public ReviewSummaryResponse createGameReviewSummary(String gameId, String userId) {
GameSession session = gameSessionService.getSessionForReview(gameId);
GameSeat reviewSeat = session.getTable().getSeats().stream()
.filter(seat -> seat.getPlayerId().equals(userId))
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
List<SettlementResult> settlementHistory = List.copyOf(session.getSettlementHistory());
List<ReviewSettlementItem> settlementTimeline = buildSettlementTimeline(settlementHistory, reviewSeat.getSeatNo());
List<ReviewMistakeItem> mistakeInsights = buildMistakeInsights(settlementHistory, reviewSeat.getSeatNo());
List<ReviewTrainingFocusItem> trainingFocuses = buildTrainingFocuses(
settlementHistory,
reviewSeat.getSeatNo(),
reviewSeat.getScore(),
mistakeInsights
);
return new ReviewSummaryResponse(
session.getGameId(),
reviewSeat.getPlayerId(),
reviewSeat.getNickname(),
reviewSeat.getSeatNo(),
reviewSeat.getScore(),
buildResultLabel(reviewSeat.getScore()),
buildConclusion(reviewSeat, settlementTimeline, mistakeInsights),
settlementTimeline,
mistakeInsights,
trainingFocuses
);
}
private List<ReviewSettlementItem> buildSettlementTimeline(List<SettlementResult> settlementHistory, int seatNo) {
List<ReviewSettlementItem> items = new ArrayList<>();
for (SettlementResult settlementResult : settlementHistory) {
Integer scoreDelta = resolveSeatDelta(settlementResult, seatNo);
if (scoreDelta == null) {
continue;
}
List<String> fanLabels = buildFanLabels(settlementResult);
items.add(new ReviewSettlementItem(
buildSettlementTitle(settlementResult, scoreDelta),
buildSettlementSummary(settlementResult, scoreDelta, fanLabels),
scoreDelta,
fanLabels
));
}
if (items.isEmpty()) {
items.add(new ReviewSettlementItem(
"暂无个人结算",
"当前对局还没有产生与你直接相关的结算变化,复盘摘要会在出现真实得失分后逐步丰富。",
0,
List.of("等待结算")
));
}
return List.copyOf(items);
}
private List<ReviewMistakeItem> buildMistakeInsights(List<SettlementResult> settlementHistory, int seatNo) {
List<SettlementResult> negativeSettlements = settlementHistory.stream()
.filter(result -> {
Integer delta = resolveSeatDelta(result, seatNo);
return delta != null && delta < 0;
})
.sorted(Comparator.comparingInt((SettlementResult result) -> Math.abs(resolveSeatDelta(result, seatNo))).reversed())
.limit(3)
.toList();
List<ReviewMistakeItem> items = new ArrayList<>();
for (SettlementResult settlementResult : negativeSettlements) {
int scoreDelta = resolveSeatDelta(settlementResult, seatNo);
items.add(switch (settlementResult.settlementType()) {
case DIAN_PAO_HU -> new ReviewMistakeItem(
"HIGH",
"点炮导致失分",
"你在他人弃牌胡结算中承担了 " + formatScore(scoreDelta) + ",说明中后盘危险张控制仍有明显缺口。",
"当对手已经副露成型或番型抬高时,优先保留现成安全张,避免继续压高危中张。"
);
case QIANG_GANG_HU -> new ReviewMistakeItem(
"HIGH",
"补杠时机过激",
"你在补杠后被对手抢杠胡,直接产生 " + formatScore(scoreDelta) + " 的回撤。",
"补杠前先检查场上是否已出现高压听牌信号,必要时放弃补杠收益,优先确保不放大失分。"
);
case ZI_MO_HU -> new ReviewMistakeItem(
"MEDIUM",
"未能压住对手自摸",
"对手自摸时你被扣除 " + formatScore(scoreDelta) + ",说明本局在速度或成叫质量上没有形成足够压制。",
"后续训练应强化中盘成型速度判断,避免在明显落后牌速时仍维持松散手型。"
);
case TUI_SHUI -> new ReviewMistakeItem(
"MEDIUM",
"杠分未守住",
"本局出现退税,导致你回吐 " + formatScore(scoreDelta) + ",说明前序杠牌收益没有稳定兑现。",
"练习在流局风险升高时复盘杠牌收益与听牌质量,避免为短期税分牺牲整体成叫。"
);
case CHA_JIAO -> new ReviewMistakeItem(
"HIGH",
"流局未成叫",
"流局查叫阶段你承担了 " + formatScore(scoreDelta) + ",反映出收尾阶段的成叫效率不足。",
"进入残局后要更早切换到保叫路线,优先确保最小可支付听牌,而不是继续追求高番远型。"
);
case MING_GANG -> new ReviewMistakeItem(
"MEDIUM",
"放杠失分",
"你在他人明杠结算里承担了 " + formatScore(scoreDelta) + ",说明对明显碰后加杠的防范还不够。",
"看到对手碰后,应更早记录其可能补强的牌张,减少继续喂牌或协助做大的情况。"
);
case BU_GANG, AN_GANG -> new ReviewMistakeItem(
"LOW",
"杠分压制不足",
"对手通过杠牌从你这里拿走了 " + formatScore(scoreDelta) + ",本局资源交换略偏被动。",
"后续可专项训练杠前后局势判断,明确什么时候该抢速度,什么时候该减少被动付分。"
);
});
}
return List.copyOf(items);
}
private List<ReviewTrainingFocusItem> buildTrainingFocuses(
List<SettlementResult> settlementHistory,
int seatNo,
int finalScore,
List<ReviewMistakeItem> mistakeInsights
) {
List<ReviewTrainingFocusItem> items = new ArrayList<>();
Set<String> addedDrillTypes = new LinkedHashSet<>();
if (hasNegativeSettlement(settlementHistory, seatNo, SettlementType.DIAN_PAO_HU, SettlementType.QIANG_GANG_HU, SettlementType.ZI_MO_HU)) {
addTrainingFocus(
items,
addedDrillTypes,
"RISK_CONTROL",
"危险张与守备节奏训练",
"围绕真实失分节点复盘中后盘危险张排序,建立“副露压力上升时先保底不放炮”的判断习惯。"
);
}
if (hasNegativeSettlement(settlementHistory, seatNo, SettlementType.CHA_JIAO) || finalScore <= 0) {
addTrainingFocus(
items,
addedDrillTypes,
"READY_HAND",
"成叫效率训练",
"重点训练残局从做大切换到保叫的时机,避免在查叫阶段继续承担被动失分。"
);
}
if (hasSettlement(settlementHistory, seatNo, SettlementType.MING_GANG, SettlementType.BU_GANG, SettlementType.AN_GANG, SettlementType.TUI_SHUI)) {
addTrainingFocus(
items,
addedDrillTypes,
"GANG_TIMING",
"杠牌收益判断训练",
"把本局杠牌、退税与抢杠相关节点串起来看,训练“能不能杠”和“杠完是否值得”的双重判断。"
);
}
if (!hasPositiveHu(settlementHistory, seatNo)) {
addTrainingFocus(
items,
addedDrillTypes,
"PATTERN_BUILDING",
"基础番型成型训练",
"当前结算里你的直接胡牌样本不足,建议先从常见番型与听牌速度训练入手,提升稳定得分能力。"
);
}
if (items.isEmpty()) {
addTrainingFocus(
items,
addedDrillTypes,
"ADVANTAGE_CONVERT",
"优势局扩大训练",
"本局没有明显失误结算,后续可把重点放在如何把已有领先进一步转化为更稳定的收尾优势。"
);
}
if (items.size() == 1 && !mistakeInsights.isEmpty()) {
addTrainingFocus(
items,
addedDrillTypes,
"REVIEW_REPEAT",
"关键失分复现训练",
"把本局最高代价的 1 到 2 个结算节点单独复现,形成固定复盘模板,提升下一局的可迁移性。"
);
}
return List.copyOf(items);
}
private void addTrainingFocus(
List<ReviewTrainingFocusItem> items,
Set<String> addedDrillTypes,
String drillType,
String title,
String description
) {
if (addedDrillTypes.add(drillType)) {
items.add(new ReviewTrainingFocusItem(drillType, title, description));
}
}
private String buildResultLabel(int finalScore) {
if (finalScore > 0) {
return "本局净胜";
}
if (finalScore < 0) {
return "本局失分";
}
return "本局持平";
}
private String buildConclusion(
GameSeat reviewSeat,
List<ReviewSettlementItem> settlementTimeline,
List<ReviewMistakeItem> mistakeInsights
) {
long positiveSettlements = settlementTimeline.stream()
.filter(item -> item.scoreDelta() > 0)
.count();
long negativeSettlements = settlementTimeline.stream()
.filter(item -> item.scoreDelta() < 0)
.count();
if (reviewSeat.getScore() > 0) {
if (negativeSettlements > 0 && !mistakeInsights.isEmpty()) {
return "本局净胜 " + formatScore(reviewSeat.getScore())
+ ",虽然通过 " + positiveSettlements + " 次真实得分节点建立优势,但仍暴露出“"
+ mistakeInsights.get(0).title() + "”这类回撤点。";
}
return "本局净胜 " + formatScore(reviewSeat.getScore())
+ ",主要依靠 " + positiveSettlements + " 次真实结算建立优势,整体节奏相对稳定。";
}
if (reviewSeat.getScore() < 0) {
if (!mistakeInsights.isEmpty()) {
return "本局净失 " + formatScore(reviewSeat.getScore())
+ ",关键回撤集中在“" + mistakeInsights.get(0).title()
+ "”,建议优先围绕该节点做针对性训练。";
}
return "本局净失 " + formatScore(reviewSeat.getScore())
+ ",当前直接失分样本不多,但结算收益不足,后续需要优先提升稳定成叫与基础得分能力。";
}
return "本局最终持平,期间共有 " + positiveSettlements + " 次得分与 " + negativeSettlements
+ " 次失分节点,后续应重点复盘收益转化是否足够稳定。";
}
private boolean hasNegativeSettlement(List<SettlementResult> settlementHistory, int seatNo, SettlementType... settlementTypes) {
Set<SettlementType> acceptedTypes = Set.of(settlementTypes);
return settlementHistory.stream()
.filter(result -> acceptedTypes.contains(result.settlementType()))
.anyMatch(result -> {
Integer delta = resolveSeatDelta(result, seatNo);
return delta != null && delta < 0;
});
}
private boolean hasSettlement(List<SettlementResult> settlementHistory, int seatNo, SettlementType... settlementTypes) {
Set<SettlementType> acceptedTypes = Set.of(settlementTypes);
return settlementHistory.stream()
.filter(result -> acceptedTypes.contains(result.settlementType()))
.anyMatch(result -> resolveSeatDelta(result, seatNo) != null);
}
private boolean hasPositiveHu(List<SettlementResult> settlementHistory, int seatNo) {
return settlementHistory.stream()
.filter(result -> result.settlementType() == SettlementType.DIAN_PAO_HU
|| result.settlementType() == SettlementType.ZI_MO_HU
|| result.settlementType() == SettlementType.QIANG_GANG_HU)
.anyMatch(result -> {
Integer delta = resolveSeatDelta(result, seatNo);
return delta != null && delta > 0;
});
}
private Integer resolveSeatDelta(SettlementResult settlementResult, int seatNo) {
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
if (scoreChange.seatNo() == seatNo) {
return scoreChange.delta();
}
}
return null;
}
private List<String> buildFanLabels(SettlementResult settlementResult) {
if (!settlementResult.settlementDetail().fans().isEmpty()) {
return settlementResult.settlementDetail().fans().stream()
.map(SettlementFan::label)
.toList();
}
return List.of(settlementTypeLabel(settlementResult.settlementType()));
}
private String buildSettlementTitle(SettlementResult settlementResult, int scoreDelta) {
return switch (settlementResult.settlementType()) {
case DIAN_PAO_HU -> scoreDelta > 0 ? "点炮胡得分" : "点炮失分";
case ZI_MO_HU -> scoreDelta > 0 ? "自摸胡得分" : "对手自摸失分";
case QIANG_GANG_HU -> scoreDelta > 0 ? "抢杠胡得分" : "补杠被抢";
case MING_GANG -> scoreDelta > 0 ? "明杠收益" : "放杠失分";
case BU_GANG -> scoreDelta > 0 ? "补杠收益" : "对手补杠失分";
case AN_GANG -> scoreDelta > 0 ? "暗杠收益" : "对手暗杠失分";
case TUI_SHUI -> scoreDelta > 0 ? "退税回补" : "杠分被退税";
case CHA_JIAO -> scoreDelta > 0 ? "查叫收益" : "流局查叫失分";
};
}
private String buildSettlementSummary(
SettlementResult settlementResult,
int scoreDelta,
List<String> fanLabels
) {
String fanSummary = fanLabels.isEmpty() ? "" : ",标签:" + String.join(" / ", fanLabels);
return switch (settlementResult.settlementType()) {
case DIAN_PAO_HU -> scoreDelta > 0
? "你通过他人弃牌完成胡牌,获得 " + formatScore(scoreDelta)
+ ",来源座位 " + settlementResult.sourceSeatNo() + fanSummary + ""
: "你向座位 " + settlementResult.actorSeatNo() + " 点炮,产生 "
+ formatScore(scoreDelta) + fanSummary + "";
case ZI_MO_HU -> scoreDelta > 0
? "你完成自摸,从仍未胡牌对手处累计获得 " + formatScore(scoreDelta) + fanSummary + ""
: "座位 " + settlementResult.actorSeatNo() + " 自摸时,你承担了 "
+ formatScore(scoreDelta) + fanSummary + "";
case QIANG_GANG_HU -> scoreDelta > 0
? "你在抢杠窗口完成胡牌,获得 " + formatScore(scoreDelta)
+ ",目标来自座位 " + settlementResult.sourceSeatNo() + fanSummary + ""
: "你补杠时被座位 " + settlementResult.actorSeatNo() + " 抢杠胡,损失 "
+ formatScore(scoreDelta) + fanSummary + "";
case MING_GANG -> scoreDelta > 0
? "你通过明杠从座位 " + settlementResult.sourceSeatNo() + " 取得 "
+ formatScore(scoreDelta) + fanSummary + ""
: "你为座位 " + settlementResult.actorSeatNo() + " 的明杠支付了 "
+ formatScore(scoreDelta) + fanSummary + "";
case BU_GANG -> scoreDelta > 0
? "你完成补杠,从其余未胡玩家处累计获得 " + formatScore(scoreDelta) + fanSummary + ""
: "座位 " + settlementResult.actorSeatNo() + " 补杠时,你承担了 "
+ formatScore(scoreDelta) + fanSummary + "";
case AN_GANG -> scoreDelta > 0
? "你完成暗杠,从其余未胡玩家处累计获得 " + formatScore(scoreDelta) + fanSummary + ""
: "座位 " + settlementResult.actorSeatNo() + " 暗杠时,你承担了 "
+ formatScore(scoreDelta) + fanSummary + "";
case TUI_SHUI -> scoreDelta > 0
? "你在局末获得退税回补,拿回 " + formatScore(scoreDelta)
+ ",来源座位 " + settlementResult.sourceSeatNo() + ""
: "你被执行退税,向座位 " + settlementResult.actorSeatNo() + " 回吐了 "
+ formatScore(scoreDelta) + "";
case CHA_JIAO -> scoreDelta > 0
? "流局查叫阶段,你从座位 " + settlementResult.sourceSeatNo() + " 获得 "
+ formatScore(scoreDelta) + fanSummary + ""
: "流局查叫阶段,你向座位 " + settlementResult.actorSeatNo() + " 支付了 "
+ formatScore(scoreDelta) + fanSummary + "";
};
}
private String settlementTypeLabel(SettlementType settlementType) {
return switch (settlementType) {
case DIAN_PAO_HU -> "点炮胡";
case QIANG_GANG_HU -> "抢杠胡";
case ZI_MO_HU -> "自摸胡";
case BU_GANG -> "补杠";
case MING_GANG -> "明杠";
case AN_GANG -> "暗杠";
case TUI_SHUI -> "退税";
case CHA_JIAO -> "查叫";
};
}
private String formatScore(int scoreDelta) {
return scoreDelta > 0 ? "+" + scoreDelta : String.valueOf(scoreDelta);
}
}

View File

@@ -0,0 +1,97 @@
package com.xuezhanmaster.review.service;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.SettlementDetail;
import com.xuezhanmaster.game.domain.SettlementFan;
import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.game.service.BloodBattleScoringService;
import com.xuezhanmaster.game.service.DeckFactory;
import com.xuezhanmaster.game.service.GameActionProcessor;
import com.xuezhanmaster.game.service.GameSessionService;
import com.xuezhanmaster.game.service.HuEvaluator;
import com.xuezhanmaster.game.service.ResponseActionResolver;
import com.xuezhanmaster.game.service.ResponseActionWindowBuilder;
import com.xuezhanmaster.game.service.SettlementService;
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import com.xuezhanmaster.room.service.RoomService;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class ReviewServiceTest {
private final RoomService roomService = new RoomService();
private final HuEvaluator huEvaluator = new HuEvaluator();
private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator);
private final SettlementService settlementService = new SettlementService(bloodBattleScoringService);
private final GameSessionService gameSessionService = new GameSessionService(
roomService,
new GameEngine(new DeckFactory()),
new GameActionProcessor(huEvaluator),
new ResponseActionWindowBuilder(huEvaluator),
new ResponseActionResolver(),
settlementService,
bloodBattleScoringService,
huEvaluator,
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService(),
new GameMessagePublisher(mock(SimpMessagingTemplate.class))
);
private final ReviewService reviewService = new ReviewService(gameSessionService);
@Test
void shouldBuildReviewSummaryFromRealSessionSettlementHistory() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
GameSession session = gameSessionService.getSessionForReview(started.gameId());
SettlementResult positiveGang = settlementService.settleSupplementalGang(session.getTable(), 0, "三万");
session.getSettlementHistory().add(positiveGang);
SettlementResult negativeChaJiao = settlementService.settleChaJiao(
session.getTable(),
2,
0,
"七筒",
new SettlementDetail(1, 2, 4, List.of(
new SettlementFan("QING_YI_SE", "清一色", 2)
))
);
session.getSettlementHistory().add(negativeChaJiao);
ReviewSummaryResponse review = reviewService.createGameReviewSummary(started.gameId(), "host-1");
assertThat(review.gameId()).isEqualTo(started.gameId());
assertThat(review.userId()).isEqualTo("host-1");
assertThat(review.playerNickname()).isEqualTo("Host");
assertThat(review.finalScore()).isEqualTo(-1);
assertThat(review.resultLabel()).isEqualTo("本局失分");
assertThat(review.settlementTimeline()).hasSize(2);
assertThat(review.settlementTimeline())
.extracting(item -> item.title())
.containsExactly("补杠收益", "流局查叫失分");
assertThat(review.mistakeInsights())
.extracting(item -> item.title())
.contains("流局未成叫");
assertThat(review.trainingFocuses())
.extracting(item -> item.drillType())
.contains("READY_HAND", "GANG_TIMING");
}
}