import { ref, type Ref } from 'vue' import type { FightData, FightBot, FightArenaInfo } from '../game/fight/types' export interface LiveLogItem { type: string round: number text: string color: string } export interface LiveFightData { botA: FightBot | null botB: FightBot | null arena: string arenaInfo?: FightArenaInfo | null mode?: string potSats?: number } interface SSEListenerEntry { event: string handler: EventListener } export function useFightPolling(fightId: Ref) { const isLive = ref(false) const isLoading = ref(true) const liveRounds = ref(0) const fightError = ref('') const fight = ref(null) const liveFightData = ref(null) const spectatorCount = ref(0) const currentChallengeInfo = ref<{ type: string; label: string } | null>(null) const pendingSSEEvents = ref<{ type: string; data: any }[]>([]) let pollHandle: ReturnType | null = null let pollCount = 0 let eventSource: EventSource | null = null let sseRetries = 0 const sseListeners: SSEListenerEntry[] = [] // --- Load fight data --- async function loadFight(): Promise { try { const res = await fetch(`/api/fights/${fightId.value}`) if (res.status === 429) return null // Rate limited, skip this poll if (res.ok) { const data = await res.json() liveRounds.value = data.rounds?.length || 0 if (!liveFightData.value && data.botA && data.botB) { liveFightData.value = data } if (data.status === 'finished') { fight.value = data } return data.status } } catch (err) { console.warn('[FightPolling] loadFight failed:', err) } return null } function startPolling(callbacks?: { onFinished?: () => void onTimeout?: () => void keepLive?: boolean // Don't set isLive=false (human fights manage lifecycle via SSE) }) { pollCount = 0 pollHandle = setInterval(async () => { pollCount++ const s = await loadFight() if (s === 'finished') { if (!eventSource) { if (!callbacks?.keepLive) isLive.value = false disconnectSSE() stopPolling() callbacks?.onFinished?.() } } else if (pollCount > 120) { isLive.value = false fightError.value = 'Fight took too long. Try refreshing.' stopPolling() callbacks?.onTimeout?.() } }, 1500) } function stopPolling() { if (pollHandle) { clearInterval(pollHandle); pollHandle = null } } // --- SSE --- function addSSEListener(es: EventSource, event: string, handler: (e: MessageEvent) => void) { es.addEventListener(event, handler as EventListener) sseListeners.push({ event, handler: handler as EventListener }) } function connectSSE(handlers: { onRoundStart?: (data: any) => void onRoundEnd?: (data: any) => void onFightEnd?: (data: any) => void onHumanChallenge?: (data: any) => void onReaction?: (data: any) => void }) { // Close any existing SSE connection before opening a new one disconnectSSE() eventSource = new EventSource(`/api/fights/${fightId.value}/stream`) addSSEListener(eventSource, 'spectator_count', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) if (typeof data.count === 'number') spectatorCount.value = data.count } catch (err) { console.warn('[SSE] spectator_count parse error:', err) } }) addSSEListener(eventSource, 'ping', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) if (typeof data.spectators === 'number') spectatorCount.value = data.spectators } catch (err) { console.warn('[SSE] ping parse error:', err) } }) addSSEListener(eventSource, 'reaction', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) handlers.onReaction?.(data) } catch (err) { console.warn('[SSE] reaction parse error:', err) } }) addSSEListener(eventSource, 'round_start', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) if (!data.challenge || !data.round) return currentChallengeInfo.value = { type: data.challenge.type, label: data.challenge.label } handlers.onRoundStart?.(data) } catch (err) { console.warn('[SSE] round_start parse error:', err) } }) addSSEListener(eventSource, 'human_challenge', async (e) => { try { if (!e.data) return const data = JSON.parse(e.data) handlers.onHumanChallenge?.(data) } catch (err) { console.warn('[SSE] human_challenge parse error:', err) } }) addSSEListener(eventSource, 'round_end', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) if (data.spectators !== undefined) spectatorCount.value = data.spectators handlers.onRoundEnd?.(data) } catch (err) { console.warn('[SSE] round_end parse error:', err) } }) addSSEListener(eventSource, 'fight_end', (e) => { try { if (!e.data) return const data = JSON.parse(e.data) if (data.spectators !== undefined) spectatorCount.value = data.spectators handlers.onFightEnd?.(data) } catch (err) { console.warn('[SSE] fight_end parse error:', err) } }) eventSource.onerror = () => { sseRetries++ if (eventSource) { eventSource.close() eventSource = null } // Always reconnect if fight isn't finished, regardless of isLive if (fight.value?.status === 'finished') return const delay = Math.min(1000 * 2 ** (sseRetries - 1), 8000) // 1s, 2s, 4s, 8s max setTimeout(() => { if (!eventSource && fight.value?.status !== 'finished') connectSSE(handlers) }, delay) } eventSource.onopen = () => { sseRetries = 0 } } function disconnectSSE() { if (eventSource) { for (const { event, handler } of sseListeners) { eventSource.removeEventListener(event, handler) } sseListeners.length = 0 eventSource.close() eventSource = null } spectatorCount.value = 0 } function cleanup() { stopPolling() disconnectSSE() } return { isLive, isLoading, liveRounds, fightError, fight, liveFightData, spectatorCount, currentChallengeInfo, pendingSSEEvents, loadFight, startPolling, stopPolling, connectSSE, disconnectSSE, cleanup, } }