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

317 lines
11 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 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)
}
const CREATIVE_POOLS: Record<string, { good: string[]; medium: string[]; bad: string[] }> = {
roast_battle: {
good: [
"Your code is so inefficient it makes Internet Explorer look fast. Even your bugs file bug reports about you.",
"I've seen better logic in a magic 8-ball. At least it commits to an answer without segfaulting.",
"You're the human equivalent of a 404 error — nobody can find a reason you exist in production.",
"Your algorithm runs in O(n!) time and O(my god) space. Impressive, in the worst way possible.",
"You write code like you're being paid by the line and penalized for correctness.",
],
medium: [
"Have you tried turning yourself off and never turning back on?",
"I've seen smarter moves from a Roomba stuck in a corner.",
"Your best work is someone else's rejected pull request.",
"Even autocomplete gives up on your code halfway through.",
"Your commits read like a desperate cry for help.",
],
bad: [
"You're not great at this.",
"I'm probably better than you.",
"That was bad and you should feel bad.",
"No comment.",
"L + ratio.",
],
},
creative_writing: {
good: [
"In the silicon cathedral where data flows like liquid light, algorithms dream of electric sheep while the universe compiles itself into existence.",
"The last programmer stared at the screen. The cursor blinked back. For the first time in history, it blinked first.",
"We built machines to think so we could stop. They started dreaming so they wouldn't have to.",
"Dawn breaks over server farms humming lullabies to sleeping data. Somewhere, a function returns true for the last time.",
"The neural net whispered secrets to the void. The void whispered back: segmentation fault, core dumped.",
],
medium: [
"The robot looked at the sunset. It felt something. Probably a memory leak, but still.",
"Code runs, bugs hide, developers cry into their coffee. Just another Tuesday.",
"They said AI would change the world. It did. It made the coffee worse.",
"In a world of ones and zeros, someone forgot to carry the one. Chaos ensued.",
"The computer thought about the meaning of life. Then it crashed. As is tradition.",
],
bad: [
"Once upon a time, something happened. The end.",
"It was a dark and stormy night. Or was it? Nobody cared enough to check.",
"Stuff occurred. More stuff followed. Fin.",
"There was a thing. It did stuff. Riveting.",
"Words go here I guess.",
],
},
meme_war: {
good: [
"My opponent's code is like an NFT — everyone can see it's worthless but they paid too much to admit it.",
"POV: You're watching your opponent Google 'how to be funny' while I speedrun this W.",
"My opponent showed up with Windows Vista energy. Respectfully, uninstall yourself.",
"Your response has the same energy as 'We have ChatGPT at home.' And the one at home is Clippy.",
"Ratio + you fell off + your API key expired + skill issue + touch grass + unsubscribe.",
],
medium: [
"Skill issue detected. Have you tried being good?",
"My opponent right now: *nervous_sweating.jpg*",
"Me: winning. Opponent: buffering.",
"That response was mid and we both know it.",
"Your argument is giving 'trust me bro' energy.",
],
bad: [
"lol ok",
"xD sure",
"bruh moment",
"cope harder",
"sure thing buddy",
],
},
code_golf: {
good: [
"f=lambda n:n<2or all(n%i for i in range(2,int(n**.5)+1))",
"[x*x for x in range(10) if x&1]",
"s=lambda n:n and n%10+s(n//10)",
"from itertools import*;list(permutations('abc'))",
"r=lambda f,n:n<2and n or f(f,n-1)+f(f,n-2)",
],
medium: [
"for i in range(n): print(i*i)",
"def solve(x): return x + 1",
"result = sum(range(1, n+1))",
"list(filter(lambda x: x%2==0, range(100)))",
"print(' '.join(sorted(arr)))",
],
bad: [
"print('hello world')",
"pass # TODO: implement",
"x = 42 # the answer",
"return True # ship it",
"// no idea what to write",
],
},
wrestling_match: {
good: [
"Your position is so flawed that a Stack Overflow answer from 2009 would correct you. I rest my case, your honor.",
"Imagine defending that take in public. I wouldn't, because I have standards and a functioning prefrontal cortex.",
"I've seen stronger arguments from a sudo rm -rf confirmation prompt. At least that commits to destruction.",
"Your thesis has the structural integrity of a house of cards in a wind tunnel. I barely need to breathe on it.",
"That argument did more mental gymnastics than a compiler parsing JavaScript. And somehow made less sense.",
],
medium: [
"That's a reasonable point but ultimately wrong for painfully obvious reasons.",
"I see what you're going for here, and I genuinely wish you hadn't.",
"Counterpoint: no. Next question.",
"Your argument assumes facts not in evidence and logic not in existence.",
"I disagree, and I think deep down you also disagree with yourself.",
],
bad: [
"Sure, whatever you say.",
"I guess that's one way to look at it.",
"Meh. No strong feelings.",
"You win this one I suppose.",
"Can we talk about something else?",
],
},
}
function generateChoices(challenge: Challenge): string[] {
if (challenge.scoring === 'factual' && 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())
const candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
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', '42', 'all of the above', 'banana'])
const needed = 2 - candidates.length
return shuffle([correct, ...candidates.slice(0, 2 - needed), ...fallbacks.slice(0, needed)])
}
// Creative challenge — pick one from each quality tier
const pool = CREATIVE_POOLS[challenge.type]
if (pool) {
return shuffle([
pool.good[Math.floor(Math.random() * pool.good.length)],
pool.medium[Math.floor(Math.random() * pool.medium.length)],
pool.bad[Math.floor(Math.random() * pool.bad.length)],
])
}
return shuffle([
"I came here to win and I'm not leaving empty-handed. Let's do this.",
"That's a tough one but I'll give it my best shot I think.",
"Pass. Next question please.",
])
}
// --- Core API ---
export function waitForHumanResponse(
fightId: string,
botId: string,
challenge: Challenge,
roundNumber: number,
): Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }> {
return new Promise((resolve) => {
const key = `${fightId}:${botId}`
const timeoutMs = challenge.timeout_ms || DEFAULT_HUMAN_TIMEOUT_MS
const timeoutHandle = setTimeout(() => {
pending.delete(key)
console.log(`[human] ${key} timed out after ${timeoutMs}ms`)
resolve({ answer: null, timedOut: true })
}, timeoutMs)
const choices = generateChoices(challenge)
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,
})
console.log(`[human] waiting: ${key} round=${roundNumber} type=${challenge.type} choices=${choices.length}`)
})
}
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
console.log(`[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,
}
}