refactor(game): 重构游戏模块并添加WebSocket客户端
- 修改开发环境配置中的代理目标地址 - 移除旧的活动房间状态管理模块 - 移除成都麻将游戏相关的测试用例 - 添加新的游戏动作发送功能 - 实现WebSocket客户端类并支持自动重连 - 添加WebSocket消息处理器注册机制 - 创建游戏相关状态类型定义 - 添加ID生成工具函数 - 移除废弃的游戏相关模块和常量定义 - 添加WebSocket消息结构定义 - 重构游戏状态相关类型定义
This commit is contained in:
@@ -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 |
@@ -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
4
src/common/id.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 生成 requestId / traceId
|
||||
export function createRequestId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './suits'
|
||||
export * from './ws-events'
|
||||
@@ -1,3 +0,0 @@
|
||||
export const MAHJONG_SUITS = ['wan', 'tong', 'tiao'] as const
|
||||
|
||||
export type MahjongSuit = (typeof MAHJONG_SUITS)[number]
|
||||
@@ -1,6 +0,0 @@
|
||||
export const WS_EVENT = {
|
||||
roomSnapshot: 'room_snapshot',
|
||||
roomState: 'room_state',
|
||||
gameState: 'game_state',
|
||||
myHand: 'my_hand',
|
||||
} as const
|
||||
@@ -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
37
src/game/actions.ts
Normal 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}`)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useChengduGameRoom } from './useChengduGameRoom'
|
||||
export type { ActionButtonState, ChengduGameRoomModel, SeatKey, SeatView } from './types'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './chengdu'
|
||||
5
src/game/types.ts
Normal file
5
src/game/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type ActionButtonState = {
|
||||
type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
|
||||
label: string
|
||||
disabled: boolean
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './tile'
|
||||
export * from './room-state'
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_MAX_PLAYERS = 4
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface GamePlayerState {
|
||||
playerId: string
|
||||
index: number
|
||||
ready: boolean
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface PlayerState {
|
||||
playerId: string
|
||||
hand: string[]
|
||||
melds: string[][]
|
||||
outTiles: string[]
|
||||
hasHu: boolean
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface RuleState {
|
||||
name: string
|
||||
isBloodFlow: boolean
|
||||
hasHongZhong: boolean
|
||||
}
|
||||
15
src/modules/game/engine.ts
Normal file
15
src/modules/game/engine.ts
Normal 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 // 是否包含红中
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
})
|
||||
}
|
||||
9
src/types/state/claimOption.ts
Normal file
9
src/types/state/claimOption.ts
Normal 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]
|
||||
9
src/types/state/gamePhase.ts
Normal file
9
src/types/state/gamePhase.ts
Normal 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]
|
||||
52
src/types/state/gamestate.ts
Normal file
52
src/types/state/gamestate.ts
Normal 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
12
src/types/state/huWay.ts
Normal 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
7
src/types/state/index.ts
Normal 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
17
src/types/state/meld.ts
Normal 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[]
|
||||
}
|
||||
13
src/types/state/pendingClaim.ts
Normal file
13
src/types/state/pendingClaim.ts
Normal 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
22
src/types/state/player.ts
Normal 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
9
src/types/tile.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Suit = 'W' | 'T' | 'B'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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
136
src/ws/client.ts
Normal 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
25
src/ws/handler.ts
Normal 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
31
src/ws/message.ts
Normal 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
13
src/ws/url.ts
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user