feat: 实现响应候选模型与私有动作消息结构化
新增响应候选领域模型和结构化私有动作消息,支持响应窗口和候选动作下发。主要变更包括: - 新增 ResponseActionOption、ResponseActionSeatCandidate 和 ResponseActionWindow 模型 - 扩展 PrivateActionMessage 支持响应候选上下文 - 实现 ResponseActionWindowBuilder 构建弃牌响应候选 - 拆分 GameMessagePublisher 支持回合动作和响应动作消息 - 更新前端原型页展示结构化候选动作 - 新增响应优先级规则文档 RESPONSE_RESOLUTION_RULES.md
This commit is contained in:
@@ -30,6 +30,7 @@ class GameSessionServiceTest {
|
||||
roomService,
|
||||
new GameEngine(new DeckFactory()),
|
||||
new GameActionProcessor(),
|
||||
new ResponseActionWindowBuilder(),
|
||||
new StrategyService(),
|
||||
new PlayerVisibilityService(),
|
||||
new TeachingService(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user