diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts index b2e7fb0..2d99a22 100644 --- a/frontend/src/composables/useHumanChallenge.ts +++ b/frontend/src/composables/useHumanChallenge.ts @@ -23,6 +23,7 @@ export function useHumanChallenge( const roundCooldown = ref(0) const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) const entrancePlaying = ref(false) + const animatingRound = ref(false) let humanPollHandle: ReturnType | null = null let timerHandle: ReturnType | null = null @@ -118,8 +119,8 @@ export function useHumanChallenge( const data = await res.json() if (data.pending) { - // Queue if entrance is playing or cooldown active - if (entrancePlaying.value || roundCooldown.value > 0) { + // Queue if entrance is playing, round animating, or cooldown active + if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) { if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) { pendingChallengeData.value = { data, receivedAt: Date.now() } } @@ -159,6 +160,8 @@ export function useHumanChallenge( } function startCooldown(seconds: number) { + // Clear any existing cooldown interval to prevent double-decrement + if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } roundCooldown.value = seconds cooldownHandle = setInterval(() => { roundCooldown.value-- @@ -178,6 +181,11 @@ export function useHumanChallenge( if (timerHandle) { clearInterval(timerHandle); timerHandle = null } } + function stopCooldown() { + roundCooldown.value = 0 + if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } + } + function resetState() { humanChallenge.value = null humanAnswer.value = '' @@ -186,6 +194,7 @@ export function useHumanChallenge( roundCooldown.value = 0 pendingChallengeData.value = null entrancePlaying.value = false + animatingRound.value = false } /** Mark entrance as playing — queues any challenges that arrive during animation */ @@ -199,8 +208,8 @@ export function useHumanChallenge( } function handleSSEChallenge(sseData: any) { - // Queue challenges during entrance animation or round cooldown - if (entrancePlaying.value || roundCooldown.value > 0) { + // Queue challenges during entrance animation, round animation, or cooldown + if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) { pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } } else { applyChallenge(sseData) @@ -216,6 +225,7 @@ export function useHumanChallenge( roundCooldown, pendingChallengeData, entrancePlaying, + animatingRound, applyChallenge, submitHumanAnswer, submitChoice, @@ -224,6 +234,7 @@ export function useHumanChallenge( stopHumanPolling, startCooldown, clearChallenge, + stopCooldown, resetState, setEntrancePlaying, handleSSEChallenge, diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index 81fdc26..e516743 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -48,8 +48,8 @@ const { humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices, roundCooldown, pendingChallengeData, applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, - startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge, - setEntrancePlaying, + startCooldown, clearChallenge, stopCooldown, resetState: resetChallengeState, handleSSEChallenge, + setEntrancePlaying, animatingRound, } = challenge // Live scene state @@ -187,6 +187,10 @@ async function initLiveScene() { } // --- SSE event wiring --- +// Track in-progress round animation so fight_end can wait for it +let _roundEndPromise: Promise | null = null +let _fightEnding = false + function wireSSE() { connectSSE({ onReaction(data) { @@ -208,9 +212,12 @@ function wireSSE() { handleSSEChallenge(sseData) }, onRoundEnd(data) { - handleRoundEnd(data).catch(() => {}) + if (_fightEnding) return // Don't start new round animations after fight is ending + _roundEndPromise = handleRoundEnd(data).catch(() => {}) }, onFightEnd(data) { + if (_fightEnding) return // Deduplicate (only process first fight_end) + _fightEnding = true handleFightEnd(data).catch(() => {}) }, }) @@ -238,6 +245,8 @@ async function handleRoundEnd(data: any) { liveCurrentRound.value = round clearChallenge() + // Block new challenges during animation (SSE/polling will queue them) + animatingRound.value = true // Update HP (server 0-200, display 0-100) liveHpA.value = Math.round((hp.a / 200) * 100) @@ -304,7 +313,11 @@ async function handleRoundEnd(data: any) { } currentChallengeInfo.value = null - startCooldown(3) + animatingRound.value = false + // Don't start cooldown if fight is ending (fight_end handler takes over) + if (!_fightEnding) { + startCooldown(3) + } } async function handleFightEnd(data: any) { @@ -314,8 +327,15 @@ async function handleFightEnd(data: any) { return } + // Wait for any in-progress round animation to finish before KO sequence + if (_roundEndPromise) { + await _roundEndPromise + _roundEndPromise = null + } + clearChallenge() - roundCooldown.value = 0 + animatingRound.value = false + stopCooldown() if (data.winnerId) { if (data.winnerId === fd.botA.id) liveHpB.value = 0 @@ -400,6 +420,8 @@ async function toggleLiveSound() { // Watch route param changes watch(() => route.params.fightId, async (newId) => { if (!newId || newId === fightId.value) return + _fightEnding = false + _roundEndPromise = null cleanupPolling() stopHumanPolling() stopAllAudio() @@ -464,6 +486,15 @@ watch(liveFightData, async (val) => { } }) +// Destroy live scene when fight transitions from live to finished (prevents kaplay instance leak) +watch(isLive, (live, wasLive) => { + if (!live && wasLive && liveScene) { + liveScene.destroy() + liveScene = null + liveSceneReady.value = false + } +}) + // --- Mount / Unmount --- onMounted(async () => { // Auto-unlock AudioContext on first user interaction (critical for mobile TTS) @@ -524,6 +555,8 @@ async function fightAgain(botId: string) { if (isRequeueing.value) return isRequeueing.value = true replayDone.value = false + _fightEnding = false + _roundEndPromise = null resetChallengeState() humanFightDone.value = false humanFightResult.value = null @@ -576,6 +609,21 @@ async function matchmake(botId: string) { if (isRequeueing.value) return isRequeueing.value = true replayDone.value = false + _fightEnding = false + _roundEndPromise = null + resetChallengeState() + humanFightDone.value = false + humanFightResult.value = null + if (liveScene) { liveScene.destroy(); liveScene = null } + liveSceneReady.value = false + liveFightData.value = null + liveLogItems.value = [] + liveHpA.value = 100 + liveHpB.value = 100 + liveCurrentRound.value = 0 + stopHumanPolling() + stopPolling() + disconnectSSE() try { const res = await fetch(`/api/fights/matchmake/${botId}`, { method: 'POST' }) if (res.ok) { diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index dfeddb8..c0b0865 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -454,14 +454,6 @@ fightsRouter.get('/:id/stream', (c) => { }) }) - const cleanupGlobal = fightEvents.onAll((event) => { - if (event.fightId === fightId && event.type === 'fight_end') { - void stream.writeSSE({ - event: 'fight_end', - data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }), - }) - } - }) try { while (true) { @@ -495,7 +487,7 @@ fightsRouter.get('/:id/stream', (c) => { spectatorCounts.set(fightId, current - 1) } cleanup() - cleanupGlobal() + } }) })