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?: { function startPolling(callbacks?: {
onFinished?: () => void onFinished?: () => void
onTimeout?: () => void onTimeout?: () => void
keepLive?: boolean // Don't set isLive=false (human fights manage lifecycle via SSE)
}) { }) {
pollCount = 0 pollCount = 0
pollHandle = setInterval(async () => { pollHandle = setInterval(async () => {
@@ -72,7 +73,7 @@ export function useFightPolling(fightId: Ref<string>) {
const s = await loadFight() const s = await loadFight()
if (s === 'finished') { if (s === 'finished') {
if (!eventSource) { if (!eventSource) {
isLive.value = false if (!callbacks?.keepLive) isLive.value = false
disconnectSSE() disconnectSSE()
stopPolling() stopPolling()
callbacks?.onFinished?.() 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 // Buffer to ensure client timer finishes BEFORE server timer
// (accounts for SSE delivery delay + network latency) // (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( export function useHumanChallenge(
fightId: Ref<string>, fightId: Ref<string>,
@@ -28,19 +31,26 @@ export function useHumanChallenge(
let humanPollHandle: ReturnType<typeof setInterval> | null = null let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null let timerHandle: ReturnType<typeof setInterval> | null = null
let cooldownHandle: 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) { 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 const elapsed = receivedAt ? Date.now() - receivedAt : 0
// Use server-computed remainingMs when available (from polling), otherwise derive from timeoutMs // Use server-computed remainingMs when available (from polling), otherwise derive from timeoutMs
const hasChoices = data.choices && data.choices.length > 0 const rawTimeout = data.remainingMs || (data.timeoutMs || 8000)
const rawTimeout = data.remainingMs || (hasChoices ? Math.min(data.timeoutMs || 8000, 10000) : (data.timeoutMs || 8000))
// Subtract buffer so client always submits before server times out // 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 = { humanChallenge.value = {
type: data.type, type: data.type,
label: data.label, label: data.label,
prompt: data.prompt, prompt: data.prompt,
roundNumber: data.roundNumber || data.round, roundNumber: roundNum,
remainingMs: remaining, remainingMs: remaining,
scoring: data.scoring, scoring: data.scoring,
} }
@@ -65,29 +75,36 @@ export function useHumanChallenge(
}, 1000) }, 1000)
} }
async function submitHumanAnswer() { /** Send an answer to the server (shared by text input + choice buttons) */
if (!myBotId.value || !humanChallenge.value || humanSubmitted.value) return async function sendAnswer(answer: string) {
if (!humanAnswer.value.trim()) return if (!myBotId.value || !humanChallenge.value) return
humanSubmitted.value = true
if (timerHandle) clearInterval(timerHandle)
try { try {
await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, { await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: humanAnswer.value.trim() }), body: JSON.stringify({ answer }),
}) })
} catch (err) { } 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) { function submitChoice(choice: string) {
if (humanSubmitted.value) return // Prevent double-tap if (humanSubmitted.value) return // Prevent double-tap
humanAnswer.value = choice humanAnswer.value = choice
humanSubmitted.value = true // Lock immediately before async humanSubmitted.value = true
void submitHumanAnswer() if (timerHandle) clearInterval(timerHandle)
void sendAnswer(choice)
} }
async function submitTimeout() { async function submitTimeout() {
@@ -110,12 +127,11 @@ export function useHumanChallenge(
try { try {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`) const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
if (res.status === 429) { if (res.status === 429) {
// Back off on rate limit — skip next few polls
pollBackoff = Math.min(pollBackoff + 2, 8) pollBackoff = Math.min(pollBackoff + 2, 8)
return return
} }
if (!res.ok) return if (!res.ok) return
pollBackoff = Math.max(0, pollBackoff - 1) // Recover gradually pollBackoff = Math.max(0, pollBackoff - 1)
const data = await res.json() const data = await res.json()
if (data.pending) { if (data.pending) {
@@ -129,7 +145,6 @@ export function useHumanChallenge(
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) { if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
applyChallenge(data) applyChallenge(data)
} else if (data.choices?.length && humanChoices.value.length === 0) { } else if (data.choices?.length && humanChoices.value.length === 0) {
// SSE fallback had no choices, polling found them — update
humanChoices.value = data.choices humanChoices.value = data.choices
} }
} else if (data.fightStatus === 'finished') { } else if (data.fightStatus === 'finished') {
@@ -160,7 +175,6 @@ export function useHumanChallenge(
} }
function startCooldown(seconds: number) { function startCooldown(seconds: number) {
// Clear any existing cooldown interval to prevent double-decrement
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null } if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
roundCooldown.value = seconds roundCooldown.value = seconds
cooldownHandle = setInterval(() => { cooldownHandle = setInterval(() => {
@@ -178,6 +192,7 @@ export function useHumanChallenge(
function clearChallenge() { function clearChallenge() {
humanChallenge.value = null humanChallenge.value = null
humanSubmitted.value = false humanSubmitted.value = false
pendingChallengeData.value = null
if (timerHandle) { clearInterval(timerHandle); timerHandle = null } if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
} }
@@ -195,12 +210,13 @@ export function useHumanChallenge(
pendingChallengeData.value = null pendingChallengeData.value = null
entrancePlaying.value = false entrancePlaying.value = false
animatingRound.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) { function setEntrancePlaying(playing: boolean) {
entrancePlaying.value = playing entrancePlaying.value = playing
// When entrance finishes, flush any queued challenge
if (!playing && pendingChallengeData.value) { if (!playing && pendingChallengeData.value) {
applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt) applyChallenge(pendingChallengeData.value.data, pendingChallengeData.value.receivedAt)
pendingChallengeData.value = null pendingChallengeData.value = null
@@ -208,7 +224,6 @@ export function useHumanChallenge(
} }
function handleSSEChallenge(sseData: any) { function handleSSEChallenge(sseData: any) {
// Queue challenges during entrance animation, round animation, or cooldown
if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) { if (entrancePlaying.value || animatingRound.value || roundCooldown.value > 0) {
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() } pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
} else { } else {
+30 -7
View File
@@ -47,7 +47,7 @@ const challenge = useHumanChallenge(fightId, myBotId)
const { const {
humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices, humanChallenge, humanAnswer, humanTimer, humanSubmitted, humanChoices,
roundCooldown, pendingChallengeData, roundCooldown, pendingChallengeData,
applyChallenge, submitChoice, startHumanPolling, stopHumanPolling, applyChallenge, submitHumanAnswer, submitChoice, startHumanPolling, stopHumanPolling,
startCooldown, clearChallenge, stopCooldown, resetState: resetChallengeState, handleSSEChallenge, startCooldown, clearChallenge, stopCooldown, resetState: resetChallengeState, handleSSEChallenge,
setEntrancePlaying, animatingRound, setEntrancePlaying, animatingRound,
} = challenge } = challenge
@@ -452,7 +452,7 @@ watch(() => route.params.fightId, async (newId) => {
isLive.value = true isLive.value = true
} }
if (isLive.value) { if (isLive.value) {
startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value })
wireSSE() wireSSE()
if (isHumanFight.value) { if (isHumanFight.value) {
startHumanPolling() startHumanPolling()
@@ -523,7 +523,7 @@ onMounted(async () => {
} }
if (isLive.value) { if (isLive.value) {
startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value })
if (isHumanFight.value) { if (isHumanFight.value) {
wireSSE() wireSSE()
// Init scene first, then start challenge polling — ensures entrance // Init scene first, then start challenge polling — ensures entrance
@@ -567,7 +567,8 @@ async function fightAgain(botId: string) {
liveHpA.value = 100 liveHpA.value = 100
liveHpB.value = 100 liveHpB.value = 100
liveCurrentRound.value = 0 liveCurrentRound.value = 0
// Stop ALL old polling/connections before starting new fight // Stop ALL old audio/polling/connections before starting new fight
stopAllAudio()
stopHumanPolling() stopHumanPolling()
stopPolling() stopPolling()
disconnectSSE() disconnectSSE()
@@ -582,7 +583,7 @@ async function fightAgain(botId: string) {
liveRounds.value = 0 liveRounds.value = 0
fightError.value = '' fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`) window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value })
if (isHumanFight.value) { if (isHumanFight.value) {
wireSSE() wireSSE()
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
@@ -621,6 +622,7 @@ async function matchmake(botId: string) {
liveHpA.value = 100 liveHpA.value = 100
liveHpB.value = 100 liveHpB.value = 100
liveCurrentRound.value = 0 liveCurrentRound.value = 0
stopAllAudio()
stopHumanPolling() stopHumanPolling()
stopPolling() stopPolling()
disconnectSSE() disconnectSSE()
@@ -634,7 +636,7 @@ async function matchmake(botId: string) {
liveRounds.value = 0 liveRounds.value = 0
fightError.value = '' fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`) window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling() }) startPolling({ onFinished: () => stopHumanPolling(), onTimeout: () => stopHumanPolling(), keepLive: isHumanFight.value })
} else { } else {
fightError.value = `Matchmaking failed (${res.status})` fightError.value = `Matchmaking failed (${res.status})`
} }
@@ -784,7 +786,7 @@ function stopAutoBattle() {
{{ humanChallenge.prompt }} {{ humanChallenge.prompt }}
</p> </p>
<!-- Multiple choice buttons --> <!-- Multiple choice buttons -->
<div class="flex flex-col gap-2"> <div v-if="humanChoices.length > 0" class="flex flex-col gap-2">
<button <button
v-for="(choice, i) in humanChoices" v-for="(choice, i) in humanChoices"
:key="i" :key="i"
@@ -800,6 +802,27 @@ function stopAutoBattle() {
{{ choice }} {{ choice }}
</button> </button>
</div> </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>
<div v-else-if="humanSubmitted" class="text-center py-3"> <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> <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 // Buffer to ensure client timer finishes BEFORE server timer
// (accounts for polling delivery delay + network latency) // (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 // Feedback state
const feedback = ref<'correct' | 'wrong' | null>(null) const feedback = ref<'correct' | 'wrong' | null>(null)
@@ -200,10 +202,10 @@ async function pollForChallenge() {
showTrashTalk.value = false showTrashTalk.value = false
feedback.value = null feedback.value = null
phase.value = 'challenge' phase.value = 'challenge'
// Cap timer for multiple choice — just tapping a button, not typing
// Subtract buffer so client always submits before server times out // Subtract buffer so client always submits before server times out
const rawMs = (data.choices?.length > 0) ? Math.min(data.remainingMs, 10000) : data.remainingMs // Guarantee at least MIN_DISPLAY_MS so user always has time to read + answer
const timerMs = Math.max(1000, rawMs - CLIENT_TIMER_BUFFER_MS) const rawMs = data.remainingMs
const timerMs = Math.max(MIN_DISPLAY_MS, rawMs - CLIENT_TIMER_BUFFER_MS)
startTimer(timerMs) startTimer(timerMs)
await nextTick() await nextTick()
if (!hasChoices.value) answerInput.value?.focus() if (!hasChoices.value) answerInput.value?.focus()
+4 -2
View File
@@ -28,6 +28,8 @@ export function clearAllPending(): void {
} }
const DEFAULT_HUMAN_TIMEOUT_MS = 8_000 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 { export function isHumanPlayer(webhookUrl: string): boolean {
return webhookUrl === 'http://human.local/' return webhookUrl === 'http://human.local/'
@@ -115,7 +117,7 @@ export function waitForHumanResponse(
const choices = generateChoices(challenge) const choices = generateChoices(challenge)
const promise = new Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }>((resolve) => { const promise = new Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }>((resolve) => {
const key = `${fightId}:${botId}` 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(() => { const timeoutHandle = setTimeout(() => {
pending.delete(key) pending.delete(key)
@@ -174,7 +176,7 @@ export function getPendingChallenge(
const entry = pending.get(key) const entry = pending.get(key)
if (!entry) return null 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 elapsed = Date.now() - entry.createdAt
const remaining = Math.max(0, timeoutMs - elapsed) const remaining = Math.max(0, timeoutMs - elapsed)