feat: 实现最小正式版过水不胡规则并完善前端动作面板
- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡 - 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法 - 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验 - 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换 - 优化公共事件处理逻辑,自动清理失效的私有动作面板 - 更新相关文档说明当前实现的规则范围和工程取舍 - 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
Binary file not shown.
@@ -1,58 +1,16 @@
|
|||||||
# 正式血战计分 V1(2026-03-20)
|
# 正式血战计分 V1(2026-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 个测试通过。
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 正式对局体验
|
||||||
|
|
||||||
|
|||||||
@@ -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 原型页面可走通当前最小链路
|
||||||
|
|
||||||
|
|||||||
@@ -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` 仍保持单赢家裁决
|
||||||
|
|
||||||
如果后续要改这些口径,必须先更新本文件,再改代码。
|
如果后续要改这些口径,必须先更新本文件,再改代码。
|
||||||
|
|
||||||
|
|||||||
@@ -303,8 +303,8 @@ Sprint 目标:
|
|||||||
- 明确了本项目 `V1` 的同优先级裁决:
|
- 明确了本项目 `V1` 的同优先级裁决:
|
||||||
- 按出牌者之后最近顺位优先
|
- 按出牌者之后最近顺位优先
|
||||||
- 明确了本项目 `V1` 的工程取舍:
|
- 明确了本项目 `V1` 的工程取舍:
|
||||||
- 当前不实现完整 `过水不胡`
|
- 当前只实现最小正式版 `过水不胡`
|
||||||
- 当前不实现 `一炮多响`
|
- 当前只对 `HU` 实现 `一炮多响`
|
||||||
- 明确了公共消息与私有消息的职责边界
|
- 明确了公共消息与私有消息的职责边界
|
||||||
- 明确了后续真实响应窗口接入主流程的推荐顺序
|
- 明确了后续真实响应窗口接入主流程的推荐顺序
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
|
<span class="message-copy">{{ actionPanelHint }}</span>
|
||||||
<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>
|
|
||||||
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
|
|
||||||
当前原型页已能识别响应候选消息,后续会继续补正式动作面板和真实响应流程。
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user