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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-420
@@ -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})`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user