refactor: extract FightPage polling and challenge composables
Split FightPage.vue into useFightPolling (SSE, polling, reconnect) and useHumanChallenge (timer, submission, cooldown) composables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7c8e404cf1
commit
eb4d52438d
@@ -0,0 +1,228 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export interface LiveLogItem {
|
||||
type: string
|
||||
round: number
|
||||
text: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface LiveFightData {
|
||||
botA: any
|
||||
botB: any
|
||||
arena: string
|
||||
arenaInfo?: { name: string }
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
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<any>(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
|
||||
const sseListeners: SSEListenerEntry[] = []
|
||||
|
||||
// --- Load fight data ---
|
||||
async function loadFight(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}`)
|
||||
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
|
||||
}) {
|
||||
pollCount = 0
|
||||
pollHandle = setInterval(async () => {
|
||||
pollCount++
|
||||
const s = await loadFight()
|
||||
if (s === 'finished') {
|
||||
if (!eventSource) {
|
||||
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
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
let sseRetries = 0
|
||||
eventSource.onerror = () => {
|
||||
sseRetries++
|
||||
if (sseRetries > 5 && eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
const delay = Math.min(1000 * 2 ** (sseRetries - 5), 10000)
|
||||
setTimeout(() => {
|
||||
if (isLive.value && !eventSource) 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export function useHumanChallenge(
|
||||
fightId: Ref<string>,
|
||||
myBotId: Ref<string | null>,
|
||||
) {
|
||||
const humanChallenge = ref<{
|
||||
type: string
|
||||
label: string
|
||||
prompt: string
|
||||
roundNumber: number
|
||||
remainingMs: number
|
||||
scoring: string
|
||||
} | null>(null)
|
||||
const humanAnswer = ref('')
|
||||
const humanTimer = ref(5)
|
||||
const humanSubmitted = ref(false)
|
||||
const humanChoices = ref<string[]>([])
|
||||
const roundCooldown = ref(0)
|
||||
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
||||
|
||||
let humanPollHandle: ReturnType<typeof setInterval> | null = null
|
||||
let timerHandle: ReturnType<typeof setInterval> | null = null
|
||||
let cooldownHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function applyChallenge(data: any, receivedAt?: number) {
|
||||
const elapsed = receivedAt ? Date.now() - receivedAt : 0
|
||||
const remaining = Math.max(1000, (data.timeoutMs || 8000) - elapsed)
|
||||
humanChallenge.value = {
|
||||
type: data.type,
|
||||
label: data.label,
|
||||
prompt: data.prompt,
|
||||
roundNumber: data.roundNumber || data.round,
|
||||
remainingMs: remaining,
|
||||
scoring: data.scoring,
|
||||
}
|
||||
humanChoices.value = data.choices || []
|
||||
humanAnswer.value = ''
|
||||
humanSubmitted.value = false
|
||||
humanTimer.value = Math.ceil(remaining / 1000)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
timerHandle = setInterval(() => {
|
||||
humanTimer.value--
|
||||
if (humanTimer.value <= 0) {
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
if (!humanSubmitted.value) {
|
||||
if (humanChoices.value.length > 0 && !humanAnswer.value.trim()) {
|
||||
humanAnswer.value = humanChoices.value[Math.floor(Math.random() * humanChoices.value.length)]
|
||||
}
|
||||
submitHumanAnswer()
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submitHumanAnswer() {
|
||||
if (!myBotId.value || !humanChallenge.value || humanSubmitted.value) return
|
||||
if (!humanAnswer.value.trim()) return
|
||||
|
||||
humanSubmitted.value = true
|
||||
if (timerHandle) clearInterval(timerHandle)
|
||||
|
||||
try {
|
||||
await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer: humanAnswer.value.trim() }),
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[HumanChallenge] submitHumanAnswer failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function submitChoice(choice: string) {
|
||||
humanAnswer.value = choice
|
||||
submitHumanAnswer()
|
||||
}
|
||||
|
||||
async function pollForChallenge() {
|
||||
if (!myBotId.value) return
|
||||
try {
|
||||
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
if (data.pending) {
|
||||
if (roundCooldown.value > 0) {
|
||||
if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) {
|
||||
pendingChallengeData.value = { data, receivedAt: Date.now() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
|
||||
applyChallenge(data)
|
||||
}
|
||||
} else if (data.fightStatus === 'finished') {
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
} else if (humanSubmitted.value) {
|
||||
humanChallenge.value = null
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[HumanChallenge] pollForChallenge failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function startHumanPolling() {
|
||||
if (!myBotId.value) return
|
||||
pollForChallenge()
|
||||
humanPollHandle = setInterval(pollForChallenge, 400)
|
||||
}
|
||||
|
||||
function stopHumanPolling() {
|
||||
if (humanPollHandle) { clearInterval(humanPollHandle); humanPollHandle = null }
|
||||
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
|
||||
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
|
||||
}
|
||||
|
||||
function startCooldown(seconds: number) {
|
||||
roundCooldown.value = seconds
|
||||
cooldownHandle = setInterval(() => {
|
||||
roundCooldown.value--
|
||||
if (roundCooldown.value <= 0) {
|
||||
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
|
||||
if (pendingChallengeData.value) {
|
||||
applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt)
|
||||
pendingChallengeData.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function clearChallenge() {
|
||||
humanChallenge.value = null
|
||||
humanSubmitted.value = false
|
||||
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
humanChallenge.value = null
|
||||
humanAnswer.value = ''
|
||||
humanSubmitted.value = false
|
||||
humanChoices.value = []
|
||||
roundCooldown.value = 0
|
||||
pendingChallengeData.value = null
|
||||
}
|
||||
|
||||
function handleSSEChallenge(sseData: any) {
|
||||
if (roundCooldown.value > 0) {
|
||||
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
|
||||
} else {
|
||||
applyChallenge(sseData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
humanChallenge,
|
||||
humanAnswer,
|
||||
humanTimer,
|
||||
humanSubmitted,
|
||||
humanChoices,
|
||||
roundCooldown,
|
||||
pendingChallengeData,
|
||||
applyChallenge,
|
||||
submitHumanAnswer,
|
||||
submitChoice,
|
||||
pollForChallenge,
|
||||
startHumanPolling,
|
||||
stopHumanPolling,
|
||||
startCooldown,
|
||||
clearChallenge,
|
||||
resetState,
|
||||
handleSSEChallenge,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user