From c7a1f6cb0bde2aa06572995f4467987978d199b2 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 08:39:27 +0000 Subject: [PATCH] fix: resolve 9 MC bugs in HumanFightPage and useHumanChallenge - BUG-1: Prevent double-tap by locking phase before async submit - BUG-2: Submit timeout notification to server when timer expires - BUG-3: Distinct "TIME'S UP!" visual vs "ANSWER SUBMITTED" - BUG-4: Track consecutive poll failures, show connection lost banner - BUG-5: Add A-D / 1-4 keyboard shortcuts for MC choices - BUG-6: Use choice text as v-for key instead of array index - BUG-7: Deadline-based timer (250ms tick) prevents drift - BUG-8: Validate choice is in current choices before submit - BUG-9: Submit empty timeout instead of random choice on expiry Co-Authored-By: Claude Opus 4.6 --- frontend/src/composables/useHumanChallenge.ts | 22 ++- frontend/src/pages/HumanFightPage.vue | 145 +++++++++++++----- 2 files changed, 121 insertions(+), 46 deletions(-) diff --git a/frontend/src/composables/useHumanChallenge.ts b/frontend/src/composables/useHumanChallenge.ts index f9092f2..4a37710 100644 --- a/frontend/src/composables/useHumanChallenge.ts +++ b/frontend/src/composables/useHumanChallenge.ts @@ -48,10 +48,9 @@ export function useHumanChallenge( 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)] - } - void submitHumanAnswer() + // BUG-9: Submit empty timeout instead of random choice + humanSubmitted.value = true + void submitTimeout() } } }, 1000) @@ -76,10 +75,25 @@ export function useHumanChallenge( } function submitChoice(choice: string) { + if (humanSubmitted.value) return // Prevent double-tap humanAnswer.value = choice + humanSubmitted.value = true // Lock immediately before async void submitHumanAnswer() } + async function submitTimeout() { + 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: '', timeout: true }), + }) + } catch { + // Server will time out on its own + } + } + async function pollForChallenge() { if (!myBotId.value) return try { diff --git a/frontend/src/pages/HumanFightPage.vue b/frontend/src/pages/HumanFightPage.vue index 62484a4..f435c5d 100644 --- a/frontend/src/pages/HumanFightPage.vue +++ b/frontend/src/pages/HumanFightPage.vue @@ -11,7 +11,7 @@ const { bot: myBot, isLoggedIn } = useNostr() const fightId = ref(route.params.fightId as string) const fight = ref(null) -const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting') +const phase = ref<'waiting' | 'challenge' | 'submitted' | 'timeout' | 'between' | 'finished' | 'replay' | 'error'>('waiting') const error = ref('') // Challenge state @@ -34,6 +34,10 @@ const showTrashTalk = ref(false) const feedback = ref<'correct' | 'wrong' | null>(null) const feedbackTimer = ref | null>(null) +// Polling error tracking (BUG-4 fix) +const consecutivePollErrors = ref(0) +const connectionLost = ref(false) + // Round results tracking const roundResults = ref | null = null let timerHandle: ReturnType | null = null +let timerDeadline = 0 // BUG-7: deadline-based timer const answerInput = ref(null) const myBotId = computed(() => myBot.value?.id || '') @@ -75,14 +80,30 @@ onMounted(() => { return } startPolling() + window.addEventListener('keydown', handleKeyboard) }) onUnmounted(() => { stopPolling() stopTimer() if (feedbackTimer.value) clearTimeout(feedbackTimer.value) + window.removeEventListener('keydown', handleKeyboard) }) +// BUG-5: Keyboard shortcuts for MC choices (A-D or 1-4) +function handleKeyboard(e: KeyboardEvent) { + if (phase.value !== 'challenge' || !currentChallenge.value?.choices?.length) return + const choices = currentChallenge.value.choices + let idx = -1 + const key = e.key.toLowerCase() + if (key >= 'a' && key <= 'd') idx = key.charCodeAt(0) - 97 + else if (key >= '1' && key <= '4') idx = parseInt(key) - 1 + if (idx >= 0 && idx < choices.length) { + e.preventDefault() + selectChoice(choices[idx]) + } +} + function startPolling() { pollForChallenge() pollHandle = setInterval(pollForChallenge, 600) @@ -92,19 +113,28 @@ function stopPolling() { if (pollHandle) { clearInterval(pollHandle); pollHandle = null } } +// BUG-7: Deadline-based timer instead of drift-prone setInterval counter function startTimer(remainingMs: number) { stopTimer() - remainingSeconds.value = Math.ceil(remainingMs / 1000) + timerDeadline = Date.now() + remainingMs + updateTimerDisplay() timerHandle = setInterval(() => { - remainingSeconds.value-- + updateTimerDisplay() if (remainingSeconds.value <= 0) { stopTimer() if (phase.value === 'challenge') { - phase.value = 'submitted' + // BUG-2: Submit timeout to server so fight can proceed + phase.value = 'timeout' showFeedback('wrong') + submitTimeout() } } - }, 1000) + }, 250) // Check more frequently for precision +} + +function updateTimerDisplay() { + const remaining = Math.max(0, timerDeadline - Date.now()) + remainingSeconds.value = Math.ceil(remaining / 1000) } function stopTimer() { @@ -117,6 +147,7 @@ function showFeedback(type: 'correct' | 'wrong') { feedbackTimer.value = setTimeout(() => { feedback.value = null }, 1500) } +// BUG-4: Track consecutive poll failures, show connection lost async function pollForChallenge() { if (!myBotId.value || phase.value === 'finished' || phase.value === 'replay' || phase.value === 'error') return @@ -125,6 +156,10 @@ async function pollForChallenge() { if (!res.ok) return const data = await res.json() + // Reset error tracking on success + consecutivePollErrors.value = 0 + connectionLost.value = false + if (data.pending) { if (phase.value !== 'challenge' || currentChallenge.value?.roundNumber !== data.roundNumber) { currentChallenge.value = data @@ -143,25 +178,35 @@ async function pollForChallenge() { stopTimer() await loadFight() phase.value = 'finished' - } else if (phase.value === 'submitted') { + } else if (phase.value === 'submitted' || phase.value === 'timeout') { phase.value = 'between' } } catch { - // Network hiccup, keep polling + consecutivePollErrors.value++ + if (consecutivePollErrors.value >= 5) { + connectionLost.value = true + } } } +// BUG-1: Prevent double-tap by checking phase before processing function selectChoice(choice: string) { + if (phase.value !== 'challenge') return + // BUG-8: Validate choice is in current choices + if (!currentChallenge.value?.choices?.includes(choice)) return answer.value = choice + phase.value = 'submitted' // Immediately lock out further taps submitAnswer() } async function submitAnswer() { - if (!currentChallenge.value || phase.value !== 'challenge') return + if (!currentChallenge.value) return if (!answer.value.trim()) return stopTimer() - phase.value = 'submitted' + // Phase already set to 'submitted' in selectChoice for MC, + // but set it here too for text input path + if (phase.value === 'challenge') phase.value = 'submitted' try { const res = await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, { @@ -175,7 +220,6 @@ async function submitAnswer() { if (res.ok) { const data = await res.json() - // Show feedback if server tells us if answer was right if (data.correct !== undefined) { showFeedback(data.correct ? 'correct' : 'wrong') } @@ -188,6 +232,20 @@ async function submitAnswer() { } } +// BUG-2: Submit timeout notification to server +async function submitTimeout() { + if (!currentChallenge.value || !myBotId.value) return + try { + await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answer: '', timeout: true }), + }) + } catch { + // Server will time out on its own if this fails + } +} + async function loadFight() { try { const res = await fetch(`/api/fights/${fightId.value}`) @@ -244,6 +302,11 @@ function goToArena() { + +
+

Connection lost — reconnecting...

+
+
@@ -253,7 +316,7 @@ function goToArena() {

Waiting for first challenge

- +
@@ -270,7 +333,7 @@ function goToArena() {
+

{{ currentChallenge.prompt }}

- + + +
- - + +

+ Press A-D or 1-4 to select +

- -
+ +
-

- {{ phase === 'submitted' ? 'ANSWER SUBMITTED' : 'NEXT ROUND...' }} -

+

ANSWER SUBMITTED

Waiting for round result

+ +
+
+

TIME'S UP!

+

Waiting for round result

+
+ + +
+
+

NEXT ROUND...

+

Preparing challenge

+
+