2026-03-07 15:20:14 +00:00
|
|
|
// 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.
|
|
|
|
|
|
2026-03-09 09:06:36 +00:00
|
|
|
import { logger } from '../lib/logger.js'
|
2026-03-07 15:20:14 +00:00
|
|
|
import type { Challenge } from './challenges.js'
|
2026-03-07 19:42:49 +00:00
|
|
|
import { getAnswerPool } from './challenges.js'
|
2026-03-07 15:20:14 +00:00
|
|
|
|
|
|
|
|
interface PendingChallenge {
|
|
|
|
|
fightId: string
|
|
|
|
|
botId: string
|
|
|
|
|
challenge: Challenge
|
|
|
|
|
roundNumber: number
|
|
|
|
|
createdAt: number
|
2026-03-07 19:42:49 +00:00
|
|
|
choices: string[]
|
2026-03-07 15:20:14 +00:00
|
|
|
resolve: (response: { answer: string; trashTalk?: string }) => void
|
|
|
|
|
timeoutHandle: ReturnType<typeof setTimeout>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pending = new Map<string, PendingChallenge>()
|
|
|
|
|
|
2026-03-08 16:23:47 +00:00
|
|
|
/** 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 23:43:08 +00:00
|
|
|
const DEFAULT_HUMAN_TIMEOUT_MS = 8_000
|
2026-03-07 15:20:14 +00:00
|
|
|
|
|
|
|
|
export function isHumanPlayer(webhookUrl: string): boolean {
|
|
|
|
|
return webhookUrl === 'http://human.local/'
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 19:42:49 +00:00
|
|
|
// --- Choice generation ---
|
|
|
|
|
|
|
|
|
|
function shuffle<T>(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<string>()
|
|
|
|
|
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[] {
|
2026-03-11 00:13:31 +00:00
|
|
|
// Use pre-built choices from templateToChallenge if available (hand-picked distractors)
|
|
|
|
|
if (challenge.choices && challenge.choices.length >= 2) {
|
|
|
|
|
return shuffle([...challenge.choices])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 08:35:03 +00:00
|
|
|
if (challenge.answers && challenge.answers.length > 0) {
|
2026-03-07 19:42:49 +00:00
|
|
|
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())
|
2026-03-09 08:28:22 +00:00
|
|
|
let candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
|
|
|
|
|
candidates = shuffle(candidates)
|
2026-03-07 19:42:49 +00:00
|
|
|
|
|
|
|
|
if (candidates.length >= 2) {
|
|
|
|
|
return shuffle([correct, candidates[0], candidates[1]])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 08:35:03 +00:00
|
|
|
const fallbacks = shuffle(['none of the above', 'impossible to say', 'not enough info'])
|
2026-03-07 19:42:49 +00:00
|
|
|
const needed = 2 - candidates.length
|
|
|
|
|
return shuffle([correct, ...candidates.slice(0, 2 - needed), ...fallbacks.slice(0, needed)])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 08:35:03 +00:00
|
|
|
// Fallback — should not happen with all-factual challenges
|
|
|
|
|
return ['Option A', 'Option B', 'Option C']
|
2026-03-07 19:42:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Core API ---
|
|
|
|
|
|
2026-03-07 15:20:14 +00:00
|
|
|
export function waitForHumanResponse(
|
|
|
|
|
fightId: string,
|
|
|
|
|
botId: string,
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
roundNumber: number,
|
2026-03-11 00:13:31 +00:00
|
|
|
): { 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) => {
|
2026-03-07 15:20:14 +00:00
|
|
|
const key = `${fightId}:${botId}`
|
2026-03-07 23:43:08 +00:00
|
|
|
const timeoutMs = challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS
|
2026-03-07 15:20:14 +00:00
|
|
|
|
|
|
|
|
const timeoutHandle = setTimeout(() => {
|
|
|
|
|
pending.delete(key)
|
2026-03-09 09:06:36 +00:00
|
|
|
logger.info('human', `${key} timed out after ${timeoutMs}ms`)
|
2026-03-07 15:20:14 +00:00
|
|
|
resolve({ answer: null, timedOut: true })
|
2026-03-07 23:43:08 +00:00
|
|
|
}, timeoutMs)
|
2026-03-07 15:20:14 +00:00
|
|
|
|
|
|
|
|
pending.set(key, {
|
|
|
|
|
fightId,
|
|
|
|
|
botId,
|
|
|
|
|
challenge,
|
|
|
|
|
roundNumber,
|
|
|
|
|
createdAt: Date.now(),
|
2026-03-07 19:42:49 +00:00
|
|
|
choices,
|
2026-03-07 15:20:14 +00:00
|
|
|
resolve: (response) => {
|
|
|
|
|
clearTimeout(timeoutHandle)
|
|
|
|
|
pending.delete(key)
|
|
|
|
|
resolve({ answer: response.answer, trashTalk: response.trashTalk, timedOut: false })
|
|
|
|
|
},
|
|
|
|
|
timeoutHandle,
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-09 09:06:36 +00:00
|
|
|
logger.info('human', `waiting: ${key} round=${roundNumber} type=${challenge.type} choices=${choices.length}`)
|
2026-03-07 15:20:14 +00:00
|
|
|
})
|
2026-03-11 00:13:31 +00:00
|
|
|
return { promise, choices }
|
2026-03-07 15:20:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-03-09 09:06:36 +00:00
|
|
|
logger.info('human', `response: ${key} answer=${answer.slice(0, 80)}`)
|
2026-03-07 15:20:14 +00:00
|
|
|
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
|
2026-03-07 19:42:49 +00:00
|
|
|
choices: string[]
|
2026-03-07 15:20:14 +00:00
|
|
|
} | null {
|
|
|
|
|
const key = `${fightId}:${botId}`
|
|
|
|
|
const entry = pending.get(key)
|
|
|
|
|
if (!entry) return null
|
|
|
|
|
|
2026-03-07 23:43:08 +00:00
|
|
|
const timeoutMs = entry.challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS
|
2026-03-07 15:20:14 +00:00
|
|
|
const elapsed = Date.now() - entry.createdAt
|
2026-03-07 23:43:08 +00:00
|
|
|
const remaining = Math.max(0, timeoutMs - elapsed)
|
2026-03-07 15:20:14 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: entry.challenge.type,
|
|
|
|
|
label: entry.challenge.label,
|
|
|
|
|
prompt: entry.challenge.prompt,
|
|
|
|
|
roundNumber: entry.roundNumber,
|
2026-03-07 23:43:08 +00:00
|
|
|
timeoutMs,
|
2026-03-07 15:20:14 +00:00
|
|
|
remainingMs: remaining,
|
|
|
|
|
scoring: entry.challenge.scoring,
|
2026-03-07 19:42:49 +00:00
|
|
|
choices: entry.choices,
|
2026-03-07 15:20:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 08:28:22 +00:00
|
|
|
|
|
|
|
|
/** 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
|
|
|
|
|
}
|