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

@@ -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;
import java.util.List;
/**
* 复盘页里的“关键失误”条目。
* 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。
@@ -8,6 +10,17 @@ public record ReviewMistakeItem(
String severity,
String title,
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(
String gameId,
@@ -14,6 +15,7 @@ public record ReviewSummaryResponse(
int finalScore,
String resultLabel,
String conclusion,
List<ReviewActionTimelineItem> actionTimeline,
List<ReviewSettlementItem> settlementTimeline,
List<ReviewMistakeItem> mistakeInsights,
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.SettlementResult;
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.review.dto.ReviewActionTimelineItem;
import com.xuezhanmaster.review.dto.ReviewMistakeItem;
import com.xuezhanmaster.review.dto.ReviewSettlementItem;
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
@@ -18,6 +21,7 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@@ -37,8 +41,9 @@ public class ReviewService {
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
List<SettlementResult> settlementHistory = List.copyOf(session.getSettlementHistory());
List<ReviewActionTimelineItem> actionTimeline = buildActionTimeline(session, reviewSeat);
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(
settlementHistory,
reviewSeat.getSeatNo(),
@@ -54,12 +59,40 @@ public class ReviewService {
reviewSeat.getScore(),
buildResultLabel(reviewSeat.getScore()),
buildConclusion(reviewSeat, settlementTimeline, mistakeInsights),
actionTimeline,
settlementTimeline,
mistakeInsights,
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) {
List<ReviewSettlementItem> items = new ArrayList<>();
for (SettlementResult settlementResult : settlementHistory) {
@@ -86,7 +119,7 @@ public class ReviewService {
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()
.filter(result -> {
Integer delta = resolveSeatDelta(result, seatNo);
@@ -99,48 +132,131 @@ public class ReviewService {
List<ReviewMistakeItem> items = new ArrayList<>();
for (SettlementResult settlementResult : negativeSettlements) {
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()) {
case DIAN_PAO_HU -> new ReviewMistakeItem(
"HIGH",
"点炮导致失分",
"你在他人弃牌胡结算中承担了 " + 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(
"HIGH",
"补杠时机过激",
"你在补杠后被对手抢杠胡,直接产生 " + 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(
"MEDIUM",
"未能压住对手自摸",
"对手自摸时你被扣除 " + 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(
"MEDIUM",
"杠分未守住",
"本局出现退税,导致你回吐 " + 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(
"HIGH",
"流局未成叫",
"流局查叫阶段你承担了 " + 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(
"MEDIUM",
"放杠失分",
"你在他人明杠结算里承担了 " + 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(
"LOW",
"杠分压制不足",
"对手通过杠牌从你这里拿走了 " + 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) {
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
) {
}
}