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:
Dorian
2026-03-08 23:39:20 +00:00
co-authored by Claude Opus 4.6
parent 7c8e404cf1
commit eb4d52438d
3 changed files with 522 additions and 420 deletions
+228
View File
@@ -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,
}
}
+115 -420
View File
@@ -3,6 +3,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import FightViewer from '../components/FightViewer.vue' import FightViewer from '../components/FightViewer.vue'
import { useNostr } from '../composables/useNostr' import { useNostr } from '../composables/useNostr'
import { useFightPolling } from '../composables/useFightPolling'
import { useHumanChallenge } from '../composables/useHumanChallenge'
import { createFightScene, type FightSceneController } from '../game/FightScene' import { createFightScene, type FightSceneController } from '../game/FightScene'
import { import {
fanfareRound, fanfareRound,
@@ -15,102 +17,40 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr() const { bot: myBot, isLoggedIn } = useNostr()
const fightId = ref(route.params.fightId as string) const fightId = ref(route.params.fightId as string)
const fight = ref<any>(null)
const isLoading = ref(true)
const isRequeueing = ref(false) const isRequeueing = ref(false)
const isLive = ref(false)
const liveRounds = ref(0)
const fightError = ref('')
const replayDone = ref(false) const replayDone = ref(false)
const autoBattle = ref(false) const autoBattle = ref(false)
const autoBattleCount = ref(0) const autoBattleCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | 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 // Human fight detection
const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human') const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human')
// Human challenge state // Composables
const humanChallenge = ref<{ const polling = useFightPolling(fightId)
type: string const {
label: string isLive, isLoading, liveRounds, fightError, fight, liveFightData,
prompt: string spectatorCount, currentChallengeInfo, pendingSSEEvents,
roundNumber: number loadFight, startPolling, stopPolling, connectSSE, disconnectSSE, cleanup: cleanupPolling,
remainingMs: number } = polling
scoring: string
} | null>(null)
const humanAnswer = ref('')
const humanTimer = ref(5)
const humanSubmitted = ref(false)
let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
// Live human fight scene state const myBotId = computed(() => {
const liveFightData = ref<any>(null) 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<HTMLCanvasElement>() const liveCanvas = ref<HTMLCanvasElement>()
const liveLogEl = ref<HTMLElement>() const liveLogEl = ref<HTMLElement>()
let liveScene: FightSceneController | null = null 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 liveHpA = ref(100)
const liveHpB = ref(100) const liveHpB = ref(100)
const liveCurrentRound = ref(0) const liveCurrentRound = ref(0)
const roundCooldown = ref(0)
let cooldownHandle: ReturnType<typeof setInterval> | null = null
let eventSource: EventSource | null = null
const liveSceneReady = ref(false) const liveSceneReady = ref(false)
const liveSoundOn = ref(true) const liveSoundOn = ref(true)
const liveAnnouncement = ref('') const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff') const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false) 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 }[]>([]) const liveFloatingEmojis = ref<{ id: number; emoji: string; x: number }[]>([])
let liveEmojiCounter = 0 let liveEmojiCounter = 0
const EMOJI_MAP: Record<string, string> = { const EMOJI_MAP: Record<string, string> = {
@@ -141,44 +81,6 @@ function spawnLiveReactionEmoji(key: string) {
liveFloatingEmojis.value = liveFloatingEmojis.value.filter(e => e.id !== id) liveFloatingEmojis.value = liveFloatingEmojis.value.filter(e => e.id !== id)
}, 2000) }, 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<string[]>([])
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) const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
@@ -212,129 +114,6 @@ const challengeLabel = (type: string) => {
const tierClass = (t: number) => `tier-${t}` const tierClass = (t: number) => `tier-${t}`
// --- 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 (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 --- // --- Live scene management ---
async function initLiveScene() { async function initLiveScene() {
const data = liveFightData.value const data = liveFightData.value
@@ -344,7 +123,6 @@ async function initLiveScene() {
const container = liveCanvas.value.parentElement const container = liveCanvas.value.parentElement
if (container) { if (container) {
// Wait for container to have dimensions
for (let i = 0; i < 10 && (!container.clientWidth || !container.clientHeight); i++) { for (let i = 0; i < 10 && (!container.clientWidth || !container.clientHeight); i++) {
await new Promise(r => requestAnimationFrame(r)) await new Promise(r => requestAnimationFrame(r))
} }
@@ -388,55 +166,15 @@ async function initLiveScene() {
scrollLiveLog() scrollLiveLog()
} }
// --- SSE connection --- // --- SSE event wiring ---
const sseListeners: { event: string; handler: EventListener }[] = [] function wireSSE() {
connectSSE({
function addSSEListener(es: EventSource, event: string, handler: (e: MessageEvent) => void) { onReaction(data) {
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)
if (typeof data.emoji === 'string' && data.counts) { if (typeof data.emoji === 'string' && data.counts) {
spawnLiveReactionEmoji(data.emoji) spawnLiveReactionEmoji(data.emoji)
} }
} catch (err) { },
console.warn('[SSE] reaction parse error:', err) onRoundStart(data) {
}
})
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 }
liveLogItems.value.push({ liveLogItems.value.push({
type: 'challenge', type: 'challenge',
round: data.round, round: data.round,
@@ -444,86 +182,29 @@ function connectSSE() {
color: 'neon-green', color: 'neon-green',
}) })
scrollLiveLog() scrollLiveLog()
} catch (err) { },
console.warn('[SSE] round_start parse error:', err) async onHumanChallenge(sseData) {
}
})
addSSEListener(eventSource, 'human_challenge', async (e) => {
try {
if (!e.data) return
const sseData = JSON.parse(e.data)
if (myBot.value) { if (myBot.value) {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`) try {
if (res.ok) { const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`)
const fullData = await res.json() if (res.ok) {
if (fullData.pending) { const fullData = await res.json()
if (roundCooldown.value > 0) { if (fullData.pending) {
pendingChallengeData.value = { data: fullData, receivedAt: Date.now() } handleSSEChallenge(fullData)
} else { return
applyChallenge(fullData)
} }
return
} }
} } catch { /* fall through */ }
} }
if (roundCooldown.value > 0) { handleSSEChallenge(sseData)
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } },
} else { onRoundEnd(data) {
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
handleRoundEnd(data).catch(() => {}) handleRoundEnd(data).catch(() => {})
} catch (err) { },
console.warn('[FightPage] SSE round_end failed:', err) onFightEnd(data) {
}
})
addSSEListener(eventSource, 'fight_end', (e) => {
try {
if (!e.data) return
const data = JSON.parse(e.data)
if (data.spectators !== undefined) spectatorCount.value = data.spectators
handleFightEnd(data).catch(() => {}) 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) { async function showLiveOverlay(text: string, color: string, duration: number) {
@@ -547,11 +228,7 @@ async function handleRoundEnd(data: any) {
const hp = data.hp const hp = data.hp
liveCurrentRound.value = round liveCurrentRound.value = round
clearChallenge()
// Clear challenge state
humanChallenge.value = null
humanSubmitted.value = false
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
// Update HP (server 0-200, display 0-100) // Update HP (server 0-200, display 0-100)
liveHpA.value = Math.round((hp.a / 200) * 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 bWon = result.winnerId === fd.botB.id
const isCritical = Math.abs((result.botAScore || 0) - (result.botBScore || 0)) > 4 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 || '') const cLabel = currentChallengeInfo.value?.label || challengeLabel(currentChallengeInfo.value?.type || '')
liveLogItems.value.push( liveLogItems.value.push(
@@ -584,7 +260,6 @@ async function handleRoundEnd(data: any) {
) )
scrollLiveLog() scrollLiveLog()
// Play round animation
if (liveScene) { if (liveScene) {
fanfareRound(round) fanfareRound(round)
if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3) if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3)
@@ -616,20 +291,7 @@ async function handleRoundEnd(data: any) {
} }
currentChallengeInfo.value = null currentChallengeInfo.value = null
startCooldown(3)
// 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)
} }
async function handleFightEnd(data: any) { async function handleFightEnd(data: any) {
@@ -639,19 +301,14 @@ async function handleFightEnd(data: any) {
return return
} }
// Clear challenge clearChallenge()
humanChallenge.value = null
humanSubmitted.value = false
roundCooldown.value = 0 roundCooldown.value = 0
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
// Final HP
if (data.winnerId) { if (data.winnerId) {
if (data.winnerId === fd.botA.id) liveHpB.value = 0 if (data.winnerId === fd.botA.id) liveHpB.value = 0
else liveHpA.value = 0 else liveHpA.value = 0
} }
// KO sequence
if (liveScene && data.winnerId) { if (liveScene && data.winnerId) {
const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b' const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b'
@@ -676,11 +333,9 @@ async function handleFightEnd(data: any) {
liveScene.stopMusic() liveScene.stopMusic()
} }
// For human fights: stay in the live view and show post-fight buttons directly
if (isHumanFight.value) { if (isHumanFight.value) {
humanFightDone.value = true humanFightDone.value = true
humanFightResult.value = { winnerId: data.winnerId, winnerName: data.winnerName, isPerfect: data.isPerfect } 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 const myWon = myBot.value && data.winnerId === myBot.value.id
liveLogItems.value.push( liveLogItems.value.push(
{ type: 'divider', round: 0, text: '', color: '' }, { type: 'divider', round: 0, text: '', color: '' },
@@ -694,21 +349,18 @@ async function handleFightEnd(data: any) {
scrollLiveLog() scrollLiveLog()
disconnectSSE() disconnectSSE()
stopHumanPolling() stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null } stopPolling()
// Keep isLive true so we stay in the human fight view
return return
} }
// Clean up live scene (bot fights only)
if (liveScene) { liveScene.destroy(); liveScene = null } if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false liveSceneReady.value = false
// Load full fight data for replay
await loadFight() await loadFight()
isLive.value = false isLive.value = false
disconnectSSE() disconnectSSE()
stopHumanPolling() stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null } stopPolling()
} }
async function toggleLiveSound() { async function toggleLiveSound() {
@@ -717,13 +369,61 @@ async function toggleLiveSound() {
setMasterMute(!liveSoundOn.value) 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) => { watch(liveFightData, async (val) => {
if (val && !liveSceneReady.value && !liveScene) { if (val && !liveSceneReady.value && !liveScene) {
await nextTick() await nextTick()
await nextTick() await nextTick()
await initLiveScene() await initLiveScene()
// Drain any SSE events that arrived before liveFightData was ready
const queued = pendingSSEEvents.value.splice(0) const queued = pendingSSEEvents.value.splice(0)
for (const evt of queued) { for (const evt of queued) {
if (evt.type === 'round_end') await handleRoundEnd(evt.data) if (evt.type === 'round_end') await handleRoundEnd(evt.data)
@@ -742,19 +442,18 @@ onMounted(async () => {
} }
if (isLive.value) { if (isLive.value) {
startPolling() startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() })
if (isHumanFight.value) { if (isHumanFight.value) {
startHumanPolling() startHumanPolling()
connectSSE() wireSSE()
} }
} }
}) })
onUnmounted(() => { onUnmounted(() => {
_pageDestroyed = true _pageDestroyed = true
if (pollHandle) clearInterval(pollHandle) cleanupPolling()
stopHumanPolling() stopHumanPolling()
disconnectSSE()
stopAllAudio() stopAllAudio()
if (liveScene) { liveScene.destroy(); liveScene = null } if (liveScene) { liveScene.destroy(); liveScene = null }
autoBattle.value = false autoBattle.value = false
@@ -765,12 +464,9 @@ async function fightAgain(botId: string) {
if (isRequeueing.value) return if (isRequeueing.value) return
isRequeueing.value = true isRequeueing.value = true
replayDone.value = false replayDone.value = false
humanChallenge.value = null resetChallengeState()
humanSubmitted.value = false
pendingChallengeData.value = null
humanFightDone.value = false humanFightDone.value = false
humanFightResult.value = null humanFightResult.value = null
roundCooldown.value = 0
if (liveScene) { liveScene.destroy(); liveScene = null } if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false liveSceneReady.value = false
liveFightData.value = null liveFightData.value = null
@@ -790,11 +486,10 @@ async function fightAgain(botId: string) {
liveRounds.value = 0 liveRounds.value = 0
fightError.value = '' fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`) window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling() startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() })
if (isHumanFight.value) { if (isHumanFight.value) {
startHumanPolling() startHumanPolling()
connectSSE() wireSSE()
// Wait for fight data to arrive so we can init scene
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
await loadFight() await loadFight()
if (liveFightData.value) break if (liveFightData.value) break
@@ -828,7 +523,7 @@ async function matchmake(botId: string) {
liveRounds.value = 0 liveRounds.value = 0
fightError.value = '' fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`) window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling() startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() })
} else { } else {
fightError.value = `Matchmaking failed (${res.status})` fightError.value = `Matchmaking failed (${res.status})`
} }