feat(game): 添加成都麻将打牌功能和E2E测试
- 添加discardPending状态控制丢弃牌操作 - 实现canDiscardTiles计算属性判断是否可以丢弃牌 - 新增handleRoomStateResponse函数处理房间状态响应 - 实现discardTile函数发送丢弃牌消息 - 在游戏页面添加手牌操作栏显示可丢弃的牌 - 为定缺按钮和准备按钮添加data-testid标识 - 在大厅页面为房间操作元素添加data-testid标识 - 添加手牌操作相关的CSS样式 - 配置Playwright E2E测试框架 - 创建房间流程到打牌的完整E2E测试用例
This commit is contained in:
20
playwright.config.ts
Normal file
20
playwright.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:4173',
|
||||||
|
headless: true,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm dev --host 127.0.0.1 --port 4173',
|
||||||
|
url: 'http://127.0.0.1:4173',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -917,6 +917,43 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hand-action-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: min(760px, calc(100vw - 120px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(14, 25, 22, 0.72);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-action-tile {
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(244, 228, 194, 0.98), rgba(214, 190, 145, 0.96));
|
||||||
|
color: #2e1e14;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||||
|
0 6px 14px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 120ms ease-out, box-shadow 120ms ease-out, opacity 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-action-tile:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-action-tile:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.ding-que-bar {
|
.ding-que-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 150px;
|
right: 150px;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const leaveRoomPending = ref(false)
|
|||||||
const readyTogglePending = ref(false)
|
const readyTogglePending = ref(false)
|
||||||
const startGamePending = ref(false)
|
const startGamePending = ref(false)
|
||||||
const dingQuePending = ref(false)
|
const dingQuePending = ref(false)
|
||||||
|
const discardPending = ref(false)
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
let needsInitialRoomInfo = false
|
let needsInitialRoomInfo = false
|
||||||
@@ -356,6 +357,23 @@ const showDingQueChooser = computed(() => {
|
|||||||
return player.handTiles.length > 0 && !player.missingSuit
|
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 {
|
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||||
const player = gameStore.players[playerId]
|
const player = gameStore.players[playerId]
|
||||||
if (player) {
|
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 {
|
function handleRoomInfoResponse(message: unknown): void {
|
||||||
const source = asRecord(message)
|
const source = asRecord(message)
|
||||||
if (!source || typeof source.type !== 'string') {
|
if (!source || typeof source.type !== 'string') {
|
||||||
@@ -1140,6 +1326,11 @@ function handlePlayerHandResponse(message: unknown): void {
|
|||||||
roomPlayer.hand = room.myHand
|
roomPlayer.hand = room.myHand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discardPending.value = false
|
||||||
|
if (gameStore.phase !== 'waiting') {
|
||||||
|
startGamePending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toGameAction(message: unknown): GameAction | null {
|
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 {
|
function handleLeaveRoom(): void {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
backHall()
|
backHall()
|
||||||
@@ -1633,6 +1840,7 @@ onMounted(() => {
|
|||||||
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
||||||
wsMessages.value.push(`[server] ${text}`)
|
wsMessages.value.push(`[server] ${text}`)
|
||||||
handleRoomInfoResponse(msg)
|
handleRoomInfoResponse(msg)
|
||||||
|
handleRoomStateResponse(msg)
|
||||||
handlePlayerHandResponse(msg)
|
handlePlayerHandResponse(msg)
|
||||||
handleReadyStateResponse(msg)
|
handleReadyStateResponse(msg)
|
||||||
handlePlayerDingQueResponse(msg)
|
handlePlayerDingQueResponse(msg)
|
||||||
@@ -1642,6 +1850,9 @@ onMounted(() => {
|
|||||||
if (gameAction.type === 'GAME_START') {
|
if (gameAction.type === 'GAME_START') {
|
||||||
startGamePending.value = false
|
startGamePending.value = false
|
||||||
}
|
}
|
||||||
|
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
|
||||||
|
discardPending.value = false
|
||||||
|
}
|
||||||
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||||
readyTogglePending.value = false
|
readyTogglePending.value = false
|
||||||
@@ -1854,6 +2065,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="showDingQueChooser" class="ding-que-bar">
|
<div v-if="showDingQueChooser" class="ding-que-bar">
|
||||||
<button
|
<button
|
||||||
class="ding-que-button"
|
class="ding-que-button"
|
||||||
|
data-testid="ding-que-w"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="dingQuePending"
|
:disabled="dingQuePending"
|
||||||
@click="chooseDingQue('W')"
|
@click="chooseDingQue('W')"
|
||||||
@@ -1862,6 +2074,7 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ding-que-button"
|
class="ding-que-button"
|
||||||
|
data-testid="ding-que-t"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="dingQuePending"
|
:disabled="dingQuePending"
|
||||||
@click="chooseDingQue('T')"
|
@click="chooseDingQue('T')"
|
||||||
@@ -1870,6 +2083,7 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ding-que-button"
|
class="ding-que-button"
|
||||||
|
data-testid="ding-que-b"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="dingQuePending"
|
:disabled="dingQuePending"
|
||||||
@click="chooseDingQue('B')"
|
@click="chooseDingQue('B')"
|
||||||
@@ -1881,6 +2095,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
v-if="showReadyToggle"
|
v-if="showReadyToggle"
|
||||||
class="ready-toggle ready-toggle-inline"
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="ready-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="readyTogglePending"
|
:disabled="readyTogglePending"
|
||||||
@click="toggleReadyState"
|
@click="toggleReadyState"
|
||||||
@@ -1891,6 +2106,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
v-if="showStartGameButton && isRoomOwner"
|
v-if="showStartGameButton && isRoomOwner"
|
||||||
class="ready-toggle ready-toggle-inline"
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="start-game"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canStartGame"
|
:disabled="!canStartGame"
|
||||||
@click="startGame"
|
@click="startGame"
|
||||||
@@ -1898,6 +2114,20 @@ onBeforeUnmount(() => {
|
|||||||
<span class="ready-toggle-label">开始游戏</span>
|
<span class="ready-toggle-label">开始游戏</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="primary-btn"
|
class="primary-btn"
|
||||||
|
:data-testid="`room-enter-${room.room_id}`"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="roomSubmitting"
|
:disabled="roomSubmitting"
|
||||||
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
|
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
|
||||||
@@ -459,7 +460,7 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="room-actions-footer">
|
<div class="room-actions-footer">
|
||||||
<button class="primary-btn wide-btn" type="button" @click="openCreateModal">创建房间</button>
|
<button class="primary-btn wide-btn" data-testid="open-create-room" type="button" @click="openCreateModal">创建房间</button>
|
||||||
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
|
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -471,8 +472,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<h3>快速加入</h3>
|
<h3>快速加入</h3>
|
||||||
<form class="join-line" @submit.prevent="handleJoinRoom()">
|
<form class="join-line" @submit.prevent="handleJoinRoom()">
|
||||||
<input v-model.trim="quickJoinRoomId" type="text" placeholder="输入 room_id" />
|
<input v-model.trim="quickJoinRoomId" data-testid="quick-join-room-id" type="text" placeholder="输入 room_id" />
|
||||||
<button class="primary-btn" type="submit" :disabled="roomSubmitting">加入</button>
|
<button class="primary-btn" data-testid="quick-join-submit" type="submit" :disabled="roomSubmitting">加入</button>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -486,7 +487,7 @@ onMounted(async () => {
|
|||||||
<form class="form" @submit.prevent="submitCreateRoom">
|
<form class="form" @submit.prevent="submitCreateRoom">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>房间名</span>
|
<span>房间名</span>
|
||||||
<input v-model.trim="createRoomForm.name" type="text" maxlength="24" placeholder="例如:test001" />
|
<input v-model.trim="createRoomForm.name" data-testid="create-room-name" type="text" maxlength="24" placeholder="例如:test001" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>玩法</span>
|
<span>玩法</span>
|
||||||
@@ -505,7 +506,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
|
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
|
||||||
<button class="primary-btn" type="submit" :disabled="roomSubmitting">
|
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
|
||||||
{{ roomSubmitting ? '创建中...' : '创建' }}
|
{{ roomSubmitting ? '创建中...' : '创建' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +529,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="primary-btn" type="button" @click="enterCreatedRoom">进入房间</button>
|
<button class="primary-btn" data-testid="enter-created-room" type="button" @click="enterCreatedRoom">进入房间</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
383
tests/e2e/room-flow.spec.ts
Normal file
383
tests/e2e/room-flow.spec.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { expect, test } from 'playwright/test'
|
||||||
|
|
||||||
|
test('enter room, ready, start game, ding que, and discard tile', async ({ page }) => {
|
||||||
|
let createdRoom: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'mahjong_auth',
|
||||||
|
JSON.stringify({
|
||||||
|
token: 'mock-access-token',
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
user: {
|
||||||
|
id: 'u-e2e-1',
|
||||||
|
username: '测试玩家',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tile = { id: number; suit: 'W' | 'T' | 'B'; value: number }
|
||||||
|
type Player = {
|
||||||
|
index: number
|
||||||
|
player_id: string
|
||||||
|
player_name: string
|
||||||
|
ready: boolean
|
||||||
|
missing_suit?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: {
|
||||||
|
roomId: string
|
||||||
|
status: 'waiting' | 'playing'
|
||||||
|
dealerIndex: number
|
||||||
|
currentTurn: number
|
||||||
|
selfId: string
|
||||||
|
players: Player[]
|
||||||
|
hand: Tile[]
|
||||||
|
missingSuit: string | null
|
||||||
|
} = {
|
||||||
|
roomId: 'room-e2e-001',
|
||||||
|
status: 'waiting',
|
||||||
|
dealerIndex: 0,
|
||||||
|
currentTurn: 0,
|
||||||
|
selfId: 'u-e2e-1',
|
||||||
|
players: [
|
||||||
|
{ index: 0, player_id: 'u-e2e-1', player_name: '测试玩家', ready: false, missing_suit: null },
|
||||||
|
{ index: 1, player_id: 'bot-2', player_name: '机器人二号', ready: true, missing_suit: null },
|
||||||
|
{ index: 2, player_id: 'bot-3', player_name: '机器人三号', ready: true, missing_suit: null },
|
||||||
|
{ index: 3, player_id: 'bot-4', player_name: '机器人四号', ready: true, missing_suit: null },
|
||||||
|
],
|
||||||
|
hand: [
|
||||||
|
{ id: 11, suit: 'W', value: 1 },
|
||||||
|
{ id: 12, suit: 'W', value: 2 },
|
||||||
|
{ id: 13, suit: 'W', value: 3 },
|
||||||
|
{ id: 21, suit: 'T', value: 4 },
|
||||||
|
{ id: 22, suit: 'T', value: 5 },
|
||||||
|
{ id: 23, suit: 'T', value: 6 },
|
||||||
|
{ id: 31, suit: 'B', value: 1 },
|
||||||
|
{ id: 32, suit: 'B', value: 2 },
|
||||||
|
{ id: 33, suit: 'B', value: 3 },
|
||||||
|
{ id: 34, suit: 'B', value: 4 },
|
||||||
|
{ id: 35, suit: 'B', value: 5 },
|
||||||
|
{ id: 36, suit: 'B', value: 6 },
|
||||||
|
{ id: 37, suit: 'B', value: 7 },
|
||||||
|
],
|
||||||
|
missingSuit: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||||
|
|
||||||
|
const buildRoomPayload = () => ({
|
||||||
|
room: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
name: 'E2E 测试房间',
|
||||||
|
game_type: 'chengdu',
|
||||||
|
owner_id: state.selfId,
|
||||||
|
max_players: 4,
|
||||||
|
player_count: state.players.length,
|
||||||
|
players: clone(state.players),
|
||||||
|
status: state.status,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
game_state:
|
||||||
|
state.status === 'playing'
|
||||||
|
? {
|
||||||
|
room_id: state.roomId,
|
||||||
|
phase: 'playing',
|
||||||
|
status: 'playing',
|
||||||
|
wall_count: 55,
|
||||||
|
current_turn_player: state.players.find((player) => player.index === state.currentTurn)?.player_id ?? '',
|
||||||
|
players: state.players.map((player) => ({
|
||||||
|
player_id: player.player_id,
|
||||||
|
ding_que: player.missing_suit ?? '',
|
||||||
|
ding_que_done: Boolean(player.missing_suit),
|
||||||
|
hand_count: player.player_id === state.selfId ? state.hand.length : 13,
|
||||||
|
melds: [],
|
||||||
|
out_tiles: [],
|
||||||
|
has_hu: false,
|
||||||
|
})),
|
||||||
|
scores: {},
|
||||||
|
winners: [],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
player_view:
|
||||||
|
state.status === 'playing'
|
||||||
|
? {
|
||||||
|
room_id: state.roomId,
|
||||||
|
ding_que: state.missingSuit ?? '',
|
||||||
|
hand: clone(state.hand),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
hand: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
class MockWebSocket {
|
||||||
|
url: string
|
||||||
|
readyState = 0
|
||||||
|
onopen: ((event: Event) => void) | null = null
|
||||||
|
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
||||||
|
onerror: ((event: Event) => void) | null = null
|
||||||
|
onclose: ((event: CloseEvent) => void) | null = null
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.readyState = 1
|
||||||
|
this.onopen?.(new Event('open'))
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(raw: string) {
|
||||||
|
const message = JSON.parse(raw) as {
|
||||||
|
type?: string
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'get_room_info':
|
||||||
|
this.emit({
|
||||||
|
type: 'room_info',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: buildRoomPayload(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'set_ready': {
|
||||||
|
const nextReady = Boolean(message.payload?.ready)
|
||||||
|
state.players = state.players.map((player) =>
|
||||||
|
player.player_id === state.selfId ? { ...player, ready: nextReady } : player,
|
||||||
|
)
|
||||||
|
this.emit({
|
||||||
|
type: 'set_ready',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
user_id: state.selfId,
|
||||||
|
ready: nextReady,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.emit({
|
||||||
|
type: 'room_player_update',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
status: 'waiting',
|
||||||
|
player_ids: state.players.map((player) => player.player_id),
|
||||||
|
players: clone(state.players),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'start_game':
|
||||||
|
state.status = 'playing'
|
||||||
|
this.emit({
|
||||||
|
type: 'room_state',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: buildRoomPayload().game_state,
|
||||||
|
})
|
||||||
|
this.emit({
|
||||||
|
type: 'player_hand',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
hand: clone(state.hand),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'ding_que': {
|
||||||
|
const suit = typeof message.payload?.suit === 'string' ? message.payload.suit : ''
|
||||||
|
state.missingSuit = suit || null
|
||||||
|
state.players = state.players.map((player) =>
|
||||||
|
player.player_id === state.selfId ? { ...player, missing_suit: state.missingSuit } : player,
|
||||||
|
)
|
||||||
|
this.emit({
|
||||||
|
type: 'player_ding_que',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
player_id: state.selfId,
|
||||||
|
suit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.emit({
|
||||||
|
type: 'room_state',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: buildRoomPayload().game_state,
|
||||||
|
})
|
||||||
|
this.emit({
|
||||||
|
type: 'player_hand',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
ding_que: state.missingSuit ?? '',
|
||||||
|
hand: clone(state.hand),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'discard': {
|
||||||
|
const tile = message.payload?.tile as Tile | undefined
|
||||||
|
if (!tile) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
state.hand = state.hand.filter((item) => item.id !== tile.id)
|
||||||
|
state.currentTurn = 1
|
||||||
|
this.emit({
|
||||||
|
type: 'room_state',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: buildRoomPayload().game_state,
|
||||||
|
})
|
||||||
|
this.emit({
|
||||||
|
type: 'player_hand',
|
||||||
|
roomId: state.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: state.roomId,
|
||||||
|
hand: clone(state.hand),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.readyState = 3
|
||||||
|
this.onclose?.(new CloseEvent('close'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(payload: unknown) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.onmessage?.(
|
||||||
|
new MessageEvent('message', {
|
||||||
|
data: JSON.stringify(payload),
|
||||||
|
}) as MessageEvent<string>,
|
||||||
|
)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'WebSocket', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: MockWebSocket,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.route('**/api/v1/user/info', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'ok',
|
||||||
|
data: {
|
||||||
|
userID: 'u-e2e-1',
|
||||||
|
username: '测试玩家',
|
||||||
|
nickname: '测试玩家',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.route('**/api/v1/game/mahjong/room/list', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'ok',
|
||||||
|
data: {
|
||||||
|
items: createdRoom ? [createdRoom] : [],
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
total: createdRoom ? 1 : 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.route('**/api/v1/game/mahjong/room/create', async (route) => {
|
||||||
|
const body = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {}
|
||||||
|
createdRoom = {
|
||||||
|
room_id: 'room-e2e-001',
|
||||||
|
name: typeof body.name === 'string' ? body.name : 'E2E 测试房间',
|
||||||
|
game_type: typeof body.game_type === 'string' ? body.game_type : 'chengdu',
|
||||||
|
owner_id: 'u-e2e-1',
|
||||||
|
max_players: typeof body.max_players === 'number' ? body.max_players : 4,
|
||||||
|
player_count: 1,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
player_id: 'u-e2e-1',
|
||||||
|
player_name: '测试玩家',
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: 'waiting',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'ok',
|
||||||
|
data: createdRoom,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.route('**/api/v1/game/mahjong/room/join', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: 'ok',
|
||||||
|
data: createdRoom ?? {
|
||||||
|
room_id: 'room-e2e-001',
|
||||||
|
name: 'E2E 测试房间',
|
||||||
|
game_type: 'chengdu',
|
||||||
|
owner_id: 'u-e2e-1',
|
||||||
|
max_players: 4,
|
||||||
|
player_count: 1,
|
||||||
|
status: 'waiting',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/hall')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('open-create-room')).toBeVisible()
|
||||||
|
await page.getByTestId('open-create-room').click()
|
||||||
|
await page.getByTestId('create-room-name').fill('E2E 测试房间')
|
||||||
|
await page.getByTestId('submit-create-room').click()
|
||||||
|
await expect(page.getByTestId('enter-created-room')).toBeVisible()
|
||||||
|
await page.getByTestId('enter-created-room').click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/game\/chengdu\/room-e2e-001/)
|
||||||
|
await expect(page.getByTestId('ready-toggle')).toBeVisible()
|
||||||
|
await page.getByTestId('ready-toggle').click()
|
||||||
|
|
||||||
|
await expect(page.getByTestId('start-game')).toBeVisible()
|
||||||
|
await page.getByTestId('start-game').click()
|
||||||
|
|
||||||
|
await expect(page.getByTestId('ding-que-w')).toBeVisible()
|
||||||
|
await page.getByTestId('ding-que-w').click()
|
||||||
|
|
||||||
|
const handBar = page.getByTestId('hand-action-bar')
|
||||||
|
await expect(handBar).toBeVisible()
|
||||||
|
const tiles = handBar.locator('[data-testid^="hand-tile-"]')
|
||||||
|
await expect(tiles).toHaveCount(13)
|
||||||
|
await tiles.first().click()
|
||||||
|
await expect(tiles).toHaveCount(12)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user