feat(game): 添加成都麻将打牌功能和E2E测试

- 添加discardPending状态控制丢弃牌操作
- 实现canDiscardTiles计算属性判断是否可以丢弃牌
- 新增handleRoomStateResponse函数处理房间状态响应
- 实现discardTile函数发送丢弃牌消息
- 在游戏页面添加手牌操作栏显示可丢弃的牌
- 为定缺按钮和准备按钮添加data-testid标识
- 在大厅页面为房间操作元素添加data-testid标识
- 添加手牌操作相关的CSS样式
- 配置Playwright E2E测试框架
- 创建房间流程到打牌的完整E2E测试用例
This commit is contained in:
2026-03-29 16:47:56 +08:00
parent 4f7a54cf08
commit 5c9c2a180d
6 changed files with 681 additions and 6 deletions

View File

@@ -78,6 +78,7 @@ const leaveRoomPending = ref(false)
const readyTogglePending = ref(false)
const startGamePending = ref(false)
const dingQuePending = ref(false)
const discardPending = ref(false)
let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null
let needsInitialRoomInfo = false
@@ -356,6 +357,23 @@ const showDingQueChooser = computed(() => {
return player.handTiles.length > 0 && !player.missingSuit
})
const canDiscardTiles = computed(() => {
const player = myPlayer.value
if (!player || !gameStore.roomId) {
return false
}
if (gameStore.phase !== 'playing') {
return false
}
if (!player.missingSuit || player.handTiles.length === 0) {
return false
}
return player.seatIndex === gameStore.currentTurn
})
function applyPlayerReadyState(playerId: string, ready: boolean): void {
const player = gameStore.players[playerId]
if (player) {
@@ -591,6 +609,174 @@ function requestRoomInfo(): void {
})
}
function handleRoomStateResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'ROOM_STATE') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId') ||
gameStore.roomId
if (!roomId) {
return
}
if (gameStore.roomId && roomId !== gameStore.roomId) {
return
}
const previousPlayers = gameStore.players
const nextPlayers: typeof gameStore.players = {}
const gamePlayers = Array.isArray(payload.players) ? payload.players : []
gamePlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item)
if (!player) {
return
}
const playerId = readString(player, 'player_id', 'PlayerID')
if (!playerId) {
return
}
const previous = previousPlayers[playerId]
const seatIndex = previous?.seatIndex ?? fallbackIndex
const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
const melds = normalizeMelds(
player.melds ??
player.exposed_melds ??
player.exposedMelds ??
player.claims,
)
const hasHu = Boolean(player.has_hu ?? player.hasHu)
const dingQue = readString(player, 'ding_que', 'dingQue')
const scores = asRecord(payload.scores)
const score = scores?.[playerId]
nextPlayers[playerId] = {
playerId,
seatIndex,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
missingSuit: dingQue || previous?.missingSuit || null,
handTiles: previous?.handTiles ?? [],
handCount,
melds,
discardTiles: outTiles,
hasHu,
score: typeof score === 'number' ? score : previous?.score ?? 0,
isReady: previous?.isReady ?? false,
}
})
if (Object.keys(nextPlayers).length > 0) {
gameStore.players = nextPlayers
}
gameStore.roomId = roomId
const phase =
readString(payload, 'phase') ||
readString(payload, 'status') ||
'waiting'
const phaseMap: Record<string, typeof gameStore.phase> = {
waiting: 'waiting',
dealing: 'dealing',
playing: 'playing',
action: 'action',
settlement: 'settlement',
finished: 'settlement',
}
gameStore.phase = phaseMap[phase] ?? gameStore.phase
const wallCount = readNumber(payload, 'wall_count', 'wallCount')
if (typeof wallCount === 'number') {
gameStore.remainingTiles = wallCount
}
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
const currentTurn =
currentTurnSeat ??
(currentTurnPlayerId && gameStore.players[currentTurnPlayerId]
? gameStore.players[currentTurnPlayerId].seatIndex
: null)
if (typeof currentTurn === 'number') {
gameStore.currentTurn = currentTurn
}
const scores = asRecord(payload.scores)
if (scores) {
gameStore.scores = Object.fromEntries(
Object.entries(scores).filter(([, value]) => typeof value === 'number'),
) as Record<string, number>
}
gameStore.winners = readStringArray(payload, 'winners')
gameStore.pendingClaim = undefined
const previousRoom = activeRoom.value
const roomPlayers = Object.values(gameStore.players)
.sort((left, right) => left.seatIndex - right.seatIndex)
.map((player) => {
const previousPlayer = previousRoom?.players.find((item) => item.playerId === player.playerId)
return {
index: player.seatIndex,
playerId: player.playerId,
displayName: player.displayName,
missingSuit: player.missingSuit,
ready: previousPlayer?.ready ?? player.isReady,
hand: player.playerId === loggedInUserId.value
? player.handTiles.map((tile) => tileToText(tile))
: Array.from({length: player.handCount}, () => ''),
melds: player.melds.map((meld) => meld.type),
outTiles: player.discardTiles.map((tile) => tileToText(tile)),
hasHu: player.hasHu,
}
})
setActiveRoom({
roomId,
roomName: previousRoom?.roomName || roomName.value,
gameType: previousRoom?.gameType || 'chengdu',
ownerId: previousRoom?.ownerId || '',
maxPlayers: previousRoom?.maxPlayers ?? 4,
playerCount: roomPlayers.length,
status: phase === 'settlement' ? 'finished' : phase === 'waiting' ? 'waiting' : 'playing',
createdAt: previousRoom?.createdAt || '',
updatedAt: previousRoom?.updatedAt || '',
players: roomPlayers,
myHand: myHandTiles.value.map((tile) => tileToText(tile)),
game: {
state: {
wall: Array.from({length: wallCount ?? 0}, (_, index) => `wall-${index}`),
scores: gameStore.scores,
dealerIndex: previousRoom?.game?.state?.dealerIndex ?? gameStore.dealerIndex,
currentTurn: typeof currentTurn === 'number' ? currentTurn : previousRoom?.game?.state?.currentTurn ?? -1,
phase,
},
},
})
if (phase !== 'waiting') {
startGamePending.value = false
}
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
discardPending.value = false
}
}
function handleRoomInfoResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
@@ -1140,6 +1326,11 @@ function handlePlayerHandResponse(message: unknown): void {
roomPlayer.hand = room.myHand
}
}
discardPending.value = false
if (gameStore.phase !== 'waiting') {
startGamePending.value = false
}
}
function toGameAction(message: unknown): GameAction | null {
@@ -1539,6 +1730,22 @@ function chooseDingQue(suit: Tile['suit']): void {
})
}
function discardTile(tile: Tile): void {
if (discardPending.value || !canDiscardTiles.value) {
return
}
discardPending.value = true
sendWsMessage({
type: 'discard',
roomId: gameStore.roomId,
payload: {
room_id: gameStore.roomId,
tile,
},
})
}
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
@@ -1633,6 +1840,7 @@ onMounted(() => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
handleRoomInfoResponse(msg)
handleRoomStateResponse(msg)
handlePlayerHandResponse(msg)
handleReadyStateResponse(msg)
handlePlayerDingQueResponse(msg)
@@ -1642,6 +1850,9 @@ onMounted(() => {
if (gameAction.type === 'GAME_START') {
startGamePending.value = false
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
discardPending.value = false
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
readyTogglePending.value = false
@@ -1854,6 +2065,7 @@ onBeforeUnmount(() => {
<div v-if="showDingQueChooser" class="ding-que-bar">
<button
class="ding-que-button"
data-testid="ding-que-w"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('W')"
@@ -1862,6 +2074,7 @@ onBeforeUnmount(() => {
</button>
<button
class="ding-que-button"
data-testid="ding-que-t"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('T')"
@@ -1870,6 +2083,7 @@ onBeforeUnmount(() => {
</button>
<button
class="ding-que-button"
data-testid="ding-que-b"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('B')"
@@ -1881,6 +2095,7 @@ onBeforeUnmount(() => {
<button
v-if="showReadyToggle"
class="ready-toggle ready-toggle-inline"
data-testid="ready-toggle"
type="button"
:disabled="readyTogglePending"
@click="toggleReadyState"
@@ -1891,6 +2106,7 @@ onBeforeUnmount(() => {
<button
v-if="showStartGameButton && isRoomOwner"
class="ready-toggle ready-toggle-inline"
data-testid="start-game"
type="button"
:disabled="!canStartGame"
@click="startGame"
@@ -1898,6 +2114,20 @@ onBeforeUnmount(() => {
<span class="ready-toggle-label">开始游戏</span>
</button>
</div>
<div v-if="sortedVisibleHandTiles.length > 0" class="hand-action-bar" data-testid="hand-action-bar">
<button
v-for="tile in sortedVisibleHandTiles"
:key="tile.id"
class="hand-action-tile"
:data-testid="`hand-tile-${tile.id}`"
type="button"
:disabled="!canDiscardTiles || discardPending"
@click="discardTile(tile)"
>
{{ formatTile(tile) }}
</button>
</div>
</div>
</div>
</section>