feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录
This commit is contained in:
@@ -27,3 +27,6 @@ Preview the production build:
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
测试账号:A,B,C,D
|
||||
测试密码:123456
|
||||
@@ -8,7 +8,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:live": "PLAYWRIGHT_LIVE=1 playwright test tests/e2e/room-flow.live.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -7,14 +7,8 @@ export default defineConfig({
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
baseURL: 'http://localhost:5173',
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj
|
||||
|
||||
export async function createRoom(
|
||||
auth: AuthSession,
|
||||
input: { name: string; gameType: string; maxPlayers: number },
|
||||
input: { name: string; gameType: string; maxPlayers: number; totalRounds: number },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
@@ -45,6 +45,7 @@ export async function createRoom(
|
||||
name: input.name,
|
||||
game_type: input.gameType,
|
||||
max_players: input.maxPlayers,
|
||||
total_rounds: input.totalRounds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -635,11 +635,36 @@
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.wall-live-tile-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wall-live-tile-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wall-live-tile-button:disabled .wall-live-tile {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.wall-bottom.wall-live .wall-live-tile-button + .wall-live-tile-button,
|
||||
.wall-bottom.wall-live .wall-live-tile-button + .wall-live-tile,
|
||||
.wall-bottom.wall-live .wall-live-tile + .wall-live-tile-button {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.wall-bottom.wall-live .wall-live-tile + .wall-live-tile {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.wall-bottom.wall-live .wall-live-tile.is-group-start {
|
||||
.wall-bottom.wall-live .wall-live-tile.is-group-start,
|
||||
.wall-bottom.wall-live .wall-live-tile-button.is-group-start {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
dealerIndex: 0,
|
||||
currentTurn: 0,
|
||||
needDraw: false,
|
||||
|
||||
players: {},
|
||||
|
||||
@@ -57,6 +58,7 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
// 清除操作窗口
|
||||
this.pendingClaim = undefined
|
||||
this.needDraw = false
|
||||
|
||||
// 进入出牌阶段
|
||||
this.phase = GAME_PHASE.PLAYING
|
||||
@@ -87,6 +89,7 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
// 更新回合
|
||||
this.currentTurn = data.nextSeat
|
||||
this.needDraw = true
|
||||
|
||||
// 等待其他玩家响应
|
||||
this.phase = GAME_PHASE.ACTION
|
||||
@@ -95,6 +98,7 @@ export const useGameStore = defineStore('game', {
|
||||
// 触发操作窗口(碰/杠/胡)
|
||||
onPendingClaim(data: PendingClaimState) {
|
||||
this.pendingClaim = data
|
||||
this.needDraw = false
|
||||
this.phase = GAME_PHASE.ACTION
|
||||
},
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface GameState {
|
||||
// 当前操作玩家(座位)
|
||||
currentTurn: number
|
||||
|
||||
// 当前回合是否需要先摸牌
|
||||
needDraw: boolean
|
||||
|
||||
// 玩家列表
|
||||
players: Record<string, PlayerState>
|
||||
|
||||
@@ -28,4 +31,4 @@ export interface GameState {
|
||||
|
||||
// 分数(playerId -> score)
|
||||
scores: Record<string, number>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
|
||||
|
||||
export interface PendingClaimState {
|
||||
// 当前被响应的牌
|
||||
tile: Tile
|
||||
tile?: Tile
|
||||
|
||||
// 出牌人
|
||||
fromPlayerId: string
|
||||
fromPlayerId?: string
|
||||
|
||||
// 当前玩家可执行操作
|
||||
options: ClaimOptionState[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {sendWsMessage} from '../ws/sender'
|
||||
import {buildWsUrl} from '../ws/url'
|
||||
import {useGameStore} from '../store/gameStore'
|
||||
import {clearActiveRoom, setActiveRoom, useActiveRoomState} from '../store'
|
||||
import type {MeldState, PlayerState} from '../types/state'
|
||||
import type {ClaimOptionState, MeldState, PendingClaimState, PlayerState} from '../types/state'
|
||||
import type {Tile} from '../types/tile'
|
||||
import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts'
|
||||
import {getTileImage as getTopTileImage} from '../config/topTileMap.ts'
|
||||
@@ -56,6 +56,7 @@ interface WallTileItem {
|
||||
alt: string
|
||||
imageType: TableTileImageType
|
||||
suit?: Tile['suit']
|
||||
tile?: Tile
|
||||
}
|
||||
|
||||
interface WallSeatState {
|
||||
@@ -79,6 +80,7 @@ const readyTogglePending = ref(false)
|
||||
const startGamePending = ref(false)
|
||||
const dingQuePending = ref(false)
|
||||
const discardPending = ref(false)
|
||||
const claimActionPending = ref(false)
|
||||
let clockTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let needsInitialRoomInfo = false
|
||||
@@ -367,6 +369,10 @@ const canDiscardTiles = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (gameStore.needDraw) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!player.missingSuit || player.handTiles.length === 0) {
|
||||
return false
|
||||
}
|
||||
@@ -374,6 +380,37 @@ const canDiscardTiles = computed(() => {
|
||||
return player.seatIndex === gameStore.currentTurn
|
||||
})
|
||||
|
||||
const canDrawTile = computed(() => {
|
||||
const player = myPlayer.value
|
||||
if (!player || !gameStore.roomId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (gameStore.phase !== 'playing') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!gameStore.needDraw) {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.seatIndex === gameStore.currentTurn
|
||||
})
|
||||
|
||||
const myClaimState = computed<PendingClaimState | undefined>(() => {
|
||||
return gameStore.pendingClaim
|
||||
})
|
||||
|
||||
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
||||
const options = myClaimState.value?.options ?? []
|
||||
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
||||
return order.filter((option) => options.includes(option))
|
||||
})
|
||||
|
||||
const showClaimActions = computed(() => {
|
||||
return visibleClaimOptions.value.length > 0
|
||||
})
|
||||
|
||||
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
@@ -510,6 +547,44 @@ function normalizeTiles(value: unknown): Tile[] {
|
||||
.filter((item): item is Tile => Boolean(item))
|
||||
}
|
||||
|
||||
function normalizePendingClaim(gameState: Record<string, unknown> | null | undefined): PendingClaimState | undefined {
|
||||
if (!gameState || !loggedInUserId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
||||
if (!pendingClaim) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selfOptions = asRecord(pendingClaim[loggedInUserId.value])
|
||||
if (!selfOptions) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const options: ClaimOptionState[] = []
|
||||
if (readBoolean(selfOptions, 'hu')) {
|
||||
options.push('hu')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'gang')) {
|
||||
options.push('gang')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'peng')) {
|
||||
options.push('peng')
|
||||
}
|
||||
if (options.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
options.push('pass')
|
||||
|
||||
return {
|
||||
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
||||
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
||||
if (typeof value !== 'string') {
|
||||
return concealed ? 'an_gang' : null
|
||||
@@ -705,6 +780,7 @@ function handleRoomStateResponse(message: unknown): void {
|
||||
if (typeof wallCount === 'number') {
|
||||
gameStore.remainingTiles = wallCount
|
||||
}
|
||||
gameStore.needDraw = readBoolean(payload, 'need_draw', 'needDraw') ?? false
|
||||
|
||||
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
||||
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
|
||||
@@ -724,7 +800,10 @@ function handleRoomStateResponse(message: unknown): void {
|
||||
) as Record<string, number>
|
||||
}
|
||||
gameStore.winners = readStringArray(payload, 'winners')
|
||||
gameStore.pendingClaim = undefined
|
||||
gameStore.pendingClaim = normalizePendingClaim(payload)
|
||||
if (!gameStore.pendingClaim) {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
|
||||
const previousRoom = activeRoom.value
|
||||
const roomPlayers = Object.values(gameStore.players)
|
||||
@@ -789,6 +868,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
syncCurrentUserID(readString(source, 'target'))
|
||||
const room = asRecord(payload.room)
|
||||
const gameState = asRecord(payload.game_state)
|
||||
const playerView = asRecord(payload.player_view)
|
||||
@@ -1030,6 +1110,11 @@ function handleRoomInfoResponse(message: unknown): void {
|
||||
if (typeof currentTurn === 'number') {
|
||||
gameStore.currentTurn = currentTurn
|
||||
}
|
||||
gameStore.needDraw = readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false
|
||||
gameStore.pendingClaim = normalizePendingClaim(gameState)
|
||||
if (!gameStore.pendingClaim) {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
const scores = asRecord(gameState?.scores)
|
||||
if (scores) {
|
||||
gameStore.scores = Object.fromEntries(
|
||||
@@ -1144,6 +1229,7 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
||||
alt: formatTile(tile),
|
||||
imageType: 'hand',
|
||||
suit: tile.suit,
|
||||
tile,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -1299,6 +1385,8 @@ function handlePlayerHandResponse(message: unknown): void {
|
||||
return
|
||||
}
|
||||
|
||||
syncCurrentUserID(readString(source, 'target'))
|
||||
|
||||
const roomId =
|
||||
readString(payload, 'room_id', 'roomId') ||
|
||||
readString(source, 'roomId')
|
||||
@@ -1529,6 +1617,26 @@ function syncAuthSession(next: AuthSession): void {
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
function syncCurrentUserID(userID: string): void {
|
||||
if (!userID || loggedInUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentAuth = auth.value
|
||||
if (!currentAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...currentAuth,
|
||||
user: {
|
||||
...(currentAuth.user ?? {}),
|
||||
id: userID,
|
||||
},
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
async function ensureCurrentUserLoaded(): Promise<void> {
|
||||
if (loggedInUserId.value) {
|
||||
return
|
||||
@@ -1746,6 +1854,35 @@ function discardTile(tile: Tile): void {
|
||||
})
|
||||
}
|
||||
|
||||
function drawTile(): void {
|
||||
if (!canDrawTile.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sendWsMessage({
|
||||
type: 'draw',
|
||||
roomId: gameStore.roomId,
|
||||
payload: {
|
||||
room_id: gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitClaim(action: ClaimOptionState): void {
|
||||
if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) {
|
||||
return
|
||||
}
|
||||
|
||||
claimActionPending.value = true
|
||||
sendWsMessage({
|
||||
type: action,
|
||||
roomId: gameStore.roomId,
|
||||
payload: {
|
||||
room_id: gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleLeaveRoom(): void {
|
||||
menuOpen.value = false
|
||||
backHall()
|
||||
@@ -1857,6 +1994,9 @@ onMounted(() => {
|
||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||
readyTogglePending.value = false
|
||||
}
|
||||
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
wsClient.onError((message: string) => {
|
||||
@@ -2012,17 +2152,35 @@ onBeforeUnmount(() => {
|
||||
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
|
||||
<img
|
||||
v-for="(tile, index) in wallSeats.bottom.tiles"
|
||||
:key="tile.key"
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
||||
<button
|
||||
v-if="tile.tile && tile.imageType === 'hand'"
|
||||
class="wall-live-tile-button"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
}"
|
||||
:data-testid="`hand-tile-${tile.tile.id}`"
|
||||
type="button"
|
||||
:disabled="!canDiscardTiles || discardPending"
|
||||
@click="discardTile(tile.tile)"
|
||||
>
|
||||
<img
|
||||
class="wall-live-tile"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</button>
|
||||
<img
|
||||
v-else
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
|
||||
@@ -2103,6 +2261,16 @@ onBeforeUnmount(() => {
|
||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canDrawTile"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
data-testid="draw-tile"
|
||||
type="button"
|
||||
@click="drawTile"
|
||||
>
|
||||
<span class="ready-toggle-label">摸牌</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showStartGameButton && isRoomOwner"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
@@ -2115,17 +2283,17 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sortedVisibleHandTiles.length > 0" class="hand-action-bar" data-testid="hand-action-bar">
|
||||
<div v-if="showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
||||
<button
|
||||
v-for="tile in sortedVisibleHandTiles"
|
||||
:key="tile.id"
|
||||
class="hand-action-tile"
|
||||
:data-testid="`hand-tile-${tile.id}`"
|
||||
v-for="option in visibleClaimOptions"
|
||||
:key="option"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
:data-testid="`claim-${option}`"
|
||||
type="button"
|
||||
:disabled="!canDiscardTiles || discardPending"
|
||||
@click="discardTile(tile)"
|
||||
:disabled="claimActionPending"
|
||||
@click="submitClaim(option)"
|
||||
>
|
||||
{{ formatTile(tile) }}
|
||||
<span class="ready-toggle-label">{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ const createRoomForm = ref({
|
||||
name: '',
|
||||
gameType: 'chengdu',
|
||||
maxPlayers: 4,
|
||||
totalRounds: 8,
|
||||
})
|
||||
|
||||
const quickJoinRoomId = ref('')
|
||||
@@ -246,6 +247,7 @@ async function submitCreateRoom(): Promise<void> {
|
||||
name: createRoomForm.value.name.trim(),
|
||||
gameType: createRoomForm.value.gameType,
|
||||
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
||||
totalRounds: Number(createRoomForm.value.totalRounds),
|
||||
},
|
||||
syncAuth,
|
||||
)
|
||||
@@ -265,6 +267,7 @@ async function submitCreateRoom(): Promise<void> {
|
||||
})
|
||||
quickJoinRoomId.value = room.room_id
|
||||
createRoomForm.value.name = ''
|
||||
createRoomForm.value.totalRounds = 8
|
||||
showCreateModal.value = false
|
||||
showCreatedModal.value = true
|
||||
await refreshRooms()
|
||||
@@ -499,11 +502,16 @@ onMounted(async () => {
|
||||
|
||||
<fieldset class="radio-group">
|
||||
<legend>人数</legend>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="2" /> 2人</label>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="3" /> 3人</label>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4人</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="radio-group">
|
||||
<legend>局数</legend>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="4" /> 4局</label>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="8" /> 8局</label>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="16" /> 16局</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
|
||||
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
|
||||
|
||||
@@ -70,13 +70,13 @@ async function handleSubmit(): Promise<void> {
|
||||
<form class="form" @submit.prevent="handleSubmit">
|
||||
<label class="field">
|
||||
<span>登录ID</span>
|
||||
<input v-model.trim="form.loginId" type="text" placeholder="请输入手机号或账号" />
|
||||
<input v-model.trim="form.loginId" data-testid="login-id" type="text" placeholder="请输入手机号或账号" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
|
||||
</label>
|
||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
||||
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"782a5ad254513972d5b8-1549d022f94e0e4cc886"
|
||||
]
|
||||
}
|
||||
292
tests/e2e/room-flow.live.spec.ts
Normal file
292
tests/e2e/room-flow.live.spec.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { expect, request, test, type Browser, type BrowserContext, type Page } from 'playwright/test'
|
||||
|
||||
const liveEnabled = process.env.PLAYWRIGHT_LIVE === '1'
|
||||
const apiBaseURL = process.env.E2E_LIVE_API_BASE_URL ?? 'http://127.0.0.1:19000'
|
||||
const password = process.env.E2E_LIVE_PASSWORD ?? 'Passw0rd!'
|
||||
const configuredUsers = (process.env.E2E_LIVE_USERS ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
type PlayerPage = {
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
test.skip(!liveEnabled, 'set PLAYWRIGHT_LIVE=1 to run live integration flow')
|
||||
|
||||
test('live room flow: create, join, ready, start, ding que, multi-turn discard', async ({ browser }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const api = await request.newContext({
|
||||
baseURL: apiBaseURL,
|
||||
extraHTTPHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const players: PlayerPage[] = []
|
||||
|
||||
try {
|
||||
await expectLiveStackReady(api)
|
||||
|
||||
if (configuredUsers.length > 0) {
|
||||
for (const username of configuredUsers) {
|
||||
players.push(
|
||||
await openPlayerPage(browser, {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const sessions = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) => createLiveUserSession(api, index)),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
players.push(await openPlayerPage(browser, session))
|
||||
}
|
||||
}
|
||||
|
||||
const [owner, guest2, guest3, guest4] = players
|
||||
|
||||
await owner.page.goto('/hall')
|
||||
await expect(owner.page.getByTestId('open-create-room')).toBeVisible()
|
||||
await owner.page.getByTestId('open-create-room').click()
|
||||
await owner.page.getByTestId('create-room-name').fill(`live-room-${Date.now()}`)
|
||||
const createRoomResponsePromise = owner.page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/game/mahjong/room/create') && response.request().method() === 'POST'
|
||||
})
|
||||
await owner.page.getByTestId('submit-create-room').click()
|
||||
|
||||
const createRoomResponse = await createRoomResponsePromise
|
||||
if (!createRoomResponse.ok()) {
|
||||
throw new Error(
|
||||
`create room failed: status=${createRoomResponse.status()} body=${await createRoomResponse.text()} request=${createRoomResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
const createRoomPayload = (await createRoomResponse.json()) as { data?: { room_id?: string; name?: string } }
|
||||
const roomID = createRoomPayload.data?.room_id
|
||||
if (!roomID) {
|
||||
throw new Error('live room id not found after creating room')
|
||||
}
|
||||
const roomName = createRoomPayload.data?.name ?? ''
|
||||
|
||||
const enterCreatedRoomButton = owner.page.getByTestId('enter-created-room')
|
||||
if (await enterCreatedRoomButton.isVisible().catch(() => false)) {
|
||||
await enterCreatedRoomButton.click()
|
||||
} else {
|
||||
await owner.page.goto(`/game/chengdu/${roomID}${roomName ? `?roomName=${encodeURIComponent(roomName)}` : ''}`)
|
||||
}
|
||||
|
||||
await expect(owner.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}`))
|
||||
|
||||
for (const guest of [guest2, guest3, guest4]) {
|
||||
await guest.page.goto('/hall')
|
||||
await expect(guest.page.getByTestId('quick-join-room-id')).toBeVisible()
|
||||
await guest.page.getByTestId('quick-join-room-id').fill(roomID)
|
||||
await guest.page.getByTestId('quick-join-submit').click()
|
||||
await expect(guest.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}(\\?.*)?$`))
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
await expect(player.page.getByTestId('ready-toggle')).toBeVisible()
|
||||
await player.page.getByTestId('ready-toggle').click()
|
||||
}
|
||||
|
||||
await expect(owner.page.getByTestId('start-game')).toBeEnabled({ timeout: 20_000 })
|
||||
await owner.page.getByTestId('start-game').click()
|
||||
|
||||
const dingQueChoices: Array<'w' | 't' | 'b'> = ['w', 't', 'b', 'w']
|
||||
for (const [index, player] of players.entries()) {
|
||||
const suit = dingQueChoices[index]
|
||||
const dingQueButton = player.page.getByTestId(`ding-que-${suit}`)
|
||||
await expect(dingQueButton).toBeVisible({ timeout: 20_000 })
|
||||
await expect(dingQueButton).toBeEnabled()
|
||||
await dingQueButton.click()
|
||||
}
|
||||
|
||||
const discardActors: string[] = []
|
||||
let previousActor: PlayerPage | null = null
|
||||
|
||||
for (let turn = 0; turn < 4; turn += 1) {
|
||||
const actor = await findDiscardActor(players, 30_000, previousActor)
|
||||
const actorTiles = actor.page.locator('[data-testid^="hand-tile-"]')
|
||||
expect(await actorTiles.count()).toBeGreaterThan(0)
|
||||
expect(await countEnabledTiles(actor.page)).toBeGreaterThan(0)
|
||||
|
||||
await actorTiles.first().click()
|
||||
await expect
|
||||
.poll(() => countEnabledTiles(actor.page), { timeout: 20_000 })
|
||||
.toBe(0)
|
||||
|
||||
await resolvePendingClaims(players, 10_000)
|
||||
await drawIfNeeded(players, 10_000)
|
||||
|
||||
discardActors.push(actor.username)
|
||||
previousActor = actor
|
||||
}
|
||||
|
||||
expect(new Set(discardActors).size).toBeGreaterThan(1)
|
||||
} finally {
|
||||
await api.dispose()
|
||||
await Promise.all(players.map(async ({ context }) => context.close()))
|
||||
}
|
||||
})
|
||||
|
||||
async function expectLiveStackReady(api: Awaited<ReturnType<typeof request.newContext>>): Promise<void> {
|
||||
const response = await api.get('/healthz')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function createLiveUserSession(
|
||||
api: Awaited<ReturnType<typeof request.newContext>>,
|
||||
index: number,
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const username = `pwlive_${seed}`
|
||||
const timestampDigits = String(Date.now()).slice(-8)
|
||||
const randomDigit = Math.floor(Math.random() * 10)
|
||||
const phone = `13${timestampDigits}${index}${randomDigit}`
|
||||
const email = `${username}@example.com`
|
||||
|
||||
const registerResponse = await api.post('/api/v1/auth/register', {
|
||||
data: {
|
||||
username,
|
||||
phone,
|
||||
email,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(registerResponse.ok(), await registerResponse.text()).toBeTruthy()
|
||||
|
||||
const loginResponse = await api.post('/api/v1/auth/login', {
|
||||
data: {
|
||||
login_id: username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(loginResponse.ok(), await loginResponse.text()).toBeTruthy()
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPlayerPage(
|
||||
browser: Browser,
|
||||
session: { username: string; password: string },
|
||||
): Promise<PlayerPage> {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await page.goto('/login')
|
||||
await page.getByTestId('login-id').fill(session.username)
|
||||
await page.getByTestId('login-password').fill(session.password)
|
||||
const loginResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
|
||||
})
|
||||
await page.getByTestId('login-submit').click()
|
||||
const loginResponse = await loginResponsePromise
|
||||
if (!loginResponse.ok()) {
|
||||
throw new Error(
|
||||
`browser login failed: status=${loginResponse.status()} body=${await loginResponse.text()} request=${loginResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
await expect(page).toHaveURL(/\/hall$/)
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
}
|
||||
}
|
||||
|
||||
async function findDiscardActor(
|
||||
players: PlayerPage[],
|
||||
timeoutMs: number,
|
||||
previousActor?: PlayerPage | null,
|
||||
): Promise<PlayerPage> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
if (previousActor && player.username === previousActor.username) {
|
||||
continue
|
||||
}
|
||||
const firstTile = player.page.locator('[data-testid^="hand-tile-"]').first()
|
||||
const tileCount = await player.page.locator('[data-testid^="hand-tile-"]').count()
|
||||
if (tileCount === 0) {
|
||||
continue
|
||||
}
|
||||
if (await firstTile.isEnabled().catch(() => false)) {
|
||||
return player
|
||||
}
|
||||
}
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
const diagnostics = await Promise.all(
|
||||
players.map(async (player) => {
|
||||
const logs = await player.page.locator('.sidebar-line').allTextContents().catch(() => [])
|
||||
const claimBarVisible = await player.page.getByTestId('claim-action-bar').isVisible().catch(() => false)
|
||||
const passVisible = await player.page.getByTestId('claim-pass').isVisible().catch(() => false)
|
||||
const enabledTiles = await countEnabledTiles(player.page)
|
||||
return [
|
||||
`player=${player.username}`,
|
||||
`enabledTiles=${enabledTiles}`,
|
||||
`claimBar=${claimBarVisible}`,
|
||||
`claimPass=${passVisible}`,
|
||||
`logs=${logs.slice(0, 4).join(' || ')}`,
|
||||
].join(' ')
|
||||
}),
|
||||
)
|
||||
|
||||
throw new Error(`no player reached enabled discard state within timeout\n${diagnostics.join('\n')}`)
|
||||
}
|
||||
|
||||
async function countEnabledTiles(page: Page): Promise<number> {
|
||||
return page.locator('[data-testid^="hand-tile-"]:enabled').count()
|
||||
}
|
||||
|
||||
async function resolvePendingClaims(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
let handled = false
|
||||
|
||||
for (const player of players) {
|
||||
const passButton = player.page.getByTestId('claim-pass')
|
||||
if (await passButton.isVisible().catch(() => false)) {
|
||||
await expect(passButton).toBeEnabled({ timeout: 5_000 })
|
||||
await passButton.click()
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
return
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
async function drawIfNeeded(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
const drawButton = player.page.getByTestId('draw-tile')
|
||||
if (await drawButton.isVisible().catch(() => false)) {
|
||||
await expect(drawButton).toBeEnabled({ timeout: 5_000 })
|
||||
await drawButton.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user