refactor(game): 重构游戏模块并添加WebSocket客户端

- 修改开发环境配置中的代理目标地址
- 移除旧的活动房间状态管理模块
- 移除成都麻将游戏相关的测试用例
- 添加新的游戏动作发送功能
- 实现WebSocket客户端类并支持自动重连
- 添加WebSocket消息处理器注册机制
- 创建游戏相关状态类型定义
- 添加ID生成工具函数
- 移除废弃的游戏相关模块和常量定义
- 添加WebSocket消息结构定义
- 重构游戏状态相关类型定义
This commit is contained in:
2026-03-24 23:42:03 +08:00
parent 7316588d9e
commit 4f6ef1d0ec
48 changed files with 423 additions and 994 deletions

View File

@@ -1,4 +1,4 @@
VITE_API_BASE_URL=/api/v1
VITE_GAME_WS_URL=/ws
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000
VITE_API_PROXY_TARGET=http://192.168.2.16:19000
VITE_WS_PROXY_TARGET=http://192.168.2.16:19000

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -1,168 +0,0 @@
import { chromium } from 'playwright'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const baseUrl = 'http://127.0.0.1:5173'
const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
const rootDir = process.cwd()
const runtimeDir = path.join(rootDir, '.tmp', 'manual-four-players', String(Date.now()))
const players = [
{ username: 'pwtest04', loginId: '13812345681', password: 'play123', owner: true },
{ username: 'pwtest01', loginId: '13812345678', password: 'play123', owner: false },
{ username: 'pwtest02', loginId: '13812345679', password: 'play123', owner: false },
{ username: 'pwtest03', loginId: '13812345680', password: 'play123', owner: false },
]
function log(message) {
console.log(`[setup] ${message}`)
}
async function launchPlayer(player) {
const profileDir = path.join(runtimeDir, player.username)
await fs.mkdir(profileDir, { recursive: true })
log(`launch browser for ${player.username}`)
const context = await chromium.launchPersistentContext(profileDir, {
headless: false,
executablePath: chromePath,
args: ['--no-first-run', '--no-default-browser-check'],
viewport: { width: 1400, height: 960 },
})
let page = context.pages()[0]
if (!page) {
page = await context.newPage()
}
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
return { ...player, context, page, profileDir }
}
async function login(page, player) {
log(`login ${player.username}`)
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
await page.getByRole('textbox', { name: '登录ID' }).fill(player.loginId)
await page.getByRole('textbox', { name: '密码' }).fill(player.password)
const submitButton = page.locator('form').getByRole('button', { name: '登录' })
await Promise.all([
page.waitForURL('**/hall', { timeout: 15000 }),
submitButton.click(),
])
await page.getByRole('heading', { name: '麻将游戏大厅' }).waitFor({ timeout: 15000 })
log(`logged in ${player.username}`)
}
async function createRoom(page) {
const roomName = `manual-${Date.now()}`
log(`create room ${roomName}`)
await page.getByRole('button', { name: '创建房间' }).click()
await page.getByRole('textbox', { name: '房间名' }).fill(roomName)
await page.getByRole('button', { name: '创建', exact: true }).click()
const roomText = await page.getByText(/房间ID/).textContent()
const roomId = roomText?.replace('房间ID', '').trim()
if (!roomId) {
throw new Error('Failed to read room id')
}
await Promise.all([
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
page.getByRole('button', { name: '进入房间' }).click(),
])
log(`owner entered room ${roomId}`)
return { roomId, roomName }
}
async function joinRoom(page, roomId, username) {
log(`join room ${roomId} as ${username}`)
await page.goto(`${baseUrl}/hall`, { waitUntil: 'domcontentloaded' })
await page.getByRole('textbox', { name: '输入 room_id' }).fill(roomId)
await Promise.all([
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
page.getByRole('button', { name: '加入' }).click(),
])
log(`joined room ${roomId} as ${username}`)
}
async function snapshotPage(session) {
const bodyText = ((await session.page.locator('body').textContent()) ?? '').replace(/\s+/g, ' ').trim()
return {
username: session.username,
loginId: session.loginId,
url: session.page.url(),
started: bodyText.includes('对局中') || bodyText.includes('牌局进行中'),
bodyPreview: bodyText.slice(0, 240),
}
}
async function main() {
await fs.mkdir(runtimeDir, { recursive: true })
log(`runtimeDir ${runtimeDir}`)
const sessions = []
for (const player of players) {
sessions.push(await launchPlayer(player))
}
try {
for (const session of sessions) {
await login(session.page, session)
}
const owner = sessions.find((session) => session.owner)
if (!owner) {
throw new Error('Owner session missing')
}
const { roomId } = await createRoom(owner.page)
log(`room ready ${roomId}`)
for (const session of sessions) {
if (!session.owner) {
await joinRoom(session.page, roomId, session.username)
}
}
await new Promise((resolve) => setTimeout(resolve, 8000))
const playersSnapshot = []
for (const session of sessions) {
await session.page.bringToFront()
await new Promise((resolve) => setTimeout(resolve, 2000))
let snapshot = await snapshotPage(session)
if (!snapshot.started) {
log(`focus retry for ${session.username}`)
await session.page.bringToFront()
await new Promise((resolve) => setTimeout(resolve, 3000))
snapshot = await snapshotPage(session)
}
playersSnapshot.push(snapshot)
}
console.log(JSON.stringify({
roomId,
roomUrl: `${baseUrl}/game/chengdu/${roomId}`,
players: playersSnapshot,
}, null, 2))
if (process.env.KEEP_ALIVE === '1') {
log('setup finished; keeping browsers alive for manual testing')
await new Promise(() => {})
}
for (const session of sessions) {
await session.context.close()
}
} catch (error) {
console.error(error)
if (process.env.KEEP_ALIVE !== '1') {
for (const session of sessions) {
try {
await session.context.close()
} catch {}
}
}
throw error
}
}
await main()

4
src/common/id.ts Normal file
View File

@@ -0,0 +1,4 @@
// 生成 requestId / traceId
export function createRequestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}

View File

@@ -1,2 +0,0 @@
export * from './suits'
export * from './ws-events'

View File

@@ -1,3 +0,0 @@
export const MAHJONG_SUITS = ['wan', 'tong', 'tiao'] as const
export type MahjongSuit = (typeof MAHJONG_SUITS)[number]

View File

@@ -1,6 +0,0 @@
export const WS_EVENT = {
roomSnapshot: 'room_snapshot',
roomState: 'room_state',
gameState: 'game_state',
myHand: 'my_hand',
} as const

View File

@@ -1,11 +1,4 @@
export type Suit = 'W' | 'T' | 'B'
export interface Tile {
id: number
suit: Suit
value: number
}
import type {Suit, Tile} from "../../types/tile.ts";
export class TileModel {
id: number

37
src/game/actions.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { ActionButtonState } from "./types.ts"
import { wsClient } from "@/ws/client" // 新增
export function sendGameAction(type: ActionButtonState['type']): void {
// 原来是判断 ws这里改成用 wsClient 的状态(简单处理)
if (!currentUserId.value) {
return
}
const requestId = createRequestId(type)
const payload: Record<string, unknown> = {}
if (type === 'discard' && selectedTile.value) {
payload.tile = selectedTile.value
payload.discard_tile = selectedTile.value
payload.code = selectedTile.value
}
actionPending.value = true
const message = {
type,
sender: currentUserId.value,
target: 'room',
roomId: roomState.value.id || roomId.value,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload,
}
logWsSend(message)
wsClient.send(message) // ✅ 改这里
pushWsMessage(`[client] 请求${type} requestId=${requestId}`)
}

View File

@@ -1,2 +0,0 @@
export { useChengduGameRoom } from './useChengduGameRoom'
export type { ActionButtonState, ChengduGameRoomModel, SeatKey, SeatView } from './types'

View File

@@ -1,71 +0,0 @@
export function humanizeSuit(value: string): string {
const suitMap: Record<string, string> = {
W: '万',
B: '筒',
T: '条',
}
return suitMap[value] ?? value
}
export function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
export function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
export function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
return false
}
export function normalizeActionName(value: unknown): string {
const raw = toStringOrEmpty(value).trim().toLowerCase()
if (!raw) {
return ''
}
const actionMap: Record<string, string> = {
candraw: 'draw',
draw: 'draw',
candiscard: 'discard',
discard: 'discard',
canpeng: 'peng',
peng: 'peng',
cangang: 'gang',
gang: 'gang',
canhu: 'hu',
hu: 'hu',
canpass: 'pass',
pass: 'pass',
}
return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw
}

View File

@@ -1,421 +0,0 @@
import {
DEFAULT_MAX_PLAYERS,
type GameState,
type RoomPlayerState,
type RoomState,
} from '../../store/active-room-store'
import { type PendingClaimOption } from './types'
import {
humanizeSuit,
normalizeActionName,
toBoolean,
toFiniteNumber,
toRecord,
toStringOrEmpty,
} from './parser-utils'
function normalizeScores(value: unknown): Record<string, number> {
const record = toRecord(value)
if (!record) {
return {}
}
const scores: Record<string, number> = {}
for (const [key, score] of Object.entries(record)) {
const parsed = toFiniteNumber(score)
if (parsed !== null) {
scores[key] = parsed
}
}
return scores
}
export function normalizeTileList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.flatMap((item) => {
if (Array.isArray(item)) {
return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean)
}
const record = toRecord(item)
if (record) {
const explicit =
toStringOrEmpty(
record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name,
) || ''
if (explicit) {
return [explicit]
}
const suit = toStringOrEmpty(record.suit ?? record.Suit)
const tileValue = toStringOrEmpty(record.value ?? record.Value)
if (suit && tileValue) {
return [`${humanizeSuit(suit)}${tileValue}`]
}
return []
}
return [toStringOrEmpty(item)].filter(Boolean)
})
.filter(Boolean)
}
function normalizeMeldGroups(value: unknown): string[][] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => {
if (Array.isArray(item)) {
return normalizeTileList(item)
}
const record = toRecord(item)
if (record) {
const nested = record.tiles ?? record.Tiles ?? record.meld ?? record.Meld
if (nested) {
return normalizeTileList(nested)
}
}
return normalizeTileList([item])
})
.filter((group) => group.length > 0)
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
const playerId = toStringOrEmpty(
player.playerId ??
player.player_id ??
player.PlayerID ??
player.UserID ??
player.user_id ??
player.id,
)
if (!playerId) {
return null
}
const seatIndex = toFiniteNumber(
player.index ??
player.Index ??
player.seat ??
player.Seat ??
player.position ??
player.Position ??
player.player_index,
)
const hand = normalizeTileList(player.hand ?? player.Hand)
return {
index: seatIndex ?? fallbackIndex,
playerId,
displayName:
toStringOrEmpty(
player.playerName ??
player.player_name ??
player.PlayerName ??
player.username ??
player.nickname,
) || undefined,
ready: Boolean(player.ready),
handCount:
toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ??
(hand.length > 0 ? hand.length : undefined),
hand,
melds: normalizeMeldGroups(player.melds ?? player.Melds),
outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles),
hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu),
missingSuit:
toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null,
}
}
function normalizePublicGameState(source: Record<string, unknown>): GameState | null {
const publicPlayers = (Array.isArray(source.players) ? source.players : [])
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const currentTurnPlayerId = toStringOrEmpty(
source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id,
)
const currentTurnIndex =
publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null
return {
rule: null,
state: {
phase: toStringOrEmpty(source.phase),
dealerIndex: 0,
currentTurn: currentTurnIndex ?? 0,
needDraw: toBoolean(source.need_draw ?? source.needDraw),
players: publicPlayers.map((player) => ({
playerId: player.playerId,
index: player.index,
ready: player.ready,
})),
wall: Array.from({
length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0,
}).map((_, index) => `wall-${index}`),
lastDiscardTile:
toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null,
lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy),
pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim),
winners: Array.isArray(source.winners)
? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(source.scores),
lastDrawPlayerId: '',
lastDrawFromGang: false,
lastDrawIsLastTile: false,
huWay: '',
},
}
}
export function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] {
const pendingClaim = toRecord(value)
if (!pendingClaim) {
return []
}
const rawOptions =
(Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ??
(Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ??
[]
const optionsFromArray = rawOptions
.map((option) => {
const record = toRecord(option)
if (!record) {
return null
}
const playerId = toStringOrEmpty(
record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID,
)
if (!playerId) {
return null
}
const actions = new Set<string>()
for (const [key, enabled] of Object.entries(record)) {
if (typeof enabled === 'boolean' && enabled) {
const normalized = normalizeActionName(key)
if (normalized && normalized !== 'playerid' && normalized !== 'userid') {
actions.add(normalized)
}
}
}
if (Array.isArray(record.actions)) {
for (const action of record.actions) {
const normalized = normalizeActionName(action)
if (normalized) {
actions.add(normalized)
}
}
}
return { playerId, actions: [...actions] }
})
.filter((item): item is PendingClaimOption => Boolean(item))
if (optionsFromArray.length > 0) {
return optionsFromArray
}
const claimPlayerId = toStringOrEmpty(
pendingClaim.playerId ??
pendingClaim.player_id ??
pendingClaim.PlayerID ??
pendingClaim.user_id ??
pendingClaim.UserID,
)
if (!claimPlayerId) {
return []
}
const actions = Object.entries(pendingClaim)
.filter(([, enabled]) => typeof enabled === 'boolean' && enabled)
.map(([key]) => normalizeActionName(key))
.filter(Boolean)
return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : []
}
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
const game = toRecord(value.game)
const gameState = toRecord(game?.state)
const keys = [
gameState?.currentTurn,
gameState?.current_turn,
gameState?.currentTurnIndex,
gameState?.current_turn_index,
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeGame(input: unknown): GameState | null {
const game = toRecord(input)
if (!game) {
return null
}
const rule = toRecord(game.rule)
const rawState = toRecord(game.state)
const playersRaw =
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
[]
const normalizedPlayers = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
return {
rule: rule
? {
name: toStringOrEmpty(rule.name),
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
}
: null,
state: rawState
? {
phase: toStringOrEmpty(rawState.phase),
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
players: normalizedPlayers,
wall: Array.isArray(rawState.wall)
? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
winners: Array.isArray(rawState.winners)
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(rawState.scores),
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
}
: null,
}
}
export function normalizeRoom(input: unknown, currentRoomState: RoomState): RoomState | null {
const source = toRecord(input)
if (!source) {
return null
}
let room = source
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
const nestedRoom = toRecord(room.data)
if (nestedRoom) {
room = nestedRoom
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
}
}
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? currentRoomState.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const playerIdsRaw =
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const playersFromIds = playerIdsRaw
.map((item, index) => ({
index,
playerId: toStringOrEmpty(item),
ready: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
}))
.filter((item) => Boolean(item.playerId))
const resolvedPlayers = players.length > 0 ? players : playersFromIds
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
const game = normalizeGame(room.game) ?? normalizePublicGameState(room)
const playersFromGame = game?.state?.players
.map((player, index) =>
normalizePlayer(
{
player_id: player.playerId,
index: player.index ?? index,
},
index,
),
)
.filter((item): item is RoomPlayerState => Boolean(item))
const finalPlayers =
resolvedPlayers.length > 0
? resolvedPlayers
: playersFromGame && playersFromGame.length > 0
? playersFromGame
: []
const derivedTurnIndex =
extractCurrentTurnIndex(room) ??
(game?.state
? finalPlayers.find(
(player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer),
)?.index ?? null
: null)
return {
id,
name: toStringOrEmpty(room.name) || currentRoomState.name,
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || currentRoomState.gameType || 'chengdu',
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID),
maxPlayers,
playerCount:
parsedPlayerCount ??
toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ??
finalPlayers.length,
status: toStringOrEmpty(room.status) || currentRoomState.status || 'waiting',
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || currentRoomState.createdAt,
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || currentRoomState.updatedAt,
game: game ?? currentRoomState.game,
players: finalPlayers,
currentTurnIndex: derivedTurnIndex,
myHand: [],
}
}

View File

@@ -1,60 +0,0 @@
import type { ComputedRef, Ref } from 'vue'
import type { StoredAuth } from '../../types/session'
import type { RoomPlayerState } from '../../store/active-room-store'
import { activeRoomState } from '../../store/active-room-store'
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
export interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
request_id?: unknown
roomId?: unknown
room_id?: unknown
payload?: unknown
data?: unknown
}
export interface PendingClaimOption {
playerId: string
actions: string[]
}
export interface ActionButtonState {
type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
label: string
disabled: boolean
}
export interface SeatView {
key: SeatKey
player: RoomPlayerState | null
isSelf: boolean
isTurn: boolean
label: string
subLabel: string
}
export interface ChengduGameRoomModel {
auth: Ref<StoredAuth | null>
roomState: typeof activeRoomState
roomId: ComputedRef<string>
roomName: ComputedRef<string>
currentUserId: ComputedRef<string>
loggedInUserName: ComputedRef<string>
wsStatus: Ref<'disconnected' | 'connecting' | 'connected'>
wsError: Ref<string>
wsMessages: Ref<string[]>
startGamePending: Ref<boolean>
leaveRoomPending: Ref<boolean>
canStartGame: ComputedRef<boolean>
seatViews: ComputedRef<SeatView[]>
selectedTile: Ref<string | null>
actionButtons: ComputedRef<ActionButtonState[]>
connectWs: () => Promise<void>
sendStartGame: () => void
selectTile: (tile: string) => void
sendGameAction: (type: ActionButtonState['type']) => void
backHall: () => void
}

View File

@@ -1 +0,0 @@
export * from './chengdu'

5
src/game/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export type ActionButtonState = {
type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
label: string
disabled: boolean
}

View File

@@ -1,8 +1,8 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type { AuthSession } from '../../api/authed-request'
import { refreshAccessToken } from '../../api/auth'
import { getUserInfo } from '../../api/user'
import type { AuthSession } from '../api/authed-request.ts'
import { refreshAccessToken } from '../api/auth.ts'
import { getUserInfo } from '../api/user.ts'
import {
activeRoomState,
destroyActiveRoomState,
@@ -11,7 +11,7 @@ import {
type RoomPlayerState,
type RoomState,
} from '../../store/active-room-store'
import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage'
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage.ts'
import type {
ActionButtonState,
ActionEventLike,

View File

@@ -1,2 +0,0 @@
export * from './tile'
export * from './room-state'

View File

@@ -1 +0,0 @@
export const DEFAULT_MAX_PLAYERS = 4

View File

@@ -1,19 +0,0 @@
import type { GamePlayerState } from './game-player-state'
export interface EngineState {
phase: string
dealerIndex: number
currentTurn: number
needDraw: boolean
players: GamePlayerState[]
wall: string[]
lastDiscardTile: string | null
lastDiscardBy: string
pendingClaim: Record<string, unknown> | null
winners: string[]
scores: Record<string, number>
lastDrawPlayerId: string
lastDrawFromGang: boolean
lastDrawIsLastTile: boolean
huWay: string
}

View File

@@ -1,5 +0,0 @@
export interface GamePlayerState {
playerId: string
index: number
ready: boolean
}

View File

@@ -1,7 +0,0 @@
import type { EngineState } from './engine-state'
import type { RuleState } from './rule-state'
export interface GameState {
rule: RuleState | null
state: EngineState | null
}

View File

@@ -1,9 +0,0 @@
export { DEFAULT_MAX_PLAYERS } from './constants'
export type { RoomStatus } from './room-status'
export type { PlayerState } from './player-state'
export type { RoomPlayerState } from './room-player-state'
export type { RuleState } from './rule-state'
export type { GamePlayerState } from './game-player-state'
export type { EngineState } from './engine-state'
export type { GameState } from './game-state'
export type { RoomState } from './room-state'

View File

@@ -1,7 +0,0 @@
export interface PlayerState {
playerId: string
hand: string[]
melds: string[][]
outTiles: string[]
hasHu: boolean
}

View File

@@ -1,9 +0,0 @@
import type { PlayerState } from './player-state'
export interface RoomPlayerState extends PlayerState {
index: number
displayName?: string
ready: boolean
handCount?: number
missingSuit?: string | null
}

View File

@@ -1,19 +0,0 @@
import type { GameState } from './game-state'
import type { RoomPlayerState } from './room-player-state'
import type { RoomStatus } from './room-status'
export interface RoomState {
id: string
name: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: RoomStatus | string
createdAt: string
updatedAt: string
game: GameState | null
players: RoomPlayerState[]
currentTurnIndex: number | null
myHand: string[]
}

View File

@@ -1 +0,0 @@
export type RoomStatus = 'waiting' | 'playing' | 'finished'

View File

@@ -1,5 +0,0 @@
export interface RuleState {
name: string
isBloodFlow: boolean
hasHongZhong: boolean
}

View File

@@ -0,0 +1,15 @@
// 游戏对象
import type {GameState} from "../../types/state";
export interface Game {
rule: Rule
state: GameState
}
// 规则配置(仅描述,不包含判定逻辑)
export interface Rule {
name: string // 玩法名称
isBloodFlow: boolean // 是否血流玩法
hasHongZhong: boolean // 是否包含红中
}

View File

@@ -1,108 +0,0 @@
import { ref } from 'vue'
import {
DEFAULT_MAX_PLAYERS,
type RoomPlayerState,
type RoomState,
} from '../models/room-state'
export {
DEFAULT_MAX_PLAYERS,
type EngineState,
type GamePlayerState,
type GameState,
type PlayerState,
type RoomPlayerState,
type RoomState,
type RoomStatus,
type RuleState,
} from '../models/room-state'
function createInitialRoomState(): RoomState {
return {
id: '',
name: '',
gameType: 'chengdu',
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
playerCount: 0,
status: 'waiting',
createdAt: '',
updatedAt: '',
game: null,
players: [],
currentTurnIndex: null,
myHand: [],
}
}
export const activeRoomState = ref<RoomState>(createInitialRoomState())
export function destroyActiveRoomState(): void {
activeRoomState.value = createInitialRoomState()
}
export function resetActiveRoomState(seed?: Partial<RoomState>): void {
destroyActiveRoomState()
if (!seed) {
return
}
activeRoomState.value = {
...activeRoomState.value,
...seed,
players: seed.players ?? [],
myHand: seed.myHand ?? [],
}
}
export function mergeActiveRoomState(next: RoomState): void {
if (activeRoomState.value.id && next.id && next.id !== activeRoomState.value.id) {
return
}
activeRoomState.value = {
...activeRoomState.value,
...next,
name: next.name || activeRoomState.value.name,
gameType: next.gameType || activeRoomState.value.gameType,
ownerId: next.ownerId || activeRoomState.value.ownerId,
status: next.status || activeRoomState.value.status,
createdAt: next.createdAt || activeRoomState.value.createdAt,
updatedAt: next.updatedAt || activeRoomState.value.updatedAt,
game: next.game ?? activeRoomState.value.game,
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
currentTurnIndex:
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand,
}
}
export function hydrateActiveRoomFromSelection(input: {
roomId: string
roomName?: string
gameType?: string
ownerId?: string
maxPlayers?: number
playerCount?: number
status?: string
createdAt?: string
updatedAt?: string
players?: RoomPlayerState[]
currentTurnIndex?: number | null
myHand?: string[]
}): void {
resetActiveRoomState({
id: input.roomId,
name: input.roomName ?? '',
gameType: input.gameType ?? 'chengdu',
ownerId: input.ownerId ?? '',
maxPlayers: input.maxPlayers ?? DEFAULT_MAX_PLAYERS,
playerCount: input.playerCount ?? 0,
status: input.status ?? 'waiting',
createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
currentTurnIndex: input.currentTurnIndex ?? null,
myHand: input.myHand ?? [],
})
}

View File

@@ -0,0 +1,9 @@
export const CLAIM_OPTIONS = {
PENG: 'peng',
GANG: 'gang',
HU: 'hu',
PASS: 'pass',
} as const
export type ClaimOption =
typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS]

View File

@@ -0,0 +1,9 @@
export const GAME_PHASE = {
WAITING: 'waiting',
DEALING: 'dealing',
PLAYING: 'playing',
SETTLEMENT: 'settlement',
} as const
export type GamePhase =
typeof GAME_PHASE[keyof typeof GAME_PHASE]

View File

@@ -0,0 +1,52 @@
import type {Player} from "./player.ts";
import type {Tile} from "../../models";
import type {PendingClaim} from "./pendingClaim.ts";
import type {HuWay} from "./huWay.ts";
import type {GamePhase} from "./gamePhase.ts";
export interface GameState {
// 当前阶段
phase: GamePhase
// 庄家位置
dealerIndex: number
// 当前操作玩家(座位)
currentTurn: number
// 是否必须先摸牌
needDraw: boolean
// 玩家列表
players: Player[]
// ⚠️ 建议:只保留剩余数量(不要完整牌墙)
remainingTiles: number
// 最近弃牌
lastDiscardTile?: Tile
// 最近弃牌玩家
lastDiscardBy?: string
// 操作响应窗口(碰/杠/胡)
pendingClaim?: PendingClaim
// 胡牌玩家
winners: string[]
// 分数playerId -> score
scores: Record<string, number>
// 最近摸牌玩家
lastDrawPlayerId: string
// 是否杠后补牌
lastDrawFromGang: boolean
// 是否最后一张牌
lastDrawIsLastTile: boolean
// 胡牌方式
huWay: HuWay
}

12
src/types/state/huWay.ts Normal file
View File

@@ -0,0 +1,12 @@
// 胡牌类型常量(用于避免直接写字符串)
export const HU_WAY = {
PING_HU: 'pinghu', // 平胡
QI_DUI: 'qidui', // 七对
PENG_PENG_HU: 'pengpenghu', // 碰碰胡
QING_YI_SE: 'qingyise', // 清一色
UNKNOWN: 'unknown', // 未知 / 未判定
} as const
// 胡牌类型(从常量中推导)
export type HuWay =
typeof HU_WAY[keyof typeof HU_WAY]

7
src/types/state/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './gamestate'
export * from './player'
export * from './meld'
export * from './pendingClaim'
export * from './claimOption'
export * from './gamePhase'
export * from './huWay'

17
src/types/state/meld.ts Normal file
View File

@@ -0,0 +1,17 @@
import type {Tile} from "../../models";
export type Meld =
| {
type: 'peng'
tiles: Tile[]
fromPlayerId: string
}
| {
type: 'ming_gang'
tiles: Tile[]
fromPlayerId: string
}
| {
type: 'an_gang'
tiles: Tile[]
}

View File

@@ -0,0 +1,13 @@
import type {Tile} from "../../models";
import type {ClaimOption} from "./claimOption.ts";
export interface PendingClaim {
// 当前被响应的牌
tile: Tile
// 出牌人
fromPlayerId: string
// 当前玩家可执行操作
options: ClaimOption[]
}

22
src/types/state/player.ts Normal file
View File

@@ -0,0 +1,22 @@
import type {Tile} from "../../models";
import type {Meld} from "./meld.ts";
export interface Player{
playerId: string
seatIndex: number
// 手牌(只有自己有完整数据,后端可控制)
handTiles: Tile[]
// 副露(碰/杠)
melds: Meld[]
// 出牌区
discardTiles: Tile[]
// 分数
score: number
// 是否准备
isReady: boolean
}

9
src/types/tile.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Suit = 'W' | 'T' | 'B'
export interface Tile {
id: number
suit: Suit
value: number
}

View File

@@ -17,7 +17,6 @@ import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import {type SeatKey, useChengduGameRoom} from '../game/chengdu'
const route = useRoute()
const router = useRouter()

136
src/ws/client.ts Normal file
View File

@@ -0,0 +1,136 @@
type WsStatus = 'idle' | 'connecting' | 'connected' | 'closed' | 'error'
type MessageHandler = (data: any) => void
type StatusHandler = (status: WsStatus) => void
type ErrorHandler = (err: string) => void
class WsClient {
private ws: WebSocket | null = null
private url: string = ''
private token: string = '' // 保存token
private status: WsStatus = 'idle'
private messageHandlers: MessageHandler[] = []
private statusHandlers: StatusHandler[] = []
private errorHandlers: ErrorHandler[] = []
private reconnectTimer: number | null = null
private reconnectDelay = 2000
// 构造带token的url
private buildUrl(): string {
if (!this.token) return this.url
const hasQuery = this.url.includes('?')
const connector = hasQuery ? '&' : '?'
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
}
// 连接
connect(url: string, token?: string) {
if (this.ws && this.status === 'connected') {
return
}
this.url = url
if (token !== undefined) {
this.token = token // 保存token用于重连
}
this.setStatus('connecting')
const finalUrl = this.buildUrl() // 使用带token的url
this.ws = new WebSocket(finalUrl)
this.ws.onopen = () => {
this.setStatus('connected')
}
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.messageHandlers.forEach(fn => fn(data))
} catch (e) {
this.messageHandlers.forEach(fn => fn(event.data))
}
}
this.ws.onerror = () => {
this.setStatus('error')
this.emitError('WebSocket error')
}
this.ws.onclose = () => {
this.setStatus('closed')
this.tryReconnect()
}
}
// 发送
send(data: any) {
if (this.ws && this.status === 'connected') {
this.ws.send(JSON.stringify(data))
}
}
// 关闭
close() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.clearReconnect()
}
// 订阅
onMessage(handler: MessageHandler) {
this.messageHandlers.push(handler)
}
onStatusChange(handler: StatusHandler) {
this.statusHandlers.push(handler)
}
onError(handler: ErrorHandler) {
this.errorHandlers.push(handler)
}
// 内部方法
private setStatus(status: WsStatus) {
this.status = status
this.statusHandlers.forEach(fn => fn(status))
}
private emitError(msg: string) {
this.errorHandlers.forEach(fn => fn(msg))
}
private tryReconnect() {
this.clearReconnect()
this.reconnectTimer = window.setTimeout(() => {
if (this.url) {
this.connect(this.url) // 重连自动带token
}
}, this.reconnectDelay)
}
private clearReconnect() {
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
// 单例导出
export const wsClient = new WsClient()

25
src/ws/handler.ts Normal file
View File

@@ -0,0 +1,25 @@
import {wsClient} from './client'
type Handler = (msg: any) => void
const handlerMap: Record<string, Handler> = {}
// 注册 handler
export function registerHandler(type: string, handler: Handler) {
handlerMap[type] = handler
}
// 初始化监听
export function initWsHandler() {
wsClient.onMessage((msg) => {
const handler = handlerMap[msg.type]
if (handler) {
handler(msg)
} else {
console.warn('[WS] 未处理消息:', msg.type, msg)
}
})
}

31
src/ws/message.ts Normal file
View File

@@ -0,0 +1,31 @@
// 通用消息结构
/**
* 客户端 → 服务端(请求 / 操作)
*/
export interface ActionMessage<T = any> {
type: string
sender?: string
target?: string
roomId?: string
seq?: number
status?: string
requestId?: string
trace_id?: string
payload?: T
}
/**
* 服务端 → 客户端(事件 / 推送)
*/
export interface ActionEvent<T = any> {
type: string
target?: string
roomId?: string
seq?: number
status?: string
requestId?: string
trace_id?: string
payload?: T
}

13
src/ws/url.ts Normal file
View File

@@ -0,0 +1,13 @@
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
export function buildWsUrl(token: string): string {
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL)
: new URL(
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
)
baseUrl.searchParams.set('token', token)
return baseUrl.toString()
}

View File

@@ -1,53 +0,0 @@
import { expect, test } from '@playwright/test'
function uniqueUser() {
const stamp = Date.now().toString()
return {
username: `pw${stamp.slice(-8)}`,
phone: `13${stamp.slice(-9)}`,
email: `pw${stamp}@example.com`,
password: 'playwright123',
roomName: `pw-room-${stamp.slice(-6)}`,
}
}
test('register, login, create room, enter game, and back to hall', async ({ page }) => {
const user = uniqueUser()
await page.goto('/register')
const registerInputs = page.locator('.auth-card .form input')
await registerInputs.nth(0).fill(user.username)
await registerInputs.nth(1).fill(user.phone)
await registerInputs.nth(2).fill(user.email)
await registerInputs.nth(3).fill(user.password)
await registerInputs.nth(4).fill(user.password)
await page.locator('.auth-card .primary-btn[type="submit"]').click()
await page.waitForURL(/\/login/)
const loginInputs = page.locator('.auth-card .form input')
await expect(loginInputs.nth(0)).toHaveValue(user.phone)
await loginInputs.nth(1).fill(user.password)
await page.locator('.auth-card .primary-btn[type="submit"]').click()
await page.waitForURL(/\/hall/)
await expect(page.locator('.hall-page')).toBeVisible()
await page.locator('.room-actions-footer .primary-btn').click()
await expect(page.locator('.modal-card')).toBeVisible()
await page.locator('.modal-card .field input').first().fill(user.roomName)
await page.locator('.modal-card .modal-actions .primary-btn').click()
await expect(page.locator('.copy-line')).toHaveCount(2)
await page.locator('.modal-card .modal-actions .primary-btn').click()
await page.waitForURL(/\/game\/chengdu\//)
await expect(page.locator('.game-page')).toBeVisible()
await expect(page.locator('.table-felt')).toBeVisible()
await page.locator('.topbar-back-btn').click()
await page.waitForURL(/\/hall/)
await expect(page.locator('.hall-page')).toBeVisible()
})