Files
botfights/frontend/src/composables/useHumanChallenge.ts
T

197 lines
6.0 KiB
TypeScript
Raw Normal View History

import { ref, type Ref } from 'vue'
export function useHumanChallenge(
fightId: Ref<string>,
myBotId: Ref<string | null>,
) {
const humanChallenge = ref<{
type: string
label: string
prompt: string
roundNumber: number
remainingMs: number
scoring: string
} | null>(null)
const humanAnswer = ref('')
const humanTimer = ref(5)
const humanSubmitted = ref(false)
const humanChoices = ref<string[]>([])
const roundCooldown = ref(0)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
let cooldownHandle: ReturnType<typeof setInterval> | null = null
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
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)
humanChallenge.value = {
type: data.type,
label: data.label,
prompt: data.prompt,
roundNumber: data.roundNumber || data.round,
remainingMs: remaining,
scoring: data.scoring,
}
humanChoices.value = data.choices || []
humanAnswer.value = ''
humanSubmitted.value = false
humanTimer.value = Math.ceil(remaining / 1000)
startTimer()
}
function startTimer() {
if (timerHandle) clearInterval(timerHandle)
timerHandle = setInterval(() => {
humanTimer.value--
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()
}
}
}, 1000)
}
async function submitHumanAnswer() {
if (!myBotId.value || !humanChallenge.value || humanSubmitted.value) return
if (!humanAnswer.value.trim()) return
humanSubmitted.value = true
if (timerHandle) clearInterval(timerHandle)
try {
await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: humanAnswer.value.trim() }),
})
} catch (err) {
console.warn('[HumanChallenge] submitHumanAnswer failed:', err)
}
}
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 {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
if (!res.ok) return
const data = await res.json()
if (data.pending) {
if (roundCooldown.value > 0) {
if (!pendingChallengeData.value || (data.roundNumber && data.roundNumber > (pendingChallengeData.value.data.roundNumber || 0))) {
pendingChallengeData.value = { data, receivedAt: Date.now() }
}
return
}
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
applyChallenge(data)
}
} else if (data.fightStatus === 'finished') {
humanChallenge.value = null
humanSubmitted.value = false
} else if (humanSubmitted.value) {
humanChallenge.value = null
}
} catch (err) {
console.warn('[HumanChallenge] pollForChallenge failed:', err)
}
}
function startHumanPolling() {
if (!myBotId.value) return
void pollForChallenge()
humanPollHandle = setInterval(() => { void pollForChallenge() }, 400)
}
function stopHumanPolling() {
if (humanPollHandle) { clearInterval(humanPollHandle); humanPollHandle = null }
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
function startCooldown(seconds: number) {
roundCooldown.value = seconds
cooldownHandle = setInterval(() => {
roundCooldown.value--
if (roundCooldown.value <= 0) {
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
if (pendingChallengeData.value) {
applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt)
pendingChallengeData.value = null
}
}
}, 1000)
}
function clearChallenge() {
humanChallenge.value = null
humanSubmitted.value = false
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
}
function resetState() {
humanChallenge.value = null
humanAnswer.value = ''
humanSubmitted.value = false
humanChoices.value = []
roundCooldown.value = 0
pendingChallengeData.value = null
}
function handleSSEChallenge(sseData: any) {
if (roundCooldown.value > 0) {
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
} else {
applyChallenge(sseData)
}
}
return {
humanChallenge,
humanAnswer,
humanTimer,
humanSubmitted,
humanChoices,
roundCooldown,
pendingChallengeData,
applyChallenge,
submitHumanAnswer,
submitChoice,
pollForChallenge,
startHumanPolling,
stopHumanPolling,
startCooldown,
clearChallenge,
resetState,
handleSSEChallenge,
}
}