"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { getDrawCurrent } from "@/api/draw"; import { isHallAwaitingDrawProcessing, isHallSealedCountdownUi } from "@/features/draw/draw-status-meta"; import { useActivePlayerCurrency } from "@/hooks/use-active-player-currency"; import { getLotteryEcho } from "@/lib/lottery-echo"; import { startWalletRefreshBurst } from "@/lib/wallet-refresh-burst"; import { PLAYER_CURRENCY_CHANGE_EVENT } from "@/lib/player-currency-preference"; import { useNetworkConnectionStore } from "@/stores/network-connection-store"; import type { DrawCurrentPayload } from "@/types/api/draw-current"; /** 大厅共享的当期快照(由 {@link useHallDrawLive} 产出,供期号条与下注表共用)。 */ export type HallDrawLiveSnapshot = { raw: DrawCurrentPayload | null | undefined; display: DrawCurrentPayload | null | undefined; serverNowMs: number; /** 与 display 漂移推演一致的本地「当前」毫秒时间戳 */ nowMs: number; error: string | null; reload: () => Promise; isBettable: boolean; }; /** 界面文档 §2.1:`draw.countdown` / `draw.status_change` / `result.published` 载荷 */ export type HallWsEnvelope = { data: DrawCurrentPayload | null; emitted_at_ms?: number; }; function secondsUntilIso(iso: string | null | undefined, effectiveNowMs: number): number { if (iso == null || iso === "") { return 0; } const targetMs = Date.parse(iso); if (Number.isNaN(targetMs)) { return 0; } return Math.max(0, Math.ceil((targetMs - effectiveNowMs) / 1000)); } /** * 以服务端 `server_now_ms` 为锚、本地时钟仅负责推进,倒计时与 ISO 时刻一致。 */ function mergeJackpotForCurrency( incoming: DrawCurrentPayload | null, previous: DrawCurrentPayload | null | undefined, activeCurrency: string, ): DrawCurrentPayload | null { if (incoming === null) { return null; } const incomingCode = ( incoming.jackpot_currency_code ?? incoming.jackpot?.currency_code ?? "" ) .trim() .toUpperCase(); const wanted = activeCurrency.trim().toUpperCase(); if (incomingCode !== "" && incomingCode !== wanted && previous?.jackpot) { return { ...incoming, jackpot_currency_code: wanted, jackpot: previous.jackpot, }; } return incoming; } function applySnapshotDrift( payload: DrawCurrentPayload, emittedAtMs: number, clientNowMs: number, serverNowMs: number, ): DrawCurrentPayload { const effectiveNowMs = serverNowMs + (clientNowMs - emittedAtMs); return { ...payload, seconds_to_close: secondsUntilIso(payload.close_time, effectiveNowMs), seconds_to_start: secondsUntilIso(payload.start_time, effectiveNowMs), seconds_to_draw: secondsUntilIso(payload.draw_time, effectiveNowMs), seconds_remaining_in_cooldown: payload.cooling_end_time == null ? null : secondsUntilIso(payload.cooling_end_time, effectiveNowMs), }; } /** * 大厅期号:WebSocket `lottery-hall` + 轮询降级;由 {@link HallScreen} 调用一次,注入 {@link HallDrawPanel} 与 {@link HallBettingGrid}。 * 已集成网络连接管理,WebSocket断开时自动切换到轮询模式。 */ export function useHallDrawLive(): HallDrawLiveSnapshot { const { activeCurrency } = useActivePlayerCurrency(); const [raw, setRaw] = useState(undefined); const [serverNowMs, setServerNowMs] = useState(() => Date.now()); const [emittedAtMs, setEmittedAtMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now()); const [error, setError] = useState(null); // 网络连接状态 const mode = useNetworkConnectionStore((s) => s.mode); const isWebSocketConnected = useNetworkConnectionStore( (s) => s.isWebSocketConnected, ); const setDrawPollingIntervalId = useNetworkConnectionStore( (s) => s.setDrawPollingIntervalId, ); const clearDrawPolling = useNetworkConnectionStore((s) => s.clearDrawPolling); const latestSnapshotMsRef = useRef(0); const applySnapshot = useCallback( (anchorMs: number, data: DrawCurrentPayload | null) => { if (anchorMs < latestSnapshotMsRef.current) { return; } latestSnapshotMsRef.current = anchorMs; setServerNowMs(anchorMs); setRaw((prev) => mergeJackpotForCurrency(data, prev, activeCurrency)); setEmittedAtMs(anchorMs); }, [activeCurrency], ); const mergeFromWs = useCallback( (evt: HallWsEnvelope) => { const anchor = evt.emitted_at_ms ?? Date.now(); applySnapshot(anchor, evt.data); }, [applySnapshot], ); const mergeCountdownFromWs = useCallback( (evt: HallWsEnvelope) => { if (evt.data === null) return; const anchor = evt.emitted_at_ms ?? Date.now(); applySnapshot(anchor, evt.data); }, [applySnapshot], ); const load = useCallback(async (options?: { force?: boolean }) => { try { setError(null); const d = await getDrawCurrent({ currency: activeCurrency }); const wsConnected = useNetworkConnectionStore.getState().isWebSocketConnected; if (!options?.force && wsConnected && d.server_now_ms < latestSnapshotMsRef.current) { return; } applySnapshot(d.server_now_ms, d.data); } catch { setError("draw.loadFailedRefresh"); setRaw(undefined); } }, [activeCurrency, applySnapshot]); useEffect(() => { const onCurrencyChange = () => { void load({ force: true }); }; window.addEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); return () => window.removeEventListener(PLAYER_CURRENCY_CHANGE_EVENT, onCurrencyChange); }, [load]); // 爆池等场景:刷新大厅快照(含奖池余额) useEffect(() => { const onHallRefresh = () => { void load(); }; window.addEventListener("lottery-hall-refresh", onHallRefresh); return () => window.removeEventListener("lottery-hall-refresh", onHallRefresh); }, [load]); // 本地倒计时计时器(用于 UI 更新) useEffect(() => { const bump = () => setNowMs(Date.now()); bump(); const sid = window.setInterval(bump, 1000); const onVisibility = () => { if (document.visibilityState === "visible") bump(); }; document.addEventListener("visibilitychange", onVisibility); return () => { window.clearInterval(sid); document.removeEventListener("visibilitychange", onVisibility); }; }, []); // WebSocket 订阅(期号 HTTP 轮询由下方单一 effect 负责) useEffect(() => { const echo = getLotteryEcho(); if (!echo) return; const channel = echo.channel("lottery-hall"); channel.listen(".draw.countdown", mergeCountdownFromWs); channel.listen(".draw.status_change", mergeFromWs); channel.listen(".result.published", (evt: HallWsEnvelope) => { mergeFromWs(evt); startWalletRefreshBurst(); }); return () => { channel.stopListening(".draw.countdown"); channel.stopListening(".draw.status_change"); channel.stopListening(".result.published"); }; }, [mergeCountdownFromWs, mergeFromWs]); const display: DrawCurrentPayload | null | undefined = raw === undefined || raw === null ? raw : applySnapshotDrift(raw, emittedAtMs, nowMs, serverNowMs); const isBettable = display != null && display.status === "open"; const zeroRefreshKeyRef = useRef(null); // 本地倒计时归零时主动拉取(冷静期结束、封盘切下一期等;避免只靠手动刷新) useEffect(() => { if (!display) return; const coolingEndMs = display.cooling_end_time ? Date.parse(display.cooling_end_time) : null; const coolingDone = display.status === "cooldown" && ((display.seconds_remaining_in_cooldown ?? 1) === 0 || (coolingEndMs !== null && !Number.isNaN(coolingEndMs) && coolingEndMs <= nowMs)); const awaitingDraw = isHallAwaitingDrawProcessing( display.status, display.draw_time, display.seconds_to_draw, nowMs, ); const sealedDone = isHallSealedCountdownUi(display.status) && (display.seconds_to_draw ?? 1) === 0; const closeDone = display.status === "open" && (display.seconds_to_close ?? 1) === 0; const trigger = coolingDone ? `${display.draw_no}:cooldown-end` : awaitingDraw ? `${display.draw_no}:awaiting-draw` : sealedDone ? `${display.draw_no}:sealed-end` : closeDone ? `${display.draw_no}:close-end` : null; if (trigger && zeroRefreshKeyRef.current !== trigger) { zeroRefreshKeyRef.current = trigger; void load({ force: true }); } }, [display, nowMs, load]); useEffect(() => { if (display?.draw_no) { zeroRefreshKeyRef.current = null; } }, [display?.draw_no, display?.status]); const needsFastDrawPoll = display != null && isHallAwaitingDrawProcessing( display.status, display.draw_time, display.seconds_to_draw, nowMs, ); // 单一期号 HTTP 轮询:降级 30s / 待开奖 3s / 常态保险 45s(避免多套 interval 叠加) useEffect(() => { const wsOk = isWebSocketConnected && mode === "websocket"; const intervalMs = !wsOk ? 30_000 : needsFastDrawPoll ? 3_000 : 45_000; const initialTimer = window.setTimeout(() => { void load(); }, 0); const intervalId = window.setInterval(() => { void load(); }, intervalMs); setDrawPollingIntervalId(intervalId); return () => { window.clearTimeout(initialTimer); clearDrawPolling(); }; }, [ isWebSocketConnected, mode, needsFastDrawPoll, load, setDrawPollingIntervalId, clearDrawPolling, ]); return { raw, display, serverNowMs, nowMs, error, reload: load, isBettable }; }