import { useEffect, useRef } from 'react' import { FALLBACK_POLL_INTERVAL_MS, GAME_SOCKET_TOPIC_VALUES, GAME_SOCKET_TOPICS, PLAYER_SOCKET_TOPICS, SOCKET_DISCONNECT_DELAY_MS, } from '@/constants' import i18n from '@/i18n' import { prefetchAuthToken } from '@/lib/api/api-client' import { GameSocketClient, type GameSocketMessage, } from '@/lib/ws/game-socket-client' import { getAuthDeviceId, useAuthStore } from '@/store/auth' import { useGameAutoHostingStore, useGameRoundStore, useGameSessionStore, } from '@/store/game' import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api' import type { BetWinEventDataDto, GamePeriodTickDto, JackpotHitEventDataDto, JackpotHitItemDto, } from '../api/types' type UserStreakMessageData = { currentStreak: number oddsFactor?: number streakLevel?: number } type PeriodEventData = { openTime: number | null periodNo: string resultNumber: number | null } type WalletChangedData = { coin: string } let sharedSocketClient: GameSocketClient | null = null let sharedSocketKey: string | null = null let sharedSocketDisconnectTimerId: number | null = null function toIsoFromUnixSeconds(seconds: number) { return new Date(seconds * 1000).toISOString() } function toSocketLang(language: string | null | undefined) { return language?.startsWith('zh') ? 'zh' : 'en' } function toOptionalNumber(value: unknown) { if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined } if (typeof value === 'string') { const parsed = Number(value) return Number.isFinite(parsed) ? parsed : undefined } return undefined } function toOptionalString(value: unknown) { if (typeof value === 'string') { return value } if (typeof value === 'number' && Number.isFinite(value)) { return String(value) } return undefined } function toOptionalBoolean(value: unknown) { if (typeof value === 'boolean') { return value } if (value === 1 || value === '1' || value === 'true') { return true } if (value === 0 || value === '0' || value === 'false') { return false } return undefined } function getNestedRecord( value: unknown, key: string, ): Record | null { if (!value || typeof value !== 'object') { return null } const nested = (value as Record)[key] return nested && typeof nested === 'object' ? (nested as Record) : null } function getMessageTopic(message: GameSocketMessage) { const root = message as Record const event = typeof root.event === 'string' ? root.event : null const topic = typeof root.topic === 'string' ? root.topic : null if (event && GAME_SOCKET_TOPIC_VALUES.has(event)) { return event } if (topic && GAME_SOCKET_TOPIC_VALUES.has(topic)) { return topic } return event ?? topic } function extractServerTime(message: GameSocketMessage) { const root = message as Record if (typeof root.server_time === 'number') { return root.server_time } const data = getNestedRecord(message, 'data') return typeof data?.server_time === 'number' ? data.server_time : null } function extractUserStreakMessageData( message: GameSocketMessage, ): UserStreakMessageData | null { const data = getNestedRecord(message, 'data') const direct = getNestedRecord(message, 'user_snapshot') const nested = getNestedRecord(data, 'user_snapshot') const source = data && 'current_streak' in data ? data : (nested ?? direct ?? data) if (!source || typeof source.current_streak !== 'number') { return null } return { currentStreak: source.current_streak, oddsFactor: toOptionalNumber(source.odds_factor), streakLevel: toOptionalNumber(source.streak_level), } } function extractPeriodTick( message: GameSocketMessage, ): GamePeriodTickDto | null { const data = getNestedRecord(message, 'data') const nested = getNestedRecord(data, 'period') const source = nested ?? data if ( !source || typeof source.period_no !== 'string' || typeof source.status !== 'string' || typeof source.countdown !== 'number' || typeof source.bet_close_in !== 'number' ) { return null } return { bet_close_in: source.bet_close_in, countdown: source.countdown, period_id: typeof source.period_id === 'number' ? source.period_id : null, period_no: source.period_no, result_number: typeof source.result_number === 'number' ? source.result_number : null, runtime_enabled: typeof source.runtime_enabled === 'boolean' ? source.runtime_enabled : true, server_time: typeof source.server_time === 'number' ? source.server_time : Math.floor(Date.now() / 1000), status: source.status as GamePeriodTickDto['status'], } } function extractPeriodEventData( message: GameSocketMessage, ): PeriodEventData | null { const data = getNestedRecord(message, 'data') const source = data ?? (message as Record) const periodNo = typeof source.period_no === 'string' ? source.period_no : typeof source.periodNo === 'string' ? source.periodNo : null if (!periodNo) { return null } const resultNumber = toOptionalNumber( source.result_number ?? source.resultNumber, ) const openTime = toOptionalNumber(source.open_time ?? source.openTime) return { openTime: openTime ?? null, periodNo, resultNumber: typeof resultNumber === 'number' && Number.isInteger(resultNumber) ? resultNumber : null, } } function extractWalletChangedData( message: GameSocketMessage, ): WalletChangedData | null { const data = getNestedRecord(message, 'data') const source = data ?? (message as Record) const coin = source.coin ?? source.balance ?? source.balance_after const normalizedCoin = typeof coin === 'string' ? coin : typeof coin === 'number' && Number.isFinite(coin) ? String(coin) : null if (normalizedCoin === null) { return null } return { coin: normalizedCoin, } } function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null { if (!value || typeof value !== 'object') { return null } const source = value as Record if ( typeof source.nickname !== 'string' || typeof source.period_no !== 'string' || typeof source.total_win !== 'string' ) { return null } const resultNumber = toOptionalNumber(source.result_number) if (typeof resultNumber !== 'number' || !Number.isInteger(resultNumber)) { return null } return { nickname: source.nickname, period_no: source.period_no, result_number: resultNumber, total_win: source.total_win, } } function extractJackpotHitData( message: GameSocketMessage, ): JackpotHitEventDataDto | null { const data = getNestedRecord(message, 'data') if (!data || typeof data.period_no !== 'string') { return null } const nestedHits = data.hits const sourceHits = Array.isArray(nestedHits) ? nestedHits : nestedHits && typeof nestedHits === 'object' ? [nestedHits] : [data] const firstHitSource = sourceHits.find( (item): item is Record => Boolean(item) && typeof item === 'object', ) const hits = sourceHits .map((item) => extractJackpotHitItem(item)) .filter((item): item is JackpotHitItemDto => item !== null) const root = message as Record const serverTime = toOptionalNumber( data.server_time ?? firstHitSource?.server_time ?? root.server_time, ) const resultNumber = toOptionalNumber( data.result_number ?? data['result number'], ) if (typeof serverTime !== 'number') { return null } return { hits, period_id: typeof data.period_id === 'number' && Number.isInteger(data.period_id) ? data.period_id : null, period_no: data.period_no, result_number: typeof resultNumber === 'number' && Number.isInteger(resultNumber) ? resultNumber : null, server_time: serverTime, } } function extractBetWinData( message: GameSocketMessage, ): BetWinEventDataDto | null { const data = getNestedRecord(message, 'data') if (!data) { return null } const root = message as Record const userId = toOptionalNumber(data.user_id) const periodId = toOptionalNumber(data.period_id) const resultNumber = toOptionalNumber(data.result_number) const totalWin = toOptionalString(data.total_win) const balanceAfter = toOptionalString(data.balance_after) const serverTime = toOptionalNumber(data.server_time ?? root.server_time) const currentStreak = toOptionalNumber(data.current_streak) const streakLevel = toOptionalNumber(data.streak_level) const oddsFactor = toOptionalNumber(data.odds_factor) const isJackpot = toOptionalBoolean(data.is_jackpot) if ( typeof totalWin !== 'string' || typeof data.period_no !== 'string' || typeof isJackpot !== 'boolean' ) { return null } return { balance_after: balanceAfter, bets: Array.isArray(data.bets) ? data.bets .map((item) => { if (!item || typeof item !== 'object') { return null } const bet = item as Record const betId = toOptionalNumber(bet.bet_id) const winAmount = toOptionalString(bet.win_amount) return typeof betId === 'number' && typeof winAmount === 'string' ? { bet_id: betId, win_amount: winAmount, } : null }) .filter( (item): item is BetWinEventDataDto['bets'][number] => item !== null, ) : [], current_streak: currentStreak, is_jackpot: isJackpot, is_win: toOptionalBoolean(data.is_win) ?? true, odds_factor: typeof oddsFactor === 'number' ? oddsFactor : undefined, payout_pending_review: toOptionalBoolean(data.payout_pending_review) ?? false, period_id: periodId, period_no: data.period_no, result_number: typeof resultNumber === 'number' && Number.isInteger(resultNumber) ? resultNumber : null, server_time: serverTime, streak_level: typeof streakLevel === 'number' ? streakLevel : undefined, total_win: totalWin, user_id: userId, } } function applyLobbySync(result: Awaited>) { const currentRoundState = useGameRoundStore.getState() const currentSessionState = useGameSessionStore.getState() useGameRoundStore.getState().hydrateRound({ cells: result.snapshot.cells, chips: result.snapshot.chips, history: currentRoundState.history, maxSelectionCount: result.snapshot.maxSelectionCount, round: currentRoundState.round, selections: currentRoundState.selections, trends: currentRoundState.trends, }) useGameSessionStore.getState().hydrateSession({ announcements: result.snapshot.announcements, connection: { ...result.snapshot.connection, status: 'connected', transport: 'polling', }, dashboard: { ...currentSessionState.dashboard, tableLimitMax: result.snapshot.dashboard.tableLimitMax, tableLimitMin: result.snapshot.dashboard.tableLimitMin, }, }) const currentUser = useAuthStore.getState().currentUser if (currentUser) { useAuthStore.getState().setCurrentUser({ ...currentUser, coin: result.userSnapshot.coin, currentStreak: result.userSnapshot.current_streak, isJackpot: result.userSnapshot.is_jackpot, oddsFactor: result.userSnapshot.odds_factor, streakLevel: result.userSnapshot.streak_level, }) } } function applyPeriodMessage( message: GameSocketMessage, serverTime: number | null, ) { const period = extractPeriodTick(message) if (!period) { return } const previousRound = useGameRoundStore.getState().round const round = normalizePeriodTickRound( { ...period, server_time: serverTime ?? period.server_time, }, previousRound, ) useGameRoundStore.getState().syncRound({ bettingClosesAt: round.bettingClosesAt, id: round.id, phase: round.phase, revealingAt: round.revealingAt, settledAt: round.settledAt, startedAt: round.startedAt, winningCellId: previousRound.winningCellId, }) useGameSessionStore.getState().syncDashboard({ countdownMs: period.countdown * 1000, updatedAt: serverTime !== null ? toIsoFromUnixSeconds(serverTime) : toIsoFromUnixSeconds(period.server_time), }) } function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') { useGameRoundStore.getState().setPhase(phase) } function applyPeriodLockedMessage( message: GameSocketMessage, serverTime: number | null, ) { applyPeriodMessage(message, serverTime) const period = extractPeriodEventData(message) const roundState = useGameRoundStore.getState() const roundId = period?.periodNo ?? roundState.round.id if (roundId) { roundState.syncRound({ id: roundId, phase: 'locked', }) } else { roundState.setPhase('locked') } } function applyPeriodOpenedMessage( message: GameSocketMessage, serverTime: number | null, ) { console.log('%c[period.opened 开奖数据]', 'color: red;', message) applyPeriodMessage(message, serverTime) const period = extractPeriodEventData(message) if (!period || period.resultNumber === null) { applyPeriodPhase('revealing') return } const roundState = useGameRoundStore.getState() const openedAt = toIsoFromUnixSeconds( period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000), ) const revealKey = `${period.periodNo}:${period.resultNumber}` roundState.syncRound({ id: period.periodNo, phase: 'revealing', revealingAt: openedAt, winningCellId: period.resultNumber, }) useGameRoundStore.getState().prepareRevealAnimation({ revealKey, roundId: period.periodNo, winningCellId: period.resultNumber, }) } function applyPeriodPayoutMessage( message: GameSocketMessage, serverTime: number | null, ) { applyPeriodMessage(message, serverTime) const period = extractPeriodEventData(message) if (period?.resultNumber !== null && period?.resultNumber !== undefined) { const roundState = useGameRoundStore.getState() const revealKey = `${period.periodNo}:${period.resultNumber}` roundState.syncRound({ id: period.periodNo, winningCellId: period.resultNumber, }) roundState.prepareRevealAnimation({ revealKey, roundId: period.periodNo, winningCellId: period.resultNumber, }) } const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id applyPeriodPhase('settled') useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null) } function applyUserStreakMessage(message: GameSocketMessage) { const streakData = extractUserStreakMessageData(message) const currentUser = useAuthStore.getState().currentUser if (!streakData || !currentUser) { return } useAuthStore.getState().setCurrentUser({ ...currentUser, currentStreak: streakData.currentStreak, oddsFactor: streakData.oddsFactor ?? currentUser.oddsFactor, streakLevel: streakData.streakLevel ?? currentUser.streakLevel, }) } function applyWalletChangedMessage(message: GameSocketMessage) { const walletData = extractWalletChangedData(message) const currentUser = useAuthStore.getState().currentUser if (!walletData || !currentUser) { return } useAuthStore.getState().setCurrentUser({ ...currentUser, coin: walletData.coin, }) } function applyJackpotHitMessage(message: GameSocketMessage) { console.log('%c[jackpot.hit 数据]', 'color: red;', message) const jackpotHitData = extractJackpotHitData(message) if (jackpotHitData?.hits.length) { useGameSessionStore.getState().pushJackpotBroadcasts( jackpotHitData.hits.map((hit) => ({ id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`, message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`, nickname: hit.nickname, periodNo: hit.period_no, totalWin: hit.total_win, })), ) } } function applyBetWinMessage(message: GameSocketMessage) { const betWinData = extractBetWinData(message) const currentUser = useAuthStore.getState().currentUser if (!betWinData) { return } useGameRoundStore.getState().setPendingBetWinReward({ isJackpot: betWinData.is_jackpot, revealKey: `${betWinData.period_no}:${betWinData.result_number ?? 'pending'}:${betWinData.total_win}`, roundId: betWinData.period_no, totalWin: betWinData.total_win, winningCellId: betWinData.result_number, }) useGameAutoHostingStore.getState().recordBetWin({ isJackpot: betWinData.is_jackpot, singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null, }) if (!currentUser) { return } useAuthStore.getState().setCurrentUser({ ...currentUser, coin: betWinData.balance_after ?? currentUser.coin, currentStreak: betWinData.current_streak ?? currentUser.currentStreak, isJackpot: betWinData.is_jackpot, oddsFactor: betWinData.odds_factor ?? currentUser.oddsFactor, streakLevel: betWinData.streak_level ?? currentUser.streakLevel, }) } function applyRealtimeMessage(message: GameSocketMessage) { const serverTime = extractServerTime(message) const topic = getMessageTopic(message) switch (topic) { case GAME_SOCKET_TOPICS.periodTick: applyPeriodMessage(message, serverTime) break case GAME_SOCKET_TOPICS.periodLocked: applyPeriodLockedMessage(message, serverTime) break case GAME_SOCKET_TOPICS.periodOpened: applyPeriodOpenedMessage(message, serverTime) break case GAME_SOCKET_TOPICS.periodPayout: applyPeriodPayoutMessage(message, serverTime) break case GAME_SOCKET_TOPICS.userStreak: applyUserStreakMessage(message) break case GAME_SOCKET_TOPICS.walletChanged: applyWalletChangedMessage(message) break case GAME_SOCKET_TOPICS.jackpotHit: applyJackpotHitMessage(message) break case GAME_SOCKET_TOPICS.betWin: applyBetWinMessage(message) break case GAME_SOCKET_TOPICS.betAccepted: case GAME_SOCKET_TOPICS.autoSpinProgress: break } useGameSessionStore.getState().syncConnection({ lastMessageAt: serverTime !== null ? toIsoFromUnixSeconds(serverTime) : new Date().toISOString(), }) } export function useGameRealtimeSync() { const accessToken = useAuthStore((state) => state.accessToken) const authStatus = useAuthStore((state) => state.status) const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt) const shouldConnectRealtime = useGameSessionStore( (state) => state.shouldConnectRealtime, ) const socketClientRef = useRef(null) const isReloginRequired = authStatus === 'anonymous' && Boolean(lastUnauthorizedAt) useEffect(() => { if (sharedSocketDisconnectTimerId !== null) { window.clearTimeout(sharedSocketDisconnectTimerId) sharedSocketDisconnectTimerId = null } if (isReloginRequired) { sharedSocketClient?.disconnect() sharedSocketClient = null sharedSocketKey = null socketClientRef.current = null const gameSession = useGameSessionStore.getState() gameSession.resetRealtimeConnectionRequest() gameSession.syncConnection({ lastError: null, latencyMs: null, reconnectAttempt: 0, status: 'disconnected', transport: 'offline', }) return } if ( !shouldConnectRealtime || authStatus !== 'authenticated' || !accessToken ) { sharedSocketDisconnectTimerId = window.setTimeout(() => { sharedSocketClient?.disconnect() sharedSocketClient = null sharedSocketKey = null sharedSocketDisconnectTimerId = null }, SOCKET_DISCONNECT_DELAY_MS) socketClientRef.current = sharedSocketClient return } const websocketUrl = import.meta.env.VITE_WEBSOCKET_URL?.trim() || null const socketKey = `${websocketUrl ?? ''}::${accessToken}` if (sharedSocketClient && sharedSocketKey === socketKey) { socketClientRef.current = sharedSocketClient return () => { sharedSocketDisconnectTimerId = window.setTimeout(() => { sharedSocketClient?.disconnect() sharedSocketClient = null sharedSocketKey = null sharedSocketDisconnectTimerId = null }, SOCKET_DISCONNECT_DELAY_MS) } } sharedSocketClient?.disconnect() const socketClient = new GameSocketClient({ getContext: async () => { await prefetchAuthToken() const authToken = useAuthStore.getState().apiAuthToken if (!authToken) { return null } return { token: accessToken, authToken, deviceId: getAuthDeviceId(), lang: toSocketLang(i18n.resolvedLanguage), } }, getUrl: () => websocketUrl, onError: (error) => { useGameSessionStore.getState().syncConnection({ lastError: 'message' in error && typeof error.message === 'string' ? error.message : 'WebSocket error', }) }, onLatencyChange: (latencyMs) => { useGameSessionStore.getState().syncConnection({ latencyMs, }) }, onMessage: (message) => { if (message.event === 'ws.connected') { const serverTime = extractServerTime(message) useGameSessionStore.getState().syncConnection({ connectedAt: serverTime !== null ? toIsoFromUnixSeconds(serverTime) : new Date().toISOString(), lastError: null, lastMessageAt: serverTime !== null ? toIsoFromUnixSeconds(serverTime) : new Date().toISOString(), reconnectAttempt: 0, status: 'connected', transport: 'websocket', }) } applyRealtimeMessage(message) }, onStatusChange: (status, reconnectAttempt) => { const mappedStatus = status === 'idle' ? 'idle' : status === 'connected' ? 'connected' : status useGameSessionStore.getState().syncConnection({ latencyMs: mappedStatus === 'connected' ? undefined : null, reconnectAttempt, status: mappedStatus, transport: websocketUrl ? 'websocket' : 'polling', }) }, }) sharedSocketClient = socketClient sharedSocketKey = socketKey socketClientRef.current = socketClient socketClient.subscribe([...PLAYER_SOCKET_TOPICS]) void socketClient.connect() return () => { sharedSocketDisconnectTimerId = window.setTimeout(() => { if (sharedSocketClient === socketClient) { socketClient.disconnect() sharedSocketClient = null sharedSocketKey = null } sharedSocketDisconnectTimerId = null }, SOCKET_DISCONNECT_DELAY_MS) socketClientRef.current = sharedSocketClient } }, [accessToken, authStatus, isReloginRequired, shouldConnectRealtime]) useEffect(() => { if ( isReloginRequired || !shouldConnectRealtime || authStatus !== 'authenticated' ) { return } let cancelled = false let intervalId = 0 const pollLobbyState = async () => { const connection = useGameSessionStore.getState().connection if ( connection.status === 'connected' && connection.transport === 'websocket' ) { return } const startedAt = Date.now() try { const result = await getGameLobbyInit() if (cancelled) { return } applyLobbySync(result) useGameSessionStore.getState().syncConnection({ lastError: null, latencyMs: Date.now() - startedAt, status: 'connected', transport: 'polling', }) } catch (error) { if (cancelled) { return } useGameSessionStore.getState().syncConnection({ lastError: error instanceof Error ? error.message : 'Polling failed', status: 'reconnecting', transport: 'polling', }) } } intervalId = window.setInterval(() => { void pollLobbyState() }, FALLBACK_POLL_INTERVAL_MS) void pollLobbyState() return () => { cancelled = true window.clearInterval(intervalId) } }, [authStatus, isReloginRequired, shouldConnectRealtime]) }