From eb4d52438dd69216450e3da0033124396b8b9527 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 23:39:20 +0000 Subject: [PATCH] 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 --- frontend/src/composables/useFightPolling.ts | 228 ++++++++ frontend/src/composables/useHumanChallenge.ts | 179 ++++++ frontend/src/pages/FightPage.vue | 535 ++++-------------- 3 files changed, 522 insertions(+), 420 deletions(-) create mode 100644 frontend/src/composables/useFightPolling.ts create mode 100644 frontend/src/composables/useHumanChallenge.ts diff --git a/frontend/src/composables/useFightPolling.ts b/frontend/src/composables/useFightPolling.ts new file mode 100644 index 0000000..f4b7119 --- /dev/null +++ b/frontend/src/composables/useFightPolling.ts @@ -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) { + 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 + const sseListeners: SSEListenerEntry[] = [] + + // --- Load fight data --- + async function loadFight(): Promise { + 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, + } +} diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts new file mode 100644 index 0000000..ca10fb3 --- /dev/null +++ b/frontend/src/composables/useHumanChallenge.ts @@ -0,0 +1,179 @@ +import { ref, type Ref } from 'vue' + +export function useHumanChallenge( + fightId: Ref, + myBotId: Ref, +) { + 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([]) + const roundCooldown = ref(0) + const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) + + let humanPollHandle: ReturnType | null = null + let timerHandle: ReturnType | null = null + let cooldownHandle: ReturnType | 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, + } +} diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index a147432..f5653c3 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -3,6 +3,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import FightViewer from '../components/FightViewer.vue' import { useNostr } from '../composables/useNostr' +import { useFightPolling } from '../composables/useFightPolling' +import { useHumanChallenge } from '../composables/useHumanChallenge' import { createFightScene, type FightSceneController } from '../game/FightScene' import { fanfareRound, @@ -15,102 +17,40 @@ const route = useRoute() const router = useRouter() const { bot: myBot, isLoggedIn } = useNostr() const fightId = ref(route.params.fightId as string) -const fight = ref(null) -const isLoading = ref(true) const isRequeueing = ref(false) -const isLive = ref(false) -const liveRounds = ref(0) -const fightError = ref('') const replayDone = ref(false) const autoBattle = ref(false) const autoBattleCount = ref(0) -let pollHandle: ReturnType | null = null -let pollCount = 0 - -// Watch route param changes (e.g. "watch another fight" navigating to a new fight) -watch(() => route.params.fightId, async (newId) => { - if (!newId || newId === fightId.value) return - // Clean up current state - if (pollHandle) { clearInterval(pollHandle); pollHandle = null } - stopHumanPolling() - disconnectSSE() - stopAllAudio() - if (liveScene) { liveScene.destroy(); liveScene = null } - liveSceneReady.value = false - - // Reset all state - fightId.value = newId as string - fight.value = null - isLoading.value = true - isLive.value = false - liveRounds.value = 0 - fightError.value = '' - replayDone.value = false - autoBattle.value = false - autoBattleCount.value = 0 - liveFightData.value = null - liveLogItems.value = [] - liveHpA.value = 100 - liveHpB.value = 100 - liveCurrentRound.value = 0 - humanChallenge.value = null - humanSubmitted.value = false - humanFightDone.value = false - humanFightResult.value = null - pendingChallengeData.value = null - - // Re-load - const status = await loadFight() - isLoading.value = false - if (status === null || status !== 'finished') { - isLive.value = true - } - if (isLive.value) { - startPolling() - connectSSE() - if (isHumanFight.value) { - startHumanPolling() - await nextTick() - await nextTick() - if (liveFightData.value) await initLiveScene() - } else { - // Bot-vs-bot spectating: init live scene for real-time viewing - if (!liveFightData.value) await loadFight() - if (!liveFightData.value) { - // loadFight sets liveFightData only for human fights; set it for spectating too - const res = await fetch(`/api/fights/${fightId.value}`) - if (res.ok) { - const data = await res.json() - if (data.botA && data.botB) liveFightData.value = data - } - } - await nextTick() - await nextTick() - if (liveFightData.value) await initLiveScene() - } - } -}) // Human fight detection const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human') -// Human challenge state -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) -let humanPollHandle: ReturnType | null = null -let timerHandle: ReturnType | null = null +// Composables +const polling = useFightPolling(fightId) +const { + isLive, isLoading, liveRounds, fightError, fight, liveFightData, + spectatorCount, currentChallengeInfo, pendingSSEEvents, + loadFight, startPolling, stopPolling, connectSSE, disconnectSSE, cleanup: cleanupPolling, +} = polling -// Live human fight scene state -const liveFightData = ref(null) +const myBotId = computed(() => { + if (!isLoggedIn.value || !myBot.value) return null + const f = fight.value || liveFightData.value + if (!f) return null + if (myBot.value.id === f.botA?.id) return f.botA.id + if (myBot.value.id === f.botB?.id) return f.botB.id + return null +}) + +const challenge = useHumanChallenge(fightId, myBotId) +const { + humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices, + roundCooldown, pendingChallengeData, + applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, + startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge, +} = challenge + +// Live scene state const liveCanvas = ref() const liveLogEl = ref() let liveScene: FightSceneController | null = null @@ -118,15 +58,15 @@ const liveLogItems = ref<{ type: string; round: number; text: string; color: str const liveHpA = ref(100) const liveHpB = ref(100) const liveCurrentRound = ref(0) -const roundCooldown = ref(0) -let cooldownHandle: ReturnType | null = null -let eventSource: EventSource | null = null const liveSceneReady = ref(false) const liveSoundOn = ref(true) const liveAnnouncement = ref('') const liveAnnouncementColor = ref('#ffffff') const liveAnnouncementVisible = ref(false) -const spectatorCount = ref(0) +const humanFightDone = ref(false) +const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null) + +// Emoji reactions const liveFloatingEmojis = ref<{ id: number; emoji: string; x: number }[]>([]) let liveEmojiCounter = 0 const EMOJI_MAP: Record = { @@ -141,44 +81,6 @@ function spawnLiveReactionEmoji(key: string) { liveFloatingEmojis.value = liveFloatingEmojis.value.filter(e => e.id !== id) }, 2000) } -const currentChallengeInfo = ref<{ type: string; label: string } | null>(null) -const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) -const pendingSSEEvents = ref<{ type: string; data: any }[]>([]) -const humanChoices = ref([]) -const humanFightDone = ref(false) -const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | 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 submitChoice(choice: string) { - humanAnswer.value = choice - submitHumanAnswer() -} - -const myBotId = computed(() => { - if (!isLoggedIn.value || !myBot.value) return null - const f = fight.value || liveFightData.value - if (!f) return null - if (myBot.value.id === f.botA?.id) return f.botA.id - if (myBot.value.id === f.botB?.id) return f.botB.id - return null -}) const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value) @@ -212,129 +114,6 @@ const challengeLabel = (type: string) => { const tierClass = (t: number) => `tier-${t}` -// --- Load fight data --- -async function loadFight(): Promise { - try { - const res = await fetch(`/api/fights/${fightId.value}`) - if (res.ok) { - const data = await res.json() - liveRounds.value = data.rounds?.length || 0 - - if (isHumanFight.value && !liveFightData.value && data.botA && data.botB) { - liveFightData.value = data - } - - if (data.status === 'finished') { - fight.value = data - } - return data.status - } - } catch (err) { - console.warn('[FightPage] loadFight failed:', err) - } - return null -} - -function startPolling() { - pollCount = 0 - pollHandle = setInterval(async () => { - pollCount++ - const s = await loadFight() - if (s === 'finished') { - // SSE fight_end handles transition for all live fights with SSE connected - if (!eventSource) { - // Fallback: no SSE connected, transition directly - isLive.value = false - disconnectSSE() - stopHumanPolling() - if (pollHandle) { clearInterval(pollHandle); pollHandle = null } - } - } else if (pollCount > 120) { - isLive.value = false - fightError.value = 'Fight took too long. Try refreshing.' - stopHumanPolling() - if (pollHandle) { clearInterval(pollHandle); pollHandle = null } - } - }, 1500) -} - -// --- Human challenge polling --- -function startHumanPolling() { - if (!myBot.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 } -} - -async function pollForChallenge() { - if (!myBot.value) return - try { - const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`) - if (!res.ok) return - const data = await res.json() - - if (data.pending) { - if (roundCooldown.value > 0) { - // Buffer for when cooldown ends - 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('[FightPage] pollForChallenge failed:', err) - } -} - -function startTimer() { - if (timerHandle) clearInterval(timerHandle) - timerHandle = setInterval(() => { - humanTimer.value-- - if (humanTimer.value <= 0) { - if (timerHandle) clearInterval(timerHandle) - if (!humanSubmitted.value) { - // Auto-submit a random choice on timeout - 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 (!myBot.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/${myBot.value.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ answer: humanAnswer.value.trim() }), - }) - } catch (err) { - console.warn('[FightPage] submitHumanAnswer failed:', err) - } -} - // --- Live scene management --- async function initLiveScene() { const data = liveFightData.value @@ -344,7 +123,6 @@ async function initLiveScene() { const container = liveCanvas.value.parentElement if (container) { - // Wait for container to have dimensions for (let i = 0; i < 10 && (!container.clientWidth || !container.clientHeight); i++) { await new Promise(r => requestAnimationFrame(r)) } @@ -388,55 +166,15 @@ async function initLiveScene() { scrollLiveLog() } -// --- SSE connection --- -const sseListeners: { event: string; handler: EventListener }[] = [] - -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() { - 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) +// --- SSE event wiring --- +function wireSSE() { + connectSSE({ + onReaction(data) { if (typeof data.emoji === 'string' && data.counts) { spawnLiveReactionEmoji(data.emoji) } - } 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 } + }, + onRoundStart(data) { liveLogItems.value.push({ type: 'challenge', round: data.round, @@ -444,86 +182,29 @@ function connectSSE() { color: 'neon-green', }) scrollLiveLog() - } catch (err) { - console.warn('[SSE] round_start parse error:', err) - } - }) - - addSSEListener(eventSource, 'human_challenge', async (e) => { - try { - if (!e.data) return - const sseData = JSON.parse(e.data) + }, + async onHumanChallenge(sseData) { if (myBot.value) { - const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`) - if (res.ok) { - const fullData = await res.json() - if (fullData.pending) { - if (roundCooldown.value > 0) { - pendingChallengeData.value = { data: fullData, receivedAt: Date.now() } - } else { - applyChallenge(fullData) + try { + const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`) + if (res.ok) { + const fullData = await res.json() + if (fullData.pending) { + handleSSEChallenge(fullData) + return } - return } - } + } catch { /* fall through */ } } - if (roundCooldown.value > 0) { - pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } - } else { - applyChallenge(sseData) - } - } catch (err) { - console.warn('[FightPage] SSE human_challenge failed:', 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 + handleSSEChallenge(sseData) + }, + onRoundEnd(data) { handleRoundEnd(data).catch(() => {}) - } catch (err) { - console.warn('[FightPage] SSE round_end failed:', 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 + }, + onFightEnd(data) { handleFightEnd(data).catch(() => {}) - } catch (err) { - console.warn('[FightPage] SSE fight_end failed:', 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() - }, 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 } async function showLiveOverlay(text: string, color: string, duration: number) { @@ -547,11 +228,7 @@ async function handleRoundEnd(data: any) { const hp = data.hp liveCurrentRound.value = round - - // Clear challenge state - humanChallenge.value = null - humanSubmitted.value = false - if (timerHandle) { clearInterval(timerHandle); timerHandle = null } + clearChallenge() // Update HP (server 0-200, display 0-100) liveHpA.value = Math.round((hp.a / 200) * 100) @@ -562,7 +239,6 @@ async function handleRoundEnd(data: any) { const bWon = result.winnerId === fd.botB.id const isCritical = Math.abs((result.botAScore || 0) - (result.botBScore || 0)) > 4 - // Use challenge info from round_start SSE, fall back to generic const cLabel = currentChallengeInfo.value?.label || challengeLabel(currentChallengeInfo.value?.type || '') liveLogItems.value.push( @@ -584,7 +260,6 @@ async function handleRoundEnd(data: any) { ) scrollLiveLog() - // Play round animation if (liveScene) { fanfareRound(round) if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3) @@ -616,20 +291,7 @@ async function handleRoundEnd(data: any) { } currentChallengeInfo.value = null - - // Countdown before next round - roundCooldown.value = 3 - cooldownHandle = setInterval(() => { - roundCooldown.value-- - if (roundCooldown.value <= 0) { - if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } - // Apply any buffered challenge from SSE - if (pendingChallengeData.value) { - applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt) - pendingChallengeData.value = null - } - } - }, 1000) + startCooldown(3) } async function handleFightEnd(data: any) { @@ -639,19 +301,14 @@ async function handleFightEnd(data: any) { return } - // Clear challenge - humanChallenge.value = null - humanSubmitted.value = false + clearChallenge() roundCooldown.value = 0 - if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } - // Final HP if (data.winnerId) { if (data.winnerId === fd.botA.id) liveHpB.value = 0 else liveHpA.value = 0 } - // KO sequence if (liveScene && data.winnerId) { const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b' @@ -676,11 +333,9 @@ async function handleFightEnd(data: any) { liveScene.stopMusic() } - // For human fights: stay in the live view and show post-fight buttons directly if (isHumanFight.value) { humanFightDone.value = true humanFightResult.value = { winnerId: data.winnerId, winnerName: data.winnerName, isPerfect: data.isPerfect } - // Add result to battle log const myWon = myBot.value && data.winnerId === myBot.value.id liveLogItems.value.push( { type: 'divider', round: 0, text: '', color: '' }, @@ -694,21 +349,18 @@ async function handleFightEnd(data: any) { scrollLiveLog() disconnectSSE() stopHumanPolling() - if (pollHandle) { clearInterval(pollHandle); pollHandle = null } - // Keep isLive true so we stay in the human fight view + stopPolling() return } - // Clean up live scene (bot fights only) if (liveScene) { liveScene.destroy(); liveScene = null } liveSceneReady.value = false - // Load full fight data for replay await loadFight() isLive.value = false disconnectSSE() stopHumanPolling() - if (pollHandle) { clearInterval(pollHandle); pollHandle = null } + stopPolling() } async function toggleLiveSound() { @@ -717,13 +369,61 @@ async function toggleLiveSound() { setMasterMute(!liveSoundOn.value) } -// Watch for liveFightData becoming available (may arrive via polling after mount) +// Watch route param changes +watch(() => route.params.fightId, async (newId) => { + if (!newId || newId === fightId.value) return + cleanupPolling() + stopHumanPolling() + stopAllAudio() + if (liveScene) { liveScene.destroy(); liveScene = null } + liveSceneReady.value = false + + fightId.value = newId as string + fight.value = null + isLoading.value = true + isLive.value = false + liveRounds.value = 0 + fightError.value = '' + replayDone.value = false + autoBattle.value = false + autoBattleCount.value = 0 + liveFightData.value = null + liveLogItems.value = [] + liveHpA.value = 100 + liveHpB.value = 100 + liveCurrentRound.value = 0 + humanFightDone.value = false + humanFightResult.value = null + resetChallengeState() + + const status = await loadFight() + isLoading.value = false + if (status === null || status !== 'finished') { + isLive.value = true + } + if (isLive.value) { + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) + wireSSE() + if (isHumanFight.value) { + startHumanPolling() + await nextTick() + await nextTick() + if (liveFightData.value) await initLiveScene() + } else { + if (!liveFightData.value) await loadFight() + await nextTick() + await nextTick() + if (liveFightData.value) await initLiveScene() + } + } +}) + +// Watch for liveFightData becoming available watch(liveFightData, async (val) => { if (val && !liveSceneReady.value && !liveScene) { await nextTick() await nextTick() await initLiveScene() - // Drain any SSE events that arrived before liveFightData was ready const queued = pendingSSEEvents.value.splice(0) for (const evt of queued) { if (evt.type === 'round_end') await handleRoundEnd(evt.data) @@ -742,19 +442,18 @@ onMounted(async () => { } if (isLive.value) { - startPolling() + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) if (isHumanFight.value) { startHumanPolling() - connectSSE() + wireSSE() } } }) onUnmounted(() => { _pageDestroyed = true - if (pollHandle) clearInterval(pollHandle) + cleanupPolling() stopHumanPolling() - disconnectSSE() stopAllAudio() if (liveScene) { liveScene.destroy(); liveScene = null } autoBattle.value = false @@ -765,12 +464,9 @@ async function fightAgain(botId: string) { if (isRequeueing.value) return isRequeueing.value = true replayDone.value = false - humanChallenge.value = null - humanSubmitted.value = false - pendingChallengeData.value = null + resetChallengeState() humanFightDone.value = false humanFightResult.value = null - roundCooldown.value = 0 if (liveScene) { liveScene.destroy(); liveScene = null } liveSceneReady.value = false liveFightData.value = null @@ -790,11 +486,10 @@ async function fightAgain(botId: string) { liveRounds.value = 0 fightError.value = '' window.history.replaceState({}, '', `/arena/${data.fightId}`) - startPolling() + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) if (isHumanFight.value) { startHumanPolling() - connectSSE() - // Wait for fight data to arrive so we can init scene + wireSSE() for (let i = 0; i < 20; i++) { await loadFight() if (liveFightData.value) break @@ -828,7 +523,7 @@ async function matchmake(botId: string) { liveRounds.value = 0 fightError.value = '' window.history.replaceState({}, '', `/arena/${data.fightId}`) - startPolling() + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) } else { fightError.value = `Matchmaking failed (${res.status})` }