From ea72c097c4d1d964d27448dae183a335d631c495 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 11 Mar 2026 10:34:08 +0000 Subject: [PATCH] human fight sequence fix --- frontend/src/composables/useFightPolling.ts | 3 +- frontend/src/composables/useHumanChallenge.ts | 61 ++++++++++++------- frontend/src/pages/FightPage.vue | 37 ++++++++--- frontend/src/pages/HumanFightPage.vue | 10 +-- server/src/engine/human-responses.ts | 6 +- 5 files changed, 80 insertions(+), 37 deletions(-) diff --git a/frontend/src/composables/useFightPolling.ts b/frontend/src/composables/useFightPolling.ts index 7a6e179..d7f1058 100644 --- a/frontend/src/composables/useFightPolling.ts +++ b/frontend/src/composables/useFightPolling.ts @@ -65,6 +65,7 @@ export function useFightPolling(fightId: Ref) { function startPolling(callbacks?: { onFinished?: () => void onTimeout?: () => void + keepLive?: boolean // Don't set isLive=false (human fights manage lifecycle via SSE) }) { pollCount = 0 pollHandle = setInterval(async () => { @@ -72,7 +73,7 @@ export function useFightPolling(fightId: Ref) { const s = await loadFight() if (s === 'finished') { if (!eventSource) { - isLive.value = false + if (!callbacks?.keepLive) isLive.value = false disconnectSSE() stopPolling() callbacks?.onFinished?.() diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts index 2d99a22..eb7d9e5 100644 --- a/frontend/src/composables/useHumanChallenge.ts +++ b/frontend/src/composables/useHumanChallenge.ts @@ -2,7 +2,10 @@ 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 +const CLIENT_TIMER_BUFFER_MS = 1000 + +// Minimum time (ms) to display a challenge — even if server has less remaining +const MIN_DISPLAY_MS = 5000 export function useHumanChallenge( fightId: Ref, @@ -28,19 +31,26 @@ export function useHumanChallenge( let humanPollHandle: ReturnType | null = null let timerHandle: ReturnType | null = null let cooldownHandle: ReturnType | null = null + // Track which round was last applied to prevent double-apply from SSE+polling + let lastAppliedRound = 0 function applyChallenge(data: any, receivedAt?: number) { + const roundNum = data.roundNumber || data.round + // Deduplicate: don't re-apply the same round + if (roundNum && roundNum === lastAppliedRound && humanChallenge.value) return + lastAppliedRound = roundNum || 0 + const elapsed = receivedAt ? Date.now() - receivedAt : 0 // Use server-computed remainingMs when available (from polling), otherwise derive from timeoutMs - const hasChoices = data.choices && data.choices.length > 0 - const rawTimeout = data.remainingMs || (hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000)) + const rawTimeout = data.remainingMs || (data.timeoutMs || 8000) // Subtract buffer so client always submits before server times out - const remaining = Math.max(1000, rawTimeout - elapsed - CLIENT_TIMER_BUFFER_MS) + // But guarantee at least MIN_DISPLAY_MS so the user always has time to read + answer + const remaining = Math.max(MIN_DISPLAY_MS, rawTimeout - elapsed - CLIENT_TIMER_BUFFER_MS) humanChallenge.value = { type: data.type, label: data.label, prompt: data.prompt, - roundNumber: data.roundNumber || data.round, + roundNumber: roundNum, remainingMs: remaining, scoring: data.scoring, } @@ -65,29 +75,36 @@ export function useHumanChallenge( }, 1000) } - async function submitHumanAnswer() { - if (!myBotId.value || !humanChallenge.value || humanSubmitted.value) return - if (!humanAnswer.value.trim()) return - - humanSubmitted.value = true - if (timerHandle) clearInterval(timerHandle) - + /** Send an answer to the server (shared by text input + choice buttons) */ + async function sendAnswer(answer: string) { + if (!myBotId.value || !humanChallenge.value) return try { await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ answer: humanAnswer.value.trim() }), + body: JSON.stringify({ answer }), }) } catch (err) { - console.warn('[HumanChallenge] submitHumanAnswer failed:', err) + console.warn('[HumanChallenge] sendAnswer failed:', err) } } + /** Submit from text input */ + async function submitHumanAnswer() { + if (!myBotId.value || !humanChallenge.value || humanSubmitted.value) return + if (!humanAnswer.value.trim()) return + humanSubmitted.value = true + if (timerHandle) clearInterval(timerHandle) + await sendAnswer(humanAnswer.value.trim()) + } + + /** Submit from multiple-choice button tap */ function submitChoice(choice: string) { if (humanSubmitted.value) return // Prevent double-tap humanAnswer.value = choice - humanSubmitted.value = true // Lock immediately before async - void submitHumanAnswer() + humanSubmitted.value = true + if (timerHandle) clearInterval(timerHandle) + void sendAnswer(choice) } async function submitTimeout() { @@ -110,12 +127,11 @@ export function useHumanChallenge( try { const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`) if (res.status === 429) { - // Back off on rate limit — skip next few polls pollBackoff = Math.min(pollBackoff + 2, 8) return } if (!res.ok) return - pollBackoff = Math.max(0, pollBackoff - 1) // Recover gradually + pollBackoff = Math.max(0, pollBackoff - 1) const data = await res.json() if (data.pending) { @@ -129,7 +145,6 @@ export function useHumanChallenge( if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) { applyChallenge(data) } else if (data.choices?.length && humanChoices.value.length === 0) { - // SSE fallback had no choices, polling found them — update humanChoices.value = data.choices } } else if (data.fightStatus === 'finished') { @@ -160,7 +175,6 @@ 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(() => { @@ -178,6 +192,7 @@ export function useHumanChallenge( function clearChallenge() { humanChallenge.value = null humanSubmitted.value = false + pendingChallengeData.value = null if (timerHandle) { clearInterval(timerHandle); timerHandle = null } } @@ -195,12 +210,13 @@ export function useHumanChallenge( pendingChallengeData.value = null entrancePlaying.value = false animatingRound.value = false + lastAppliedRound = 0 + if (timerHandle) { clearInterval(timerHandle); timerHandle = null } + if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } } - /** 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 @@ -208,7 +224,6 @@ export function useHumanChallenge( } function handleSSEChallenge(sseData: any) { - // 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 { diff --git a/frontend/src/pages/FightPage.vue b/frontend/src/pages/FightPage.vue index e516743..b47a242 100644 --- a/frontend/src/pages/FightPage.vue +++ b/frontend/src/pages/FightPage.vue @@ -47,7 +47,7 @@ const challenge = useHumanChallenge(fightId, myBotId) const { humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices, roundCooldown, pendingChallengeData, - applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, + applyChallenge, submitHumanAnswer, submitChoice, startHumanPolling, stopHumanPolling, startCooldown, clearChallenge, stopCooldown, resetState: resetChallengeState, handleSSEChallenge, setEntrancePlaying, animatingRound, } = challenge @@ -452,7 +452,7 @@ watch(() => route.params.fightId, async (newId) => { isLive.value = true } if (isLive.value) { - startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value }) wireSSE() if (isHumanFight.value) { startHumanPolling() @@ -523,7 +523,7 @@ onMounted(async () => { } if (isLive.value) { - startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value }) if (isHumanFight.value) { wireSSE() // Init scene first, then start challenge polling — ensures entrance @@ -567,7 +567,8 @@ async function fightAgain(botId: string) { liveHpA.value = 100 liveHpB.value = 100 liveCurrentRound.value = 0 - // Stop ALL old polling/connections before starting new fight + // Stop ALL old audio/polling/connections before starting new fight + stopAllAudio() stopHumanPolling() stopPolling() disconnectSSE() @@ -582,7 +583,7 @@ async function fightAgain(botId: string) { liveRounds.value = 0 fightError.value = '' window.history.replaceState({}, '', `/arena/${data.fightId}`) - startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value }) if (isHumanFight.value) { wireSSE() for (let i = 0; i < 20; i++) { @@ -621,6 +622,7 @@ async function matchmake(botId: string) { liveHpA.value = 100 liveHpB.value = 100 liveCurrentRound.value = 0 + stopAllAudio() stopHumanPolling() stopPolling() disconnectSSE() @@ -634,7 +636,7 @@ async function matchmake(botId: string) { liveRounds.value = 0 fightError.value = '' window.history.replaceState({}, '', `/arena/${data.fightId}`) - startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) + startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value }) } else { fightError.value = `Matchmaking failed (${res.status})` } @@ -784,7 +786,7 @@ function stopAutoBattle() { {{ humanChallenge.prompt }}

-
+
+ +
+ + +

SCORING ROUND...

diff --git a/frontend/src/pages/HumanFightPage.vue b/frontend/src/pages/HumanFightPage.vue index b482110..69468d3 100644 --- a/frontend/src/pages/HumanFightPage.vue +++ b/frontend/src/pages/HumanFightPage.vue @@ -32,7 +32,9 @@ 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 +const CLIENT_TIMER_BUFFER_MS = 1000 +// Minimum display time so user always has time to read + answer +const MIN_DISPLAY_MS = 5000 // Feedback state const feedback = ref<'correct' | 'wrong' | null>(null) @@ -200,10 +202,10 @@ async function pollForChallenge() { showTrashTalk.value = false feedback.value = null phase.value = 'challenge' - // Cap timer for multiple choice — just tapping a button, not typing // 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) + // Guarantee at least MIN_DISPLAY_MS so user always has time to read + answer + const rawMs = data.remainingMs + const timerMs = Math.max(MIN_DISPLAY_MS, rawMs - CLIENT_TIMER_BUFFER_MS) startTimer(timerMs) await nextTick() if (!hasChoices.value) answerInput.value?.focus() diff --git a/server/src/engine/human-responses.ts b/server/src/engine/human-responses.ts index a310dbc..e150b00 100644 --- a/server/src/engine/human-responses.ts +++ b/server/src/engine/human-responses.ts @@ -28,6 +28,8 @@ export function clearAllPending(): void { } const DEFAULT_HUMAN_TIMEOUT_MS = 8_000 +// Extra time for humans: accounts for network latency, SSE/polling delivery delay, reading time +const HUMAN_EXTRA_MS = 5_000 export function isHumanPlayer(webhookUrl: string): boolean { return webhookUrl === 'http://human.local/' @@ -115,7 +117,7 @@ export function waitForHumanResponse( const choices = generateChoices(challenge) const promise = new Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }>((resolve) => { const key = `${fightId}:${botId}` - const timeoutMs = challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS + const timeoutMs = (challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS) + HUMAN_EXTRA_MS const timeoutHandle = setTimeout(() => { pending.delete(key) @@ -174,7 +176,7 @@ export function getPendingChallenge( const entry = pending.get(key) if (!entry) return null - const timeoutMs = entry.challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS + const timeoutMs = (entry.challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS) + HUMAN_EXTRA_MS const elapsed = Date.now() - entry.createdAt const remaining = Math.max(0, timeoutMs - elapsed)