diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts index 3415e38..b2e7fb0 100644 --- a/frontend/src/composables/useHumanChallenge.ts +++ b/frontend/src/composables/useHumanChallenge.ts @@ -1,5 +1,9 @@ import { ref, type Ref } from 'vue' +// Buffer to ensure client timer finishes BEFORE server timer +// (accounts for SSE delivery delay + network latency) +const CLIENT_TIMER_BUFFER_MS = 1500 + export function useHumanChallenge( fightId: Ref, myBotId: Ref, @@ -18,6 +22,7 @@ export function useHumanChallenge( const humanChoices = ref([]) const roundCooldown = ref(0) const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null) + const entrancePlaying = ref(false) let humanPollHandle: ReturnType | null = null let timerHandle: ReturnType | null = null @@ -25,10 +30,11 @@ export function useHumanChallenge( function applyChallenge(data: any, receivedAt?: number) { const elapsed = receivedAt ? Date.now() - receivedAt : 0 - // Cap timeout for multiple choice — you're just tapping a button, not typing + // Use server-computed remainingMs when available (from polling), otherwise derive from timeoutMs const hasChoices = data.choices && data.choices.length > 0 - const baseTimeout = hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000) - const remaining = Math.max(1000, baseTimeout - elapsed) + const rawTimeout = data.remainingMs || (hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000)) + // Subtract buffer so client always submits before server times out + const remaining = Math.max(1000, rawTimeout - elapsed - CLIENT_TIMER_BUFFER_MS) humanChallenge.value = { type: data.type, label: data.label, @@ -51,7 +57,6 @@ export function useHumanChallenge( if (humanTimer.value <= 0) { if (timerHandle) clearInterval(timerHandle) if (!humanSubmitted.value) { - // BUG-9: Submit empty timeout instead of random choice humanSubmitted.value = true void submitTimeout() } @@ -113,7 +118,8 @@ export function useHumanChallenge( const data = await res.json() if (data.pending) { - if (roundCooldown.value > 0) { + // Queue if entrance is playing or cooldown active + if (entrancePlaying.value || roundCooldown.value > 0) { if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) { pendingChallengeData.value = { data, receivedAt: Date.now() } } @@ -179,10 +185,22 @@ export function useHumanChallenge( humanChoices.value = [] roundCooldown.value = 0 pendingChallengeData.value = null + entrancePlaying.value = false + } + + /** Mark entrance as playing — queues any challenges that arrive during animation */ + function setEntrancePlaying(playing: boolean) { + entrancePlaying.value = playing + // When entrance finishes, flush any queued challenge + if (!playing && pendingChallengeData.value) { + applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt) + pendingChallengeData.value = null + } } function handleSSEChallenge(sseData: any) { - if (roundCooldown.value > 0) { + // Queue challenges during entrance animation or round cooldown + if (entrancePlaying.value || roundCooldown.value > 0) { pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } } else { applyChallenge(sseData) @@ -197,6 +215,7 @@ export function useHumanChallenge( humanChoices, roundCooldown, pendingChallengeData, + entrancePlaying, applyChallenge, submitHumanAnswer, submitChoice, @@ -206,6 +225,7 @@ export function useHumanChallenge( startCooldown, clearChallenge, resetState, + setEntrancePlaying, handleSSEChallenge, } } diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index de7bec9..81fdc26 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -49,6 +49,7 @@ const { roundCooldown, pendingChallengeData, applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, startCooldown, clearChallenge, resetState: resetChallengeState, handleSSEChallenge, + setEntrancePlaying, } = challenge // Live scene state @@ -155,6 +156,10 @@ async function initLiveScene() { await ensureAudioContext() liveScene.startMusic() } + // Let Kaplay register game objects before entrance (prevents invisible fighters) + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + // Block challenges during entrance so the animation plays fully + setEntrancePlaying(true) announceDeepIntro() try { await liveScene.playEntrance() @@ -163,6 +168,7 @@ async function initLiveScene() { } // Safety: ensure fighters are visible after entrance (prevents invisible characters on mobile) liveScene._resetPositions() + setEntrancePlaying(false) } liveLogItems.value.push( diff --git a/frontend/src/pages/HumanFightPage.vue b/frontend/src/pages/HumanFightPage.vue index 464ba7d..b482110 100644 --- a/frontend/src/pages/HumanFightPage.vue +++ b/frontend/src/pages/HumanFightPage.vue @@ -30,6 +30,10 @@ const trashTalk = ref('') const remainingSeconds = ref(45) const showTrashTalk = ref(false) +// Buffer to ensure client timer finishes BEFORE server timer +// (accounts for polling delivery delay + network latency) +const CLIENT_TIMER_BUFFER_MS = 1500 + // Feedback state const feedback = ref<'correct' | 'wrong' | null>(null) const feedbackTimer = ref | null>(null) @@ -197,7 +201,9 @@ async function pollForChallenge() { feedback.value = null phase.value = 'challenge' // Cap timer for multiple choice — just tapping a button, not typing - const timerMs = (data.choices?.length > 0) ? Math.min(data.remainingMs, 10000) : data.remainingMs + // Subtract buffer so client always submits before server times out + const rawMs = (data.choices?.length > 0) ? Math.min(data.remainingMs, 10000) : data.remainingMs + const timerMs = Math.max(1000, rawMs - CLIENT_TIMER_BUFFER_MS) startTimer(timerMs) await nextTick() if (!hasChoices.value) answerInput.value?.focus()