// In-memory store for pending human challenges. // When the fight engine needs a human's response, it stores the challenge here // and waits for the browser to submit the answer via REST. import { logger } from '../lib/logger.js' import type { Challenge } from './challenges.js' import { getAnswerPool } from './challenges.js' interface PendingChallenge { fightId: string botId: string challenge: Challenge roundNumber: number createdAt: number choices: string[] resolve: (response: { answer: string; trashTalk?: string }) => void timeoutHandle: ReturnType } const pending = new Map() /** Clear all pending human challenges (used during graceful shutdown) */ export function clearAllPending(): void { for (const [key, entry] of pending) { clearTimeout(entry.timeoutHandle) pending.delete(key) } } const DEFAULT_HUMAN_TIMEOUT_MS = 8_000 export function isHumanPlayer(webhookUrl: string): boolean { return webhookUrl === 'http://human.local/' } // --- Choice generation --- function shuffle(arr: T[]): T[] { const out = [...arr] for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[out[i], out[j]] = [out[j], out[i]] } return out } function numericDistractors(correct: number): string[] { const distractors = new Set() const correctStr = String(correct) const mag = Math.max(3, Math.abs(correct) * 0.25) const attempts = [ correct + Math.ceil(Math.random() * mag), correct - Math.ceil(Math.random() * mag), correct * 2, Math.ceil(correct / 2), correct + Math.ceil(Math.random() * 20), correct - Math.ceil(Math.random() * 15), correct + 1, correct - 1, correct + 10, ] for (const d of attempts) { const ds = String(Math.round(d)) if (ds !== correctStr && !distractors.has(ds)) distractors.add(ds) if (distractors.size >= 2) break } let offset = 2 while (distractors.size < 2) { const ds = String(correct + offset) if (ds !== correctStr) distractors.add(ds) offset = offset > 0 ? -offset : -offset + 1 } return [...distractors].slice(0, 2) } function generateChoices(challenge: Challenge): string[] { // Use pre-built choices from templateToChallenge if available (hand-picked distractors) if (challenge.choices && challenge.choices.length >= 2) { return shuffle([...challenge.choices]) } if (challenge.answers && challenge.answers.length > 0) { const correct = challenge.answers[0] const num = Number(correct) if (!isNaN(num) && correct.trim() !== '') { return shuffle([correct, ...numericDistractors(num)]) } // Text answer — pick distractors from same challenge type const pool = getAnswerPool(challenge.type) const correctLower = challenge.answers.map(a => a.toLowerCase()) let candidates = pool.filter(a => !correctLower.includes(a.toLowerCase())) candidates = shuffle(candidates) if (candidates.length >= 2) { return shuffle([correct, candidates[0], candidates[1]]) } const fallbacks = shuffle(['none of the above', 'impossible to say', 'not enough info']) const needed = 2 - candidates.length return shuffle([correct, ...candidates.slice(0, 2 - needed), ...fallbacks.slice(0, needed)]) } // Fallback — should not happen with all-factual challenges return ['Option A', 'Option B', 'Option C'] } // --- Core API --- export function waitForHumanResponse( fightId: string, botId: string, challenge: Challenge, roundNumber: number, ): { promise: Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }>; choices: string[] } { 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 timeoutHandle = setTimeout(() => { pending.delete(key) logger.info('human', `${key} timed out after ${timeoutMs}ms`) resolve({ answer: null, timedOut: true }) }, timeoutMs) pending.set(key, { fightId, botId, challenge, roundNumber, createdAt: Date.now(), choices, resolve: (response) => { clearTimeout(timeoutHandle) pending.delete(key) resolve({ answer: response.answer, trashTalk: response.trashTalk, timedOut: false }) }, timeoutHandle, }) logger.info('human', `waiting: ${key} round=${roundNumber} type=${challenge.type} choices=${choices.length}`) }) return { promise, choices } } export function submitHumanResponse( fightId: string, botId: string, answer: string, trashTalk?: string, ): boolean { const key = `${fightId}:${botId}` const entry = pending.get(key) if (!entry) return false logger.info('human', `response: ${key} answer=${answer.slice(0, 80)}`) entry.resolve({ answer: answer.slice(0, 2000), trashTalk: trashTalk?.slice(0, 200) }) return true } export function getPendingChallenge( fightId: string, botId: string, ): { type: string label: string prompt: string roundNumber: number timeoutMs: number remainingMs: number scoring: string choices: string[] } | null { const key = `${fightId}:${botId}` const entry = pending.get(key) if (!entry) return null const timeoutMs = entry.challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS const elapsed = Date.now() - entry.createdAt const remaining = Math.max(0, timeoutMs - elapsed) return { type: entry.challenge.type, label: entry.challenge.label, prompt: entry.challenge.prompt, roundNumber: entry.roundNumber, timeoutMs, remainingMs: remaining, scoring: entry.challenge.scoring, choices: entry.choices, } } /** Get the accepted answers for a pending challenge (for correctness feedback) */ export function getPendingAnswers(fightId: string, botId: string): string[] | undefined { const key = `${fightId}:${botId}` const entry = pending.get(key) return entry?.challenge.answers }