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)