feat: 实现响应候选模型与私有动作消息结构化

新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括:

- 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型
- 扩展 PrivateActionMessage 支持响应候选上下文
- 实现 ResponseActionWindowBuilder 构建弃牌响应候选
- 拆分 GameMessagePublisher 支持回合动作和响应动作消息
- 更新前端原型页展示结构化候选动作
- 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md
This commit is contained in:
hujun
2026-03-20 13:04:59 +08:00
parent 24fce055fd
commit 48da7d4990
20 changed files with 1151 additions and 208 deletions

View File

@@ -30,6 +30,7 @@ class GameSessionServiceTest {
roomService,
new GameEngine(new DeckFactory()),
new GameActionProcessor(),
new ResponseActionWindowBuilder(),
new StrategyService(),
new PlayerVisibilityService(),
new TeachingService(),

View File

@@ -0,0 +1,86 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class ResponseActionWindowBuilderTest {
private final ResponseActionWindowBuilder builder = new ResponseActionWindowBuilder();
@Test
void shouldBuildResponseCandidatesForDiscardedTile() {
Tile discardedTile = new Tile(TileSuit.WAN, 3);
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
seat(1, "u1", discardedTile, discardedTile),
seat(2, "u2", discardedTile, discardedTile, discardedTile),
seat(3, "u3", new Tile(TileSuit.TIAO, 1))
));
Optional<ResponseActionWindow> result = builder.buildForDiscard(session, 0, discardedTile);
assertThat(result).isPresent();
ResponseActionWindow window = result.orElseThrow();
assertThat(window.triggerEventType()).isEqualTo("TILE_DISCARDED");
assertThat(window.sourceSeatNo()).isEqualTo(0);
assertThat(window.triggerTile()).isEqualTo("3万");
assertThat(window.seatCandidates()).hasSize(2);
ResponseActionSeatCandidate seat1 = window.seatCandidates().get(0);
assertThat(seat1.seatNo()).isEqualTo(1);
assertThat(seat1.userId()).isEqualTo("u1");
assertThat(seat1.options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.PENG, ActionType.PASS);
ResponseActionSeatCandidate seat2 = window.seatCandidates().get(1);
assertThat(seat2.seatNo()).isEqualTo(2);
assertThat(seat2.options())
.extracting(option -> option.actionType())
.containsExactly(ActionType.PENG, ActionType.GANG, ActionType.PASS);
}
@Test
void shouldReturnEmptyWhenNoSeatCanRespond() {
Tile discardedTile = new Tile(TileSuit.TONG, 5);
GameSession session = new GameSession("room-1", createPlayingTable(
seat(0, "u0"),
seat(1, "u1", new Tile(TileSuit.WAN, 1)),
seat(2, "u2", new Tile(TileSuit.WAN, 2)),
seat(3, "u3", new Tile(TileSuit.WAN, 3))
));
Optional<ResponseActionWindow> result = builder.buildForDiscard(session, 0, discardedTile);
assertThat(result).isEmpty();
}
private GameTable createPlayingTable(GameSeat... seats) {
GameTable table = new GameTable(List.of(seats), new ArrayList<>());
table.setPhase(GamePhase.PLAYING);
table.setCurrentSeatNo(0);
return table;
}
private GameSeat seat(int seatNo, String userId, Tile... tiles) {
GameSeat seat = new GameSeat(seatNo, false, userId, "seat-" + seatNo);
for (Tile tile : tiles) {
seat.receiveTile(tile);
}
return seat;
}
}

View File

@@ -0,0 +1,78 @@
package com.xuezhanmaster.ws.service;
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.ws.dto.PrivateActionMessage;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class GameMessagePublisherTest {
private final SimpMessagingTemplate messagingTemplate = mock(SimpMessagingTemplate.class);
private final GameMessagePublisher gameMessagePublisher = new GameMessagePublisher(messagingTemplate);
@Test
void shouldPublishStructuredTurnActionMessage() {
gameMessagePublisher.publishPrivateTurnActionRequired("game-1", "user-1", List.of("DISCARD"), 2);
ArgumentCaptor<PrivateActionMessage> captor = ArgumentCaptor.forClass(PrivateActionMessage.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture());
PrivateActionMessage message = captor.getValue();
assertThat(message.actionScope()).isEqualTo("TURN");
assertThat(message.availableActions()).containsExactly("DISCARD");
assertThat(message.currentSeatNo()).isEqualTo(2);
assertThat(message.candidates()).hasSize(1);
assertThat(message.candidates().get(0).actionType()).isEqualTo("DISCARD");
assertThat(message.candidates().get(0).tile()).isNull();
}
@Test
void shouldPublishStructuredResponseActionMessage() {
ResponseActionWindow window = ResponseActionWindow.create(
"game-1",
"TILE_DISCARDED",
0,
"3万",
List.of(new ResponseActionSeatCandidate(
1,
"user-1",
List.of(
new ResponseActionOption(ActionType.PENG, "3万"),
new ResponseActionOption(ActionType.PASS, null)
)
))
);
gameMessagePublisher.publishPrivateResponseActionRequired(
"game-1",
"user-1",
0,
window,
window.seatCandidates().get(0)
);
ArgumentCaptor<PrivateActionMessage> captor = ArgumentCaptor.forClass(PrivateActionMessage.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/actions"), captor.capture());
PrivateActionMessage message = captor.getValue();
assertThat(message.actionScope()).isEqualTo("RESPONSE");
assertThat(message.windowId()).isEqualTo(window.windowId());
assertThat(message.triggerEventType()).isEqualTo("TILE_DISCARDED");
assertThat(message.sourceSeatNo()).isEqualTo(0);
assertThat(message.triggerTile()).isEqualTo("3万");
assertThat(message.availableActions()).containsExactly("PENG", "PASS");
assertThat(message.candidates())
.extracting(candidate -> candidate.actionType())
.containsExactly("PENG", "PASS");
}
}