Files
botfights/server/src/engine/human-responses.ts
T

199 lines
6.0 KiB
TypeScript
Raw Normal View History

// 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<typeof setTimeout>
}
const pending = new Map<string, PendingChallenge>()
/** 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<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) {
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]])
}
2026-03-11 08:35:03 +00:00
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)])
}
2026-03-11 08:35:03 +00:00
// 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,
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) => {
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}`)
})
2026-03-11 00:13:31 +00:00
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
}