human fight sequence fix

This commit is contained in:
Dorian
2026-03-11 10:02:37 +00:00
parent 974566778e
commit 29a0a48eb1
3 changed files with 69 additions and 18 deletions
+15 -4
View File
@@ -23,6 +23,7 @@ export function useHumanChallenge(
const roundCooldown = ref(0) const roundCooldown = ref(0)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
const entrancePlaying = ref(false) const entrancePlaying = ref(false)
const animatingRound = ref(false)
let humanPollHandle: ReturnType<typeof setInterval> | null = null let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null let timerHandle: ReturnType<typeof setInterval> | null = null
@@ -118,8 +119,8 @@ export function useHumanChallenge(
const data = await res.json() const data = await res.json()
if (data.pending) { if (data.pending) {
// Queue if entrance is playing or cooldown active // Queue if entrance is playing, round animating, or cooldown active
if (entrancePlaying.value || roundCooldown.value > 0) { if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) {
if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) { if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) {
pendingChallengeData.value = { data, receivedAt: Date.now() } pendingChallengeData.value = { data, receivedAt: Date.now() }
} }
@@ -159,6 +160,8 @@ export function useHumanChallenge(
} }
function startCooldown(seconds: number) { function startCooldown(seconds: number) {
// Clear any existing cooldown interval to prevent double-decrement
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
roundCooldown.value = seconds roundCooldown.value = seconds
cooldownHandle = setInterval(() => { cooldownHandle = setInterval(() => {
roundCooldown.value-- roundCooldown.value--
@@ -178,6 +181,11 @@ export function useHumanChallenge(
if (timerHandle) { clearInterval(timerHandle); timerHandle = null } if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
} }
function stopCooldown() {
roundCooldown.value = 0
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
function resetState() { function resetState() {
humanChallenge.value = null humanChallenge.value = null
humanAnswer.value = '' humanAnswer.value = ''
@@ -186,6 +194,7 @@ export function useHumanChallenge(
roundCooldown.value = 0 roundCooldown.value = 0
pendingChallengeData.value = null pendingChallengeData.value = null
entrancePlaying.value = false entrancePlaying.value = false
animatingRound.value = false
} }
/** Mark entrance as playing — queues any challenges that arrive during animation */ /** Mark entrance as playing — queues any challenges that arrive during animation */
@@ -199,8 +208,8 @@ export function useHumanChallenge(
} }
function handleSSEChallenge(sseData: any) { function handleSSEChallenge(sseData: any) {
// Queue challenges during entrance animation or round cooldown // Queue challenges during entrance animation, round animation, or cooldown
if (entrancePlaying.value || roundCooldown.value > 0) { if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) {
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
} else { } else {
applyChallenge(sseData) applyChallenge(sseData)
@@ -216,6 +225,7 @@ export function useHumanChallenge(
roundCooldown, roundCooldown,
pendingChallengeData, pendingChallengeData,
entrancePlaying, entrancePlaying,
animatingRound,
applyChallenge, applyChallenge,
submitHumanAnswer, submitHumanAnswer,
submitChoice, submitChoice,
@@ -224,6 +234,7 @@ export function useHumanChallenge(
stopHumanPolling, stopHumanPolling,
startCooldown, startCooldown,
clearChallenge, clearChallenge,
stopCooldown,
resetState, resetState,
setEntrancePlaying, setEntrancePlaying,
handleSSEChallenge, handleSSEChallenge,
+53 -5
View File
@@ -48,8 +48,8 @@ const {
humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices, humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices,
roundCooldown, pendingChallengeData, roundCooldown, pendingChallengeData,
applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, applyChallenge, submitChoice, startHumanPolling, stopHumanPolling,
startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge, startCooldown, clearChallenge, stopCooldown, resetState: resetChallengeState, handleSSEChallenge,
setEntrancePlaying, setEntrancePlaying, animatingRound,
} = challenge } = challenge
// Live scene state // Live scene state
@@ -187,6 +187,10 @@ async function initLiveScene() {
} }
// --- SSE event wiring --- // --- SSE event wiring ---
// Track in-progress round animation so fight_end can wait for it
let _roundEndPromise: Promise<void> | null = null
let _fightEnding = false
function wireSSE() { function wireSSE() {
connectSSE({ connectSSE({
onReaction(data) { onReaction(data) {
@@ -208,9 +212,12 @@ function wireSSE() {
handleSSEChallenge(sseData) handleSSEChallenge(sseData)
}, },
onRoundEnd(data) { onRoundEnd(data) {
handleRoundEnd(data).catch(() => {}) if (_fightEnding) return // Don't start new round animations after fight is ending
_roundEndPromise = handleRoundEnd(data).catch(() => {})
}, },
onFightEnd(data) { onFightEnd(data) {
if (_fightEnding) return // Deduplicate (only process first fight_end)
_fightEnding = true
handleFightEnd(data).catch(() => {}) handleFightEnd(data).catch(() => {})
}, },
}) })
@@ -238,6 +245,8 @@ async function handleRoundEnd(data: any) {
liveCurrentRound.value = round liveCurrentRound.value = round
clearChallenge() clearChallenge()
// Block new challenges during animation (SSE/polling will queue them)
animatingRound.value = true
// 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)
@@ -304,7 +313,11 @@ async function handleRoundEnd(data: any) {
} }
currentChallengeInfo.value = null 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) { async function handleFightEnd(data: any) {
@@ -314,8 +327,15 @@ async function handleFightEnd(data: any) {
return return
} }
// Wait for any in-progress round animation to finish before KO sequence
if (_roundEndPromise) {
await _roundEndPromise
_roundEndPromise = null
}
clearChallenge() clearChallenge()
roundCooldown.value = 0 animatingRound.value = false
stopCooldown()
if (data.winnerId) { if (data.winnerId) {
if (data.winnerId === fd.botA.id) liveHpB.value = 0 if (data.winnerId === fd.botA.id) liveHpB.value = 0
@@ -400,6 +420,8 @@ async function toggleLiveSound() {
// Watch route param changes // Watch route param changes
watch(() => route.params.fightId, async (newId) => { watch(() => route.params.fightId, async (newId) => {
if (!newId || newId === fightId.value) return if (!newId || newId === fightId.value) return
_fightEnding = false
_roundEndPromise = null
cleanupPolling() cleanupPolling()
stopHumanPolling() stopHumanPolling()
stopAllAudio() 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 --- // --- Mount / Unmount ---
onMounted(async () => { onMounted(async () => {
// Auto-unlock AudioContext on first user interaction (critical for mobile TTS) // Auto-unlock AudioContext on first user interaction (critical for mobile TTS)
@@ -524,6 +555,8 @@ 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
_fightEnding = false
_roundEndPromise = null
resetChallengeState() resetChallengeState()
humanFightDone.value = false humanFightDone.value = false
humanFightResult.value = null humanFightResult.value = null
@@ -576,6 +609,21 @@ async function matchmake(botId: string) {
if (isRequeueing.value) return if (isRequeueing.value) return
isRequeueing.value = true isRequeueing.value = true
replayDone.value = false 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 { try {
const res = await fetch(`/api/fights/matchmake/${botId}`, { method: 'POST' }) const res = await fetch(`/api/fights/matchmake/${botId}`, { method: 'POST' })
if (res.ok) { if (res.ok) {
+1 -9
View File
@@ -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 { try {
while (true) { while (true) {
@@ -495,7 +487,7 @@ fightsRouter.get('/:id/stream', (c) => {
spectatorCounts.set(fightId, current - 1) spectatorCounts.set(fightId, current - 1)
} }
cleanup() cleanup()
cleanupGlobal()
} }
}) })
}) })