SSE now always attempts reconnection when fight isn't finished, regardless of isLive.value. Uses exponential backoff (1s, 2s, 4s, max 8s). Moved sseRetries to outer scope to persist across reconnects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
237 lines
6.6 KiB
TypeScript
237 lines
6.6 KiB
TypeScript
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<string>) {
|
|
const isLive = ref(false)
|
|
const isLoading = ref(true)
|
|
const liveRounds = ref(0)
|
|
const fightError = ref('')
|
|
const fight = ref<FightData | null>(null)
|
|
const liveFightData = ref<LiveFightData | null>(null)
|
|
const spectatorCount = ref(0)
|
|
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
|
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
|
|
|
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
|
let pollCount = 0
|
|
let eventSource: EventSource | null = null
|
|
let sseRetries = 0
|
|
const sseListeners: SSEListenerEntry[] = []
|
|
|
|
// --- Load fight data ---
|
|
async function loadFight(): Promise<string | null> {
|
|
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,
|
|
}
|
|
}
|