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

@@ -1,10 +1,15 @@
# 当前执行入口
- 当前 Sprint 文档:`docs/SPRINT_01_ISSUES_BOARD.md`
- Sprint 1 当前进度:`S1-01``S1-02``S1-03` 已完成,推荐的下一个编码任务是 `S1-04`
- `S1-03` 已完成内容:
- `GameEventType` 已补齐未来响应窗口和响应动作事件类型
- `GameEvent` 已新增统一工厂方法,统一现有公共事件和未来响应事件的载荷格式
- `GameSessionService``GameActionProcessor` 的现有公共事件已改为复用统一工厂方法
-新增 `GameEventTest`,验证新增动作事件与标准公共事件的载荷结构
- 当前推荐的后续依赖主线:`S1-04 -> S1-05 -> S1-07`
- 注意:目前新增事件类型仍主要是模型与约定层准备,真实响应窗口和响应动作事件还没有开始实际发出,下一步要用 `S1-04` 补候选动作模型。
- Sprint 1 当前进度:`S1-01``S1-02``S1-03``S1-04``S1-05``S1-06``S1-07` 已完成
- `S1-06` 已完成内容:
- 已新增 `docs/RESPONSE_RESOLUTION_RULES.md`
- 已明确项目 V1 响应优先级:`HU > GANG > PENG > PASS`
- 已明确同优先级裁决按出牌者之后最近顺位优先
-明确当前 V1 不实现完整 `过水不胡``一炮多响`
- 已明确公共消息与私有消息边界,以及后续裁决器接入顺序
- 当前推荐的下一步有两条:
- 文档主线:`S1-08` 对局页信息架构与页面拆分方案。
- 代码主线:开始下一轮“真实响应窗口停顿 + 候选下发 + 裁决器”实现,不再停留在模型和消息层。
- 重要现状说明:
- 后端和前端已经具备结构化响应候选模型与私有消息结构。
- 当前仍未在弃牌后真正暂停主流程等待响应,这是下一轮核心开发点。

View File

@@ -96,6 +96,7 @@ npm run build
- [周计划看板](/D:/WorkSpace/me/xzmaster/docs/WEEKLY_PLAN_BOARD.md)
- [Issue 模板与看板](/D:/WorkSpace/me/xzmaster/docs/ISSUE_TEMPLATES_BOARD.md)
- [Sprint 1 Issue 看板](/D:/WorkSpace/me/xzmaster/docs/SPRINT_01_ISSUES_BOARD.md)
- [响应优先级规则澄清](/D:/WorkSpace/me/xzmaster/docs/RESPONSE_RESOLUTION_RULES.md)
这三份文档用于把主计划进一步拆成可直接执行的落地材料:
@@ -103,6 +104,7 @@ npm run build
- `周计划看板`:回答“接下来每周做什么、如何验收、怎样滚动调整”
- `Issue 模板与看板`:回答“单个任务如何立项、描述、拆解、验收、进入看板”
- `Sprint 1 Issue 看板`:回答“当前这一轮开发具体先做哪些真实任务、按什么顺序推进”
- `响应优先级规则澄清`:回答“碰 / 杠 / 胡 / 过 的冲突怎么裁决、哪些属于项目 V1 约定”
### 推荐阅读顺序

View File

@@ -4,7 +4,9 @@ import com.xuezhanmaster.game.event.GameEvent;
import java.util.ArrayList;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class GameSession {
@@ -14,6 +16,8 @@ public class GameSession {
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
private ResponseActionWindow pendingResponseActionWindow;
private final Map<Integer, ActionType> responseActionSelections;
public GameSession(String roomId, GameTable table) {
this.gameId = UUID.randomUUID().toString();
@@ -21,6 +25,7 @@ public class GameSession {
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
this.responseActionSelections = new LinkedHashMap<>();
}
public String getGameId() {
@@ -42,4 +47,20 @@ public class GameSession {
public List<GameEvent> getEvents() {
return events;
}
public ResponseActionWindow getPendingResponseActionWindow() {
return pendingResponseActionWindow;
}
public void setPendingResponseActionWindow(ResponseActionWindow pendingResponseActionWindow) {
this.pendingResponseActionWindow = pendingResponseActionWindow;
}
public Map<Integer, ActionType> getResponseActionSelections() {
return responseActionSelections;
}
public void clearResponseActionSelections() {
responseActionSelections.clear();
}
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.game.domain;
public record ResponseActionOption(
ActionType actionType,
String tile
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.game.domain;
import java.util.List;
public record ResponseActionSeatCandidate(
int seatNo,
String userId,
List<ResponseActionOption> options
) {
}

View File

@@ -0,0 +1,34 @@
package com.xuezhanmaster.game.domain;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record ResponseActionWindow(
String windowId,
String gameId,
String triggerEventType,
int sourceSeatNo,
String triggerTile,
List<ResponseActionSeatCandidate> seatCandidates,
Instant createdAt
) {
public static ResponseActionWindow create(
String gameId,
String triggerEventType,
int sourceSeatNo,
String triggerTile,
List<ResponseActionSeatCandidate> seatCandidates
) {
return new ResponseActionWindow(
UUID.randomUUID().toString(),
gameId,
triggerEventType,
sourceSeatNo,
triggerTile,
List.copyOf(seatCandidates),
Instant.now()
);
}
}

View File

@@ -6,6 +6,8 @@ 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 com.xuezhanmaster.game.dto.GameActionRequest;
@@ -35,29 +37,29 @@ public class GameActionProcessor {
private List<GameEvent> peng(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.PENG);
}
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.GANG);
}
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
validateResponseAction(session, table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU);
}
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, null, "", false);
return unsupportedAction(ActionType.PASS);
validateResponseAction(session, table, seat, sourceSeatNo, null, "", false);
return List.of();
}
private List<GameEvent> unsupportedAction(ActionType actionType) {
@@ -143,16 +145,18 @@ public class GameActionProcessor {
}
private void validateResponseAction(
GameSession session,
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName
) {
validateResponseAction(table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
validateResponseAction(session, table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
}
private void validateResponseAction(
GameSession session,
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
@@ -172,12 +176,70 @@ public class GameActionProcessor {
if (sourceSeatNo == actorSeat.getSeatNo()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不能是自己");
}
ResponseActionWindow responseActionWindow = sessionResponseWindow(session, sourceSeatNo, actionName);
validateSeatCandidate(responseActionWindow, actorSeat, actionName);
if (requiresTile && isBlank(tileDisplayName)) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少目标牌");
}
if (requiresTile) {
validateCandidateAction(responseActionWindow, actorSeat, tileDisplayName, actionName);
} else {
validateCandidateAction(responseActionWindow, actorSeat, null, actionName);
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private ResponseActionWindow sessionResponseWindow(GameSession session, Integer sourceSeatNo, String actionName) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口");
}
if (responseActionWindow.sourceSeatNo() != sourceSeatNo) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", actionName + "动作来源窗口不匹配");
}
return responseActionWindow;
}
private void validateSeatCandidate(ResponseActionWindow responseActionWindow, GameSeat actorSeat, String actionName) {
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有可执行的" + actionName + "窗口");
}
boolean isCandidateSeat = responseActionWindow.seatCandidates().stream()
.anyMatch(candidate -> candidate.seatNo() == actorSeat.getSeatNo());
if (!isCandidateSeat) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中");
}
}
private void validateCandidateAction(
ResponseActionWindow responseActionWindow,
GameSeat actorSeat,
String tileDisplayName,
String actionName
) {
ResponseActionSeatCandidate seatCandidate = responseActionWindow.seatCandidates().stream()
.filter(candidate -> candidate.seatNo() == actorSeat.getSeatNo())
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中"));
boolean matched = seatCandidate.options().stream()
.anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName))
&& (tileDisplayName == null || tileDisplayName.equals(option.tile())));
if (!matched) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前窗口不支持该" + actionName + "动作");
}
}
private String toActionTypeName(String actionName) {
return switch (actionName) {
case "" -> ActionType.PENG.name();
case "" -> ActionType.GANG.name();
case "" -> ActionType.HU.name();
case "" -> ActionType.PASS.name();
default -> actionName;
};
}
}

View File

@@ -6,6 +6,8 @@ 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.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
@@ -29,6 +31,7 @@ import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
@@ -37,6 +40,7 @@ public class GameSessionService {
private final RoomService roomService;
private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor;
private final ResponseActionWindowBuilder responseActionWindowBuilder;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
@@ -47,6 +51,7 @@ public class GameSessionService {
RoomService roomService,
GameEngine gameEngine,
GameActionProcessor gameActionProcessor,
ResponseActionWindowBuilder responseActionWindowBuilder,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService,
@@ -55,6 +60,7 @@ public class GameSessionService {
this.roomService = roomService;
this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor;
this.responseActionWindowBuilder = responseActionWindowBuilder;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
@@ -82,7 +88,7 @@ public class GameSessionService {
ActionType actionType = parseActionType(request.actionType());
List<GameEvent> events = gameActionProcessor.process(session, request);
appendAndPublish(session, events);
handlePostActionEffects(session, actionType);
handlePostActionEffects(session, actionType, request, events);
return toStateResponse(session, request.userId());
}
@@ -195,7 +201,7 @@ public class GameSessionService {
return;
}
gameMessagePublisher.publishPrivateActionRequired(
gameMessagePublisher.publishPrivateTurnActionRequired(
session.getGameId(),
currentSeat.getPlayerId(),
List.of("DISCARD"),
@@ -214,24 +220,165 @@ public class GameSessionService {
);
}
private void handlePostActionEffects(GameSession session, ActionType actionType) {
private void handlePostActionEffects(
GameSession session,
ActionType actionType,
GameActionRequest request,
List<GameEvent> events
) {
switch (actionType) {
case SELECT_LACK_SUIT -> {
if (session.getTable().getPhase() == GamePhase.PLAYING) {
notifyActionIfHumanTurn(session);
}
}
case DISCARD -> {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
case DISCARD -> handleDiscardPostAction(session, request, events);
case PASS -> handlePassPostAction(session, request);
default -> {
// 其余动作在后续 Sprint 中补充对应副作用
}
}
}
private void notifyResponseCandidates(GameSession session, ResponseActionWindow responseActionWindow) {
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
if (seat.isAi()) {
continue;
}
gameMessagePublisher.publishPrivateResponseActionRequired(
session.getGameId(),
seatCandidate.userId(),
session.getTable().getCurrentSeatNo(),
responseActionWindow,
seatCandidate
);
}
}
private void handleDiscardPostAction(GameSession session, GameActionRequest request, List<GameEvent> events) {
GameSeat sourceSeat = findSeatByUserId(session.getTable(), request.userId());
Tile discardedTile = resolveDiscardedTile(events, request.tile());
Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForDiscard(
session,
sourceSeat.getSeatNo(),
discardedTile
);
if (window.isPresent()) {
openResponseWindow(session, window.get());
autoPassAiCandidates(session);
if (session.getPendingResponseActionWindow() == null) {
continueAfterDiscardWithoutResponse(session);
}
return;
}
continueAfterDiscardWithoutResponse(session);
}
private void handlePassPostAction(GameSession session, GameActionRequest request) {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
if (allResponseCandidatesResolved(session, responseActionWindow)) {
closeResponseWindowAsPass(session, responseActionWindow);
continueAfterDiscardWithoutResponse(session);
}
}
private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
session.setPendingResponseActionWindow(responseActionWindow);
session.clearResponseActionSelections();
appendAndPublish(session, GameEvent.responseWindowOpened(
session.getGameId(),
responseActionWindow.sourceSeatNo(),
responseActionWindow.sourceSeatNo(),
responseActionWindow.triggerTile()
));
notifyResponseCandidates(session, responseActionWindow);
}
private void autoPassAiCandidates(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
return;
}
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
if (seat.isAi()) {
session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS);
}
}
if (allResponseCandidatesResolved(session, responseActionWindow)) {
closeResponseWindowAsPass(session, responseActionWindow);
}
}
private boolean allResponseCandidatesResolved(GameSession session, ResponseActionWindow responseActionWindow) {
return responseActionWindow.seatCandidates().stream()
.allMatch(candidate -> session.getResponseActionSelections().containsKey(candidate.seatNo()));
}
private void closeResponseWindowAsPass(GameSession session, ResponseActionWindow responseActionWindow) {
appendAndPublish(session, GameEvent.responseWindowClosed(
session.getGameId(),
null,
responseActionWindow.sourceSeatNo(),
ActionType.PASS.name()
));
session.setPendingResponseActionWindow(null);
session.clearResponseActionSelections();
}
private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
if (responseActionWindow == null) {
throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有待处理的响应窗口");
}
return responseActionWindow;
}
private Tile resolveDiscardedTile(List<GameEvent> events, String fallbackTileDisplayName) {
return events.stream()
.filter(event -> event.eventType() == GameEventType.TILE_DISCARDED)
.map(event -> event.payload().get("tile"))
.filter(String.class::isInstance)
.map(String.class::cast)
.findFirst()
.map(this::parseTile)
.orElseGet(() -> parseTile(fallbackTileDisplayName));
}
private Tile parseTile(String tileDisplayName) {
if (tileDisplayName == null || tileDisplayName.isBlank()) {
throw new BusinessException("GAME_TILE_INVALID", "无法解析当前动作对应的牌");
}
String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1);
String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1);
int rank;
try {
rank = Integer.parseInt(rankPart);
} catch (NumberFormatException exception) {
throw new BusinessException("GAME_TILE_INVALID", "牌面点数不合法");
}
Tile tile = switch (suitLabel) {
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.WAN, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TONG, rank);
case "" -> new Tile(com.xuezhanmaster.game.domain.TileSuit.TIAO, rank);
default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
};
return tile;
}
private void continueAfterDiscardWithoutResponse(GameSession session) {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
private ActionType parseActionType(String actionType) {
try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));

View File

@@ -0,0 +1,76 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.ResponseActionOption;
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
import com.xuezhanmaster.game.domain.ResponseActionWindow;
import com.xuezhanmaster.game.domain.Tile;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Component
public class ResponseActionWindowBuilder {
public Optional<ResponseActionWindow> buildForDiscard(GameSession session, int sourceSeatNo, Tile discardedTile) {
GameTable table = session.getTable();
List<ResponseActionSeatCandidate> seatCandidates = new ArrayList<>();
for (GameSeat seat : table.getSeats()) {
if (seat.getSeatNo() == sourceSeatNo) {
continue;
}
List<ResponseActionOption> options = buildSeatOptions(seat, discardedTile);
if (!options.isEmpty()) {
seatCandidates.add(new ResponseActionSeatCandidate(
seat.getSeatNo(),
seat.getPlayerId(),
List.copyOf(options)
));
}
}
if (seatCandidates.isEmpty()) {
return Optional.empty();
}
return Optional.of(ResponseActionWindow.create(
session.getGameId(),
"TILE_DISCARDED",
sourceSeatNo,
discardedTile.getDisplayName(),
seatCandidates
));
}
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile);
if (sameTileCount < 2) {
return List.of();
}
List<ResponseActionOption> options = new ArrayList<>();
options.add(new ResponseActionOption(ActionType.PENG, discardedTile.getDisplayName()));
if (sameTileCount >= 3) {
options.add(new ResponseActionOption(ActionType.GANG, discardedTile.getDisplayName()));
}
options.add(new ResponseActionOption(ActionType.PASS, null));
return options;
}
private int countSameTileInHand(GameSeat seat, Tile discardedTile) {
int count = 0;
for (Tile tile : seat.getHandTiles()) {
if (tile.equals(discardedTile)) {
count++;
}
}
return count;
}
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.ws.dto;
public record PrivateActionCandidate(
String actionType,
String tile
) {
}

View File

@@ -5,8 +5,13 @@ import java.util.List;
public record PrivateActionMessage(
String gameId,
String userId,
String actionScope,
List<String> availableActions,
int currentSeatNo
int currentSeatNo,
String windowId,
String triggerEventType,
Integer sourceSeatNo,
String triggerTile,
List<PrivateActionCandidate> candidates
) {
}

View File

@@ -1,6 +1,9 @@
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.ws.dto.PrivateActionCandidate;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
import com.xuezhanmaster.ws.dto.PublicGameMessage;
@@ -31,10 +34,52 @@ public class GameMessagePublisher {
);
}
public void publishPrivateActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
public void publishPrivateTurnActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(gameId, userId, availableActions, currentSeatNo)
new PrivateActionMessage(
gameId,
userId,
"TURN",
availableActions,
currentSeatNo,
null,
null,
null,
null,
toCandidates(availableActions)
)
);
}
public void publishPrivateResponseActionRequired(
String gameId,
String userId,
int currentSeatNo,
ResponseActionWindow window,
ResponseActionSeatCandidate seatCandidate
) {
List<String> availableActions = seatCandidate.options().stream()
.map(option -> option.actionType().name())
.toList();
List<PrivateActionCandidate> candidates = seatCandidate.options().stream()
.map(option -> new PrivateActionCandidate(option.actionType().name(), option.tile()))
.toList();
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(
gameId,
userId,
"RESPONSE",
availableActions,
currentSeatNo,
window.windowId(),
window.triggerEventType(),
window.sourceSeatNo(),
window.triggerTile(),
candidates
)
);
}
@@ -44,4 +89,10 @@ public class GameMessagePublisher {
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
);
}
private List<PrivateActionCandidate> toCandidates(List<String> availableActions) {
return availableActions.stream()
.map(actionType -> new PrivateActionCandidate(actionType, null))
.toList();
}
}

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");
}
}

View File

@@ -895,6 +895,7 @@ npm run build
- `WEEKLY_PLAN_BOARD.md`:把近期执行拆成按周推进的工作节奏
- `ISSUE_TEMPLATES_BOARD.md`:把单个任务的立项模板和看板推进方式固化下来
- `SPRINT_01_ISSUES_BOARD.md`:把当前最优先的 Week 1 / Week 2 工作直接展开成可执行 Issue
- `RESPONSE_RESOLUTION_RULES.md`:把响应窗口、优先级、同优先级冲突处理和 V1 工程取舍写清楚
### 15.2 建议协作节奏
@@ -953,5 +954,6 @@ npm run build
- `docs/WEEKLY_PLAN_BOARD.md`
- `docs/ISSUE_TEMPLATES_BOARD.md`
- `docs/SPRINT_01_ISSUES_BOARD.md`
- `docs/RESPONSE_RESOLUTION_RULES.md`
这些文档用于把主计划拆成更细的执行层内容,并提供实际推进时的状态管理结构。

View File

@@ -0,0 +1,373 @@
# XueZhanMaster 响应优先级规则澄清
本文档用于澄清四川麻将血战到底在本项目中的响应窗口、优先级与裁决方式。
它不等于“所有地区、所有平台唯一正确规则”,而是:
1. 先吸收公开规则中的高共识部分
2. 再明确本项目 `V1` 的工程实现边界
3. 给后续真实裁决代码提供单一依据
当前状态快照日期:`2026-03-20`
---
## 1. 文档结论先看
### 1.1 本项目 V1 采用的核心结论
- 只支持 `碰 / 杠 / 胡 / 过`,不支持 `吃`
- 响应窗口首先只围绕“弃牌后响应”建立
- 优先级顺序采用:
- `HU > GANG > PENG > PASS`
- AI 与真人使用同一套优先级规则
- 同优先级冲突时,按出牌者之后的最近顺位优先
- `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会
- `过水不胡` 作为后续增强规则,不在当前 `V1` 强制启用
- `一炮多响` 在项目 `V1` 中暂不实现,采用“单窗口单胜出动作”的工程裁决
### 1.2 为什么这样定
- 这样能和当前后端结构最自然衔接
- 可以先把响应窗口停顿与恢复流程做稳
- 避免在 `HU` 判定、多人同时胡牌、结算分摊都未稳定时,提前引入高复杂度冲突逻辑
这符合:
- `KISS`:先做单窗口单胜出
- `YAGNI`:当前不抢做一炮多响和完整过手胡体系
- `SOLID`:先把规则澄清文档与裁决器边界固定
- `DRY`:所有动作冲突统一走一套优先级裁决
---
## 2. 公开规则共识与本项目采用范围
### 2.1 高共识部分
结合公开资料,以下规则共识较高:
- 四川麻将 / 成都麻将常见规则中:
- 只有 `万 / 筒 / 条`
- `可碰、可杠,不可吃`
- 血战到底是一家胡后牌局不立即结束,其他未胡家继续
- 四川麻将公开资料中常见“点炮”“杠分”“查叫”“花猪”等扩展规则
- 多个公开资料都把血战到底视为成都规则或四川规则的典型变体
### 2.2 规则不完全统一的部分
公开资料中存在差异或版本不完全一致的地方:
- 是否支持 `一炮多响`
- 是否严格执行 `过水不胡`
- `过水不胡` 的解除时机
- 抢杠胡、补杠、绕杠与响应时机的细节
- 比赛规则、地方线下习惯和手游平台的实现差异
因此本项目必须明确“工程采用规则”,不能把互相矛盾的外部说法直接混进代码。
---
## 3. 本项目 V1 规则边界
### 3.1 当前先实现哪些响应窗口
`V1` 只实现:
- 某玩家打出一张牌后
- 其他仍在牌局中的玩家基于这张弃牌触发响应窗口
`V1` 暂不实现:
- 抢杠胡窗口
- 补杠后二次响应窗口
- 最后一张牌必须胡的特殊强制窗口
- 多轮连续嵌套响应窗口
### 3.2 当前先支持哪些候选动作
当前候选结构已经准备支持:
- `PENG`
- `GANG`
- `PASS`
`HU` 的动作入口和事件枚举虽然已预留,但真正候选生成依赖胡牌判定完成后再接入。
因此要区分两个状态:
1. 接口和模型层:`HU` 已被保留
2. 当前实际候选生成层:以 `PENG / GANG / PASS` 为主
---
## 4. 优先级规则
### 4.1 基础优先级
本项目 `V1` 的响应优先级顺序固定为:
1. `HU`
2. `GANG`
3. `PENG`
4. `PASS`
解释:
- `PASS` 不参与争夺,只表示当前窗口内放弃
- `PENG``GANG` 都是夺取弃牌控制权的动作
- `HU` 直接改变对局参与关系和结算路径,因此优先级最高
### 4.2 同优先级冲突
当多个玩家对同一张弃牌拥有同优先级动作时,`V1` 采用:
- 按出牌者之后的最近顺位优先
具体定义:
- 当前系统座位轮转顺序是 `seatNo` 递增后取模
- 因此从 `sourceSeatNo + 1` 开始,沿当前行牌方向逐个查找
- 先遇到的候选玩家优先获胜
示例:
- 4 人桌,出牌者为 `seat 0`
- `seat 1``seat 3` 同时都能 `PENG`
-`seat 1` 获胜
### 4.3 为什么不在 V1 支持一炮多响
这是一个项目级工程取舍,不是宣称外部规则世界只有这一种。
原因:
- 一炮多响会直接放大以下复杂度:
- 多赢家结算
- 胡牌先后次序
- 胡后谁退出牌局、谁继续
- 多人同时胡后下一个行动座位怎么定
- 当前项目还未完成:
- 胡牌判定
- 基础结算
- 胡后继续行牌完整链路
所以 `V1` 先采用:
- `单窗口单胜出动作`
后续若要升级到一炮多响,应作为 `V2` 明确需求,不应在当前实现里偷偷混入。
---
## 5. PASS 与过手规则
### 5.1 PASS 在本项目中的定义
`PASS` 只表示:
- 当前响应窗口内
- 当前玩家放弃对当前触发牌的响应
它不表示:
- 永久放弃此类动作
- 放弃后续整局碰、杠、胡能力
### 5.2 当前窗口内的限制
在同一个响应窗口内:
- 玩家一旦提交 `PASS`
- 不能再次回到同一个窗口重新声明 `PENG / GANG / HU`
这是当前必须实现的最小一致性规则。
### 5.3 `过水不胡` 的项目处理
公开资料中可见类似“过水不胡 / 同巡振听 / 过手胡”的描述,但不同平台和地方规则细节并不完全统一。
因此本项目采取分阶段策略:
#### V1
- 不实现完整 `过水不胡`
- 只保证“同一响应窗口内PASS 后不能反悔”
#### V2
- 评估是否加入:
- 玩家错过可胡后,直到自己下一次摸牌或动牌前不能再胡
- 是否仅限制同张牌
- 是否限制同一路听牌
- 是否允许加番后破除限制
---
## 6. 响应窗口生命周期
### 6.1 打开条件
当一名玩家完成弃牌后:
1. 系统生成 `TILE_DISCARDED` 公共事件
2. 系统用弃牌和当前牌桌状态构建响应候选
3. 若存在候选,则创建 `ResponseActionWindow`
4. 发出 `RESPONSE_WINDOW_OPENED` 公共事件
5. 向有资格响应的玩家分别下发私有动作消息
### 6.2 关闭条件
响应窗口在以下任一条件满足时关闭:
- 所有候选玩家都已响应
- 响应超时
- 裁决器已确定唯一胜出动作
关闭后应发出:
- `RESPONSE_WINDOW_CLOSED`
并带上至少以下信息:
- 来源座位
- 最终裁决动作类型
### 6.3 关闭后的流转
#### 若有人胜出
- 执行胜出动作
- 广播对应公共事件:
- `PENG_DECLARED`
- `GANG_DECLARED`
- `HU_DECLARED`
- `PASS_DECLARED` 一般不作为最终胜出动作事件使用
#### 若所有人都放弃
- 广播窗口关闭事件
- 继续原始出牌后的下家摸牌流程
---
## 7. 公共消息与私有消息边界
### 7.1 公共消息应该包含什么
公共事件只应表达“桌面上发生了什么”,例如:
- 某张牌被打出
- 响应窗口已打开
- 响应窗口已关闭
- 某玩家碰 / 杠 / 胡
公共事件不应广播:
- 每个玩家分别可做哪些动作
- 某玩家私下收到的教学建议
### 7.2 私有消息应该包含什么
私有动作消息应表达“你现在能做什么”,例如:
- 作用域:当前回合动作 / 响应动作
- 来源牌与来源座位
- 当前候选动作列表
- 当前窗口 ID
这也是当前后端已升级为结构化 `PrivateActionMessage` 的原因。
---
## 8. 对代码实现的直接指导
### 8.1 裁决器建议输入
后续真正实现裁决器时,建议输入至少包括:
- `ResponseActionWindow`
- 各候选玩家的最终响应
- 当前出牌座位
- 当前行牌顺序
### 8.2 裁决器建议输出
建议输出至少包括:
- 是否有人胜出
- 胜出玩家
- 胜出动作
- 窗口内已放弃玩家
- 是否继续原出牌后流转
### 8.3 当前推荐实现顺序
1. 先把 `ResponseActionWindow` 真正接入弃牌后主流程
2. 再实现窗口停顿与私有候选下发
3. 再实现优先级裁决器
4. 最后再接入 `HU` 判定与结算
不要反过来做,否则会出现:
- 胡牌判定先写了,但没有窗口停顿
- 前端已经能显示候选,但后端还无法裁决
- 公共事件和私有动作消息互相打架
---
## 9. 与当前代码进度的对应关系
当前已经完成:
- 新动作统一入口
- 新动作基础校验
- 新动作事件模型
- 响应候选模型
- 结构化私有动作消息
- H5 原型页候选消息展示占位
当前尚未完成:
- 真正的响应窗口停顿
- 基于窗口的多人响应收集
- 优先级裁决器
- `HU` 候选生成与胡牌判定
因此本文件的直接用途是:
- 让下一步裁决实现有明确规则依据
- 降低“每轮新对话都重新讨论优先级”的沟通成本
---
## 10. 本项目 V1 的最终裁决口径
为了避免歧义,本项目 `V1` 最终采用如下口径:
1. 血战到底按成都麻将大框架实现
2. 不可吃,只处理 `碰 / 杠 / 胡 / 过`
3. 当前第一阶段只围绕“弃牌后响应”建窗口
4. 优先级固定为 `HU > GANG > PENG > PASS`
5. 同优先级按出牌者之后最近顺位优先
6. `PASS` 仅放弃当前窗口
7. 当前不实现完整 `过水不胡`
8. 当前不实现 `一炮多响`
如果后续要改这些口径,必须先更新本文件,再改代码。
---
## 11. 参考来源与说明
以下来源用于确认“四川麻将 / 成都麻将血战到底”的高共识规则与差异点:
- [四川麻将 - 维基百科](https://zh.wikipedia.org/wiki/%E5%9B%9B%E5%B7%9D%E9%BA%BB%E5%B0%87)
- [成都麻将:血战到底 - 快懂百科](https://www.baike.com/wikiid/6198029954197195589)
- [四川麻将规则 - 同城游](https://www.tcy365.com/news/d30057.html)
- [成都麻将换三张规则 - 游戏茶苑](https://gametea.com/news/201910/9099.html)
说明:
- 上述来源之间对 `一炮多响``过水不胡` 等细节并不完全一致
- 因此本文中的 `V1` 规则属于“基于公开共识后的工程裁决”
- 若未来需要做更地道或更赛事化的模式,应再增加“规则模式配置层”

View File

@@ -44,101 +44,6 @@ Sprint 目标:
## 2. 待做
### S1-05 [功能] 扩展私有动作消息体,支持响应候选下发
## 背景
后续 `HU / GANG / PENG / PASS` 不是任何时刻都可点,必须先有“当前玩家可执行哪些动作”的私有候选列表。现在虽有私有动作主题,但消息体还不足以表达响应窗口和候选动作。
## 目标
把私有动作消息体扩展成可支持:
- 当前可行动作列表
- 候选动作来源
- 响应截止上下文
- 与当前回合/弃牌事件的关联关系
## 范围
- 定义候选动作 DTO
- 定义是否为响应窗口动作
- 定义关联事件 ID 或动作上下文
- 补充前端消费字段说明
## 非范围
- 不在本任务里完成最终 UI 交互
- 不在本任务里实现多人竞争裁决
## 依赖
- `S1-01`
- `S1-02`
- `S1-04`
## 产出物
- 后端私有动作消息模型
- 消息发布说明
- 前端订阅字段适配说明
## 验收标准
- 服务端能向指定用户发送结构化候选动作
- 消息能区分“主动出牌动作”和“被动响应动作”
- 前端收到后无需猜测字段语义
## 验证方式
- 后端:`mvn test`
- 前端:`npm run build`
- 手工:模拟一次弃牌后,检查候选动作消息结构是否完整
### S1-06 [研究] 响应优先级裁决规则澄清
## 背景
在真正实现多人响应之前,必须先统一项目内部的动作优先级和冲突处理规则,否则后端、前端、教学系统会各自假设,后续很容易返工。
## 目标
形成一份明确的规则澄清结果,至少回答:
- `HU / GANG / PENG / PASS` 的优先级顺序
- 多人同时可响应时的裁决方式
- 响应窗口何时打开、何时关闭
- AI 与真人竞争时是否采用同一裁决规则
## 范围
- 梳理当前产品约束
- 梳理实现层需要的最小规则集
- 输出推荐裁决策略
## 非范围
- 不在本任务里直接落代码
## 依赖
- `S1-04`
## 产出物
- 规则澄清文档
- 后续开发任务拆分建议
## 验收标准
- 可以直接指导 `S1-07`
- 后续实现不再需要对优先级做二次猜测
## 验证方式
- 文档评审
- 与主计划、阶段看板、周计划保持一致
### S1-08 [H5] 对局页信息架构与页面拆分方案
## 背景
@@ -222,92 +127,6 @@ Sprint 目标:
- 前端已知道如何接响应候选
- 下一轮 H5 正式页面改造可以直接开始
### S1-04 [功能] 响应候选模型初版
## 背景
要支持 `PENG / GANG / HU / PASS`,系统不能只知道“执行了什么”,还必须知道“现在允许谁做什么”。这个模型是后续优先级裁决、前端动作面板、教学提示的共同基础。
## 目标
定义响应候选模型,能表达:
- 当前响应来源于哪次弃牌或事件
- 哪些玩家可以响应
- 每个玩家可响应哪些动作
- 响应窗口的生命周期
## 范围
- 设计候选动作数据结构
- 设计响应窗口上下文
- 设计与座位、玩家 ID、事件 ID 的关联
## 非范围
- 不在本任务里完成最终竞争裁决实现
## 依赖
- `S1-02`
- `S1-03`
## 产出物
- 后端候选动作模型
- 前后端字段语义说明
## 验收标准
- 候选结构可表达“谁现在能做什么”
- 后续可直接承接 `PASS`
- 前端动作面板无需再自行推导
## 验证方式
- 模型评审
- 后端:`mvn test`
### S1-07 [功能] H5 动作面板字段对齐与占位接入
## 背景
后端一旦开始发送响应候选,前端至少要能消费这些字段并用最小方式展示,否则会形成后端已支持、前端完全不可见的断层。
## 目标
让 H5 原型页先具备读取并展示响应候选字段的占位能力,为后续正式动作面板铺路。
## 范围
- 订阅并解析新的私有动作消息字段
- 在原型页中增加最小占位展示
- 区分“主动动作按钮”和“响应动作按钮”
## 非范围
- 不在本任务里完成正式视觉设计
- 不在本任务里完成多状态动画
## 依赖
- `S1-05`
## 产出物
- 前端字段适配
- H5 原型占位展示
## 验收标准
- 当前原型页能看到候选动作
- 不会影响现有定缺和出牌功能
## 验证方式
- 前端:`npm run build`
- H5 手工:检查候选动作占位区是否出现
---
## 4. 已完成
@@ -397,6 +216,94 @@ Sprint 目标:
- 后续 `S1-04 / S1-05` 可以直接复用事件约定
- `mvn test` 已通过
### S1-04 [功能] 响应候选模型初版
## 已完成内容
- 新增响应候选领域模型:
- `ResponseActionOption`
- `ResponseActionSeatCandidate`
- `ResponseActionWindow`
- `GameSession` 已预留 `pendingResponseActionWindow` 字段,为后续真实响应流程挂载窗口对象做准备
- 新增 `ResponseActionWindowBuilder`,可基于一次弃牌构建候选窗口
- 当前候选规则支持:
- 手里 2 张同牌时生成 `PENG + PASS`
- 手里 3 张同牌时生成 `PENG + GANG + PASS`
- 已补单测验证:
- 有候选时可正确构建多座位响应窗口
- 无候选时返回空结果
## 验收结果
- 后端已经能表达“谁现在可以响应什么”
- `PASS` 已纳入候选动作模型
- 下一步 `S1-05` 可以直接把候选窗口映射到私有动作消息
- `mvn test` 已通过
### S1-05 [功能] 扩展私有动作消息体,支持响应候选下发
## 已完成内容
- `PrivateActionMessage` 已升级为结构化消息,补充字段:
- `actionScope`
- `windowId`
- `triggerEventType`
- `sourceSeatNo`
- `triggerTile`
- `candidates`
- 新增 `PrivateActionCandidate` DTO
- `GameMessagePublisher` 已拆分:
- `publishPrivateTurnActionRequired`
- `publishPrivateResponseActionRequired`
- 当前回合动作消息与响应候选消息已可共用同一消息结构
- 已补消息发布单测,验证 turn / response 两类消息形状
## 验收结果
- 私有动作消息已经能区分“主动回合动作”和“被动响应动作”
- 前端后续无需再猜测候选字段语义
- `mvn test` 已通过
### S1-07 [功能] H5 动作面板字段对齐与占位接入
## 已完成内容
- `App.vue` 已对齐新的私有动作消息结构
- H5 原型页已支持识别:
- `TURN`
- `RESPONSE`
两类动作消息作用域
- 私有动作区已增加候选动作展示占位
- 私有动作区已增加来源座位、目标牌等上下文字段展示
- 已补样式支持候选动作标签展示
## 验收结果
- H5 原型页已经能消费结构化私有动作消息
- 当前定缺和出牌流程未被破坏
- `npm run build` 已通过
### S1-06 [研究] 响应优先级裁决规则澄清
## 已完成内容
- 新增规则澄清文档 `docs/RESPONSE_RESOLUTION_RULES.md`
- 明确了本项目 `V1` 的响应优先级:
- `HU > GANG > PENG > PASS`
- 明确了本项目 `V1` 的同优先级裁决:
- 按出牌者之后最近顺位优先
- 明确了本项目 `V1` 的工程取舍:
- 当前不实现完整 `过水不胡`
- 当前不实现 `一炮多响`
- 明确了公共消息与私有消息的职责边界
- 明确了后续真实响应窗口接入主流程的推荐顺序
## 验收结果
- 后续实现不需要再重新讨论优先级口径
- 裁决器实现已有单一依据
- 文档已纳入 README 与主计划索引
---
## 5. 依赖关系图

View File

@@ -63,11 +63,22 @@ type PublicGameMessage = {
createdAt: string
}
type PrivateActionCandidate = {
actionType: string
tile: string | null
}
type PrivateActionMessage = {
gameId: string
userId: string
actionScope: string
availableActions: string[]
currentSeatNo: number
windowId: string | null
triggerEventType: string | null
sourceSeatNo: number | null
triggerTile: string | null
candidates: PrivateActionCandidate[]
}
type PrivateTeachingMessage = {
@@ -107,11 +118,24 @@ const phaseLabelMap: Record<string, string> = {
LACK_SELECTION: '定缺阶段'
}
const actionScopeLabelMap: Record<string, string> = {
TURN: '当前回合动作',
RESPONSE: '响应候选动作'
}
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed(
() => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo
)
const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
const privateActionSummary = computed(() => {
if (!privateAction.value) {
return ''
}
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
return `${scopeLabel}${privateAction.value.availableActions.join(' / ')}`
})
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -516,7 +540,27 @@ function discard(tile: string) {
<div class="message-stack">
<div class="message-card">
<span class="meta-label">私有动作消息</span>
<strong v-if="privateAction">{{ privateAction.availableActions.join(' / ') }}</strong>
<template v-if="privateAction">
<strong>{{ privateActionSummary }}</strong>
<div class="mini-tags action-meta-row">
<span class="mini-tag">座位 {{ privateAction.currentSeatNo }}</span>
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span>
<span v-if="privateAction.triggerTile" class="mini-tag">目标牌 {{ privateAction.triggerTile }}</span>
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
</div>
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
<span
v-for="(candidate, index) in privateActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip"
>
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
</span>
</div>
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
当前原型页已能识别响应候选消息后续会继续补正式动作面板和真实响应流程
</span>
</template>
<span v-else class="empty-copy">尚未收到私有动作消息</span>
</div>
<div class="message-card">

View File

@@ -333,6 +333,31 @@ h2 {
word-break: break-word;
}
.action-meta-row {
margin-top: 10px;
}
.candidate-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.candidate-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(113, 82, 47, 0.18);
background: linear-gradient(180deg, rgba(255, 247, 235, 0.98) 0%, rgba(241, 224, 196, 0.92) 100%);
color: var(--text);
font-size: 13px;
font-weight: 800;
}
.compact-field {
flex: 1;
min-width: 110px;