- 移除中心模态框的背景模糊效果并调整透明度 - 为桌面游戏历史组件添加空状态显示组件 - 重构头部时钟显示逻辑,提取为独立组件并优化时间同步 - 移除用户ID遮罩功能,直接使用昵称显示中奖信息 - 调整入口页面的模态框渲染结构和认证状态检查逻辑 - 更新奖池广播数据结构,替换用户ID为昵称字段 - 优化实时同步中的数据验证和映射逻辑 - 调整移动端头部时钟组件的实现方式
914 lines
24 KiB
TypeScript
914 lines
24 KiB
TypeScript
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<string, unknown> | null {
|
|
if (!value || typeof value !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const nested = (value as Record<string, unknown>)[key]
|
|
|
|
return nested && typeof nested === 'object'
|
|
? (nested as Record<string, unknown>)
|
|
: null
|
|
}
|
|
|
|
function getMessageTopic(message: GameSocketMessage) {
|
|
const root = message as Record<string, unknown>
|
|
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<string, unknown>
|
|
|
|
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<string, unknown>)
|
|
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<string, unknown>)
|
|
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<string, unknown>
|
|
|
|
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<string, unknown> =>
|
|
Boolean(item) && typeof item === 'object',
|
|
)
|
|
const hits = sourceHits
|
|
.map((item) => extractJackpotHitItem(item))
|
|
.filter((item): item is JackpotHitItemDto => item !== null)
|
|
const root = message as Record<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<ReturnType<typeof getGameLobbyInit>>) {
|
|
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<GameSocketClient | null>(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])
|
|
}
|