feat(复盘): 实现关键动作时间线并增强失误分析

新增关键动作时间线记录和展示功能,完善失误分析模块:
1. 添加 ReviewActionTimelineItem 记录结构,用于记录关键动作事件
2. 在 ReviewSummaryResponse 中增加 actionTimeline 字段
3. 扩展 ReviewMistakeItem 结构,新增关联动作、推荐方向、压力来源等字段
4. 实现动作时间线构建和失误关联动作查找逻辑
5. 前端增加动作时间线和详细失误信息的展示组件
6. 更新文档说明当前复盘协议已支持真实对局数据

这些改动使复盘系统能够展示更详细的对局过程分析和更精准的失误定位,帮助玩家更好地理解对局关键节点和改进方向。
This commit is contained in:
hujun
2026-03-20 17:24:41 +08:00
parent faf87fe3d6
commit 27142804d6
9 changed files with 889 additions and 24 deletions

View File

@@ -1,16 +1,25 @@
# 局后复盘服务 V12026-03-20 # 局后复盘服务 V12026-03-20
- 已移除 demo 复盘链路:`DemoGameController` 不再暴露 `GET /api/demo/review``DemoGameService` 也已移除 demo review 生成逻辑。 - 已移除 demo 复盘链路:`DemoGameController` 不再暴露 `GET /api/demo/review``DemoGameService` 也已移除 demo review 生成逻辑。
- 新增真实复盘接口:`GET /api/games/{gameId}/review?userId={userId}` - 当前真实复盘接口:`GET /api/games/{gameId}/review?userId={userId}`
- `ReviewController` 负责暴露接口,`ReviewService` 负责基于真实 `GameSession``settlementHistory` 生成 `ReviewSummaryResponse` - `ReviewController` 负责暴露接口,`ReviewService` 负责基于真实 `GameSession``events``settlementHistory` 生成 `ReviewSummaryResponse`
- `GameSessionService` 新增只读查询口 `getSessionForReview(String gameId)`,当前仅供复盘服务读取内存态会话;未引入新持久化或快照表。 - `GameSessionService` 提供只读查询口 `getSessionForReview(String gameId)`,当前仅供复盘服务读取内存态会话;未引入新持久化或快照表。
- 当前复盘算法范围KISS/YAGNI 版本) - 当前复盘协议内容
- 个人总览:读取当前座位最终分数、结果标签、总结文案。 - 个人总览:最终分数、结果标签、总结文案。
- 关键动作时间线:`actionTimeline`,从真实 `GameEvent` 中提炼个人相关关键节点与重要桌面节点,当前包含开局、阶段切换、定缺、个人摸打、响应窗口、个人响应动作、与本人相关结算等。
- 关键结算:从 `settlementHistory` 中筛出与该座位直接相关的结算,生成标题、摘要、分值和番型/结算标签。 - 关键结算:从 `settlementHistory` 中筛出与该座位直接相关的结算,生成标题、摘要、分值和番型/结算标签。
- 关键失误:基于真实负向结算生成启发式问题项,当前覆盖点炮、自摸失分、抢杠胡、退税、查叫、明杠/杠类被动付分。 - 关键失误:基于真实负向结算生成启发式问题项,并补齐四层信息:
- 关联动作:`relatedStepNo``relatedEventType``relatedActionTitle`
- 推荐方向:`recommendedDirectionType``recommendedDirectionLabel``recommendedAction`
- 局面信号:`contextSignals`,当前从公开事件历史提炼触发牌、他家副露次数、牌墙余量、是否直接触发后续失分、是否已接近流局、是否处于补杠风险窗口等。
- 压力来源解释:`pressureSourceType``pressureSourceLabel``pressureSourceDetail``pressureSummary`,其中 `pressureSourceDetail` 会把“谁在施压、由什么公开动作或残局窗口形成、你这一步为什么撞上去”串成一段玩家可读说明。
- 训练方向:基于负向结算类型和是否有正向胡牌样本,生成风险控制、成叫效率、杠牌时机、基础番型等训练建议。 - 训练方向:基于负向结算类型和是否有正向胡牌样本,生成风险控制、成叫效率、杠牌时机、基础番型等训练建议。
- 当前限制: - 当前限制:
- 仍是内存态复盘,服务重启后对局与复盘数据会丢失。 - 仍是内存态复盘,服务重启后对局与复盘数据会丢失。
- 还没有逐手牌谱回放、动作级失误定位、个人可见信息回放时间线 - 动作时间线是“关键动作摘要”,还不是完整逐手牌谱播放器
- `mistakeInsights` 仍是启发式生成,不是严格牌效/安牌分析 - 失误解释仍是启发式,不是严格牌效/安牌搜索;也没有真正的逐步可见信息快照
- 前端已改为手动加载当前真实对局/当前视角复盘;切换视角或切换对局时会清空旧复盘,避免显示过期摘要。 - 前端状态:
- 复盘页已能手动加载当前真实对局、当前视角的复盘摘要。
- 复盘页已展示:总览、关键动作回放、关键结算、关键失误、训练方向。
- 关键失误卡片当前已展示:关联动作、建议方向、压力来源标签、压力来源细节、压力摘要、局面信号。
- 切换视角或切换对局时会清空旧复盘,避免显示过期摘要。
- 已验证:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。 - 已验证:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.review.dto;
/**
* 复盘页里的“关键动作时间线”条目。
* 当前先做可读摘要,不直接暴露完整牌谱结构,避免过早把前后端锁死在重型回放协议上。
*/
public record ReviewActionTimelineItem(
int stepNo,
String eventType,
String title,
String summary
) {
}

View File

@@ -1,5 +1,7 @@
package com.xuezhanmaster.review.dto; package com.xuezhanmaster.review.dto;
import java.util.List;
/** /**
* 复盘页里的“关键失误”条目。 * 复盘页里的“关键失误”条目。
* 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。 * 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。
@@ -8,6 +10,17 @@ public record ReviewMistakeItem(
String severity, String severity,
String title, String title,
String issue, String issue,
String suggestion String suggestion,
String recommendedDirectionType,
String recommendedDirectionLabel,
String recommendedAction,
String pressureSourceType,
String pressureSourceLabel,
String pressureSourceDetail,
String pressureSummary,
List<String> contextSignals,
Integer relatedStepNo,
String relatedEventType,
String relatedActionTitle
) { ) {
} }

View File

@@ -4,7 +4,8 @@ import java.util.List;
/** /**
* 局后复盘页的最小协议骨架。 * 局后复盘页的最小协议骨架。
* 当前覆盖“总览、关键失误、训练方向”块正式页面必需信息,后续再按真实复盘算法逐步扩展。 * 当前覆盖“总览、动作时间线、关键失误、训练方向”块正式页面必需信息,
* 后续再按真实复盘算法逐步扩展到逐手回放与动作级纠错。
*/ */
public record ReviewSummaryResponse( public record ReviewSummaryResponse(
String gameId, String gameId,
@@ -14,6 +15,7 @@ public record ReviewSummaryResponse(
int finalScore, int finalScore,
String resultLabel, String resultLabel,
String conclusion, String conclusion,
List<ReviewActionTimelineItem> actionTimeline,
List<ReviewSettlementItem> settlementTimeline, List<ReviewSettlementItem> settlementTimeline,
List<ReviewMistakeItem> mistakeInsights, List<ReviewMistakeItem> mistakeInsights,
List<ReviewTrainingFocusItem> trainingFocuses List<ReviewTrainingFocusItem> trainingFocuses

View File

@@ -7,7 +7,10 @@ import com.xuezhanmaster.game.domain.ScoreChange;
import com.xuezhanmaster.game.domain.SettlementFan; 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 com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.event.GameEventType;
import com.xuezhanmaster.game.service.GameSessionService; import com.xuezhanmaster.game.service.GameSessionService;
import com.xuezhanmaster.review.dto.ReviewActionTimelineItem;
import com.xuezhanmaster.review.dto.ReviewMistakeItem; import com.xuezhanmaster.review.dto.ReviewMistakeItem;
import com.xuezhanmaster.review.dto.ReviewSettlementItem; import com.xuezhanmaster.review.dto.ReviewSettlementItem;
import com.xuezhanmaster.review.dto.ReviewSummaryResponse; import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
@@ -18,6 +21,7 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
@Service @Service
@@ -37,8 +41,9 @@ public class ReviewService {
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中")); .orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
List<SettlementResult> settlementHistory = List.copyOf(session.getSettlementHistory()); List<SettlementResult> settlementHistory = List.copyOf(session.getSettlementHistory());
List<ReviewActionTimelineItem> actionTimeline = buildActionTimeline(session, reviewSeat);
List<ReviewSettlementItem> settlementTimeline = buildSettlementTimeline(settlementHistory, reviewSeat.getSeatNo()); List<ReviewSettlementItem> settlementTimeline = buildSettlementTimeline(settlementHistory, reviewSeat.getSeatNo());
List<ReviewMistakeItem> mistakeInsights = buildMistakeInsights(settlementHistory, reviewSeat.getSeatNo()); List<ReviewMistakeItem> mistakeInsights = buildMistakeInsights(session, settlementHistory, reviewSeat.getSeatNo());
List<ReviewTrainingFocusItem> trainingFocuses = buildTrainingFocuses( List<ReviewTrainingFocusItem> trainingFocuses = buildTrainingFocuses(
settlementHistory, settlementHistory,
reviewSeat.getSeatNo(), reviewSeat.getSeatNo(),
@@ -54,12 +59,40 @@ public class ReviewService {
reviewSeat.getScore(), reviewSeat.getScore(),
buildResultLabel(reviewSeat.getScore()), buildResultLabel(reviewSeat.getScore()),
buildConclusion(reviewSeat, settlementTimeline, mistakeInsights), buildConclusion(reviewSeat, settlementTimeline, mistakeInsights),
actionTimeline,
settlementTimeline, settlementTimeline,
mistakeInsights, mistakeInsights,
trainingFocuses trainingFocuses
); );
} }
private List<ReviewActionTimelineItem> buildActionTimeline(GameSession session, GameSeat reviewSeat) {
List<ReviewActionTimelineItem> items = new ArrayList<>();
List<GameEvent> events = session.getEvents();
for (int index = 0; index < events.size(); index++) {
GameEvent event = events.get(index);
if (!shouldIncludeInTimeline(event, reviewSeat.getSeatNo())) {
continue;
}
items.add(new ReviewActionTimelineItem(
index + 1,
event.eventType().name(),
buildActionTitle(event, reviewSeat.getSeatNo()),
buildActionSummary(event, reviewSeat)
));
}
if (items.isEmpty()) {
items.add(new ReviewActionTimelineItem(
0,
"NO_RELEVANT_EVENT",
"暂无关键动作",
"当前对局还没有形成与你直接相关的关键动作样本,后续随着摸打、响应和结算发生会逐步补齐。"
));
}
int fromIndex = Math.max(items.size() - 12, 0);
return List.copyOf(items.subList(fromIndex, items.size()));
}
private List<ReviewSettlementItem> buildSettlementTimeline(List<SettlementResult> settlementHistory, int seatNo) { private List<ReviewSettlementItem> buildSettlementTimeline(List<SettlementResult> settlementHistory, int seatNo) {
List<ReviewSettlementItem> items = new ArrayList<>(); List<ReviewSettlementItem> items = new ArrayList<>();
for (SettlementResult settlementResult : settlementHistory) { for (SettlementResult settlementResult : settlementHistory) {
@@ -86,7 +119,7 @@ public class ReviewService {
return List.copyOf(items); return List.copyOf(items);
} }
private List<ReviewMistakeItem> buildMistakeInsights(List<SettlementResult> settlementHistory, int seatNo) { private List<ReviewMistakeItem> buildMistakeInsights(GameSession session, List<SettlementResult> settlementHistory, int seatNo) {
List<SettlementResult> negativeSettlements = settlementHistory.stream() List<SettlementResult> negativeSettlements = settlementHistory.stream()
.filter(result -> { .filter(result -> {
Integer delta = resolveSeatDelta(result, seatNo); Integer delta = resolveSeatDelta(result, seatNo);
@@ -99,48 +132,131 @@ public class ReviewService {
List<ReviewMistakeItem> items = new ArrayList<>(); List<ReviewMistakeItem> items = new ArrayList<>();
for (SettlementResult settlementResult : negativeSettlements) { for (SettlementResult settlementResult : negativeSettlements) {
int scoreDelta = resolveSeatDelta(settlementResult, seatNo); int scoreDelta = resolveSeatDelta(settlementResult, seatNo);
ReviewActionTimelineItem relatedAction = findRelatedMistakeAction(session, settlementResult, seatNo);
MistakeRecommendation recommendation = buildMistakeRecommendation(settlementResult);
List<String> contextSignals = buildContextSignals(session, settlementResult, seatNo, relatedAction);
PressureSource pressureSource = buildPressureSource(session, settlementResult, seatNo, relatedAction);
String pressureSourceDetail = buildPressureSourceDetail(session, settlementResult, seatNo, relatedAction);
String pressureSummary = buildPressureSummary(session, settlementResult, seatNo, relatedAction);
items.add(switch (settlementResult.settlementType()) { items.add(switch (settlementResult.settlementType()) {
case DIAN_PAO_HU -> new ReviewMistakeItem( case DIAN_PAO_HU -> new ReviewMistakeItem(
"HIGH", "HIGH",
"点炮导致失分", "点炮导致失分",
"你在他人弃牌胡结算中承担了 " + formatScore(scoreDelta) + ",说明中后盘危险张控制仍有明显缺口。", "你在他人弃牌胡结算中承担了 " + formatScore(scoreDelta) + ",说明中后盘危险张控制仍有明显缺口。",
"当对手已经副露成型或番型抬高时,优先保留现成安全张,避免继续压高危中张。" "当对手已经副露成型或番型抬高时,优先保留现成安全张,避免继续压高危中张。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case QIANG_GANG_HU -> new ReviewMistakeItem( case QIANG_GANG_HU -> new ReviewMistakeItem(
"HIGH", "HIGH",
"补杠时机过激", "补杠时机过激",
"你在补杠后被对手抢杠胡,直接产生 " + formatScore(scoreDelta) + " 的回撤。", "你在补杠后被对手抢杠胡,直接产生 " + formatScore(scoreDelta) + " 的回撤。",
"补杠前先检查场上是否已出现高压听牌信号,必要时放弃补杠收益,优先确保不放大失分。" "补杠前先检查场上是否已出现高压听牌信号,必要时放弃补杠收益,优先确保不放大失分。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case ZI_MO_HU -> new ReviewMistakeItem( case ZI_MO_HU -> new ReviewMistakeItem(
"MEDIUM", "MEDIUM",
"未能压住对手自摸", "未能压住对手自摸",
"对手自摸时你被扣除 " + formatScore(scoreDelta) + ",说明本局在速度或成叫质量上没有形成足够压制。", "对手自摸时你被扣除 " + formatScore(scoreDelta) + ",说明本局在速度或成叫质量上没有形成足够压制。",
"后续训练应强化中盘成型速度判断,避免在明显落后牌速时仍维持松散手型。" "后续训练应强化中盘成型速度判断,避免在明显落后牌速时仍维持松散手型。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case TUI_SHUI -> new ReviewMistakeItem( case TUI_SHUI -> new ReviewMistakeItem(
"MEDIUM", "MEDIUM",
"杠分未守住", "杠分未守住",
"本局出现退税,导致你回吐 " + formatScore(scoreDelta) + ",说明前序杠牌收益没有稳定兑现。", "本局出现退税,导致你回吐 " + formatScore(scoreDelta) + ",说明前序杠牌收益没有稳定兑现。",
"练习在流局风险升高时复盘杠牌收益与听牌质量,避免为短期税分牺牲整体成叫。" "练习在流局风险升高时复盘杠牌收益与听牌质量,避免为短期税分牺牲整体成叫。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case CHA_JIAO -> new ReviewMistakeItem( case CHA_JIAO -> new ReviewMistakeItem(
"HIGH", "HIGH",
"流局未成叫", "流局未成叫",
"流局查叫阶段你承担了 " + formatScore(scoreDelta) + ",反映出收尾阶段的成叫效率不足。", "流局查叫阶段你承担了 " + formatScore(scoreDelta) + ",反映出收尾阶段的成叫效率不足。",
"进入残局后要更早切换到保叫路线,优先确保最小可支付听牌,而不是继续追求高番远型。" "进入残局后要更早切换到保叫路线,优先确保最小可支付听牌,而不是继续追求高番远型。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case MING_GANG -> new ReviewMistakeItem( case MING_GANG -> new ReviewMistakeItem(
"MEDIUM", "MEDIUM",
"放杠失分", "放杠失分",
"你在他人明杠结算里承担了 " + formatScore(scoreDelta) + ",说明对明显碰后加杠的防范还不够。", "你在他人明杠结算里承担了 " + formatScore(scoreDelta) + ",说明对明显碰后加杠的防范还不够。",
"看到对手碰后,应更早记录其可能补强的牌张,减少继续喂牌或协助做大的情况。" "看到对手碰后,应更早记录其可能补强的牌张,减少继续喂牌或协助做大的情况。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
case BU_GANG, AN_GANG -> new ReviewMistakeItem( case BU_GANG, AN_GANG -> new ReviewMistakeItem(
"LOW", "LOW",
"杠分压制不足", "杠分压制不足",
"对手通过杠牌从你这里拿走了 " + formatScore(scoreDelta) + ",本局资源交换略偏被动。", "对手通过杠牌从你这里拿走了 " + formatScore(scoreDelta) + ",本局资源交换略偏被动。",
"后续可专项训练杠前后局势判断,明确什么时候该抢速度,什么时候该减少被动付分。" "后续可专项训练杠前后局势判断,明确什么时候该抢速度,什么时候该减少被动付分。",
recommendation.directionType(),
recommendation.directionLabel(),
recommendation.recommendedAction(),
pressureSource.sourceType(),
pressureSource.sourceLabel(),
pressureSourceDetail,
pressureSummary,
contextSignals,
relatedAction == null ? null : relatedAction.stepNo(),
relatedAction == null ? null : relatedAction.eventType(),
relatedAction == null ? null : relatedAction.title()
); );
}); });
} }
@@ -397,4 +513,553 @@ public class ReviewService {
private String formatScore(int scoreDelta) { private String formatScore(int scoreDelta) {
return scoreDelta > 0 ? "+" + scoreDelta : String.valueOf(scoreDelta); return scoreDelta > 0 ? "+" + scoreDelta : String.valueOf(scoreDelta);
} }
private MistakeRecommendation buildMistakeRecommendation(SettlementResult settlementResult) {
return switch (settlementResult.settlementType()) {
case DIAN_PAO_HU -> new MistakeRecommendation(
"RISK_CONTROL",
"优先处理危险张",
"若桌面已经出现连续副露或高番压力,下一次应先切出现成安全张,再决定是否继续进攻。"
);
case QIANG_GANG_HU -> new MistakeRecommendation(
"ABANDON_GANG",
"高压局面不补杠",
"当补杠只带来小收益、却可能暴露给多家抢杠时,优先保留碰牌形态继续过渡。"
);
case ZI_MO_HU -> new MistakeRecommendation(
"SPEED_UP",
"提速成型并压制",
"中盘若牌速落后,应尽快收缩远型组合,把手牌切回更快的成型路线。"
);
case TUI_SHUI -> new MistakeRecommendation(
"TAX_PROTECTION",
"杠后优先守住收益",
"拿到杠分后要尽快检查成叫质量,若流局风险升高,应转成保税而不是继续冒进。"
);
case CHA_JIAO -> new MistakeRecommendation(
"READY_HAND",
"残局优先保叫",
"进入残局后先确保最小可支付听牌,再考虑是否有空间继续追求更高番型。"
);
case MING_GANG -> new MistakeRecommendation(
"DANGER_TRACKING",
"记录对手碰后危险张",
"对手碰牌后,优先把该组相关牌张标成高危候选,减少继续喂牌的概率。"
);
case BU_GANG, AN_GANG -> new MistakeRecommendation(
"RISK_CONTROL",
"减少被动付分窗口",
"当对手已经连续拿杠分时,后续应更快转守,避免继续给出额外交换空间。"
);
};
}
private List<String> buildContextSignals(
GameSession session,
SettlementResult settlementResult,
int seatNo,
ReviewActionTimelineItem relatedAction
) {
List<String> signals = new ArrayList<>();
if (settlementResult.triggerTile() != null && !settlementResult.triggerTile().isBlank()) {
signals.add("触发牌:" + settlementResult.triggerTile());
}
int settlementEventIndex = findSettlementEventIndex(session.getEvents(), settlementResult, seatNo);
int referenceIndex = relatedAction != null ? relatedAction.stepNo() - 1 : settlementEventIndex;
if (referenceIndex < 0) {
return List.copyOf(signals);
}
int remainingWallCount = findLatestRemainingWallCount(session.getEvents(), referenceIndex);
if (remainingWallCount >= 0) {
if (remainingWallCount <= 16) {
signals.add("已接近流局,牌墙剩余 " + remainingWallCount + "");
} else {
signals.add("当时牌墙剩余 " + remainingWallCount + "");
}
}
List<String> opponentMeldSignals = buildOpponentMeldSignals(session.getEvents(), seatNo, referenceIndex);
signals.addAll(opponentMeldSignals);
if (relatedAction != null && "个人出牌".equals(relatedAction.title())) {
signals.add("你的这次出牌直接连到了后续失分结算");
}
if (settlementResult.settlementType() == SettlementType.QIANG_GANG_HU) {
signals.add("这是一次补杠窗口,场上存在抢杠风险");
}
if (settlementResult.settlementType() == SettlementType.CHA_JIAO) {
signals.add("流局阶段仍未形成有效成叫");
}
return List.copyOf(signals);
}
private String buildPressureSummary(
GameSession session,
SettlementResult settlementResult,
int seatNo,
ReviewActionTimelineItem relatedAction
) {
int settlementEventIndex = findSettlementEventIndex(session.getEvents(), settlementResult, seatNo);
int referenceIndex = relatedAction != null ? relatedAction.stepNo() - 1 : settlementEventIndex;
int remainingWallCount = referenceIndex >= 0 ? findLatestRemainingWallCount(session.getEvents(), referenceIndex) : -1;
int actorSeatNo = settlementResult.actorSeatNo();
int actorMeldCount = referenceIndex >= 0 ? countSeatMeldBefore(session.getEvents(), actorSeatNo, referenceIndex) : 0;
if (settlementResult.settlementType() == SettlementType.CHA_JIAO) {
return "主要压力来自残局收尾:你在流局前仍未形成有效成叫,最终被座位 "
+ actorSeatNo + " 在查叫阶段完成收分。";
}
if (settlementResult.settlementType() == SettlementType.QIANG_GANG_HU) {
return "主要压力来自补杠暴露:你的补杠把结算窗口直接交给了座位 "
+ actorSeatNo + ",这类高压局面不适合继续追求小额杠分。";
}
if (actorSeatNo != seatNo && actorMeldCount >= 2) {
return "主要压力来自座位 " + actorSeatNo + ":在这一步前其已完成 "
+ actorMeldCount + " 次副露,公开进攻信号已经明显抬高。";
}
if (remainingWallCount >= 0 && remainingWallCount <= 16) {
return "主要压力来自残局阶段:当时牌墙仅剩 " + remainingWallCount
+ " 张,局面已经更偏向保叫和防守,而不是继续放大交换。";
}
if (settlementResult.settlementType() == SettlementType.ZI_MO_HU) {
return "主要压力来自速度落后:对手在你完成有效压制前率先成牌,自摸把这手的被动直接放大。";
}
if (actorSeatNo != seatNo) {
return "主要压力来自座位 " + actorSeatNo + ":这次失分说明你没有及时根据该家的公开动作调整守备等级。";
}
return "主要压力来自场面节奏失衡:当前这手交换更偏被动,应更早识别风险并切回稳健路线。";
}
private String buildPressureSourceDetail(
GameSession session,
SettlementResult settlementResult,
int seatNo,
ReviewActionTimelineItem relatedAction
) {
List<GameEvent> events = session.getEvents();
int settlementEventIndex = findSettlementEventIndex(events, settlementResult, seatNo);
int referenceIndex = relatedAction != null ? relatedAction.stepNo() - 1 : settlementEventIndex;
int remainingWallCount = referenceIndex >= 0 ? findLatestRemainingWallCount(events, referenceIndex) : -1;
int actorSeatNo = settlementResult.actorSeatNo();
int actorMeldCount = referenceIndex >= 0 ? countSeatMeldBefore(events, actorSeatNo, referenceIndex) : 0;
GameEvent latestActorMeld = referenceIndex >= 0 ? findLatestSeatMeldEventBefore(events, actorSeatNo, referenceIndex) : null;
String triggerPhrase = buildPressureTriggerPhrase(settlementResult, relatedAction);
String latestMeldSummary = buildLatestMeldSummary(latestActorMeld);
if (settlementResult.settlementType() == SettlementType.CHA_JIAO) {
String wallSummary = remainingWallCount >= 0 ? "牌墙只剩 " + remainingWallCount + " 张时," : "";
return "座位 " + actorSeatNo + " 把这手拖进了查叫收尾窗口;"
+ wallSummary
+ triggerPhrase
+ " 仍没能换来有效成叫,最终让这家把残局压力直接兑现成查叫收分。";
}
if (settlementResult.settlementType() == SettlementType.QIANG_GANG_HU) {
return "座位 " + actorSeatNo + " 等到你的补杠才真正拿到结算入口;"
+ triggerPhrase
+ " 直接把隐藏风险翻成可抢窗口,收益和失分上限明显不对称。";
}
if (actorSeatNo != seatNo && actorMeldCount >= 2) {
return "座位 " + actorSeatNo + " 在这步前已连续副露 " + actorMeldCount + ""
+ latestMeldSummary
+ ""
+ triggerPhrase
+ ",等于继续把交换留给已经亮出进攻线的玩家。";
}
if (actorSeatNo != seatNo && actorMeldCount == 1) {
return "座位 " + actorSeatNo + " 在这步前已经出现过一次公开副露"
+ latestMeldSummary
+ ""
+ triggerPhrase
+ " 时,危险等级其实已经高于普通平推局。";
}
if (remainingWallCount >= 0 && remainingWallCount <= 16) {
return "这手已经进入残局窗口,牌墙只剩 " + remainingWallCount + " 张;"
+ triggerPhrase
+ " 时更该优先保留容错,而不是继续放大交换。";
}
if (settlementResult.settlementType() == SettlementType.ZI_MO_HU) {
return "座位 " + actorSeatNo + " 在公开动作并不夸张的情况下仍先完成自摸,说明其速度线已经提前成型;"
+ triggerPhrase
+ " 前你没有建立起足够的压制。";
}
if (actorSeatNo != seatNo) {
return "这次压力主要是由座位 " + actorSeatNo + " 主导出来的;"
+ triggerPhrase
+ " 前你没有及时根据该家的公开动作切换守备等级。";
}
return "这次失分更多来自节奏判断偏慢;"
+ triggerPhrase
+ " 时,你没有及时把交换切回更稳的路线。";
}
private PressureSource buildPressureSource(
GameSession session,
SettlementResult settlementResult,
int seatNo,
ReviewActionTimelineItem relatedAction
) {
int settlementEventIndex = findSettlementEventIndex(session.getEvents(), settlementResult, seatNo);
int referenceIndex = relatedAction != null ? relatedAction.stepNo() - 1 : settlementEventIndex;
int remainingWallCount = referenceIndex >= 0 ? findLatestRemainingWallCount(session.getEvents(), referenceIndex) : -1;
int actorSeatNo = settlementResult.actorSeatNo();
int actorMeldCount = referenceIndex >= 0 ? countSeatMeldBefore(session.getEvents(), actorSeatNo, referenceIndex) : 0;
if (settlementResult.settlementType() == SettlementType.CHA_JIAO) {
return new PressureSource("ENDGAME", "残局查叫收尾(座位 " + actorSeatNo + "");
}
if (settlementResult.settlementType() == SettlementType.QIANG_GANG_HU) {
return new PressureSource("ROBBING_GANG", "补杠暴露为抢杠窗口");
}
if (actorSeatNo != seatNo && actorMeldCount >= 2) {
return new PressureSource("MULTI_MELD", "座位 " + actorSeatNo + " 已连续副露 " + actorMeldCount + "");
}
if (actorSeatNo != seatNo && actorMeldCount == 1) {
return new PressureSource("SINGLE_MELD", "座位 " + actorSeatNo + " 已出现副露");
}
if (remainingWallCount >= 0 && remainingWallCount <= 16) {
return new PressureSource("ENDGAME", "残局阶段,牌墙剩余 " + remainingWallCount + "");
}
if (settlementResult.settlementType() == SettlementType.ZI_MO_HU) {
return new PressureSource("SPEED_LOSS", "对手先成型,你在速度上已落后");
}
return new PressureSource("TABLE_TENSION", "公开动作已使场面压强上升");
}
private ReviewActionTimelineItem findRelatedMistakeAction(GameSession session, SettlementResult settlementResult, int seatNo) {
List<GameEvent> events = session.getEvents();
int settlementEventIndex = findSettlementEventIndex(events, settlementResult, seatNo);
if (settlementEventIndex < 0) {
return null;
}
GameEvent relatedEvent = switch (settlementResult.settlementType()) {
case DIAN_PAO_HU, MING_GANG, CHA_JIAO -> findLatestEventBefore(
events,
settlementEventIndex,
event -> event.eventType() == GameEventType.TILE_DISCARDED && event.seatNo() != null && event.seatNo() == seatNo
);
case QIANG_GANG_HU -> findLatestEventBefore(
events,
settlementEventIndex,
event -> event.eventType() == GameEventType.GANG_DECLARED && event.seatNo() != null && event.seatNo() == seatNo
);
case ZI_MO_HU -> findLatestEventBefore(
events,
settlementEventIndex,
event -> event.seatNo() != null && event.seatNo() == seatNo
&& (event.eventType() == GameEventType.TILE_DISCARDED
|| event.eventType() == GameEventType.TURN_SWITCHED
|| event.eventType() == GameEventType.TILE_DRAWN)
);
case TUI_SHUI, BU_GANG, AN_GANG -> findLatestEventBefore(
events,
settlementEventIndex,
event -> event.seatNo() != null && event.seatNo() == seatNo
&& (event.eventType() == GameEventType.GANG_DECLARED
|| event.eventType() == GameEventType.TILE_DISCARDED)
);
};
if (relatedEvent == null) {
return null;
}
int relatedIndex = events.indexOf(relatedEvent);
return new ReviewActionTimelineItem(
relatedIndex + 1,
relatedEvent.eventType().name(),
buildActionTitle(relatedEvent, seatNo),
buildActionSummary(relatedEvent, session.getTable().getSeats().get(seatNo))
);
}
private int findSettlementEventIndex(List<GameEvent> events, SettlementResult settlementResult, int seatNo) {
for (int index = events.size() - 1; index >= 0; index--) {
GameEvent event = events.get(index);
if (event.eventType() != GameEventType.SETTLEMENT_APPLIED) {
continue;
}
if (!safeString(event.payload().get("settlementType")).equals(settlementResult.settlementType().name())) {
continue;
}
if (!safeNumber(event.payload().get("actorSeatNo")).equals(String.valueOf(settlementResult.actorSeatNo()))) {
continue;
}
if (!safeNumber(event.payload().get("sourceSeatNo")).equals(String.valueOf(settlementResult.sourceSeatNo()))) {
continue;
}
Integer scoreDelta = resolveSeatDeltaFromPayload(event.payload(), seatNo);
Integer settlementDelta = resolveSeatDelta(settlementResult, seatNo);
if (scoreDelta != null && scoreDelta.equals(settlementDelta)) {
return index;
}
}
return -1;
}
private GameEvent findLatestEventBefore(List<GameEvent> events, int endExclusive, java.util.function.Predicate<GameEvent> predicate) {
for (int index = endExclusive - 1; index >= 0; index--) {
GameEvent event = events.get(index);
if (predicate.test(event)) {
return event;
}
}
return null;
}
private GameEvent findLatestSeatMeldEventBefore(List<GameEvent> events, int seatNo, int endExclusive) {
return findLatestEventBefore(
events,
endExclusive,
event -> event.seatNo() != null
&& event.seatNo() == seatNo
&& (event.eventType() == GameEventType.PENG_DECLARED
|| event.eventType() == GameEventType.GANG_DECLARED)
);
}
private boolean shouldIncludeInTimeline(GameEvent event, int seatNo) {
return switch (event.eventType()) {
case GAME_STARTED, GAME_PHASE_CHANGED -> true;
case LACK_SELECTED, TILE_DISCARDED, TILE_DRAWN, TURN_SWITCHED,
PENG_DECLARED, GANG_DECLARED, HU_DECLARED, PASS_DECLARED -> event.seatNo() != null && event.seatNo() == seatNo;
case RESPONSE_WINDOW_OPENED, RESPONSE_WINDOW_CLOSED -> event.seatNo() != null && event.seatNo() == seatNo;
case SETTLEMENT_APPLIED -> hasSettlementImpact(event.payload(), seatNo);
case SCORE_CHANGED, ACTION_REQUIRED -> false;
};
}
@SuppressWarnings("unchecked")
private boolean hasSettlementImpact(Map<String, Object> payload, int seatNo) {
Object scoreChangesObject = payload.get("scoreChanges");
if (!(scoreChangesObject instanceof List<?> scoreChanges)) {
return false;
}
for (Object scoreChangeObject : scoreChanges) {
if (!(scoreChangeObject instanceof Map<?, ?> rawItem)) {
continue;
}
Object impactedSeatNo = rawItem.get("seatNo");
if (impactedSeatNo instanceof Number number && number.intValue() == seatNo) {
return true;
}
}
return false;
}
private String buildActionTitle(GameEvent event, int seatNo) {
return switch (event.eventType()) {
case GAME_STARTED -> "牌局开始";
case GAME_PHASE_CHANGED -> "阶段切换";
case LACK_SELECTED -> "完成定缺";
case TILE_DISCARDED -> "个人出牌";
case TILE_DRAWN -> "个人摸牌";
case TURN_SWITCHED -> "轮到自己";
case RESPONSE_WINDOW_OPENED -> "进入响应窗口";
case RESPONSE_WINDOW_CLOSED -> "响应窗口关闭";
case PENG_DECLARED -> "执行碰牌";
case GANG_DECLARED -> "执行杠牌";
case HU_DECLARED -> "宣告胡牌";
case PASS_DECLARED -> "选择过";
case SETTLEMENT_APPLIED -> buildSettlementEventTitle(event.payload(), seatNo);
case SCORE_CHANGED, ACTION_REQUIRED -> event.eventType().name();
};
}
private String buildActionSummary(GameEvent event, GameSeat reviewSeat) {
return switch (event.eventType()) {
case GAME_STARTED -> "本局已创建,你当前以“" + reviewSeat.getNickname() + "”视角复盘整局关键节点。";
case GAME_PHASE_CHANGED -> "牌局阶段切换为 " + safeString(event.payload().get("phase")) + "";
case LACK_SELECTED -> "你已完成定缺,选择花色为 " + safeString(event.payload().get("lackSuit")) + "";
case TILE_DISCARDED -> "你打出了 " + safeString(event.payload().get("tile")) + "";
case TILE_DRAWN -> "你摸牌后,牌墙剩余 " + safeNumber(event.payload().get("remainingWallCount")) + " 张。";
case TURN_SWITCHED -> "当前轮到你行动,可以据此回看本轮之前的桌面信息。";
case RESPONSE_WINDOW_OPENED -> "你获得了一个响应窗口,来源座位 "
+ safeNumber(event.payload().get("sourceSeatNo")) + ",目标牌为 "
+ safeString(event.payload().get("triggerTile")) + "";
case RESPONSE_WINDOW_CLOSED -> "你的响应窗口已关闭,最终裁决动作为 "
+ safeString(event.payload().get("resolvedActionType")) + "";
case PENG_DECLARED, GANG_DECLARED, HU_DECLARED, PASS_DECLARED -> "你执行了 "
+ actionTypeLabel(event.eventType()) + ",关联牌为 "
+ safeString(event.payload().get("tile")) + "";
case SETTLEMENT_APPLIED -> buildSettlementEventSummary(event.payload(), reviewSeat.getSeatNo());
case SCORE_CHANGED, ACTION_REQUIRED -> event.eventType().name();
};
}
@SuppressWarnings("unchecked")
private String buildSettlementEventTitle(Map<String, Object> payload, int seatNo) {
Integer scoreDelta = resolveSeatDeltaFromPayload(payload, seatNo);
String settlementType = safeString(payload.get("settlementType"));
return switch (settlementType) {
case "DIAN_PAO_HU" -> scoreDelta != null && scoreDelta > 0 ? "点炮胡结算" : "点炮失分结算";
case "QIANG_GANG_HU" -> scoreDelta != null && scoreDelta > 0 ? "抢杠胡结算" : "补杠被抢结算";
case "ZI_MO_HU" -> scoreDelta != null && scoreDelta > 0 ? "自摸胡结算" : "对手自摸结算";
case "BU_GANG" -> scoreDelta != null && scoreDelta > 0 ? "补杠收益结算" : "补杠相关失分";
case "MING_GANG" -> scoreDelta != null && scoreDelta > 0 ? "明杠收益结算" : "明杠相关失分";
case "AN_GANG" -> scoreDelta != null && scoreDelta > 0 ? "暗杠收益结算" : "暗杠相关失分";
case "TUI_SHUI" -> scoreDelta != null && scoreDelta > 0 ? "退税回补结算" : "退税回吐结算";
case "CHA_JIAO" -> scoreDelta != null && scoreDelta > 0 ? "查叫收益结算" : "查叫失分结算";
default -> "关键结算";
};
}
private String buildSettlementEventSummary(Map<String, Object> payload, int seatNo) {
Integer scoreDelta = resolveSeatDeltaFromPayload(payload, seatNo);
String settlementType = settlementTypeLabelFromPayload(payload);
String triggerTile = safeString(payload.get("triggerTile"));
String actorSeatNo = safeNumber(payload.get("actorSeatNo"));
String sourceSeatNo = safeNumber(payload.get("sourceSeatNo"));
String tileSuffix = triggerTile.isBlank() ? "" : ",触发牌 " + triggerTile;
return "发生 " + settlementType + ",执行座位 " + actorSeatNo
+ ",来源座位 " + sourceSeatNo
+ ",你本次分值变化为 " + formatScore(scoreDelta == null ? 0 : scoreDelta)
+ tileSuffix + "";
}
@SuppressWarnings("unchecked")
private Integer resolveSeatDeltaFromPayload(Map<String, Object> payload, int seatNo) {
Object scoreChangesObject = payload.get("scoreChanges");
if (!(scoreChangesObject instanceof List<?> scoreChanges)) {
return null;
}
for (Object scoreChangeObject : scoreChanges) {
if (!(scoreChangeObject instanceof Map<?, ?> scoreChangeMap)) {
continue;
}
Object impactedSeatNo = scoreChangeMap.get("seatNo");
if (impactedSeatNo instanceof Number number && number.intValue() == seatNo) {
Object delta = scoreChangeMap.get("delta");
return delta instanceof Number deltaNumber ? deltaNumber.intValue() : null;
}
}
return null;
}
private String settlementTypeLabelFromPayload(Map<String, Object> payload) {
return switch (safeString(payload.get("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" -> "查叫";
default -> "关键结算";
};
}
private String actionTypeLabel(GameEventType eventType) {
return switch (eventType) {
case PENG_DECLARED -> "";
case GANG_DECLARED -> "";
case HU_DECLARED -> "";
case PASS_DECLARED -> "";
default -> eventType.name();
};
}
private String safeString(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String safeNumber(Object value) {
return value instanceof Number number ? String.valueOf(number.intValue()) : "-";
}
private int findLatestRemainingWallCount(List<GameEvent> events, int endInclusive) {
for (int index = endInclusive; index >= 0; index--) {
GameEvent event = events.get(index);
if (event.eventType() != GameEventType.TILE_DRAWN) {
continue;
}
Object value = event.payload().get("remainingWallCount");
if (value instanceof Number number) {
return number.intValue();
}
}
return -1;
}
private int countSeatMeldBefore(List<GameEvent> events, int seatNo, int endInclusive) {
int meldCount = 0;
for (int index = 0; index <= endInclusive; index++) {
GameEvent event = events.get(index);
if (event.seatNo() == null || event.seatNo() != seatNo) {
continue;
}
if (event.eventType() == GameEventType.PENG_DECLARED || event.eventType() == GameEventType.GANG_DECLARED) {
meldCount++;
}
}
return meldCount;
}
private List<String> buildOpponentMeldSignals(List<GameEvent> events, int seatNo, int endInclusive) {
Map<Integer, Integer> meldCounts = new java.util.LinkedHashMap<>();
for (int index = 0; index <= endInclusive; index++) {
GameEvent event = events.get(index);
if ((event.eventType() != GameEventType.PENG_DECLARED && event.eventType() != GameEventType.GANG_DECLARED)
|| event.seatNo() == null
|| event.seatNo() == seatNo) {
continue;
}
meldCounts.merge(event.seatNo(), 1, Integer::sum);
}
List<String> signals = new ArrayList<>();
for (Map.Entry<Integer, Integer> entry : meldCounts.entrySet()) {
if (entry.getValue() <= 0) {
continue;
}
signals.add("座位 " + entry.getKey() + " 当时已有 " + entry.getValue() + " 次副露");
}
return signals;
}
private String buildPressureTriggerPhrase(SettlementResult settlementResult, ReviewActionTimelineItem relatedAction) {
String triggerTile = settlementResult.triggerTile();
if (settlementResult.settlementType() == SettlementType.QIANG_GANG_HU) {
return triggerTile == null || triggerTile.isBlank()
? "你的这次补杠"
: "你的这次补杠(" + triggerTile + "";
}
if (triggerTile != null && !triggerTile.isBlank()) {
if (relatedAction != null && "个人出牌".equals(relatedAction.title())) {
return "你打出的 " + triggerTile;
}
return "你围绕 " + triggerTile + " 做出的这次交换";
}
if (relatedAction != null && relatedAction.title() != null && !relatedAction.title().isBlank()) {
return "你执行的“" + relatedAction.title() + "";
}
return "你的这次处理";
}
private String buildLatestMeldSummary(GameEvent latestMeldEvent) {
if (latestMeldEvent == null) {
return "";
}
String actionLabel = actionTypeLabel(latestMeldEvent.eventType());
String tile = safeString(latestMeldEvent.payload().get("tile"));
if (tile.isBlank()) {
return ",最近一次公开动作是" + actionLabel;
}
return ",最近一次公开动作是" + actionLabel + " " + tile;
}
private record MistakeRecommendation(
String directionType,
String directionLabel,
String recommendedAction
) {
}
private record PressureSource(
String sourceType,
String sourceLabel
) {
}
} }

View File

@@ -7,6 +7,7 @@ import com.xuezhanmaster.game.domain.SettlementResult;
import com.xuezhanmaster.game.domain.TileSuit; import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameStateResponse; import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.engine.GameEngine; import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.service.BloodBattleScoringService; import com.xuezhanmaster.game.service.BloodBattleScoringService;
import com.xuezhanmaster.game.service.DeckFactory; import com.xuezhanmaster.game.service.DeckFactory;
import com.xuezhanmaster.game.service.GameActionProcessor; import com.xuezhanmaster.game.service.GameActionProcessor;
@@ -65,6 +66,14 @@ class ReviewServiceTest {
SettlementResult positiveGang = settlementService.settleSupplementalGang(session.getTable(), 0, "三万"); SettlementResult positiveGang = settlementService.settleSupplementalGang(session.getTable(), 0, "三万");
session.getSettlementHistory().add(positiveGang); session.getSettlementHistory().add(positiveGang);
session.getEvents().add(GameEvent.responseActionDeclared(
session.getGameId(),
com.xuezhanmaster.game.event.GameEventType.GANG_DECLARED,
0,
null,
"三万"
));
session.getEvents().add(GameEvent.settlementApplied(session.getGameId(), positiveGang));
SettlementResult negativeChaJiao = settlementService.settleChaJiao( SettlementResult negativeChaJiao = settlementService.settleChaJiao(
session.getTable(), session.getTable(),
2, 2,
@@ -75,6 +84,8 @@ class ReviewServiceTest {
)) ))
); );
session.getSettlementHistory().add(negativeChaJiao); session.getSettlementHistory().add(negativeChaJiao);
session.getEvents().add(GameEvent.tileDiscarded(session.getGameId(), 0, "七筒"));
session.getEvents().add(GameEvent.settlementApplied(session.getGameId(), negativeChaJiao));
ReviewSummaryResponse review = reviewService.createGameReviewSummary(started.gameId(), "host-1"); ReviewSummaryResponse review = reviewService.createGameReviewSummary(started.gameId(), "host-1");
@@ -83,6 +94,10 @@ class ReviewServiceTest {
assertThat(review.playerNickname()).isEqualTo("Host"); assertThat(review.playerNickname()).isEqualTo("Host");
assertThat(review.finalScore()).isEqualTo(-1); assertThat(review.finalScore()).isEqualTo(-1);
assertThat(review.resultLabel()).isEqualTo("本局失分"); assertThat(review.resultLabel()).isEqualTo("本局失分");
assertThat(review.actionTimeline()).isNotEmpty();
assertThat(review.actionTimeline())
.extracting(item -> item.title())
.contains("牌局开始", "完成定缺");
assertThat(review.settlementTimeline()).hasSize(2); assertThat(review.settlementTimeline()).hasSize(2);
assertThat(review.settlementTimeline()) assertThat(review.settlementTimeline())
.extracting(item -> item.title()) .extracting(item -> item.title())
@@ -90,6 +105,23 @@ class ReviewServiceTest {
assertThat(review.mistakeInsights()) assertThat(review.mistakeInsights())
.extracting(item -> item.title()) .extracting(item -> item.title())
.contains("流局未成叫"); .contains("流局未成叫");
assertThat(review.mistakeInsights())
.anySatisfy(item -> {
assertThat(item.relatedStepNo()).isNotNull();
assertThat(item.relatedActionTitle()).isEqualTo("个人出牌");
assertThat(item.recommendedDirectionType()).isEqualTo("READY_HAND");
assertThat(item.recommendedDirectionLabel()).isEqualTo("残局优先保叫");
assertThat(item.recommendedAction()).contains("最小可支付听牌");
assertThat(item.pressureSourceType()).isEqualTo("ENDGAME");
assertThat(item.pressureSourceLabel()).isEqualTo("残局查叫收尾(座位 2");
assertThat(item.pressureSourceDetail()).contains("座位 2");
assertThat(item.pressureSourceDetail()).contains("七筒");
assertThat(item.pressureSourceDetail()).contains("查叫");
assertThat(item.pressureSummary()).contains("残局收尾");
assertThat(item.pressureSummary()).contains("座位 2");
assertThat(item.contextSignals()).contains("触发牌:七筒");
assertThat(item.contextSignals()).contains("你的这次出牌直接连到了后续失分结算");
});
assertThat(review.trainingFocuses()) assertThat(review.trainingFocuses())
.extracting(item -> item.drillType()) .extracting(item -> item.drillType())
.contains("READY_HAND", "GANG_TIMING"); .contains("READY_HAND", "GANG_TIMING");

View File

@@ -71,15 +71,21 @@
- `frontend/src/pages/GamePageContainer.vue` - `frontend/src/pages/GamePageContainer.vue`
- `frontend/src/pages/ReviewPageContainer.vue` - `frontend/src/pages/ReviewPageContainer.vue`
其中 `ReviewPageContainer` 当前仍是占位实现,用于固定页面职责,不代表已经接入真实复盘数据 其中 `ReviewPageContainer` 已经接入真实复盘摘要,并开始承载动作时间线、关键结算、关键失误和训练方向
当前还补了一条最小演示协议入口 当前复盘接口已经切到真实对局协议
- 后端真实接口:`GET /api/games/{gameId}/review?userId={userId}` - 后端真实接口:`GET /api/games/{gameId}/review?userId={userId}`
它的目标不是替代真实局后数据,而是先把前后端在 `ReviewSummaryResponse` 这套字段上的语义对齐。 它的目标不是一次性变成完整牌谱播放器,而是先把前后端在 `ReviewSummaryResponse` 这套字段上的语义对齐,并逐步从结算摘要扩到过程回放
当前前端已经能在不接入正式路由的前提下,手动加载这条 demo 复盘数据并渲染 `ReviewPageContainer` 当前前端已经能手动加载当前真实对局、当前视角的复盘摘要,并渲染关键动作时间线;关键失误也开始挂接到关联动作步骤、推荐方向标签和局面信号
这一版关键失误协议还额外补了一层“压力来源细节”说明:
- `pressureSourceDetail`
- 用一段面向玩家可读的短文,把“哪家在施压、这股压力由什么公开动作或残局窗口形成、你这一步为什么正好撞上去”串起来
- 当前仍是启发式生成,不等同于完整牌谱推演或可见信息搜索
### 3.2 当前这一轮的落地点 ### 3.2 当前这一轮的落地点

View File

@@ -51,6 +51,77 @@ function formatSeverity(severity: string) {
} }
} }
function formatReviewEventType(eventType: string) {
switch (eventType) {
case 'GAME_STARTED':
return '开局'
case 'GAME_PHASE_CHANGED':
return '阶段'
case 'LACK_SELECTED':
return '定缺'
case 'TILE_DISCARDED':
return '出牌'
case 'TILE_DRAWN':
return '摸牌'
case 'TURN_SWITCHED':
return '轮转'
case 'RESPONSE_WINDOW_OPENED':
return '响应开始'
case 'RESPONSE_WINDOW_CLOSED':
return '响应结束'
case 'PENG_DECLARED':
return '碰'
case 'GANG_DECLARED':
return '杠'
case 'HU_DECLARED':
return '胡'
case 'PASS_DECLARED':
return '过'
case 'SETTLEMENT_APPLIED':
return '结算'
default:
return eventType
}
}
function formatDirectionType(directionType: string) {
switch (directionType) {
case 'RISK_CONTROL':
return '风险控制'
case 'READY_HAND':
return '优先保叫'
case 'ABANDON_GANG':
return '放弃补杠'
case 'SPEED_UP':
return '提速成型'
case 'TAX_PROTECTION':
return '守住杠税'
case 'DANGER_TRACKING':
return '追踪危险张'
default:
return directionType
}
}
function formatPressureSourceType(sourceType: string) {
switch (sourceType) {
case 'ENDGAME':
return '残局压力'
case 'ROBBING_GANG':
return '抢杠风险'
case 'MULTI_MELD':
return '连续副露'
case 'SINGLE_MELD':
return '已有副露'
case 'SPEED_LOSS':
return '速度落后'
case 'TABLE_TENSION':
return '场面压强'
default:
return sourceType
}
}
// 当前组件已经接入真实复盘协议, // 当前组件已经接入真实复盘协议,
// 但仍允许“暂无个人结算”的空态,避免首版服务在对局前期没有样本时出现空白页。 // 但仍允许“暂无个人结算”的空态,避免首版服务在对局前期没有样本时出现空白页。
</script> </script>
@@ -84,6 +155,22 @@ function formatSeverity(severity: string) {
<p class="message-copy">{{ props.review.conclusion }}</p> <p class="message-copy">{{ props.review.conclusion }}</p>
</article> </article>
<article class="placeholder-card">
<div class="section-title">
<strong>关键动作回放</strong>
<span class="mini-pill">{{ props.review.actionTimeline.length }} </span>
</div>
<div class="seat-list">
<article v-for="item in props.review.actionTimeline" :key="`${item.stepNo}-${item.title}`" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>#{{ item.stepNo }} · {{ item.title }}</strong>
<span class="mini-pill">{{ formatReviewEventType(item.eventType) }}</span>
</div>
<span class="message-copy">{{ item.summary }}</span>
</article>
</div>
</article>
<article class="placeholder-card"> <article class="placeholder-card">
<div class="section-title"> <div class="section-title">
<strong>关键结算</strong> <strong>关键结算</strong>
@@ -114,7 +201,26 @@ function formatSeverity(severity: string) {
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<span class="mini-pill">{{ formatSeverity(item.severity) }}</span> <span class="mini-pill">{{ formatSeverity(item.severity) }}</span>
</div> </div>
<div v-if="item.relatedStepNo !== null" class="mini-tags">
<span class="mini-tag">
关联动作 #{{ item.relatedStepNo }} · {{ item.relatedActionTitle ?? formatReviewEventType(item.relatedEventType ?? '') }}
</span>
<span class="mini-tag">
建议方向 · {{ item.recommendedDirectionLabel || formatDirectionType(item.recommendedDirectionType) }}
</span>
<span class="mini-tag">
压力来源 · {{ item.pressureSourceLabel || formatPressureSourceType(item.pressureSourceType) }}
</span>
</div>
<div v-if="item.contextSignals.length > 0" class="mini-tags">
<span v-for="signal in item.contextSignals" :key="`${item.title}-${signal}`" class="mini-tag">
{{ signal }}
</span>
</div>
<span class="message-copy">来源细节{{ item.pressureSourceDetail }}</span>
<span class="message-copy">压力摘要{{ item.pressureSummary }}</span>
<span class="message-copy">{{ item.issue }}</span> <span class="message-copy">{{ item.issue }}</span>
<span class="message-copy">建议动作{{ item.recommendedAction }}</span>
<span class="message-copy">{{ item.suggestion }}</span> <span class="message-copy">{{ item.suggestion }}</span>
</article> </article>
</div> </div>

View File

@@ -7,11 +7,29 @@ export type ReviewSettlementItem = {
fanLabels: string[] fanLabels: string[]
} }
export type ReviewActionTimelineItem = {
stepNo: number
eventType: string
title: string
summary: string
}
export type ReviewMistakeItem = { export type ReviewMistakeItem = {
severity: string severity: string
title: string title: string
issue: string issue: string
suggestion: string suggestion: string
recommendedDirectionType: string
recommendedDirectionLabel: string
recommendedAction: string
pressureSourceType: string
pressureSourceLabel: string
pressureSourceDetail: string
pressureSummary: string
contextSignals: string[]
relatedStepNo: number | null
relatedEventType: string | null
relatedActionTitle: string | null
} }
export type ReviewTrainingFocusItem = { export type ReviewTrainingFocusItem = {
@@ -28,6 +46,7 @@ export type ReviewSummaryResponse = {
finalScore: number finalScore: number
resultLabel: string resultLabel: string
conclusion: string conclusion: string
actionTimeline: ReviewActionTimelineItem[]
settlementTimeline: ReviewSettlementItem[] settlementTimeline: ReviewSettlementItem[]
mistakeInsights: ReviewMistakeItem[] mistakeInsights: ReviewMistakeItem[]
trainingFocuses: ReviewTrainingFocusItem[] trainingFocuses: ReviewTrainingFocusItem[]