human fight sequence fix

This commit is contained in:
Dorian
2026-03-11 10:34:08 +00:00
parent 29a0a48eb1
commit ea72c097c4
5 changed files with 80 additions and 37 deletions
+2 -1
View File
@@ -65,6 +65,7 @@ export function useFightPolling(fightId: Ref<string>) {
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<string>) {
const s = await loadFight()
if (s === 'finished') {
if (!eventSource) {
isLive.value = false
if (!callbacks?.keepLive) isLive.value = false
disconnectSSE()
stopPolling()
callbacks?.onFinished?.()
+38 -23
View File
@@ -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<string>,
@@ -28,19 +31,26 @@ export function useHumanChallenge(
let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
let cooldownHandle: ReturnType<typeof setInterval> | 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 {
+30 -7
View File
@@ -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 }}
</p>
<!-- Multiple choice buttons -->
<div class="flex flex-col gap-2">
<div v-if="humanChoices.length > 0" class="flex flex-col gap-2">
<button
v-for="(choice, i) in humanChoices"
:key="i"
@@ -800,6 +802,27 @@ function stopAutoBattle() {
{{ choice }}
</button>
</div>
<!-- Text input for open-ended / creative questions -->
<form v-else @submit.prevent="submitHumanAnswer" class="flex gap-2">
<input
v-model="humanAnswer"
type="text"
class="flex-1 px-3 py-2 bg-black/50 border-2 border-neon-cyan/30 rounded-lg
font-mono text-sm text-text-primary placeholder-text-muted/50
focus:outline-none focus:border-neon-cyan/60"
placeholder="Type your answer..."
autocomplete="off"
/>
<button
type="submit"
:disabled="!humanAnswer.trim()"
class="px-4 py-2 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-bold text-xs tracking-wider rounded-lg
hover:bg-neon-cyan/20 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
>
SEND
</button>
</form>
</div>
<div v-else-if="humanSubmitted" class="text-center py-3">
<p class="font-display text-text-muted text-sm tracking-wider animate-pulse">SCORING ROUND...</p>
+6 -4
View File
@@ -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()