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
|
```bash
|
||||||
pnpm preview
|
pnpm preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
测试账号:A,B,C,D
|
||||||
|
测试密码:123456
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@@ -7,14 +7,8 @@ export default defineConfig({
|
|||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
baseURL: 'http://localhost:5173',
|
||||||
headless: true,
|
headless: true,
|
||||||
trace: 'on-first-retry',
|
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(
|
export async function createRoom(
|
||||||
auth: AuthSession,
|
auth: AuthSession,
|
||||||
input: { name: string; gameType: string; maxPlayers: number },
|
input: { name: string; gameType: string; maxPlayers: number; totalRounds: number },
|
||||||
onAuthUpdated?: (next: AuthSession) => void,
|
onAuthUpdated?: (next: AuthSession) => void,
|
||||||
): Promise<RoomItem> {
|
): Promise<RoomItem> {
|
||||||
return authedRequest<RoomItem>({
|
return authedRequest<RoomItem>({
|
||||||
@@ -45,6 +45,7 @@ export async function createRoom(
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
game_type: input.gameType,
|
game_type: input.gameType,
|
||||||
max_players: input.maxPlayers,
|
max_players: input.maxPlayers,
|
||||||
|
total_rounds: input.totalRounds,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,11 +635,36 @@
|
|||||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
|
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 {
|
.wall-bottom.wall-live .wall-live-tile + .wall-live-tile {
|
||||||
margin-left: -4px;
|
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;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
|
|
||||||
dealerIndex: 0,
|
dealerIndex: 0,
|
||||||
currentTurn: 0,
|
currentTurn: 0,
|
||||||
|
needDraw: false,
|
||||||
|
|
||||||
players: {},
|
players: {},
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
|
|
||||||
// 清除操作窗口
|
// 清除操作窗口
|
||||||
this.pendingClaim = undefined
|
this.pendingClaim = undefined
|
||||||
|
this.needDraw = false
|
||||||
|
|
||||||
// 进入出牌阶段
|
// 进入出牌阶段
|
||||||
this.phase = GAME_PHASE.PLAYING
|
this.phase = GAME_PHASE.PLAYING
|
||||||
@@ -87,6 +89,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
|
|
||||||
// 更新回合
|
// 更新回合
|
||||||
this.currentTurn = data.nextSeat
|
this.currentTurn = data.nextSeat
|
||||||
|
this.needDraw = true
|
||||||
|
|
||||||
// 等待其他玩家响应
|
// 等待其他玩家响应
|
||||||
this.phase = GAME_PHASE.ACTION
|
this.phase = GAME_PHASE.ACTION
|
||||||
@@ -95,6 +98,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
// 触发操作窗口(碰/杠/胡)
|
// 触发操作窗口(碰/杠/胡)
|
||||||
onPendingClaim(data: PendingClaimState) {
|
onPendingClaim(data: PendingClaimState) {
|
||||||
this.pendingClaim = data
|
this.pendingClaim = data
|
||||||
|
this.needDraw = false
|
||||||
this.phase = GAME_PHASE.ACTION
|
this.phase = GAME_PHASE.ACTION
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface GameState {
|
|||||||
// 当前操作玩家(座位)
|
// 当前操作玩家(座位)
|
||||||
currentTurn: number
|
currentTurn: number
|
||||||
|
|
||||||
|
// 当前回合是否需要先摸牌
|
||||||
|
needDraw: boolean
|
||||||
|
|
||||||
// 玩家列表
|
// 玩家列表
|
||||||
players: Record<string, PlayerState>
|
players: Record<string, PlayerState>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type {Tile} from "../tile.ts";
|
|||||||
|
|
||||||
export interface PendingClaimState {
|
export interface PendingClaimState {
|
||||||
// 当前被响应的牌
|
// 当前被响应的牌
|
||||||
tile: Tile
|
tile?: Tile
|
||||||
|
|
||||||
// 出牌人
|
// 出牌人
|
||||||
fromPlayerId: string
|
fromPlayerId?: string
|
||||||
|
|
||||||
// 当前玩家可执行操作
|
// 当前玩家可执行操作
|
||||||
options: ClaimOptionState[]
|
options: ClaimOptionState[]
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {sendWsMessage} from '../ws/sender'
|
|||||||
import {buildWsUrl} from '../ws/url'
|
import {buildWsUrl} from '../ws/url'
|
||||||
import {useGameStore} from '../store/gameStore'
|
import {useGameStore} from '../store/gameStore'
|
||||||
import {clearActiveRoom, setActiveRoom, useActiveRoomState} from '../store'
|
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 type {Tile} from '../types/tile'
|
||||||
import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts'
|
import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts'
|
||||||
import {getTileImage as getTopTileImage} from '../config/topTileMap.ts'
|
import {getTileImage as getTopTileImage} from '../config/topTileMap.ts'
|
||||||
@@ -56,6 +56,7 @@ interface WallTileItem {
|
|||||||
alt: string
|
alt: string
|
||||||
imageType: TableTileImageType
|
imageType: TableTileImageType
|
||||||
suit?: Tile['suit']
|
suit?: Tile['suit']
|
||||||
|
tile?: Tile
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WallSeatState {
|
interface WallSeatState {
|
||||||
@@ -79,6 +80,7 @@ const readyTogglePending = ref(false)
|
|||||||
const startGamePending = ref(false)
|
const startGamePending = ref(false)
|
||||||
const dingQuePending = ref(false)
|
const dingQuePending = ref(false)
|
||||||
const discardPending = ref(false)
|
const discardPending = ref(false)
|
||||||
|
const claimActionPending = 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
|
||||||
@@ -367,6 +369,10 @@ const canDiscardTiles = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gameStore.needDraw) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if (!player.missingSuit || player.handTiles.length === 0) {
|
if (!player.missingSuit || player.handTiles.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -374,6 +380,37 @@ const canDiscardTiles = computed(() => {
|
|||||||
return player.seatIndex === gameStore.currentTurn
|
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 {
|
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||||
const player = gameStore.players[playerId]
|
const player = gameStore.players[playerId]
|
||||||
if (player) {
|
if (player) {
|
||||||
@@ -510,6 +547,44 @@ function normalizeTiles(value: unknown): Tile[] {
|
|||||||
.filter((item): item is Tile => Boolean(item))
|
.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 {
|
function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return concealed ? 'an_gang' : null
|
return concealed ? 'an_gang' : null
|
||||||
@@ -705,6 +780,7 @@ function handleRoomStateResponse(message: unknown): void {
|
|||||||
if (typeof wallCount === 'number') {
|
if (typeof wallCount === 'number') {
|
||||||
gameStore.remainingTiles = wallCount
|
gameStore.remainingTiles = wallCount
|
||||||
}
|
}
|
||||||
|
gameStore.needDraw = readBoolean(payload, 'need_draw', 'needDraw') ?? false
|
||||||
|
|
||||||
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
||||||
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
|
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
|
||||||
@@ -724,7 +800,10 @@ function handleRoomStateResponse(message: unknown): void {
|
|||||||
) as Record<string, number>
|
) as Record<string, number>
|
||||||
}
|
}
|
||||||
gameStore.winners = readStringArray(payload, 'winners')
|
gameStore.winners = readStringArray(payload, 'winners')
|
||||||
gameStore.pendingClaim = undefined
|
gameStore.pendingClaim = normalizePendingClaim(payload)
|
||||||
|
if (!gameStore.pendingClaim) {
|
||||||
|
claimActionPending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const previousRoom = activeRoom.value
|
const previousRoom = activeRoom.value
|
||||||
const roomPlayers = Object.values(gameStore.players)
|
const roomPlayers = Object.values(gameStore.players)
|
||||||
@@ -789,6 +868,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = asRecord(source.payload) ?? source
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
syncCurrentUserID(readString(source, 'target'))
|
||||||
const room = asRecord(payload.room)
|
const room = asRecord(payload.room)
|
||||||
const gameState = asRecord(payload.game_state)
|
const gameState = asRecord(payload.game_state)
|
||||||
const playerView = asRecord(payload.player_view)
|
const playerView = asRecord(payload.player_view)
|
||||||
@@ -1030,6 +1110,11 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
if (typeof currentTurn === 'number') {
|
if (typeof currentTurn === 'number') {
|
||||||
gameStore.currentTurn = currentTurn
|
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)
|
const scores = asRecord(gameState?.scores)
|
||||||
if (scores) {
|
if (scores) {
|
||||||
gameStore.scores = Object.fromEntries(
|
gameStore.scores = Object.fromEntries(
|
||||||
@@ -1144,6 +1229,7 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
|||||||
alt: formatTile(tile),
|
alt: formatTile(tile),
|
||||||
imageType: 'hand',
|
imageType: 'hand',
|
||||||
suit: tile.suit,
|
suit: tile.suit,
|
||||||
|
tile,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -1299,6 +1385,8 @@ function handlePlayerHandResponse(message: unknown): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncCurrentUserID(readString(source, 'target'))
|
||||||
|
|
||||||
const roomId =
|
const roomId =
|
||||||
readString(payload, 'room_id', 'roomId') ||
|
readString(payload, 'room_id', 'roomId') ||
|
||||||
readString(source, 'roomId')
|
readString(source, 'roomId')
|
||||||
@@ -1529,6 +1617,26 @@ function syncAuthSession(next: AuthSession): void {
|
|||||||
writeStoredAuth(auth.value)
|
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> {
|
async function ensureCurrentUserLoaded(): Promise<void> {
|
||||||
if (loggedInUserId.value) {
|
if (loggedInUserId.value) {
|
||||||
return
|
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 {
|
function handleLeaveRoom(): void {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
backHall()
|
backHall()
|
||||||
@@ -1857,6 +1994,9 @@ onMounted(() => {
|
|||||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||||
readyTogglePending.value = false
|
readyTogglePending.value = false
|
||||||
}
|
}
|
||||||
|
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||||
|
claimActionPending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
@@ -2012,9 +2152,26 @@ onBeforeUnmount(() => {
|
|||||||
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag">胡</span>
|
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag">胡</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
|
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
|
||||||
|
<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
|
<img
|
||||||
v-for="(tile, index) in wallSeats.bottom.tiles"
|
class="wall-live-tile"
|
||||||
:key="tile.key"
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
class="wall-live-tile"
|
class="wall-live-tile"
|
||||||
:class="{
|
:class="{
|
||||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||||
@@ -2023,6 +2180,7 @@ onBeforeUnmount(() => {
|
|||||||
:src="tile.src"
|
:src="tile.src"
|
||||||
:alt="tile.alt"
|
:alt="tile.alt"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag">胡</span>
|
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag">胡</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
|
<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>
|
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
v-if="showStartGameButton && isRoomOwner"
|
v-if="showStartGameButton && isRoomOwner"
|
||||||
class="ready-toggle ready-toggle-inline"
|
class="ready-toggle ready-toggle-inline"
|
||||||
@@ -2115,17 +2283,17 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
v-for="tile in sortedVisibleHandTiles"
|
v-for="option in visibleClaimOptions"
|
||||||
:key="tile.id"
|
:key="option"
|
||||||
class="hand-action-tile"
|
class="ready-toggle ready-toggle-inline"
|
||||||
:data-testid="`hand-tile-${tile.id}`"
|
:data-testid="`claim-${option}`"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canDiscardTiles || discardPending"
|
:disabled="claimActionPending"
|
||||||
@click="discardTile(tile)"
|
@click="submitClaim(option)"
|
||||||
>
|
>
|
||||||
{{ formatTile(tile) }}
|
<span class="ready-toggle-label">{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const createRoomForm = ref({
|
|||||||
name: '',
|
name: '',
|
||||||
gameType: 'chengdu',
|
gameType: 'chengdu',
|
||||||
maxPlayers: 4,
|
maxPlayers: 4,
|
||||||
|
totalRounds: 8,
|
||||||
})
|
})
|
||||||
|
|
||||||
const quickJoinRoomId = ref('')
|
const quickJoinRoomId = ref('')
|
||||||
@@ -246,6 +247,7 @@ async function submitCreateRoom(): Promise<void> {
|
|||||||
name: createRoomForm.value.name.trim(),
|
name: createRoomForm.value.name.trim(),
|
||||||
gameType: createRoomForm.value.gameType,
|
gameType: createRoomForm.value.gameType,
|
||||||
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
||||||
|
totalRounds: Number(createRoomForm.value.totalRounds),
|
||||||
},
|
},
|
||||||
syncAuth,
|
syncAuth,
|
||||||
)
|
)
|
||||||
@@ -265,6 +267,7 @@ async function submitCreateRoom(): Promise<void> {
|
|||||||
})
|
})
|
||||||
quickJoinRoomId.value = room.room_id
|
quickJoinRoomId.value = room.room_id
|
||||||
createRoomForm.value.name = ''
|
createRoomForm.value.name = ''
|
||||||
|
createRoomForm.value.totalRounds = 8
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
showCreatedModal.value = true
|
showCreatedModal.value = true
|
||||||
await refreshRooms()
|
await refreshRooms()
|
||||||
@@ -499,11 +502,16 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<fieldset class="radio-group">
|
<fieldset class="radio-group">
|
||||||
<legend>人数</legend>
|
<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>
|
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4人</label>
|
||||||
</fieldset>
|
</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">
|
<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" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
|
<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">
|
<form class="form" @submit.prevent="handleSubmit">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>登录ID</span>
|
<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>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>密码</span>
|
<span>密码</span>
|
||||||
<input v-model="form.password" type="password" placeholder="请输入密码" />
|
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
|
||||||
{{ submitting ? '登录中...' : '登录' }}
|
{{ submitting ? '登录中...' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "failed",
|
||||||
"failedTests": []
|
"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