diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index ba9059d..901634f 100644 --- a/src/api/mahjong.ts +++ b/src/api/mahjong.ts @@ -7,6 +7,11 @@ export interface RoomItem { owner_id: string max_players: number player_count: number + players?: Array<{ + index: number + player_id: string + ready: boolean + }> status: string created_at: string updated_at: string @@ -58,8 +63,8 @@ export async function joinRoom( auth: AuthSession, input: { roomId: string }, onAuthUpdated?: (next: AuthSession) => void, -): Promise { - await authedRequest | RoomItem>({ +): Promise { + return authedRequest({ method: 'POST', path: ROOM_JOIN_PATH, auth, diff --git a/src/state/active-room.ts b/src/state/active-room.ts new file mode 100644 index 0000000..67706e0 --- /dev/null +++ b/src/state/active-room.ts @@ -0,0 +1,139 @@ +import { ref } from 'vue' + +export const DEFAULT_MAX_PLAYERS = 4 +export type RoomStatus = 'waiting' | 'playing' | 'finished' + +export interface RoomPlayerState { + index: number + playerId: string + ready: boolean +} + +export interface RuleState { + name: string + isBloodFlow: boolean + hasHongZhong: boolean +} + +export interface GamePlayerState { + playerId: string + index: number + ready: boolean +} + +export interface EngineState { + phase: string + dealerIndex: number + currentTurn: number + needDraw: boolean + players: GamePlayerState[] + wall: string[] + lastDiscardTile: string | null + lastDiscardBy: string + pendingClaim: Record | null + winners: string[] + scores: Record + lastDrawPlayerId: string + lastDrawFromGang: boolean + lastDrawIsLastTile: boolean + huWay: string +} + +export interface GameState { + rule: RuleState | null + state: EngineState | null +} + +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 +} + +function createInitialRoomState(): RoomState { + return { + id: '', + name: '', + gameType: 'chengdu', + ownerId: '', + maxPlayers: DEFAULT_MAX_PLAYERS, + playerCount: 0, + status: 'waiting', + createdAt: '', + updatedAt: '', + game: null, + players: [], + currentTurnIndex: null, + } +} + +export const activeRoomState = ref(createInitialRoomState()) + +export function destroyActiveRoomState(): void { + activeRoomState.value = createInitialRoomState() +} + +export function resetActiveRoomState(seed?: Partial): void { + destroyActiveRoomState() + if (!seed) { + return + } + + activeRoomState.value = { + ...activeRoomState.value, + ...seed, + players: seed.players ?? [], + } +} + +export function mergeActiveRoomState(next: RoomState): void { + if (activeRoomState.value.id && next.id && next.id !== activeRoomState.value.id) { + return + } + + activeRoomState.value = { + ...activeRoomState.value, + ...next, + 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, + } +} + +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 +}): 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, + }) +} diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 6c31645..268ed75 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -3,6 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import deskImage from '../assets/images/desk/desk_01.png' import { readStoredAuth } from '../utils/auth-storage' +import type { AuthSession } from '../api/authed-request' +import { getUserInfo } from '../api/user' +import { + DEFAULT_MAX_PLAYERS, + activeRoomState, + destroyActiveRoomState, + mergeActiveRoomState, + resetActiveRoomState, + type GameState, + type RoomPlayerState, + type RoomState, +} from '../state/active-room' +import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage' const router = useRouter() const route = useRoute() @@ -20,35 +33,24 @@ const chosenMissingSuit = ref<'wan' | 'tiao' | 'tong'>('tiao') const overlayState = ref<'missing' | 'action' | 'settlement' | null>('missing') let clockTimer: number | undefined +const leaveRoomPending = ref(false) +const lastLeaveRoomRequestId = ref('') +const leaveHallAfterAck = ref(false) const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws' -const DEFAULT_MAX_PLAYERS = 4 type SeatKey = 'top' | 'right' | 'bottom' | 'left' type TileSuit = 'wan' | 'tong' | 'tiao' | 'honor' type MissingSuit = 'wan' | 'tiao' | 'tong' type TileDirection = 'bottom' | 'top' | 'left' | 'right' -interface RoomPlayerState { - index: number - playerId: string - ready: boolean -} - -interface RoomState { - id: string - name: string - ownerId: string - maxPlayers: number - status: string - players: RoomPlayerState[] - currentTurnIndex: number | null -} - interface ActionEventLike { type?: unknown status?: unknown requestId?: unknown + request_id?: unknown + roomId?: unknown + room_id?: unknown payload?: unknown data?: unknown } @@ -98,7 +100,8 @@ const roomName = computed(() => { }) const currentUserId = computed(() => { - const candidate = auth.value?.user?.id + const user = auth.value?.user as Record | undefined + const candidate = user?.id ?? user?.userID ?? user?.user_id if (typeof candidate === 'string') { return candidate } @@ -116,20 +119,12 @@ const loggedInUserName = computed(() => { return auth.value.user.nickname ?? auth.value.user.username ?? '' }) -const roomState = ref({ - id: roomId.value, - name: roomName.value, - ownerId: '', - maxPlayers: DEFAULT_MAX_PLAYERS, - status: 'waiting', - players: [], - currentTurnIndex: null, -}) +const roomState = activeRoomState const isRoomFull = computed(() => { return ( roomState.value.maxPlayers > 0 && - roomState.value.players.length === roomState.value.maxPlayers + roomState.value.playerCount === roomState.value.maxPlayers ) }) @@ -152,6 +147,16 @@ const seatViews = computed(() => { } const players = [...roomState.value.players].sort((a, b) => a.index - b.index) + const hasSelf = players.some((player) => player.playerId === currentUserId.value) + if (currentUserId.value && roomState.value.id && !hasSelf) { + // Fallback before WS full player list arrives: keep current player at bottom. + players.unshift({ + index: 0, + playerId: currentUserId.value, + ready: false, + }) + } + const me = players.find((player) => player.playerId === currentUserId.value) ?? null const anchorIndex = me?.index ?? players[0]?.index ?? 0 const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right'] @@ -533,8 +538,16 @@ function missingSuitLabel(suit: MissingSuit): string { } function backHall(): void { - sendLeaveRoom() - void router.push('/hall') + if (leaveRoomPending.value) { + return + } + + leaveHallAfterAck.value = true + const sent = sendLeaveRoom() + if (!sent) { + leaveHallAfterAck.value = false + pushWsMessage('[client] 退出房间失败:未发送请求') + } } function pushWsMessage(text: string): void { @@ -580,6 +593,56 @@ function toStringOrEmpty(value: unknown): string { return '' } +function toSession(source: NonNullable): AuthSession { + return { + token: source.token, + tokenType: source.tokenType, + refreshToken: source.refreshToken, + expiresIn: source.expiresIn, + } +} + +function syncAuth(next: AuthSession): void { + if (!auth.value) { + return + } + + auth.value = { + ...auth.value, + token: next.token, + tokenType: next.tokenType ?? auth.value.tokenType, + refreshToken: next.refreshToken ?? auth.value.refreshToken, + expiresIn: next.expiresIn, + } + writeStoredAuth(auth.value) +} + +async function ensureCurrentUserId(): Promise { + if (currentUserId.value || !auth.value) { + return + } + + try { + const userInfo = await getUserInfo(toSession(auth.value), syncAuth) + const payload = userInfo as Record + const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id) + if (!resolvedId) { + return + } + + auth.value = { + ...auth.value, + user: { + ...(auth.value.user ?? {}), + id: resolvedId, + }, + } + writeStoredAuth(auth.value) + } catch { + wsError.value = '获取当前用户ID失败,部分操作可能不可用' + } +} + function toFiniteNumber(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value @@ -591,6 +654,36 @@ function toFiniteNumber(value: unknown): number | null { return null } +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 +} + +function normalizeScores(value: unknown): Record { + const record = toRecord(value) + if (!record) { + return {} + } + + const scores: Record = {} + for (const [key, score] of Object.entries(record)) { + const parsed = toFiniteNumber(score) + if (parsed !== null) { + scores[key] = parsed + } + } + return scores +} + function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null { const player = toRecord(input) if (!player) { @@ -611,7 +704,13 @@ function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState } function extractCurrentTurnIndex(value: Record): 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, @@ -630,13 +729,70 @@ function extractCurrentTurnIndex(value: Record): number | null return null } -function normalizeRoom(input: unknown): RoomState | null { - const room = toRecord(input) - if (!room) { +function normalizeGame(input: unknown): GameState | null { + const game = toRecord(input) + if (!game) { return null } - const id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id) + 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, + } +} + +function normalizeRoom(input: unknown): 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 } @@ -648,19 +804,38 @@ function normalizeRoom(input: unknown): RoomState | 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, + })) + .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) return { id, name: toStringOrEmpty(room.name) || roomState.value.name, + gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu', ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id), maxPlayers, + playerCount: parsedPlayerCount ?? resolvedPlayers.length, status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting', - players, + createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt, + updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt, + game: game ?? roomState.value.game, + players: resolvedPlayers, currentTurnIndex: extractCurrentTurnIndex(room), } } @@ -669,14 +844,7 @@ function mergeRoomState(next: RoomState): void { if (roomId.value && next.id !== roomId.value) { return } - - roomState.value = { - ...roomState.value, - ...next, - players: next.players.length > 0 ? next.players : roomState.value.players, - currentTurnIndex: - next.currentTurnIndex !== null ? next.currentTurnIndex : roomState.value.currentTurnIndex, - } + mergeActiveRoomState(next) } function consumeGameEvent(raw: string): void { @@ -692,12 +860,58 @@ function consumeGameEvent(raw: string): void { return } - const candidates: unknown[] = [event, event.payload, event.data] const payload = toRecord(event.payload) - if (payload) { - candidates.push(payload.room, payload.state, payload.roomState) + const data = toRecord(event.data) + const eventType = toStringOrEmpty(event.type) + const eventStatus = toStringOrEmpty(event.status) + const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id) + const eventRequestId = toStringOrEmpty( + event.requestId ?? event.request_id ?? payload?.requestId ?? payload?.request_id ?? data?.requestId ?? data?.request_id, + ) + const payloadPlayerIds = Array.isArray(payload?.player_ids) + ? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean) + : Array.isArray(payload?.playerIds) + ? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean) + : null + const leaveByRequestIdMatched = Boolean(eventRequestId && eventRequestId === lastLeaveRoomRequestId.value) + const leaveByPlayerUpdateMatched = + leaveRoomPending.value && + eventType === 'room_player_update' && + eventStatus === 'ok' && + eventRoomId === (roomState.value.id || roomId.value) && + Array.isArray(payloadPlayerIds) && + Boolean(currentUserId.value) && + !payloadPlayerIds.includes(currentUserId.value) + + if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) { + leaveRoomPending.value = false + lastLeaveRoomRequestId.value = '' + if (event.status === 'error') { + leaveHallAfterAck.value = false + wsError.value = '退出房间失败,请稍后重试' + pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`) + } else { + if (leaveByPlayerUpdateMatched) { + pushWsMessage('[client] 已确认退出房间 player_update') + } else { + pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`) + } + if (leaveHallAfterAck.value) { + leaveHallAfterAck.value = false + void router.push('/hall') + } + } } + const candidates: unknown[] = [event.payload, event.data] + if (payload) { + candidates.push(payload.room, payload.state, payload.roomState, payload.data) + } + if (data) { + candidates.push(data.room, data.state, data.roomState, data.data) + } + candidates.push(event) + for (const candidate of candidates) { const normalized = normalizeRoom(candidate) if (normalized) { @@ -725,6 +939,7 @@ function sendStartGame(): void { } const sender = currentUserId.value + if (!sender) { return } @@ -749,18 +964,26 @@ function sendStartGame(): void { pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`) } -function sendLeaveRoom(): void { +function sendLeaveRoom(): boolean { if (!ws.value || ws.value.readyState !== WebSocket.OPEN) { - return + wsError.value = 'WebSocket 未连接,无法退出房间' + return false } const sender = currentUserId.value const targetRoomId = roomState.value.id || roomId.value - if (!sender || !targetRoomId) { - return + if (!sender) { + wsError.value = '缺少当前用户ID,无法退出房间' + return false + } + if (!targetRoomId) { + wsError.value = '缺少房间ID,无法退出房间' + return false } const requestId = createRequestId('leave-room') + leaveRoomPending.value = true + lastLeaveRoomRequestId.value = requestId const message = { type: 'leave_room', sender, @@ -775,6 +998,8 @@ function sendLeaveRoom(): void { logWsSend(message) ws.value.send(JSON.stringify(message)) pushWsMessage(`[client] 请求离开房间 requestId=${requestId}`) + pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`) + return true } function connectWs(): void { @@ -823,6 +1048,13 @@ function connectWs(): void { socket.onclose = () => { wsStatus.value = 'disconnected' startGamePending.value = false + if (leaveRoomPending.value) { + leaveRoomPending.value = false + lastLeaveRoomRequestId.value = '' + leaveHallAfterAck.value = false + wsError.value = '连接已断开,未收到退出房间确认' + pushWsMessage('[client] 连接断开:退出房间请求未确认') + } pushWsMessage('WebSocket 已断开') } } @@ -850,27 +1082,29 @@ function startNextRound(): void { watch( roomId, (nextRoomId) => { - roomState.value = { - id: nextRoomId, - name: roomName.value, - ownerId: '', - maxPlayers: DEFAULT_MAX_PLAYERS, - status: 'waiting', - players: [], - currentTurnIndex: null, + const currentRoom = roomState.value + if (!nextRoomId) { + destroyActiveRoomState() + } else if (currentRoom.id !== nextRoomId) { + resetActiveRoomState({ + id: nextRoomId, + name: roomName.value, + }) + } else if (!currentRoom.name && roomName.value) { + roomState.value = { ...currentRoom, name: roomName.value } } overlayState.value = 'missing' startGamePending.value = false lastStartRequestId.value = '' + leaveRoomPending.value = false + lastLeaveRoomRequestId.value = '' + leaveHallAfterAck.value = false }, { immediate: true }, ) watch(roomName, (next) => { - roomState.value = { - ...roomState.value, - name: next || roomState.value.name, - } + roomState.value = { ...roomState.value, name: next || roomState.value.name } }) watch( @@ -908,7 +1142,8 @@ watch( { immediate: true }, ) -onMounted(() => { +onMounted(async () => { + await ensureCurrentUserId() connectWs() clockTimer = window.setInterval(() => { now.value = new Date() @@ -917,6 +1152,7 @@ onMounted(() => { onBeforeUnmount(() => { disconnectWs() + destroyActiveRoomState() if (clockTimer !== undefined) { window.clearInterval(clockTimer) } @@ -937,6 +1173,10 @@ onBeforeUnmount(() => { {{ roomState.name || roomName || '成都麻将' }} +
+
@@ -952,6 +1192,25 @@ onBeforeUnmount(() => {
{{ formattedClock }}
+
+
+ 当前房间 + + 房间名: + {{ roomState.name || roomName || '未命名房间' }} + + + room_id: + {{ roomId || '未选择房间' }} + + + 状态: + {{ roomStatusText }} + + + 人数: + {{ roomState.playerCount }}/{{ roomState.maxPlayers }} +