diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index e68255e..1055192 100644 --- a/src/api/mahjong.ts +++ b/src/api/mahjong.ts @@ -1,79 +1,79 @@ -import { authedRequest, type AuthSession } from './authed-request' +import {authedRequest, type AuthSession} from './authed-request' -export interface RoomItem { - room_id: string - name: string - game_type: string - owner_id: string - max_players: number - player_count: number - players?: Array<{ - index: number - player_id: string - player_name?: string - PlayerName?: string - ready: boolean - }> - status: string - created_at: string - updated_at: string +export interface Room { + room_id: string + name: string + game_type: string + owner_id: string + max_players: number + player_count: number + players?: Array<{ + index: number + player_id: string + player_name?: string + PlayerName?: string + ready: boolean + }> + status: string + created_at: string + updated_at: string } export interface RoomListResult { - items: RoomItem[] - page: number - size: number - total: number + items: Room[] + page: number + size: number + total: number } const ROOM_CREATE_PATH = - import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create' + import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create' const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list' const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join' export async function createRoom( - auth: AuthSession, - input: { name: string; gameType: string; totalRounds: number; maxPlayers: number }, - onAuthUpdated?: (next: AuthSession) => void, -): Promise { - return authedRequest({ - method: 'POST', - path: ROOM_CREATE_PATH, - auth, - onAuthUpdated, - body: { - name: input.name, - game_type: input.gameType, - total_rounds: input.totalRounds, - max_players: input.maxPlayers, - }, - }) + auth: AuthSession, + input: { name: string; gameType: string; totalRounds: number; maxPlayers: number }, + onAuthUpdated?: (next: AuthSession) => void, +): Promise { + return authedRequest({ + method: 'POST', + path: ROOM_CREATE_PATH, + auth, + onAuthUpdated, + body: { + name: input.name, + game_type: input.gameType, + total_rounds: input.totalRounds, + max_players: input.maxPlayers, + }, + }) } export async function listRooms( - auth: AuthSession, - onAuthUpdated?: (next: AuthSession) => void, + auth: AuthSession, + onAuthUpdated?: (next: AuthSession) => void, ): Promise { - return authedRequest({ - method: 'GET', - path: ROOM_LIST_PATH, - auth, - onAuthUpdated, - }) + return authedRequest({ + method: 'GET', + path: ROOM_LIST_PATH, + auth, + onAuthUpdated, + }) } export async function joinRoom( - auth: AuthSession, - input: { roomId: string }, - onAuthUpdated?: (next: AuthSession) => void, -): Promise { - return authedRequest({ - method: 'POST', - path: ROOM_JOIN_PATH, - auth, - onAuthUpdated, - body: { - room_id: input.roomId, - }, - }) + auth: AuthSession, + input: { roomId: string }, + onAuthUpdated?: (next: AuthSession) => void, +): Promise { + return authedRequest({ + method: 'POST', + path: ROOM_JOIN_PATH, + auth, + onAuthUpdated, + body: { + room_id: input.roomId, + }, + }) } diff --git a/src/components/chengdu/ChengduBottomActions.vue b/src/components/chengdu/ChengduBottomActions.vue new file mode 100644 index 0000000..5513798 --- /dev/null +++ b/src/components/chengdu/ChengduBottomActions.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/components/chengdu/ChengduDeskZones.vue b/src/components/chengdu/ChengduDeskZones.vue new file mode 100644 index 0000000..797337a --- /dev/null +++ b/src/components/chengdu/ChengduDeskZones.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/chengdu/ChengduSettlementOverlay.vue b/src/components/chengdu/ChengduSettlementOverlay.vue new file mode 100644 index 0000000..8ecb016 --- /dev/null +++ b/src/components/chengdu/ChengduSettlementOverlay.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/chengdu/ChengduTableHeader.vue b/src/components/chengdu/ChengduTableHeader.vue new file mode 100644 index 0000000..def66ab --- /dev/null +++ b/src/components/chengdu/ChengduTableHeader.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/chengdu/ChengduWallSeats.vue b/src/components/chengdu/ChengduWallSeats.vue new file mode 100644 index 0000000..a0dc92f --- /dev/null +++ b/src/components/chengdu/ChengduWallSeats.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/game/chengdu/messageNormalizers.ts b/src/game/chengdu/messageNormalizers.ts new file mode 100644 index 0000000..7bdabdb --- /dev/null +++ b/src/game/chengdu/messageNormalizers.ts @@ -0,0 +1,283 @@ +import type { + ClaimOptionState, + MeldState, + PendingClaimState, + PlayerState, + Tile, +} from '../../types/state' + +export function normalizeWsType(type: string): string { + return type.replace(/[-\s]/g, '_').toUpperCase() +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +export function readString(source: Record, ...keys: string[]): string { + for (const key of keys) { + const value = source[key] + if (typeof value === 'string' && value.trim()) { + return value + } + } + return '' +} + +export function readNumber(source: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + } + return null +} + +export function normalizeTimestampMs(value: number | null): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + return value >= 1_000_000_000_000 ? value : value * 1000 +} + +export function readStringArray(source: Record, ...keys: string[]): string[] { + for (const key of keys) { + const value = source[key] + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + } + return [] +} + +export function readBoolean(source: Record, ...keys: string[]): boolean | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'number') { + if (value === 1) { + return true + } + if (value === 0) { + return false + } + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1') { + return true + } + if (normalized === 'false' || normalized === '0') { + return false + } + } + } + return null +} + +export function readMissingSuit(source: Record | null | undefined): string | null { + if (!source) { + return null + } + return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null +} + +export function readMissingSuitWithPresence( + source: Record | null | undefined, +): { present: boolean; value: string | null } { + if (!source) { + return { present: false, value: null } + } + + const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit'] + const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key)) + if (!hasMissingSuitField) { + return { present: false, value: null } + } + + return { present: true, value: readMissingSuit(source) } +} + +export function tileToText(tile: Tile): string { + return `${tile.suit}${tile.value}` +} + +export function readPlayerTurnPlayerId(payload: Record): string { + return ( + (typeof payload.player_id === 'string' && payload.player_id) || + (typeof payload.playerId === 'string' && payload.playerId) || + (typeof payload.PlayerID === 'string' && payload.PlayerID) || + '' + ) +} + +export function readPlayerTurnAllowActions(payload: Record): string[] { + const source = + payload.allow_actions ?? + payload.allowActions ?? + payload.AllowActions ?? + payload.available_actions ?? + payload.availableActions ?? + payload.AvailableActions + if (!Array.isArray(source)) { + return [] + } + + const actions = source + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0) + return Array.from(new Set(actions)) +} + +export function normalizeTile(tile: unknown): Tile | null { + const source = asRecord(tile) + if (!source) { + return null + } + + const id = readNumber(source, 'id') + const suit = readString(source, 'suit') as Tile['suit'] | '' + const value = readNumber(source, 'value') + if (typeof id !== 'number' || !suit || typeof value !== 'number') { + return null + } + + if (suit !== 'W' && suit !== 'T' && suit !== 'B') { + return null + } + + return { id, suit, value } +} + +export function normalizeTiles(value: unknown): Tile[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item)) +} + +export function normalizePendingClaim( + gameState: Record | null | undefined, + loggedInUserId: string, +): PendingClaimState | undefined { + if (!gameState || !loggedInUserId) { + return undefined + } + + const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim) + if (!pendingClaim) { + return undefined + } + + const selfOptions = asRecord(pendingClaim[loggedInUserId]) + if (!selfOptions) { + return undefined + } + + const options: ClaimOptionState[] = [] + if (readBoolean(selfOptions, 'hu')) { + options.push('hu') + } + if (readBoolean(selfOptions, 'gang')) { + options.push('gang') + } + if (readBoolean(selfOptions, 'peng')) { + options.push('peng') + } + if (options.length === 0) { + return undefined + } + + options.push('pass') + + return { + tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined, + fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined, + options, + } +} + +export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null { + if (typeof value !== 'string') { + return concealed ? 'an_gang' : null + } + + const normalized = value.replace(/[-\s]/g, '_').toLowerCase() + if (normalized === 'peng') { + return 'peng' + } + if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') { + return concealed ? 'an_gang' : 'ming_gang' + } + if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') { + return 'an_gang' + } + + return concealed ? 'an_gang' : null +} + +export function normalizeMelds(value: unknown): PlayerState['melds'] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((item) => { + if (Array.isArray(item)) { + const tiles = normalizeTiles(item) + if (tiles.length === 3) { + return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState + } + if (tiles.length === 4) { + return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState + } + return null + } + + const source = asRecord(item) + if (!source) { + return null + } + + const tiles = normalizeTiles( + source.tiles ?? + source.meld_tiles ?? + source.meldTiles ?? + source.cards ?? + source.card_list, + ) + if (tiles.length === 0) { + return null + } + + const concealed = + readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false + const explicitType = normalizeMeldType( + source.type ?? source.meld_type ?? source.meldType ?? source.kind, + concealed, + ) + const type = + explicitType ?? + (tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null) + + if (type === 'peng' || type === 'ming_gang') { + return { + type, + tiles, + fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'), + } satisfies MeldState + } + + if (type === 'an_gang') { + return { type, tiles } satisfies MeldState + } + + return null + }) + .filter((item): item is MeldState => Boolean(item)) +} diff --git a/src/store/index.ts b/src/store/index.ts index 1d4730c..677623d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,13 +1,13 @@ import { ref } from 'vue' import type { - ActiveRoomState, - ActiveRoomSelectionInput, + RoomMetaSnapshotInput, + RoomMetaSnapshotState, } from './state' -import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage' +import { clearRoomMetaSnapshot, readRoomMetaSnapshot, saveRoomMetaSnapshot } from './storage' -const activeRoom = ref(readActiveRoomSnapshot()) +const roomMetaSnapshot = ref(readRoomMetaSnapshot()) -function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { +function normalizeRoom(input: RoomMetaSnapshotInput): RoomMetaSnapshotState { return { roomId: input.roomId, roomName: input.roomName ?? '', @@ -32,19 +32,17 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { } } -// 设置当前房间 -export function setActiveRoom(input: ActiveRoomSelectionInput) { +export function setRoomMetaSnapshot(input: RoomMetaSnapshotInput) { const next = normalizeRoom(input) - activeRoom.value = next - saveActiveRoom(next) + roomMetaSnapshot.value = next + saveRoomMetaSnapshot(next) } -export function clearActiveRoom() { - activeRoom.value = null - clearActiveRoomSnapshot() +export function clearRoomMetaSnapshotState() { + roomMetaSnapshot.value = null + clearRoomMetaSnapshot() } -// 使用房间状态 -export function useActiveRoomState() { - return activeRoom +export function useRoomMetaSnapshotState() { + return roomMetaSnapshot } diff --git a/src/store/state.ts b/src/store/state.ts index 9a2dfdf..24b0383 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -1,5 +1,5 @@ // 房间玩家状态 -export interface RoomPlayerState { +export interface RoomMetaPlayerState { index: number playerId: string displayName?: string @@ -13,7 +13,7 @@ export interface RoomPlayerState { } // 房间整体状态 -export interface ActiveRoomState { +export interface RoomMetaSnapshotState { roomId: string roomName: string gameType: string @@ -23,7 +23,7 @@ export interface ActiveRoomState { status: string createdAt: string updatedAt: string - players: RoomPlayerState[] + players: RoomMetaPlayerState[] myHand: string[] game?: { state?: { @@ -36,7 +36,7 @@ export interface ActiveRoomState { } } -export interface ActiveRoomSelectionInput { +export interface RoomMetaSnapshotInput { roomId: string roomName?: string gameType?: string @@ -46,7 +46,7 @@ export interface ActiveRoomSelectionInput { status?: string createdAt?: string updatedAt?: string - players?: RoomPlayerState[] + players?: RoomMetaPlayerState[] myHand?: string[] - game?: ActiveRoomState['game'] + game?: RoomMetaSnapshotState['game'] } diff --git a/src/store/storage.ts b/src/store/storage.ts index c0f8f2c..fbf0270 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -1,9 +1,9 @@ -import type { ActiveRoomState } from './state' +import type { RoomMetaSnapshotState } from './state' const KEY = 'mahjong_active_room' // 读取缓存 -export function readActiveRoomSnapshot(): ActiveRoomState | null { +export function readRoomMetaSnapshot(): RoomMetaSnapshotState | null { const raw = localStorage.getItem(KEY) if (!raw) return null @@ -15,11 +15,11 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null { } // 写入缓存 -export function saveActiveRoom(state: ActiveRoomState) { +export function saveRoomMetaSnapshot(state: RoomMetaSnapshotState) { localStorage.setItem(KEY, JSON.stringify(state)) } // 清除缓存 -export function clearActiveRoomSnapshot() { +export function clearRoomMetaSnapshot() { localStorage.removeItem(KEY) } diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 83a4251..2b13222 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -1,6 +1,6 @@ -