import { asRecord, normalizeMelds, normalizePendingClaim, normalizeTiles, readBoolean, readMissingSuit, readMissingSuitWithPresence, readNumber, readString, readStringArray, tileToText, } from '../../../../game/chengdu/messageNormalizers' import type { RoomMetaSnapshotState } from '../../../../store/state' import type { PendingClaimState, PlayerState } from '../../../../types/state' interface RoomInfoSnapshotPlayerPair { roomPlayer: RoomMetaSnapshotState['players'][number] gamePlayer: PlayerState } export interface ParsedRoomInfoSnapshot { room: Record | null gameState: Record | null playerView: Record | null roomId: string roomPlayers: RoomInfoSnapshotPlayerPair[] nextPlayers: Record status: string phase: string wallCount: number | null dealerIndex: number | null currentTurnPlayerId: string currentTurn: number | null needDraw: boolean pendingClaim?: PendingClaimState scores?: Record winners: string[] currentRound: number | null totalRounds: number | null settlementDeadlineMs: number | null } interface ParseRoomInfoSnapshotOptions { message: Record loggedInUserId: string loggedInUserName: string previousPlayers: Record } function buildPlayerPairs(options: ParseRoomInfoSnapshotOptions, payload: Record) { const room = asRecord(payload.room) const gameState = asRecord(payload.game_state) const playerView = asRecord(payload.player_view) const roomPlayers = Array.isArray(room?.players) ? room.players : [] const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : [] const playerMap = new Map() roomPlayers.forEach((item, fallbackIndex) => { const player = asRecord(item) if (!player) { return } const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id') if (!playerId) { return } const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex const displayName = readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') || (playerId === options.loggedInUserId ? options.loggedInUserName : '') const ready = readBoolean(player, 'ready', 'Ready') ?? false const missingSuit = readMissingSuit(player) playerMap.set(playerId, { roomPlayer: { index: seatIndex, playerId, displayName: displayName || undefined, missingSuit, ready, trustee: false, hand: [], melds: [], outTiles: [], hasHu: false, }, gamePlayer: { playerId, seatIndex, displayName: displayName || undefined, avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined, missingSuit, isReady: ready, isTrustee: false, handTiles: [], handCount: 0, melds: [], discardTiles: [], hasHu: false, score: 0, }, }) }) gamePlayers.forEach((item, fallbackIndex) => { const player = asRecord(item) if (!player) { return } const playerId = readString(player, 'player_id', 'PlayerID') if (!playerId) { return } const existing = playerMap.get(playerId) const seatIndex = existing?.gamePlayer.seatIndex ?? readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex const displayName = existing?.gamePlayer.displayName || (playerId === options.loggedInUserId ? options.loggedInUserName : '') const missingSuit = readMissingSuitWithPresence(player) const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0 const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles) const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims) const hasHu = Boolean(player.has_hu ?? player.hasHu) playerMap.set(playerId, { roomPlayer: { index: seatIndex, playerId, displayName: displayName || undefined, missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null), ready: existing?.roomPlayer.ready ?? false, trustee: existing?.roomPlayer.trustee ?? false, hand: Array.from({ length: handCount }, () => ''), melds: melds.map((meld) => meld.type), outTiles: outTiles.map((tile) => tileToText(tile)), hasHu, }, gamePlayer: { playerId, seatIndex, displayName: displayName || undefined, avatarURL: existing?.gamePlayer.avatarURL, missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null), isReady: existing?.gamePlayer.isReady ?? false, isTrustee: existing?.gamePlayer.isTrustee ?? false, handTiles: existing?.gamePlayer.handTiles ?? [], handCount, melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [], discardTiles: outTiles, hasHu, score: existing?.gamePlayer.score ?? 0, }, }) }) const privateHandTiles = normalizeTiles(playerView?.hand) const privateHand = privateHandTiles.map((tile) => tileToText(tile)) if (options.loggedInUserId && playerMap.has(options.loggedInUserId)) { const current = playerMap.get(options.loggedInUserId) if (current) { const selfMissingSuit = readMissingSuitWithPresence(playerView) current.roomPlayer.hand = privateHand if (selfMissingSuit.present) { current.roomPlayer.missingSuit = selfMissingSuit.value } current.gamePlayer.handTiles = privateHandTiles current.gamePlayer.handCount = privateHandTiles.length if (selfMissingSuit.present) { current.gamePlayer.missingSuit = selfMissingSuit.value } } } return { room, gameState, playerView, roomPlayers: Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex), } } export function parseRoomInfoSnapshot( options: ParseRoomInfoSnapshotOptions, ): ParsedRoomInfoSnapshot | null { const payload = asRecord(options.message.payload) ?? options.message const { room, gameState, playerView, roomPlayers } = buildPlayerPairs(options, payload) const roomId = readString(room ?? {}, 'room_id', 'roomId') || readString(gameState ?? {}, 'room_id', 'roomId') || readString(playerView ?? {}, 'room_id', 'roomId') || readString(payload, 'room_id', 'roomId') || readString(options.message, 'roomId') if (!roomId) { return null } const nextPlayers: Record = {} roomPlayers.forEach(({ gamePlayer }) => { const previous = options.previousPlayers[gamePlayer.playerId] const score = gameState?.scores && typeof gameState.scores === 'object' ? (gameState.scores as Record)[gamePlayer.playerId] : undefined nextPlayers[gamePlayer.playerId] = { playerId: gamePlayer.playerId, seatIndex: gamePlayer.seatIndex, displayName: gamePlayer.displayName ?? previous?.displayName, avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL, missingSuit: typeof gamePlayer.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : gamePlayer.missingSuit, isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee, handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [], handCount: gamePlayer.handCount > 0 ? gamePlayer.handCount : gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles.length : (previous?.handCount ?? 0), melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [], discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [], hasHu: gamePlayer.hasHu || previous?.hasHu || false, score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0, isReady: gamePlayer.isReady, } }) const status = readString(gameState ?? {}, 'status') || readString(room ?? {}, 'status') || readString(gameState ?? {}, 'phase') || 'waiting' const phase = readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting' const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount') const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex') const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn') const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer') || '' const currentTurn = currentTurnSeat ?? (currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null) return { room, gameState, playerView, roomId, roomPlayers, nextPlayers, status, phase, wallCount, dealerIndex, currentTurnPlayerId, currentTurn, needDraw: readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false, pendingClaim: normalizePendingClaim(gameState, options.loggedInUserId), scores: asRecord(gameState?.scores) ? (Object.fromEntries( Object.entries(asRecord(gameState?.scores) ?? {}).filter(([, value]) => typeof value === 'number'), ) as Record) : undefined, winners: readStringArray(gameState ?? {}, 'winners'), currentRound: readNumber(gameState ?? {}, 'current_round', 'currentRound'), totalRounds: readNumber(gameState ?? {}, 'total_rounds', 'totalRounds'), settlementDeadlineMs: readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs'), } }