feat: 实现最小正式版过水不胡规则并完善前端动作面板

- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡
- 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法
- 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验
- 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换
- 优化公共事件处理逻辑,自动清理失效的私有动作面板
- 更新相关文档说明当前实现的规则范围和工程取舍
- 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
hujun
2026-03-20 15:26:22 +08:00
parent b84d0e8980
commit 6bcdf26fca
13 changed files with 832 additions and 148 deletions

View File

@@ -1,58 +1,16 @@
# 正式血战计分 V12026-03-20 # 正式血战计分 V12026-03-20
- 已从工程占位分切换到“最小可扩展正式版”计分骨架。 - 已从工程占位分切换到“最小可扩展正式版”计分骨架。
- 新增后端规则服务:`backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java` - 当前已支持的规则主线包括:七对、对对胡、金钩钓、清一色、根、抢杠胡、杠上花、杠上炮、海底捞月、海底炮、明杠/补杠/暗杠、退税、查叫、一炮多响、最小正式版过水不胡。
- `SettlementResult` 已扩展 `settlementDetail`,结算事件 `SETTLEMENT_APPLIED` 现在会携带: - 当前胡牌计分口径:`paymentScore = 1 << totalFan`
- `baseScore` - 一炮多响:只对 `HU` 开放多赢家同窗裁决,`PENG/GANG` 仍单赢家。
- `totalFan` - 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。
- `paymentScore` - 后端最小验证:`cd backend && mvn test` 通过,当前 53 个测试通过。
- `fans`(番型明细) - 前端 H5 对局页已经完成两轮联调:
- 当前 V1 已支持的基础番型/加番: - 已区分“当前回合动作”和“响应动作”两种动作面板。
- `七对`2 番 - `DISCARD` 继续通过点击手牌执行,`GANG/HU` 等额外动作走统一面板。
- `对对胡`1 番 - 响应动作面板会展示 `sourceSeatNo``triggerTile``triggerEventType``windowId`
- `金钩钓`1 番 - 已支持在对局页切换玩家视角,并自动刷新对应 `GameStateResponse` 与重连该玩家私有 WebSocket 主题。
- `清一色`2 番 - 公共事件接收已改为统一入口处理,会在 `RESPONSE_WINDOW_CLOSED``TURN_SWITCHED``TILE_DISCARDED``GAME_PHASE_CHANGED` 等场景下清理已失效的私有动作面板,避免旧窗口残留。
- `根`:每个 1 番 - 公共事件时间线已支持中文摘要文案、时间展示和原始载荷折叠查看,便于联调时同时看“业务含义”和“真实 payload”。
- `抢杠胡`1 番 - 注释约定继续有效:后端复杂规则、前端复杂交互和后续数据库脚本都要补适当偏多的中文注释。
- `杠上花`1 番 - 前端验证:`cd frontend && npm run build` 通过。
- `杠上炮`1 番
- `海底捞月`1 番
- `海底炮`1 番
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`
- 点炮胡:放炮者单独支付 `paymentScore`
- 自摸胡:所有未胡玩家各支付 `paymentScore`
- 抢杠胡:按胡牌番型 + `抢杠胡` 1 番,由补杠方单独支付
- 杠上花:在自摸胡基础上额外加 1 番
- 杠上炮:在点炮胡基础上额外加 1 番
- 海底捞月:自摸胡时若牌墙已空,额外加 1 番
- 海底炮:点炮胡时若牌墙已空,额外加 1 番
- 当前杠分口径:
- `明杠/点杠`:放杠者单独支付 2 分
- `补杠`:所有未胡玩家各支付 1 分
- `暗杠`:所有未胡玩家各支付 2 分
- `GameSession` 已新增:
- `PostGangContext`:只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
- `settlementHistory`:记录已应用结算结果,供局终 `退税/查叫` 直接复用
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`
- 查叫/退税已接入局终处理:
- 触发时机:牌墙耗尽且仍有多家未胡时
- 处理顺序:先 `退税`,再 `查叫`
- `退税`:未听牌玩家若此前有杠分收入,需要按原支付关系逐笔退还
- `查叫`:未听牌玩家向仍在场且已听牌玩家,按对方理论最大可胡牌型的点炮赔分支付
- 已新增 `SettlementType.TUI_SHUI``SettlementType.CHA_JIAO`
- 已新增 `ReadyHandOption`,用于查叫时记录最优听牌目标牌与对应结算明细
- 一炮多响已接入响应裁决:
- `HU` 现在支持多赢家同窗裁决,只要响应窗口内有多个 `HU` 声明,就会一起结算
- `PENG/GANG` 仍保持单赢家,顺位规则不变
- 抢杠胡窗口也会复用这套多赢家 `HU` 裁决路径
- 已新增 `ResponseActionResolutionBatch` 承载多赢家响应结果
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
- 当前仍未实现:
- 自摸加番/加底地方变体
- 天胡、地胡
- 过水不胡
- 文档约定已加强到 `docs/DEVELOPMENT_PLAN.md`
- 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释
- 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释
- 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程必须补中文注释说明业务含义、约束原因、字段口径和回滚要点
- 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域
- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。

View File

@@ -16,6 +16,8 @@ public class GameSeat {
private TileSuit lackSuit; private TileSuit lackSuit;
private boolean won; private boolean won;
private int score; private int score;
// 过水不胡最小版:只限制“响应胡”,直到该玩家下一次摸牌后解除;不影响自摸胡。
private boolean passedHuBlocked;
public GameSeat(int seatNo, boolean ai, String nickname) { public GameSeat(int seatNo, boolean ai, String nickname) {
this(seatNo, ai, UUID.randomUUID().toString(), nickname); this(seatNo, ai, UUID.randomUUID().toString(), nickname);
@@ -76,10 +78,22 @@ public class GameSeat {
return score; return score;
} }
public boolean isPassedHuBlocked() {
return passedHuBlocked;
}
public void addScore(int delta) { public void addScore(int delta) {
this.score += delta; this.score += delta;
} }
public void markPassedHuBlocked() {
this.passedHuBlocked = true;
}
public void clearPassedHuBlocked() {
this.passedHuBlocked = false;
}
public void receiveTile(Tile tile) { public void receiveTile(Tile tile) {
handTiles.add(tile); handTiles.add(tile);
} }

View File

@@ -287,6 +287,14 @@ public class GameActionProcessor {
.findFirst() .findFirst()
.orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中")); .orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中"));
if ("".equals(actionName) && actorSeat.isPassedHuBlocked()) {
boolean hasHuOption = seatCandidate.options().stream()
.anyMatch(option -> option.actionType() == ActionType.HU);
if (!hasHuOption) {
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前处于过水不胡限制中,需待下次摸牌后才能再胡");
}
}
boolean matched = seatCandidate.options().stream() boolean matched = seatCandidate.options().stream()
.anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName)) .anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName))
&& (tileDisplayName == null || tileDisplayName.equals(option.tile()))); && (tileDisplayName == null || tileDisplayName.equals(option.tile())));

View File

@@ -180,6 +180,8 @@ public class GameSessionService {
GameSeat nextSeat = nextSeatOptional.get(); GameSeat nextSeat = nextSeatOptional.get();
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile); nextSeat.receiveTile(drawnTile);
// 玩家真正完成下一次摸牌后,才解除此前由“过水不胡”带来的响应胡限制。
nextSeat.clearPassedHuBlocked();
table.setCurrentSeatNo(nextSeat.getSeatNo()); table.setCurrentSeatNo(nextSeat.getSeatNo());
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size())); gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo())); gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
@@ -366,6 +368,7 @@ public class GameSessionService {
private void handlePassPostAction(GameSession session, GameActionRequest request) { private void handlePassPostAction(GameSession session, GameActionRequest request) {
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session); ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId()); GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
markPassedHuBlockedIfNeeded(responseActionWindow, actorSeat);
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS); session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
tryResolveResponseWindow(session, responseActionWindow); tryResolveResponseWindow(session, responseActionWindow);
} }
@@ -413,6 +416,7 @@ public class GameSessionService {
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) { for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo()); GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
if (seat.isAi()) { if (seat.isAi()) {
markPassedHuBlockedIfNeeded(responseActionWindow, seat);
session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS); session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS);
} }
} }
@@ -590,6 +594,7 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
winnerSeat.clearPassedHuBlocked();
markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName()); markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
@@ -726,6 +731,7 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
winnerSeat.clearPassedHuBlocked();
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
@@ -786,12 +792,21 @@ public class GameSessionService {
Tile drawnTile = table.getWallTiles().remove(0); Tile drawnTile = table.getWallTiles().remove(0);
winnerSeat.receiveTile(drawnTile); winnerSeat.receiveTile(drawnTile);
winnerSeat.clearPassedHuBlocked();
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName()); markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size())); appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo())); appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
continueFromResolvedActionTurn(session, winnerSeat); continueFromResolvedActionTurn(session, winnerSeat);
} }
private void markPassedHuBlockedIfNeeded(ResponseActionWindow responseActionWindow, GameSeat seat) {
responseActionWindow.seatCandidates().stream()
.filter(candidate -> candidate.seatNo() == seat.getSeatNo())
.findFirst()
.filter(candidate -> candidate.options().stream().anyMatch(option -> option.actionType() == ActionType.HU))
.ifPresent(candidate -> seat.markPassedHuBlocked());
}
private ActionType parseActionType(String actionType) { private ActionType parseActionType(String actionType) {
try { try {
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT)); return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));

View File

@@ -63,7 +63,8 @@ public class ResponseActionWindowBuilder {
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) { if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
continue; continue;
} }
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) { // 抢杠胡也属于“响应胡”,因此同样受过水不胡限制。
if (!canHuByResponse(seat, gangTile)) {
continue; continue;
} }
seatCandidates.add(new ResponseActionSeatCandidate( seatCandidates.add(new ResponseActionSeatCandidate(
@@ -91,7 +92,8 @@ public class ResponseActionWindowBuilder {
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) { private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
int sameTileCount = countSameTileInHand(seat, discardedTile); int sameTileCount = countSameTileInHand(seat, discardedTile);
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile); // 过水不胡只屏蔽响应胡候选,不影响同窗口内仍可用的碰、杠。
boolean canHu = canHuByResponse(seat, discardedTile);
if (!canHu && sameTileCount < 2) { if (!canHu && sameTileCount < 2) {
return List.of(); return List.of();
} }
@@ -114,6 +116,13 @@ public class ResponseActionWindowBuilder {
return options; return options;
} }
private boolean canHuByResponse(GameSeat seat, Tile triggerTile) {
if (seat.isPassedHuBlocked()) {
return false;
}
return huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), triggerTile);
}
private int countSameTileInHand(GameSeat seat, Tile discardedTile) { private int countSameTileInHand(GameSeat seat, Tile discardedTile) {
int count = 0; int count = 0;
for (Tile tile : seat.getHandTiles()) { for (Tile tile : seat.getHandTiles()) {

View File

@@ -764,6 +764,125 @@ class GameSessionServiceTest {
assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2); assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2);
} }
@Test
void shouldBlockResponseHuBeforeSeatDrawsAgainAfterPassingWinningWindow() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
GameSeat winnerSeat = session.getTable().getSeats().get(1);
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);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-3", "DISCARD", "9筒", null)
);
GameStateResponse afterPass = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "PASS", null, 2)
);
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
assertThat(afterPass.currentSeatNo()).isEqualTo(3);
GameStateResponse afterSecondDiscard = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-4", "DISCARD", "9筒", null)
);
assertThat(session.getPendingResponseActionWindow()).isNull();
assertThat(afterSecondDiscard.currentSeatNo()).isEqualTo(0);
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
}
@Test
void shouldClearPassedHuRestrictionAfterSeatDrawsAgain() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
GameSession session = getSession(started.gameId());
GameSeat winnerSeat = session.getTable().getSeats().get(1);
prepareWinningHand(winnerSeat);
session.getTable().setCurrentSeatNo(2);
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 2);
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-3", "DISCARD", "9筒", null)
);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "PASS", null, 2)
);
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName();
removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null)
);
String seatZeroSafeDiscard = session.getTable().getSeats().get(0).getHandTiles().get(0).getDisplayName();
removeMatchingTilesFromOtherSeats(session, seatZeroSafeDiscard, 0);
setNextWallTile(session, "8万");
GameStateResponse afterSeatZeroDiscard = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("host-1", "DISCARD", seatZeroSafeDiscard, null)
);
assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
removeMatchingTilesFromOtherSeats(session, "8万", 1);
gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "DISCARD", "8万", null)
);
GameStateResponse afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-3", "DISCARD", "9筒", null)
);
afterHu = gameSessionService.performAction(
started.gameId(),
new GameActionRequest("player-2", "HU", "9筒", 2)
);
assertThat(afterHu.selfSeat().won()).isTrue();
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
assertThat(afterHu.currentSeatNo()).isEqualTo(3);
}
@Test @Test
void shouldRejectSelfDrawHuWhenHandIsNotWinning() { void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true)); RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));

View File

@@ -350,12 +350,10 @@ ws WebSocket 配置与消息发布
### 7.5 当前尚未完成 ### 7.5 当前尚未完成
- `PENG` - 自摸加番 / 加底等地方变体
- `GANG` - 天胡、地胡
- `HU` - 更完整的地方化 `过水不胡`
- `PASS` - 前端正式动作面板与规则联调
- 响应动作优先级裁决
- 胡牌判定与结算
- 教学开关接口 - 教学开关接口
- 局后个人复盘 - 局后个人复盘
- 数据库持久化 - 数据库持久化
@@ -393,6 +391,10 @@ ws WebSocket 配置与消息发布
- 当前支持: - 当前支持:
- `SELECT_LACK_SUIT` - `SELECT_LACK_SUIT`
- `DISCARD` - `DISCARD`
- `PENG`
- `GANG`
- `HU`
- `PASS`
- `POST /api/games/{gameId}/lack` - `POST /api/games/{gameId}/lack`
- 兼容接口 - 兼容接口
@@ -645,7 +647,7 @@ AI 不是单一模块,而是三层能力:
当前状态: 当前状态:
- 进行中 - 已基本完成
### M3 规则与结算 ### M3 规则与结算
@@ -668,7 +670,7 @@ AI 不是单一模块,而是三层能力:
当前状态: 当前状态:
- 待做 - 进行中
### M4 H5 正式对局体验 ### M4 H5 正式对局体验

View File

@@ -1,9 +1,9 @@
# XueZhanMaster 阶段任务看板 # XueZhanMaster 阶段任务看板
本文档把主计划拆成可以直接推进的阶段任务卡。 本文档把主计划拆成可以直接推进的阶段任务卡。\
当前状态快照日期:`2026-03-20` 当前状态快照日期:`2026-03-20`
--- ***
## 1. 使用说明 ## 1. 使用说明
@@ -33,7 +33,7 @@
- 验收标准 - 验收标准
- 风险提示 - 风险提示
--- ***
## 2. 待做 ## 2. 待做
@@ -205,7 +205,7 @@
- 每局结束后可获得个人复盘 - 每局结束后可获得个人复盘
- 可沉淀到错题本并二次查看 - 可沉淀到错题本并二次查看
--- ***
## 3. 进行中 ## 3. 进行中
@@ -252,7 +252,7 @@
- 验收标准: - 验收标准:
- 页面拆分方案可直接指导下一轮前端编码 - 页面拆分方案可直接指导下一轮前端编码
--- ***
## 4. 已完成 ## 4. 已完成
@@ -359,3 +359,4 @@
- `vite.config.ts` 已代理 `/api``/ws` - `vite.config.ts` 已代理 `/api``/ws`
- 验收结果: - 验收结果:
- H5 原型页面可走通当前最小链路 - H5 原型页面可走通当前最小链路

View File

@@ -22,19 +22,22 @@
- AI 与真人使用同一套优先级规则 - AI 与真人使用同一套优先级规则
- 同优先级冲突时,按出牌者之后的最近顺位优先 - 同优先级冲突时,按出牌者之后的最近顺位优先
- `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会 - `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会
- `过水不胡` 作为后续增强规则,不在当前 `V1` 强制启用 - `过水不胡` 已在项目 `V1` 中启用“最小正式版”
- `一炮多响` 在项目 `V1` 中暂不实现,采用“单窗口单胜出动作”的工程裁决 - 玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次摸牌前,不能再做响应胡
- 该限制只作用于响应胡,不影响 `PENG / GANG`,也不影响自摸胡
- `一炮多响` 已在项目 `V1` 中实现,但只对 `HU` 开放多赢家同窗裁决
- `PENG / GANG` 仍保持单赢家裁决,顺位规则不变
### 1.2 为什么这样定 ### 1.2 为什么这样定
- 这样能和当前后端结构最自然衔接 - 这样能和当前后端结构最自然衔接
- 可以先把响应窗口停顿恢复流程做稳 - 可以先把响应窗口停顿恢复和结算主链做稳
- 避免在 `HU` 判定、多人同时胡牌、结算分摊都未稳定时,提前引入高复杂度冲突逻辑 - 即使已支持一炮多响与最小版过水不胡,也仍把复杂度限制在当前统一动作入口和统一响应窗口内
这符合: 这符合:
- `KISS`先做单窗口单胜出 - `KISS`一炮多响只对 `HU` 放开,多赢家共用一张牌源,避免把 `PENG / GANG` 也做成多胜出分支
- `YAGNI`:当前不抢做一炮多响和完整过手胡体系 - `YAGNI`:当前不抢做完整地方化 `过水不胡` 体系,也不引入可配置规则模式层
- `SOLID`:先把规则澄清文档与裁决器边界固定 - `SOLID`:先把规则澄清文档与裁决器边界固定
- `DRY`:所有动作冲突统一走一套优先级裁决 - `DRY`:所有动作冲突统一走一套优先级裁决
@@ -75,11 +78,10 @@
- 某玩家打出一张牌后 - 某玩家打出一张牌后
- 其他仍在牌局中的玩家基于这张弃牌触发响应窗口 - 其他仍在牌局中的玩家基于这张弃牌触发响应窗口
- 补杠声明后的抢杠胡响应窗口
`V1` 暂不实现: `V1` 暂不实现:
- 抢杠胡窗口
- 补杠后二次响应窗口
- 最后一张牌必须胡的特殊强制窗口 - 最后一张牌必须胡的特殊强制窗口
- 多轮连续嵌套响应窗口 - 多轮连续嵌套响应窗口
@@ -89,14 +91,13 @@
- `PENG` - `PENG`
- `GANG` - `GANG`
- `HU`
- `PASS` - `PASS`
`HU` 的动作入口和事件枚举虽然已预留,但真正候选生成依赖胡牌判定完成后再接入。 其中:
因此要区分两个状态: 1. 弃牌响应窗口支持 `HU / PENG / GANG / PASS`
2. 补杠抢杠胡窗口支持 `HU / PASS`
1. 接口和模型层:`HU` 已被保留
2. 当前实际候选生成层:以 `PENG / GANG / PASS` 为主
--- ---
@@ -135,27 +136,30 @@
- `seat 1``seat 3` 同时都能 `PENG` - `seat 1``seat 3` 同时都能 `PENG`
-`seat 1` 获胜 -`seat 1` 获胜
### 4.3 为什么在 V1 支持一炮多响 ### 4.3 为什么在 V1 只对 `HU` 支持一炮多响
这是一个项目级工程取舍,不是宣称外部规则世界只有这一种。 是一个项目级工程取舍,不是宣称外部规则世界只有这一种。
原因: 原因:
- 一炮多响会直接放大以下复杂度: - `HU` 多赢家是血战到底中较常见、且用户感知较强的规则
- 多赢家结算 - 当前后端已经具备:
- 胡牌先后次序
- 胡后谁退出牌局、谁继续
- 多人同时胡后下一个行动座位怎么定
- 当前项目还未完成:
- 胡牌判定 - 胡牌判定
- 基础结算 - 基础结算
- 胡后继续行牌完整链路 - 胡后继续行牌主链
- 结算历史沉淀
- 但如果把 `PENG / GANG` 也做成多赢家分支,会明显抬高后续行牌复杂度
所以 `V1` 采用: 所以 `V1` 采用:
- `单窗口单胜出动作` - `HU` 可多赢家同窗裁决
- `PENG / GANG` 仍然单赢家裁决
后续若要升级到一炮多响,应作为 `V2` 明确需求,不应在当前实现里偷偷混入。 这样做的好处是:
- 保住高价值规则一致性
- 又不破坏现有统一动作入口、统一响应窗口和统一结算出口
- 仍符合 `KISS`
--- ---
@@ -190,16 +194,21 @@
#### V1 #### V1
- 不实现完整 `过水不胡` - 不实现完整地方化 `过水不胡`
- 只保证“同一响应窗口内PASS 后不能反悔” - 当前只实现“最小正式版”:
- 玩家在响应窗口里本可 `HU` 但选择 `PASS` 时,记录一次响应禁胡状态
- 在该玩家下一次真正摸牌前,后续弃牌胡与抢杠胡都不再给出 `HU` 候选
- 下一次真正摸牌后解除限制
- 不影响 `PENG / GANG`
- 不影响自摸胡
#### V2 #### V2
- 评估是否加入: - 评估是否加入:
- 玩家错过可胡后,直到自己下一次摸牌或动牌前不能再胡
- 是否仅限制同张牌 - 是否仅限制同张牌
- 是否限制同一路听牌 - 是否限制同一路听牌
- 是否允许加番后破除限制 - 是否允许加番后破除限制
- 是否需要把“解除时机”细化为摸牌、碰杠、换巡或加番后解除
--- ---
@@ -323,20 +332,25 @@
- 新动作基础校验 - 新动作基础校验
- 新动作事件模型 - 新动作事件模型
- 响应候选模型 - 响应候选模型
- 响应窗口停顿与关闭
- 基于窗口的多人响应收集
- `HU` 候选生成与胡牌判定
- `HU` 的一炮多响裁决
- 抢杠胡窗口
- 最小正式版 `过水不胡`
- 结构化私有动作消息 - 结构化私有动作消息
- H5 原型页候选消息展示占位 - H5 原型页候选消息展示占位
当前尚未完成: 当前尚未完成:
- 真正的响应窗口停顿 - 前端正式动作面板联调
- 基于窗口的多人响应收集 - 更完整的地方化 `过水不胡`
- 优先级裁决器 - 规则模式配置层
- `HU` 候选生成与胡牌判定
因此本文件的直接用途是: 因此本文件当前的直接用途是:
-下一步裁决实现有明确规则依据 -后续前后端联调和规则扩展有明确规则依据
- 降低“每轮新对话都重新讨论优先级”的沟通成本 - 降低“每轮新对话都重新讨论响应口径”的沟通成本
--- ---
@@ -346,12 +360,12 @@
1. 血战到底按成都麻将大框架实现 1. 血战到底按成都麻将大框架实现
2. 不可吃,只处理 `碰 / 杠 / 胡 / 过` 2. 不可吃,只处理 `碰 / 杠 / 胡 / 过`
3. 当前第一阶段只围绕“弃牌后响应”建窗口 3. 当前响应窗口覆盖“弃牌后响应”与“补杠后的抢杠胡响应”
4. 优先级固定为 `HU > GANG > PENG > PASS` 4. 优先级固定为 `HU > GANG > PENG > PASS`
5. 同优先级按出牌者之后最近顺位优先 5. 同优先级按出牌者之后最近顺位优先,且 `HU` 支持一炮多响
6. `PASS` 仅放弃当前窗口 6. `PASS` 仅放弃当前窗口
7. 当前实现完整 `过水不胡` 7. 当前实现最小正式版 `过水不胡`,解除时机为该玩家下一次真正摸牌
8. 当前不实现 `一炮多响` 8. 当前只对 `HU` 实现一炮多响,`PENG / GANG` 仍保持单赢家裁决
如果后续要改这些口径,必须先更新本文件,再改代码。 如果后续要改这些口径,必须先更新本文件,再改代码。

View File

@@ -303,8 +303,8 @@ Sprint 目标:
- 明确了本项目 `V1` 的同优先级裁决: - 明确了本项目 `V1` 的同优先级裁决:
- 按出牌者之后最近顺位优先 - 按出牌者之后最近顺位优先
- 明确了本项目 `V1` 的工程取舍: - 明确了本项目 `V1` 的工程取舍:
- 当前实现完整 `过水不胡` - 当前实现最小正式版 `过水不胡`
- 当前实现 `一炮多响` - 当前只对 `HU` 实现 `一炮多响`
- 明确了公共消息与私有消息的职责边界 - 明确了公共消息与私有消息的职责边界
- 明确了后续真实响应窗口接入主流程的推荐顺序 - 明确了后续真实响应窗口接入主流程的推荐顺序

View File

@@ -95,9 +95,15 @@ type PrivateTeachingMessage = {
explanation: string explanation: string
} }
type ViewUserOption = {
userId: string
label: string
seatNo: number
}
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。') const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。')
const ownerId = ref('host-1') const ownerId = ref('host-1')
const ownerName = ref('房主') const ownerName = ref('房主')
@@ -129,6 +135,38 @@ const actionScopeLabelMap: Record<string, string> = {
RESPONSE: '响应候选动作' RESPONSE: '响应候选动作'
} }
const actionTypeLabelMap: Record<string, string> = {
SELECT_LACK_SUIT: '定缺',
DISCARD: '出牌',
PENG: '碰',
GANG: '杠',
HU: '胡',
PASS: '过'
}
const triggerEventTypeLabelMap: Record<string, string> = {
TILE_DISCARDED: '弃牌后响应',
SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡'
}
const publicEventLabelMap: Record<string, string> = {
GAME_STARTED: '对局开始',
LACK_SELECTED: '定缺完成',
GAME_PHASE_CHANGED: '阶段切换',
TILE_DISCARDED: '出牌',
TILE_DRAWN: '摸牌',
TURN_SWITCHED: '轮转',
RESPONSE_WINDOW_OPENED: '响应窗口开启',
RESPONSE_WINDOW_CLOSED: '响应窗口关闭',
PENG_DECLARED: '碰牌宣告',
GANG_DECLARED: '杠牌宣告',
HU_DECLARED: '胡牌宣告',
PASS_DECLARED: '过牌宣告',
SETTLEMENT_APPLIED: '结算应用',
SCORE_CHANGED: '分数变化',
ACTION_REQUIRED: '动作提醒'
}
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION') const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed( const canDiscard = computed(
() => () =>
@@ -137,14 +175,117 @@ const canDiscard = computed(
game.value.selfSeat.playerId === currentUserId.value && game.value.selfSeat.playerId === currentUserId.value &&
game.value.currentSeatNo === game.value.selfSeat.seatNo game.value.currentSeatNo === game.value.selfSeat.seatNo
) )
const publicSeats = computed(() => game.value?.seats ?? []) const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? []) const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
const privateActionSummary = computed(() => { const privateActionSummary = computed(() => {
if (!privateAction.value) { if (!privateAction.value) {
return '' return ''
} }
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
return `${scopeLabel}${privateAction.value.availableActions.join(' / ')}` const readableActions = privateAction.value.availableActions.map(toActionLabel)
return `${scopeLabel}${readableActions.join(' / ')}`
})
const turnActionCandidates = computed(() => {
if (privateAction.value?.actionScope !== 'TURN') {
return []
}
// 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。
return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD')
})
const responseActionCandidates = computed(() => {
if (privateAction.value?.actionScope !== 'RESPONSE') {
return []
}
return privateAction.value.candidates
})
const responseContextSummary = computed(() => {
if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') {
return ''
}
const triggerLabel = privateAction.value.triggerEventType
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
: '响应窗口'
const sourceSeatLabel =
privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知'
const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌'
return `${triggerLabel}${sourceSeatLabel}${triggerTileLabel}`
})
const actionPanelHint = computed(() => {
if (!privateAction.value) {
return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。'
}
if (privateAction.value.actionScope === 'TURN') {
return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。'
}
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
})
const currentViewLabel = computed(() => {
if (!game.value) {
return currentUserId.value
}
return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`
})
const viewUserOptions = computed<ViewUserOption[]>(() => {
if (game.value) {
const options = new Map<string, ViewUserOption>()
options.set(game.value.selfSeat.playerId, {
userId: game.value.selfSeat.playerId,
label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`,
seatNo: game.value.selfSeat.seatNo
})
for (const seat of game.value.seats) {
options.set(seat.playerId, {
userId: seat.playerId,
label: `${seat.nickname} · 座位 ${seat.seatNo}`,
seatNo: seat.seatNo
})
}
return [...options.values()].sort((left, right) => left.seatNo - right.seatNo)
}
return [
{ userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 },
{ userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 }
]
})
const actionDiagnosticItems = computed(() => {
if (!privateAction.value) {
return []
}
return [
{
key: 'scope',
label: '作用域',
value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
},
{
key: 'window',
label: '窗口 ID',
value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合'
},
{
key: 'event',
label: '触发来源',
value: privateAction.value.triggerEventType
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
: '当前回合'
},
{
key: 'source',
label: '来源座位',
value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无'
}
]
}) })
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> { async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
@@ -208,9 +349,10 @@ function connectWs(gameId: string, userId: string) {
wsStatus.value = 'connected' wsStatus.value = 'connected'
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => { client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
const payload = JSON.parse(message.body) as PublicGameMessage const payload = JSON.parse(message.body) as PublicGameMessage
publicEvents.value = [payload, ...publicEvents.value].slice(0, 16) handlePublicEvent(payload)
}) })
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => { client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
// 私有动作消息是前端动作面板的唯一事实来源,后续所有按钮启用状态都以这里为准。
privateAction.value = JSON.parse(message.body) as PrivateActionMessage privateAction.value = JSON.parse(message.body) as PrivateActionMessage
}) })
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => { client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
@@ -236,6 +378,7 @@ function connectWs(gameId: string, userId: string) {
watch( watch(
() => [game.value?.gameId, currentUserId.value] as const, () => [game.value?.gameId, currentUserId.value] as const,
([gameId, userId]) => { ([gameId, userId]) => {
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
if (gameId) { if (gameId) {
connectWs(gameId, userId) connectWs(gameId, userId)
} else { } else {
@@ -340,6 +483,27 @@ async function refreshGameState() {
}) })
} }
async function switchUserView(userId: string) {
if (currentUserId.value === userId) {
return
}
currentUserId.value = userId
if (!game.value) {
info.value = `已切换到 ${userId} 视角。`
return
}
const targetGameId = game.value.gameId
await runTask(async () => {
game.value = await requestJson<GameStateResponse>(
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}`
)
info.value = `已切换到 ${userId} 视角,并同步最新对局状态。`
})
}
async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) { async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
if (!game.value) { if (!game.value) {
return return
@@ -355,7 +519,17 @@ async function submitAction(actionType: string, tile?: string, sourceSeatNo?: nu
sourceSeatNo: sourceSeatNo ?? null sourceSeatNo: sourceSeatNo ?? null
}) })
}) })
info.value = actionType === 'DISCARD' ? `已打出 ${tile}` : `已提交动作 ${actionType}`
const readableAction = toActionLabel(actionType)
if (actionType === 'DISCARD' && tile) {
info.value = `已打出 ${tile}`
return
}
if (tile) {
info.value = `已提交 ${readableAction} ${tile}`
return
}
info.value = `已提交动作 ${readableAction}`
}) })
} }
@@ -371,6 +545,153 @@ function submitCandidateAction(actionType: string, tile: string | null) {
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null
return submitAction(actionType, tile ?? undefined, sourceSeatNo) return submitAction(actionType, tile ?? undefined, sourceSeatNo)
} }
function toActionLabel(actionType: string) {
return actionTypeLabelMap[actionType] ?? actionType
}
function formatCandidateLabel(candidate: PrivateActionCandidate) {
const actionLabel = toActionLabel(candidate.actionType)
if (!candidate.tile) {
return actionLabel
}
return `${actionLabel} · ${candidate.tile}`
}
function handlePublicEvent(event: PublicGameMessage) {
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
if (shouldClearPrivateActionByEvent(event)) {
privateAction.value = null
}
publicEvents.value = [event, ...publicEvents.value].slice(0, 16)
}
function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
if (!privateAction.value) {
return false
}
const currentAction = privateAction.value
const selfSeatNo = game.value?.selfSeat.seatNo ?? null
if (event.eventType === 'RESPONSE_WINDOW_CLOSED') {
const sourceSeatNo = readNumber(event.payload.sourceSeatNo)
return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo
}
if (event.eventType === 'GAME_PHASE_CHANGED') {
return readString(event.payload.phase) !== 'PLAYING'
}
if (event.eventType === 'TILE_DISCARDED') {
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo
}
if (event.eventType === 'TURN_SWITCHED') {
const nextSeatNo = readNumber(event.payload.currentSeatNo)
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo
}
return false
}
function readString(value: unknown) {
return typeof value === 'string' ? value : null
}
function readNumber(value: unknown) {
return typeof value === 'number' ? value : null
}
function formatSeatLabel(seatNo: number | null | undefined) {
if (seatNo === null || seatNo === undefined) {
return '未知座位'
}
return `座位 ${seatNo}`
}
function formatPhaseLabel(phase: string | null) {
if (!phase) {
return '未知阶段'
}
return phaseLabelMap[phase] ?? phase
}
function formatPublicEventTitle(event: PublicGameMessage) {
return publicEventLabelMap[event.eventType] ?? event.eventType
}
function formatPublicEventSummary(event: PublicGameMessage) {
switch (event.eventType) {
case 'GAME_STARTED':
return `房间 ${readString(event.payload.roomId) ?? '-'} 已开局。`
case 'LACK_SELECTED':
return `${formatSeatLabel(event.seatNo)} 已完成定缺 ${readString(event.payload.lackSuit) ?? '-' }`
case 'GAME_PHASE_CHANGED':
return `阶段切换为 ${formatPhaseLabel(readString(event.payload.phase))}`
case 'TILE_DISCARDED':
return `${formatSeatLabel(event.seatNo)} 打出 ${readString(event.payload.tile) ?? '-'}`
case 'TILE_DRAWN':
return `${formatSeatLabel(event.seatNo)} 完成摸牌,牌墙剩余 ${readNumber(event.payload.remainingWallCount) ?? '-'} 张。`
case 'TURN_SWITCHED':
return `当前轮到 ${formatSeatLabel(readNumber(event.payload.currentSeatNo) ?? event.seatNo)}`
case 'RESPONSE_WINDOW_OPENED':
return `${formatSeatLabel(readNumber(event.payload.sourceSeatNo))}${readString(event.payload.tile) ?? '-'} 打开响应窗口。`
case 'RESPONSE_WINDOW_CLOSED':
return `响应窗口已关闭,最终裁决 ${toActionLabel(readString(event.payload.resolvedActionType) ?? '-')}`
case 'PENG_DECLARED':
case 'GANG_DECLARED':
case 'HU_DECLARED':
case 'PASS_DECLARED':
return `${formatSeatLabel(event.seatNo)} 执行 ${toActionLabel(readString(event.payload.actionType) ?? event.eventType.replace('_DECLARED', ''))}${readString(event.payload.tile) ? ` · ${readString(event.payload.tile)}` : ''}`
case 'SETTLEMENT_APPLIED':
return formatSettlementSummary(event)
case 'SCORE_CHANGED':
return `${formatSeatLabel(event.seatNo)} 分数变化 ${formatScoreDelta(readNumber(event.payload.delta))},当前 ${readNumber(event.payload.score) ?? '-'}`
default:
return JSON.stringify(event.payload)
}
}
function formatSettlementSummary(event: PublicGameMessage) {
const settlementType = readString(event.payload.settlementType) ?? '未知结算'
const triggerTile = readString(event.payload.triggerTile)
const detail = asRecord(event.payload.settlementDetail)
const paymentScore = readNumber(detail?.paymentScore)
const totalFan = readNumber(detail?.totalFan)
return `${settlementType}${triggerTile ? ` · ${triggerTile}` : ''}${paymentScore ?? '-'} 分,${totalFan ?? 0} 番。`
}
function formatScoreDelta(delta: number | null) {
if (delta === null) {
return '-'
}
return delta > 0 ? `+${delta}` : `${delta}`
}
function asRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
function formatEventPayload(event: PublicGameMessage) {
return JSON.stringify(event.payload, null, 2)
}
function formatEventTime(createdAt: string) {
const date = new Date(createdAt)
if (Number.isNaN(date.getTime())) {
return createdAt
}
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
</script> </script>
<template> <template>
@@ -378,9 +699,9 @@ function submitCandidateAction(actionType: string, tile: string | null) {
<section class="hero-panel"> <section class="hero-panel">
<div> <div>
<p class="eyebrow">XueZhanMaster H5</p> <p class="eyebrow">XueZhanMaster H5</p>
<h1>血战大师移动端房间流原型</h1> <h1>血战大师移动端对局台</h1>
<p class="intro"> <p class="intro">
当前页面已切成 H5 操作台并接入 WebSocket 骨架目标是让后续手机端新对话也能快速恢复上下文房间流对局流消息流都能看见 当前页面不再只做房间流演示而是开始承担正式联调职责公共事件私有动作私有教学已经连通下面的动作面板会按当前回合动作响应动作自动切换
</p> </p>
</div> </div>
<div class="signal-card"> <div class="signal-card">
@@ -420,8 +741,8 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</label> </label>
<div class="btn-row"> <div class="btn-row">
<button class="secondary-btn" @click="loadRoom">刷新房间</button> <button class="secondary-btn" @click="loadRoom">刷新房间</button>
<button class="secondary-btn" @click="currentUserId = ownerId">切到房主视角</button> <button class="secondary-btn" @click="switchUserView(ownerId)">切到房主视角</button>
<button class="secondary-btn" @click="currentUserId = joinUserId">切到玩家二视角</button> <button class="secondary-btn" @click="switchUserView(joinUserId)">切到玩家二视角</button>
</div> </div>
</div> </div>
@@ -487,7 +808,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</section> </section>
</main> </main>
<section class="play-grid" v-if="game"> <section v-if="game" class="play-grid">
<article class="panel game-panel"> <article class="panel game-panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
@@ -504,7 +825,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</div> </div>
<div> <div>
<span class="meta-label">当前视角</span> <span class="meta-label">当前视角</span>
<strong>{{ currentUserId }}</strong> <strong>{{ currentViewLabel }}</strong>
</div> </div>
<div> <div>
<span class="meta-label">剩余牌墙</span> <span class="meta-label">剩余牌墙</span>
@@ -512,6 +833,24 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</div> </div>
</div> </div>
<div class="view-switch-card">
<div class="section-title">
<strong>视角切换</strong>
<span class="mini-pill">点击即刷新该玩家视图</span>
</div>
<div class="view-switch-list">
<button
v-for="option in viewUserOptions"
:key="option.userId"
class="view-switch-chip"
:class="{ active: option.userId === currentUserId }"
@click="switchUserView(option.userId)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="btn-row vertical-on-mobile"> <div class="btn-row vertical-on-mobile">
<button class="secondary-btn" @click="refreshGameState">刷新对局</button> <button class="secondary-btn" @click="refreshGameState">刷新对局</button>
<label class="field compact-field"> <label class="field compact-field">
@@ -546,11 +885,72 @@ function submitCandidateAction(actionType: string, tile: string | null) {
</button> </button>
</div> </div>
<div class="discard-row" v-if="game.selfSeat.melds.length > 0"> <div class="discard-row" v-if="game.selfSeat.melds.length > 0">
<span v-for="(meld, index) in game.selfSeat.melds" :key="`self-meld-${meld}-${index}`" class="discard-chip"> <span
v-for="(meld, index) in game.selfSeat.melds"
:key="`self-meld-${meld}-${index}`"
class="discard-chip"
>
{{ meld }} {{ meld }}
</span> </span>
</div> </div>
</div> </div>
<div class="action-dock">
<div class="section-title">
<strong>动作面板</strong>
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
</div>
<div v-if="privateAction" class="action-panel" :class="{ 'response-panel': privateAction.actionScope === 'RESPONSE' }">
<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.windowId" class="mini-tag">窗口 {{ privateAction.windowId.slice(0, 8) }}</span>
</div>
<div v-if="privateAction.actionScope === 'TURN'" class="turn-panel">
<p class="message-copy">{{ actionPanelHint }}</p>
<div class="candidate-list" v-if="turnActionCandidates.length > 0">
<button
v-for="(candidate, index) in turnActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip action-call-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
>
{{ formatCandidateLabel(candidate) }}
</button>
</div>
<div class="helper-strip">
<span class="helper-chip">出牌直接点击上方手牌</span>
<span class="helper-chip"> / 自摸胡点击这里的动作按钮</span>
</div>
</div>
<div v-else class="response-panel-body">
<div class="response-banner">
<span class="response-kicker">响应窗口</span>
<strong>{{ responseContextSummary }}</strong>
<span class="message-copy">{{ actionPanelHint }}</span>
</div>
<div class="candidate-list" v-if="responseActionCandidates.length > 0">
<button
v-for="(candidate, index) in responseActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip action-call-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
>
{{ formatCandidateLabel(candidate) }}
</button>
</div>
</div>
</div>
<div v-else class="placeholder-card action-placeholder">
当前还没有可执行动作若轮到你出牌可自摸胡可杠或遇到碰杠胡响应窗口这里会自动出现对应按钮
</div>
</div>
</article> </article>
<article class="panel board-panel"> <article class="panel board-panel">
@@ -567,29 +967,17 @@ function submitCandidateAction(actionType: string, tile: string | null) {
<span class="meta-label">私有动作消息</span> <span class="meta-label">私有动作消息</span>
<template v-if="privateAction"> <template v-if="privateAction">
<strong>{{ privateActionSummary }}</strong> <strong>{{ privateActionSummary }}</strong>
<div class="mini-tags action-meta-row"> <div class="message-grid">
<span class="mini-tag">座位 {{ privateAction.currentSeatNo }}</span> <div v-for="item in actionDiagnosticItems" :key="item.key" class="metric-cell">
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span> <span class="meta-label">{{ item.label }}</span>
<span v-if="privateAction.triggerTile" class="mini-tag">目标牌 {{ privateAction.triggerTile }}</span> <strong>{{ item.value }}</strong>
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
</div> </div>
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
<button
v-for="(candidate, index) in privateActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
>
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
</button>
</div> </div>
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy"> <span class="message-copy">{{ actionPanelHint }}</span>
当前原型页已能识别响应候选消息后续会继续补正式动作面板和真实响应流程
</span>
</template> </template>
<span v-else class="empty-copy">尚未收到私有动作消息</span> <span v-else class="empty-copy">尚未收到私有动作消息</span>
</div> </div>
<div class="message-card"> <div class="message-card">
<span class="meta-label">私有教学消息</span> <span class="meta-label">私有教学消息</span>
<strong v-if="privateTeaching">{{ privateTeaching.recommendedAction }}</strong> <strong v-if="privateTeaching">{{ privateTeaching.recommendedAction }}</strong>
@@ -635,10 +1023,18 @@ function submitCandidateAction(actionType: string, tile: string | null) {
<div v-else class="timeline-list"> <div v-else class="timeline-list">
<article v-for="(event, index) in publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item"> <article v-for="(event, index) in publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item">
<div class="seat-top"> <div class="seat-top">
<strong>{{ event.eventType }}</strong> <strong>{{ formatPublicEventTitle(event) }}</strong>
<span class="mini-pill">{{ event.seatNo ?? '-' }}</span> <span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
</div> </div>
<div class="message-copy">{{ JSON.stringify(event.payload) }}</div> <div class="timeline-meta">
<span class="mini-tag">{{ formatSeatLabel(event.seatNo) }}</span>
<span class="mini-tag">{{ event.eventType }}</span>
</div>
<div class="message-copy">{{ formatPublicEventSummary(event) }}</div>
<details class="payload-details">
<summary>查看原始载荷</summary>
<pre class="payload-code">{{ formatEventPayload(event) }}</pre>
</details>
</article> </article>
</div> </div>
</div> </div>

View File

@@ -169,7 +169,9 @@ h2 {
.form-card, .form-card,
.self-card, .self-card,
.placeholder-card, .placeholder-card,
.seat-card { .seat-card,
.view-switch-card,
.action-dock {
padding: 16px; padding: 16px;
border-radius: 22px; border-radius: 22px;
background: var(--paper-strong); background: var(--paper-strong);
@@ -178,6 +180,8 @@ h2 {
.form-card + .form-card, .form-card + .form-card,
.self-card, .self-card,
.view-switch-card,
.action-dock,
.room-meta, .room-meta,
.seat-list, .seat-list,
.tile-grid { .tile-grid {
@@ -263,6 +267,29 @@ h2 {
gap: 14px; gap: 14px;
} }
.view-switch-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.view-switch-chip {
min-height: 38px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(113, 82, 47, 0.18);
background: rgba(182, 69, 31, 0.08);
color: var(--accent-deep);
font-weight: 800;
}
.view-switch-chip.active {
background: linear-gradient(180deg, #cc5a31 0%, #b6451f 100%);
color: #fffaf2;
box-shadow: 0 14px 28px rgba(182, 69, 31, 0.18);
}
.seat-top, .seat-top,
.section-title { .section-title {
display: flex; display: flex;
@@ -326,6 +353,13 @@ h2 {
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.timeline-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.message-copy { .message-copy {
margin-top: 8px; margin-top: 8px;
color: var(--muted); color: var(--muted);
@@ -333,6 +367,45 @@ h2 {
word-break: break-word; word-break: break-word;
} }
.payload-details {
margin-top: 12px;
border-top: 1px dashed rgba(113, 82, 47, 0.18);
padding-top: 10px;
}
.payload-details summary {
cursor: pointer;
color: var(--accent-deep);
font-size: 13px;
font-weight: 800;
}
.payload-code {
margin: 10px 0 0;
padding: 12px;
border-radius: 14px;
background: rgba(35, 23, 15, 0.04);
color: var(--muted);
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.action-panel {
margin-top: 12px;
padding: 16px;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.98) 0%, rgba(246, 236, 220, 0.92) 100%);
border: 1px solid rgba(113, 82, 47, 0.16);
}
.response-panel {
background: linear-gradient(180deg, rgba(255, 244, 234, 0.98) 0%, rgba(249, 227, 205, 0.95) 100%);
border-color: rgba(182, 69, 31, 0.2);
}
.action-meta-row { .action-meta-row {
margin-top: 10px; margin-top: 10px;
} }
@@ -348,8 +421,8 @@ h2 {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 34px; min-height: 38px;
padding: 0 12px; padding: 0 14px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(113, 82, 47, 0.18); 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%); background: linear-gradient(180deg, rgba(255, 247, 235, 0.98) 0%, rgba(241, 224, 196, 0.92) 100%);
@@ -358,6 +431,77 @@ h2 {
font-weight: 800; font-weight: 800;
} }
.action-call-chip {
box-shadow: 0 10px 22px rgba(113, 82, 47, 0.08);
}
.helper-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.helper-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(107, 89, 73, 0.08);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.response-banner {
padding: 14px;
border-radius: 18px;
background: rgba(182, 69, 31, 0.08);
border: 1px solid rgba(182, 69, 31, 0.16);
}
.response-kicker {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
background: rgba(182, 69, 31, 0.14);
color: var(--accent-deep);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.response-panel-body .candidate-list {
margin-top: 14px;
}
.action-placeholder {
margin-top: 12px;
}
.message-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.metric-cell {
padding: 12px;
border-radius: 16px;
background: rgba(182, 69, 31, 0.06);
border: 1px solid rgba(113, 82, 47, 0.12);
}
.metric-cell strong {
display: block;
margin-top: 6px;
}
.compact-field { .compact-field {
flex: 1; flex: 1;
min-width: 110px; min-width: 110px;
@@ -395,4 +539,8 @@ h2 {
.vertical-on-mobile { .vertical-on-mobile {
flex-direction: column; flex-direction: column;
} }
.message-grid {
grid-template-columns: 1fr;
}
} }