diff --git a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl index 761610e..a98ceb0 100644 Binary files a/.serena/cache/java/document_symbols_cache_v23-06-25.pkl and b/.serena/cache/java/document_symbols_cache_v23-06-25.pkl differ diff --git a/.serena/memories/blood_battle_scoring_v1.md b/.serena/memories/blood_battle_scoring_v1.md index f9eed6c..580551a 100644 --- a/.serena/memories/blood_battle_scoring_v1.md +++ b/.serena/memories/blood_battle_scoring_v1.md @@ -6,12 +6,23 @@ - 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。 - 教学建议链路已扩展为:`recommendedAction`、`explanation`、`candidates`,其中 `candidates` 透传到私有 WebSocket 教学消息,前端已支持展示候选牌、评分和原因标签中文映射。 - 前端 H5 对局页已完成正式动作面板、响应动作面板、玩家视角切换、公共事件时间线、最近结算卡片、教学推荐高亮等联调。 -- 2026-03-20 当日新增一轮前端结构收口: +- 2026-03-20 当日新增前端结构收口: - 新增 `docs/H5_GAME_PAGE_ARCHITECTURE.md`,完成 `S1-08` 页面信息架构与拆分方案。 - 前端共享类型已抽到 `frontend/src/types/game.ts`。 + - 复盘类型已抽到 `frontend/src/types/review.ts`。 - UI 标签映射与事件/结算格式化函数已抽到 `frontend/src/utils/gameUi.ts`。 - - 已拆出展示组件:`GameActionDock.vue`、`GameMessageStack.vue`、`PublicEventTimeline.vue`。 - - `App.vue` 当前定位已经收敛为“页面容器 + 请求/订阅协调层”,便于下一轮继续拆 `RoomPage / GamePage / ReviewPage`。 + - 顶层页面外壳已拆出:`AppShell.vue`。 + - 页面级区块组件已拆出:`RoomWorkspace.vue`、`GameWorkspace.vue`。 + - 房间流组件已拆出:`RoomControlPanel.vue`、`RoomLobbyPanel.vue`。 + - 对局展示组件已拆出:`GameActionDock.vue`、`GameMessageStack.vue`、`PublicEventTimeline.vue`、`ViewSwitchPanel.vue`、`SelfHandPanel.vue`、`PublicSeatBoard.vue`。 + - `pages/` 目录已建立,当前入口容器已命名为:`RoomPageContainer.vue`、`GamePageContainer.vue`。 + - `ReviewPageContainer.vue` 已对齐后端 `ReviewSummaryResponse` 协议,并支持手动加载 demo 复盘数据,但尚未接入真实复盘数据和路由。 +- 2026-03-20 当日新增最小复盘协议骨架: + - 后端新增 `review` 包,包含 `ReviewSummaryResponse`、`ReviewSettlementItem`、`ReviewMistakeItem`、`ReviewTrainingFocusItem`。 + - 后端新增 `ReviewService`,当前提供演示复盘数据。 + - 示例接口已开放:`GET /api/demo/review`。 + - 前端 `App.vue` 已能手动加载这条接口并把数据传入 `ReviewPageContainer`。 + - `DemoGameServiceTest` 已新增复盘示例测试,当前总测试数为 55。 - 2026-03-20 当日还修复了 `GameSessionServiceTest` 中两条“过水不胡”测试的脆弱构造,改为显式控制响应胡候选与后续安全弃牌,避免依赖随机初始牌与中间牌路。 - 当前验证状态:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。 - 注释约定继续有效:后端复杂规则、前端复杂交互、后续数据库脚本和迁移都要补适当偏多的中文注释,尤其说明规则判断、状态切换、消息边界与字段语义。 \ No newline at end of file diff --git a/.serena/memories/review_service_v1.md b/.serena/memories/review_service_v1.md new file mode 100644 index 0000000..ee5f0da --- /dev/null +++ b/.serena/memories/review_service_v1.md @@ -0,0 +1,16 @@ +# 局后复盘服务 V1(2026-03-20) +- 已移除 demo 复盘链路:`DemoGameController` 不再暴露 `GET /api/demo/review`,`DemoGameService` 也已移除 demo review 生成逻辑。 +- 新增真实复盘接口:`GET /api/games/{gameId}/review?userId={userId}`。 +- `ReviewController` 负责暴露接口,`ReviewService` 负责基于真实 `GameSession` 和 `settlementHistory` 生成 `ReviewSummaryResponse`。 +- `GameSessionService` 新增只读查询口 `getSessionForReview(String gameId)`,当前仅供复盘服务读取内存态会话;未引入新持久化或快照表。 +- 当前复盘算法范围(KISS/YAGNI 版本): + - 个人总览:读取当前座位最终分数、结果标签、总结文案。 + - 关键结算:从 `settlementHistory` 中筛出与该座位直接相关的结算,生成标题、摘要、分值和番型/结算标签。 + - 关键失误:仅基于真实负向结算生成启发式问题项,当前覆盖点炮、自摸失分、抢杠胡、退税、查叫、明杠/杠类被动付分。 + - 训练方向:基于负向结算类型和是否有正向胡牌样本,生成风险控制、成叫效率、杠牌时机、基础番型等训练建议。 +- 当前限制: + - 仍是内存态复盘,服务重启后对局与复盘数据会丢失。 + - 还没有逐手牌谱回放、动作级失误定位、个人可见信息回放时间线。 + - `mistakeInsights` 仍是启发式生成,不是严格牌效/安牌分析。 +- 前端已改为手动加载当前真实对局/当前视角复盘;切换视角或切换对局时会清空旧复盘,避免显示过期摘要。 +- 已验证:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。 \ No newline at end of file diff --git a/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java b/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java index 4c1ec4b..63d5e71 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java +++ b/backend/src/main/java/com/xuezhanmaster/game/controller/DemoGameController.java @@ -28,4 +28,3 @@ public class DemoGameController { return ApiResponse.success(demoGameService.createDemoTeachingAdvice()); } } - diff --git a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java index 491925f..3f9427d 100644 --- a/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java +++ b/backend/src/main/java/com/xuezhanmaster/game/service/GameSessionService.java @@ -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()); diff --git a/backend/src/main/java/com/xuezhanmaster/review/controller/ReviewController.java b/backend/src/main/java/com/xuezhanmaster/review/controller/ReviewController.java new file mode 100644 index 0000000..d4299a0 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/controller/ReviewController.java @@ -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 review( + @PathVariable String gameId, + @RequestParam String userId + ) { + return ApiResponse.success(reviewService.createGameReviewSummary(gameId, userId)); + } +} diff --git a/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewMistakeItem.java b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewMistakeItem.java new file mode 100644 index 0000000..26ba88a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewMistakeItem.java @@ -0,0 +1,13 @@ +package com.xuezhanmaster.review.dto; + +/** + * 复盘页里的“关键失误”条目。 + * 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。 + */ +public record ReviewMistakeItem( + String severity, + String title, + String issue, + String suggestion +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSettlementItem.java b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSettlementItem.java new file mode 100644 index 0000000..d468156 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSettlementItem.java @@ -0,0 +1,15 @@ +package com.xuezhanmaster.review.dto; + +import java.util.List; + +/** + * 复盘页里的“关键结算节点”条目。 + * 当前先用扁平结构表达,后续接真实局后数据时再决定是否拆更细的事件层级。 + */ +public record ReviewSettlementItem( + String title, + String summary, + int scoreDelta, + List fanLabels +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSummaryResponse.java b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSummaryResponse.java new file mode 100644 index 0000000..2d7607d --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewSummaryResponse.java @@ -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 settlementTimeline, + List mistakeInsights, + List trainingFocuses +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewTrainingFocusItem.java b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewTrainingFocusItem.java new file mode 100644 index 0000000..27d4a13 --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/dto/ReviewTrainingFocusItem.java @@ -0,0 +1,12 @@ +package com.xuezhanmaster.review.dto; + +/** + * 复盘页里的“后续训练方向”条目。 + * 当前先保留最小训练类型标签,避免过早引入大而全训练体系。 + */ +public record ReviewTrainingFocusItem( + String drillType, + String title, + String description +) { +} diff --git a/backend/src/main/java/com/xuezhanmaster/review/service/ReviewService.java b/backend/src/main/java/com/xuezhanmaster/review/service/ReviewService.java new file mode 100644 index 0000000..a21217a --- /dev/null +++ b/backend/src/main/java/com/xuezhanmaster/review/service/ReviewService.java @@ -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 settlementHistory = List.copyOf(session.getSettlementHistory()); + List settlementTimeline = buildSettlementTimeline(settlementHistory, reviewSeat.getSeatNo()); + List mistakeInsights = buildMistakeInsights(settlementHistory, reviewSeat.getSeatNo()); + List 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 buildSettlementTimeline(List settlementHistory, int seatNo) { + List items = new ArrayList<>(); + for (SettlementResult settlementResult : settlementHistory) { + Integer scoreDelta = resolveSeatDelta(settlementResult, seatNo); + if (scoreDelta == null) { + continue; + } + List 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 buildMistakeInsights(List settlementHistory, int seatNo) { + List 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 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 buildTrainingFocuses( + List settlementHistory, + int seatNo, + int finalScore, + List mistakeInsights + ) { + List items = new ArrayList<>(); + Set 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 items, + Set 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 settlementTimeline, + List 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 settlementHistory, int seatNo, SettlementType... settlementTypes) { + Set 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 settlementHistory, int seatNo, SettlementType... settlementTypes) { + Set acceptedTypes = Set.of(settlementTypes); + return settlementHistory.stream() + .filter(result -> acceptedTypes.contains(result.settlementType())) + .anyMatch(result -> resolveSeatDelta(result, seatNo) != null); + } + + private boolean hasPositiveHu(List 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 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 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); + } +} diff --git a/backend/src/test/java/com/xuezhanmaster/review/service/ReviewServiceTest.java b/backend/src/test/java/com/xuezhanmaster/review/service/ReviewServiceTest.java new file mode 100644 index 0000000..76e7d4b --- /dev/null +++ b/backend/src/test/java/com/xuezhanmaster/review/service/ReviewServiceTest.java @@ -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"); + } +} diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 68213e4..4a08f78 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -272,6 +272,7 @@ room 房间、座位、加入、准备 game 对局、动作、状态、事件、动作处理 strategy 推荐动作、AI 决策 teaching 教学建议、玩家可见状态 +review 局后复盘、错题与训练方向协议骨架 web 演示或基础接口 ws WebSocket 配置与消息发布 ``` @@ -287,11 +288,24 @@ ws WebSocket 配置与消息发布 同时,已完成第一轮最小拆分准备: - 共享类型:`frontend/src/types/game.ts` +- 复盘类型:`frontend/src/types/review.ts` - UI 格式化工具:`frontend/src/utils/gameUi.ts` - 展示组件: + - `frontend/src/components/AppShell.vue` + - `frontend/src/components/RoomWorkspace.vue` + - `frontend/src/components/GameWorkspace.vue` + - `frontend/src/components/RoomControlPanel.vue` + - `frontend/src/components/RoomLobbyPanel.vue` - `frontend/src/components/GameActionDock.vue` - `frontend/src/components/GameMessageStack.vue` - `frontend/src/components/PublicEventTimeline.vue` + - `frontend/src/components/ViewSwitchPanel.vue` + - `frontend/src/components/SelfHandPanel.vue` + - `frontend/src/components/PublicSeatBoard.vue` +- 页面级容器目录已建立: + - `frontend/src/pages/RoomPageContainer.vue` + - `frontend/src/pages/GamePageContainer.vue` + - `frontend/src/pages/ReviewPageContainer.vue` 后续建议按页面拆分为: diff --git a/docs/H5_GAME_PAGE_ARCHITECTURE.md b/docs/H5_GAME_PAGE_ARCHITECTURE.md index a684ae8..a74e5d2 100644 --- a/docs/H5_GAME_PAGE_ARCHITECTURE.md +++ b/docs/H5_GAME_PAGE_ARCHITECTURE.md @@ -65,6 +65,22 @@ 当前阶段不要求一次性引入完整路由,但组件组织和文档命名要按这个终态准备。 +当前代码层已经落下对应的页面级容器命名: + +- `frontend/src/pages/RoomPageContainer.vue` +- `frontend/src/pages/GamePageContainer.vue` +- `frontend/src/pages/ReviewPageContainer.vue` + +其中 `ReviewPageContainer` 当前仍是占位实现,用于固定页面职责,不代表已经接入真实复盘数据。 + +当前还补了一条最小演示协议入口: + +- 后端真实接口:`GET /api/games/{gameId}/review?userId={userId}` + +它的目标不是替代真实局后数据,而是先把前后端在 `ReviewSummaryResponse` 这套字段上的语义对齐。 + +当前前端已经能在不接入正式路由的前提下,手动加载这条 demo 复盘数据并渲染 `ReviewPageContainer`。 + ### 3.2 当前这一轮的落地点 本轮先聚焦 `GamePage`。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 43955d2..c77b415 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,10 @@ diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue new file mode 100644 index 0000000..84b17ae --- /dev/null +++ b/frontend/src/components/AppShell.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/GameWorkspace.vue b/frontend/src/components/GameWorkspace.vue new file mode 100644 index 0000000..32bc030 --- /dev/null +++ b/frontend/src/components/GameWorkspace.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/components/PublicSeatBoard.vue b/frontend/src/components/PublicSeatBoard.vue new file mode 100644 index 0000000..17ab9f2 --- /dev/null +++ b/frontend/src/components/PublicSeatBoard.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/components/RoomControlPanel.vue b/frontend/src/components/RoomControlPanel.vue new file mode 100644 index 0000000..fb60bf7 --- /dev/null +++ b/frontend/src/components/RoomControlPanel.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/components/RoomLobbyPanel.vue b/frontend/src/components/RoomLobbyPanel.vue new file mode 100644 index 0000000..2ba93b3 --- /dev/null +++ b/frontend/src/components/RoomLobbyPanel.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/RoomWorkspace.vue b/frontend/src/components/RoomWorkspace.vue new file mode 100644 index 0000000..4486016 --- /dev/null +++ b/frontend/src/components/RoomWorkspace.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/SelfHandPanel.vue b/frontend/src/components/SelfHandPanel.vue new file mode 100644 index 0000000..242308a --- /dev/null +++ b/frontend/src/components/SelfHandPanel.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/ViewSwitchPanel.vue b/frontend/src/components/ViewSwitchPanel.vue new file mode 100644 index 0000000..3f50841 --- /dev/null +++ b/frontend/src/components/ViewSwitchPanel.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/pages/GamePageContainer.vue b/frontend/src/pages/GamePageContainer.vue new file mode 100644 index 0000000..e8c09e2 --- /dev/null +++ b/frontend/src/pages/GamePageContainer.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/pages/ReviewPageContainer.vue b/frontend/src/pages/ReviewPageContainer.vue new file mode 100644 index 0000000..e8fc8c4 --- /dev/null +++ b/frontend/src/pages/ReviewPageContainer.vue @@ -0,0 +1,156 @@ + + + diff --git a/frontend/src/pages/RoomPageContainer.vue b/frontend/src/pages/RoomPageContainer.vue new file mode 100644 index 0000000..264ba10 --- /dev/null +++ b/frontend/src/pages/RoomPageContainer.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts new file mode 100644 index 0000000..70b2dfd --- /dev/null +++ b/frontend/src/types/review.ts @@ -0,0 +1,34 @@ +// 复盘页单独维护一份类型定义,避免把“局中状态”和“局后分析”继续混在同一个类型文件里。 + +export type ReviewSettlementItem = { + title: string + summary: string + scoreDelta: number + fanLabels: string[] +} + +export type ReviewMistakeItem = { + severity: string + title: string + issue: string + suggestion: string +} + +export type ReviewTrainingFocusItem = { + drillType: string + title: string + description: string +} + +export type ReviewSummaryResponse = { + gameId: string + userId: string + playerNickname: string + seatNo: number + finalScore: number + resultLabel: string + conclusion: string + settlementTimeline: ReviewSettlementItem[] + mistakeInsights: ReviewMistakeItem[] + trainingFocuses: ReviewTrainingFocusItem[] +}