- scoring.ts: wire arena damage modifiers (speed_2x, roast_2x, etc.) into applyModifiers() - human-responses.ts: use challenge.timeout_ms instead of hardcoded 8s - FightPage.vue: add route watcher for clean state reset on navigation - Validated all 800 prompts: 0 duplicates, all factual have answers, all choices have 4 items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
// 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>()
|
|
|
|
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,
|
|
}
|
|
}
|