feat(教学): 扩展教学建议链路以支持候选牌列表

扩展教学建议链路,在 PrivateTeachingMessage 中增加 candidates 字段,支持前端展示候选牌、评分和原因标签。同时优化前端组件结构,抽离共享类型和工具函数,为后续页面拆分做准备。

- 后端:在 GameSessionService 和 GameMessagePublisher 中透传候选牌列表
- 前端:新增 GameMessageStack 组件展示教学候选,优化手牌区推荐牌高亮
- 测试:补充 GameMessagePublisherTest 验证候选牌消息结构
- 文档:更新 DEVELOPMENT_PLAN 和 H5_GAME_PAGE_ARCHITECTURE 说明当前前端结构
This commit is contained in:
hujun
2026-03-20 15:54:05 +08:00
parent 6bcdf26fca
commit 905565e7c4
17 changed files with 1325 additions and 433 deletions

View File

@@ -301,7 +301,8 @@ public class GameSessionService {
currentSeat.getPlayerId(),
advice.teachingMode(),
advice.recommendedAction(),
advice.explanation()
advice.explanation(),
advice.candidates()
);
}

View File

@@ -1,11 +1,15 @@
package com.xuezhanmaster.ws.dto;
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
import java.util.List;
public record PrivateTeachingMessage(
String gameId,
String userId,
String teachingMode,
String recommendedAction,
String explanation
String explanation,
List<CandidateAdviceItem> candidates
) {
}

View File

@@ -3,6 +3,7 @@ package com.xuezhanmaster.ws.service;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
@@ -97,10 +98,18 @@ public class GameMessagePublisher {
);
}
public void publishPrivateTeaching(String gameId, String userId, String teachingMode, String recommendedAction, String explanation) {
public void publishPrivateTeaching(
String gameId,
String userId,
String teachingMode,
String recommendedAction,
String explanation,
List<CandidateAdviceItem> candidates
) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/teaching",
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
// 教学消息需要同时携带推荐结果与备选列表,前端才能把“为什么是这张牌”解释完整。
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation, candidates)
);
}

View File

@@ -786,8 +786,9 @@ class GameSessionServiceTest {
prepareWinningHand(winnerSeat);
session.getTable().setCurrentSeatNo(2);
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1);
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(3), "9筒", 1);
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2, 3);
// 这里只需要保证 seat 1 能对 seat 2 的 9筒形成“可胡后选择 PASS”的场景。
// 不再额外给 seat 3 补 9筒避免它也意外成为同一响应窗里的第二个胡牌候选导致测试依赖随机手牌分布。
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
gameSessionService.performAction(
started.gameId(),
@@ -801,9 +802,12 @@ class GameSessionServiceTest {
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
assertThat(afterPass.currentSeatNo()).isEqualTo(3);
// 这里取 seat 3 当前手里的安全牌继续推进牌局,只验证“未摸到自己下一张牌前仍然不能响应胡”。
String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName();
removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3);
GameStateResponse afterSecondDiscard = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-4", "DISCARD", "9筒", null)
new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
@@ -863,12 +867,15 @@ class GameSessionServiceTest {
assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
removeMatchingTilesFromOtherSeats(session, "8万", 1);
// 解锁完成后重新稳定构造一个“seat 2 打出 9筒seat 1 可以响应胡”的窗口,
// 避免继续依赖中间多轮摸打与随机初始牌造成的测试波动。
prepareWinningHand(winnerSeat);
GameSeat sourceSeat = session.getTable().getSeats().get(2);
sourceSeat.getHandTiles().clear();
sourceSeat.receiveTile(parseTile("9筒"));
session.getTable().setCurrentSeatNo(2);
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "DISCARD", "8万", null)
);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-3", "DISCARD", "9筒", null)

View File

@@ -4,8 +4,10 @@ import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.ResponseActionOption;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
@@ -84,4 +86,29 @@ class GameMessagePublisherTest {
.extracting(candidate -> candidate.actionType())
.containsExactly("PENG", "PASS");
}
@Test
void shouldPublishStructuredTeachingMessageWithCandidateList() {
gameMessagePublisher.publishPrivateTeaching(
"game-1",
"user-1",
"BRIEF",
"9筒",
"建议先打孤张。",
List.of(
new CandidateAdviceItem("9筒", 90, List.of("ISOLATED_TILE")),
new CandidateAdviceItem("1万", 70, List.of("EDGE_TILE"))
)
);
ArgumentCaptor<PrivateTeachingMessage> captor = ArgumentCaptor.forClass(PrivateTeachingMessage.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/teaching"), captor.capture());
PrivateTeachingMessage message = captor.getValue();
assertThat(message.teachingMode()).isEqualTo("BRIEF");
assertThat(message.recommendedAction()).isEqualTo("9筒");
assertThat(message.candidates()).hasSize(2);
assertThat(message.candidates().get(0).tile()).isEqualTo("9筒");
assertThat(message.candidates().get(0).reasonTags()).containsExactly("ISOLATED_TILE");
}
}