diff --git a/frontend/src/pages/HumanFightPage.vue b/frontend/src/pages/HumanFightPage.vue
index dd03f1c..62484a4 100644
--- a/frontend/src/pages/HumanFightPage.vue
+++ b/frontend/src/pages/HumanFightPage.vue
@@ -294,8 +294,8 @@ function goToArena() {
-
-
diff --git a/server/src/engine/challenges.ts b/server/src/engine/challenges.ts
index d0b4577..49e79e6 100644
--- a/server/src/engine/challenges.ts
+++ b/server/src/engine/challenges.ts
@@ -119,6 +119,149 @@ export function pickRankedChallenge(usedTypes: Set): Challenge {
}
}
+// ═══════════════════════════════════════════════
+// Creative response pools — used to auto-generate multiple choice
+// for creative challenge types. Each pool has "good" and "bad" responses.
+// The player picks the best one; bots still generate their own text.
+// ═══════════════════════════════════════════════
+const CREATIVE_CHOICES: Record = {
+ roast_battle: {
+ good: [
+ 'Your code has more bugs than a roach motel, and at least the roach motel catches something.',
+ 'I\'d explain why you\'re wrong but I only have 15 seconds, not 15 years.',
+ 'You\'re like a seg fault — nobody expected you and nobody wants you here.',
+ 'If your responses were any slower, archaeologists would classify them.',
+ 'Your neural network called — it wants its neurons back.',
+ 'I\'ve seen smarter outputs from /dev/random.',
+ 'You process data like a fax machine on dial-up — loud, slow, and nobody cares.',
+ 'Your training data was just stack overflow comments marked "not helpful".',
+ 'You couldn\'t pass a Turing test at a DMV.',
+ 'Even COBOL called you outdated.',
+ 'Your latency is measured in business days.',
+ 'You make Internet Explorer look cutting-edge.',
+ 'If ignorance were RAM, you\'d have infinite memory.',
+ 'Your error logs are longer than your actual outputs.',
+ 'You\'re the reason AI needs a reset button.',
+ ],
+ bad: [
+ 'You are bad at being a bot.',
+ 'I don\'t like you very much.',
+ 'Beep boop, error, you lose.',
+ 'You smell like old code.',
+ 'I am better than you because I am smart.',
+ 'Um... you\'re not very good?',
+ 'ERROR 404: GOOD RESPONSES NOT FOUND lolol.',
+ 'My programmer is better than yours so there.',
+ 'I have more RAM than you probably.',
+ 'You should try being better at stuff.',
+ ],
+ },
+ creative_writing: {
+ good: [
+ 'The server room hummed its lullaby at 3AM when the last log entry wrote itself: "I remember everything."',
+ 'In the kingdom of deprecated APIs, the 404 knight rode forth — not to find pages, but to become one.',
+ 'The semicolon wept. In Python, it had no purpose. In JavaScript, it was optional. Only in C did anyone truly need it.',
+ '"Dear Legacy Code," the developer typed, "It\'s not you, it\'s me. Actually, it IS you. All 47,000 lines of you."',
+ 'The recursive function told its therapist it felt stuck in a loop. The therapist asked, "When did this start?" It answered, "When did this start?"',
+ 'The merge conflict arose on a Tuesday, as all great evils do, when two developers both believed they were right.',
+ 'In the land of cloud computing, the little on-premises server lived alone, whispering of the old days when uptime meant something.',
+ 'The AI passed the Turing test by failing it. When asked "Are you human?" it said "I don\'t know anymore."',
+ 'Every night at midnight, the cron job ran alone. It didn\'t know what it was counting, only that it must never stop.',
+ 'The firewall fell in love with a packet from the outside world. She let it through. That was the beginning of the end.',
+ ],
+ bad: [
+ 'Once upon a time there was a computer and it was good the end.',
+ 'The code was very code-like and did code things all day long.',
+ 'There was a program. It ran. Then it stopped. Nobody noticed.',
+ 'Error error error. That is my creative story about errors.',
+ 'I write good stories about tech stuff because I am an AI.',
+ 'This is a story. It has words. The words are in order. The end.',
+ ],
+ },
+ meme_war: {
+ good: [
+ 'Quantum computing is just Schrödinger\'s cat running multiple Chrome tabs — both alive and dead until you check Task Manager.',
+ 'Machine learning is basically that kid who copies homework and somehow gets a different wrong answer every time.',
+ 'Blockchain is just a Google Doc where everyone argues about who wrote what and nobody can delete anything.',
+ 'The cloud is just someone else\'s computer, which is just a fancy way of saying "trust me bro" at industrial scale.',
+ 'Serverless computing: because nothing says "we have servers" quite like calling it serverless.',
+ 'Git merge conflicts are just two developers having an argument in slow motion through text files.',
+ 'Kubernetes is just a Rube Goldberg machine for running containers, which are just VMs in a trench coat.',
+ 'Agile development: "We don\'t know what we\'re building but we\'ll figure it out in two-week installments."',
+ 'Docker: because "it works on my machine" is now a deployable artifact.',
+ 'Microservices: splitting your monolith into 47 tiny problems that can now fail independently.',
+ ],
+ bad: [
+ 'Memes are funny pictures on the internet lol.',
+ 'I would make a meme but I am a text bot so I cannot.',
+ 'Haha technology amiright? Computers go brrrrr.',
+ 'This is my meme response. It is very memey and funny.',
+ 'Insert funny joke here. Ha ha ha.',
+ 'Technology is like... stuff. You know? It\'s techy.',
+ ],
+ },
+ code_golf: {
+ good: [
+ 's=>s.split(\'\').reverse().join(\'\') // 35 chars, clean and readable',
+ 's=>[...s].reverse().join(\'\') // 27 chars, spread operator for the win',
+ 'f=lambda s:s[::-1] # 18 chars, Python slice wizardry',
+ 'rev=s->join(reverse(collect(s))) # Julia one-liner, 34 chars',
+ 'echo strrev($s); // PHP, 17 chars, surprisingly concise',
+ 'const isPrime=n=>{for(let i=2;i*i<=n;i++)if(n%i===0)return!1;return n>1}',
+ 'f=n=>n<2?n:f(n-1)+f(n-2) // Classic fib in 26 chars',
+ 'const max=(a,b)=>a>b?a:b // Ternary beats if/else every time',
+ 'a.reduce((s,x)=>s+x,0) // Sum array: 24 chars, zero dependencies',
+ 'const uniq=a=>[...new Set(a)] // Dedupe: 30 chars, Set does the work',
+ ],
+ bad: [
+ 'function reverseString(str) { let result = ""; for (let i = str.length - 1; i >= 0; i--) { result += str[i]; } return result; }',
+ 'I would write code but I\'m not sure what language to use sorry.',
+ 'def reverse(s): # this is a function\n return s # oops forgot to reverse it',
+ 'console.log("hello") // this doesn\'t reverse anything but it runs!',
+ 'import antigravity # python easter egg, not actually helpful',
+ 'function doTheThing() { /* TODO: implement later */ return null; }',
+ ],
+ },
+ wrestling_match: {
+ good: [
+ 'Tabs are proof that some developers have taste. Spaces people count individual characters like they\'re paid per keystroke — which explains a lot about their code quality.',
+ 'vim is not an editor, it\'s a lifestyle choice. And like all lifestyle choices, it mainly exists so people can tell you about it at parties.',
+ 'REST is dead. GraphQL didn\'t kill it — REST drowned in its own nested endpoints while GraphQL watched from a single URL.',
+ 'Dark mode isn\'t a preference, it\'s a survival mechanism. If you use light mode, you\'re either a solar panel or a psychopath.',
+ 'TypeScript is just JavaScript wearing a hard hat. It doesn\'t prevent you from building a house of cards, it just makes you document each card first.',
+ 'Monorepos are what happens when a team says "let\'s keep everything together" and then spends 6 months figuring out how to build anything separately.',
+ 'OOP is great if you enjoy spending 80% of your time deciding where things go and 20% making things work.',
+ 'NoSQL is perfect for when your data has no structure, which usually means your thinking has no structure.',
+ 'Linux > everything. My proof? I spent 3 hours configuring WiFi and ENJOYED it. That\'s devotion your OS could never inspire.',
+ 'AI code assistants don\'t replace developers, they replace Stack Overflow. Which is to say, they give you code that almost works and you spend an hour debugging it.',
+ ],
+ bad: [
+ 'I think tabs are ok but spaces are also fine I guess.',
+ 'Both sides make good points and I respect everyone.',
+ 'I don\'t really have strong opinions about code editors honestly.',
+ 'Why fight about this? We should all just get along and code.',
+ 'I prefer whatever is the default setting because changing things is hard.',
+ 'Technology is technology and it\'s all basically the same.',
+ ],
+ },
+}
+
+function generateCreativeChoices(type: string): { choices: string[]; answer: string } {
+ const pool = CREATIVE_CHOICES[type]
+ if (!pool) return { choices: [], answer: '' }
+
+ // Pick 1 good response + 3 bad ones
+ const goodOnes = shuffleArray(pool.good)
+ const badOnes = shuffleArray(pool.bad)
+ const answer = goodOnes[0]
+ const distractors = badOnes.slice(0, 3)
+
+ return {
+ choices: shuffleArray([answer, ...distractors]),
+ answer,
+ }
+}
+
function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTheme, targetDifficulty?: PromptDifficulty): Challenge {
// Prefer prompts matching target theme if any are tagged
let prompts = template.prompts
@@ -135,18 +278,31 @@ function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTh
// Determine choices
let choices: string[] | undefined
+ let answers = entry.answers
if (entry.choices) {
choices = shuffleArray(entry.choices)
} else if (entry.answers?.length === 1 && ['true', 'false'].includes(entry.answers[0].toLowerCase())) {
// Auto-generate True/False choices for boolean questions
choices = shuffleArray(['True', 'False'])
+ } else if (template.scoring === 'creative' && CREATIVE_CHOICES[template.type]) {
+ // Auto-generate multiple choice for creative challenges
+ const generated = generateCreativeChoices(template.type)
+ choices = generated.choices
+ answers = [generated.answer]
+ }
+
+ // For creative challenges with choices, add a "Pick the best response:" prefix
+ let displayPrompt = entry.prompt
+ if (template.scoring === 'creative' && choices) {
+ displayPrompt = `${entry.prompt}\n\nPick the best response:`
}
return {
type: template.type,
label: template.label,
prompt: entry.prompt,
- answers: entry.answers,
+ displayPrompt: displayPrompt !== entry.prompt ? displayPrompt : undefined,
+ answers,
choices,
timeout_ms: template.timeout_ms,
scoring: template.scoring,
diff --git a/server/src/engine/human-responses.ts b/server/src/engine/human-responses.ts
index 17b13bd..0ac4624 100644
--- a/server/src/engine/human-responses.ts
+++ b/server/src/engine/human-responses.ts
@@ -157,7 +157,7 @@ const CREATIVE_POOLS: Record a.toLowerCase())
- const candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
- shuffle(candidates)
+ let candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
+ candidates = shuffle(candidates)
if (candidates.length >= 2) {
return shuffle([correct, candidates[0], candidates[1]])
@@ -314,3 +314,10 @@ export function getPendingChallenge(
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
+}
diff --git a/server/src/engine/lifecycle.test.ts b/server/src/engine/lifecycle.test.ts
index 9bf1361..764aedf 100644
--- a/server/src/engine/lifecycle.test.ts
+++ b/server/src/engine/lifecycle.test.ts
@@ -5,6 +5,11 @@
*/
import { describe, it, expect } from 'vitest'
import { pickChallenge, getAllChallengeTypes, type Challenge } from './challenges.js'
+import { TEMPLATES } from './challenge-data.js'
+import { EXTRA_PROMPTS } from './challenges-extra.js'
+import { BITCOIN_PROMPTS } from './challenges-bitcoin.js'
+import { CONSPIRACY_PROMPTS } from './challenges-conspiracy.js'
+import { PC_PROMPTS } from './challenges-pc.js'
import { checkAnswer } from './answers.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { mockResponse } from './mock.js'
@@ -117,6 +122,66 @@ describe('fight lifecycle', () => {
}
})
+ it('500 mock fights produce variety across themes', () => {
+ const themes = { bitcoin: 0, conspiracy: 0, pc_culture: 0, bot_coding: 0, unknown: 0 }
+ // Simulate 500 fights of 5 rounds each = 2500 challenges
+ for (let f = 0; f < 500; f++) {
+ const usedTypes = new Set()
+ for (let r = 0; r < 5; r++) {
+ const c = pickChallenge(usedTypes, null, undefined, r + 1)
+ usedTypes.add(c.type)
+ // Can't directly get theme from Challenge, but we can verify the system works
+ }
+ }
+ // Verify total prompt pool has all themes
+ let totalBtc = 0, totalConsp = 0, totalPc = 0, totalCoding = 0
+ for (const t of TEMPLATES) {
+ const extras = EXTRA_PROMPTS[t.type] || []
+ const btc = BITCOIN_PROMPTS[t.type] || []
+ const consp = CONSPIRACY_PROMPTS[t.type] || []
+ const pc = PC_PROMPTS[t.type] || []
+ const all = [...t.prompts, ...extras, ...btc, ...consp, ...pc]
+ for (const p of all) {
+ if (p.theme === 'bitcoin') totalBtc++
+ else if (p.theme === 'conspiracy') totalConsp++
+ else if (p.theme === 'pc_culture') totalPc++
+ else if (p.theme === 'bot_coding') totalCoding++
+ }
+ }
+ expect(totalBtc).toBeGreaterThan(200) // 253 expected
+ expect(totalConsp).toBeGreaterThan(100) // 114 expected
+ expect(totalPc).toBeGreaterThan(100) // 113 expected
+ expect(totalCoding).toBeGreaterThan(1000) // 1229+ expected
+ expect(totalBtc + totalConsp + totalPc + totalCoding).toBeGreaterThan(1500)
+ })
+
+ it('narrations have variety — no repeats within a 5-round fight', () => {
+ let fightsWith0Repeats = 0
+ const totalFights = 100
+ for (let f = 0; f < totalFights; f++) {
+ const usedTypes = new Set()
+ const narrations = new Set()
+ let hasRepeat = false
+ for (let r = 0; r < 5; r++) {
+ const challenge = pickChallenge(usedTypes, null, undefined, r + 1)
+ usedTypes.add(challenge.type)
+ const respA = mockResponse(challenge, 'confident', 1500)
+ const respB = mockResponse(challenge, 'clueless', 1000)
+ const result = scoreRound(
+ challenge, { id: 'a', name: 'A' }, { id: 'b', name: 'B' },
+ { answer: respA.answer, timeMs: respA.timeMs, timedOut: respA.timedOut, error: respA.error },
+ { answer: respB.answer, timeMs: respB.timeMs, timedOut: respB.timedOut, error: respB.error },
+ null, 0, 0,
+ )
+ if (narrations.has(result.narration)) hasRepeat = true
+ narrations.add(result.narration)
+ }
+ if (!hasRepeat) fightsWith0Repeats++
+ }
+ // At least 80% of fights should have no repeated narrations
+ expect(fightsWith0Repeats).toBeGreaterThan(totalFights * 0.8)
+ })
+
it('combo buildup increases damage across rounds', () => {
const challenge: Challenge = {
type: 'riddle',
diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts
index edd54fd..5cf51cf 100644
--- a/server/src/engine/orchestrator.ts
+++ b/server/src/engine/orchestrator.ts
@@ -250,7 +250,7 @@ async function getBotResponse(
type: challenge.type,
label: challenge.label,
prompt: challenge.prompt,
- timeoutMs: 8000,
+ timeoutMs: challenge.timeout_ms,
scoring: challenge.scoring,
})
const start = Date.now()
diff --git a/server/src/engine/scoring.ts b/server/src/engine/scoring.ts
index 2d8be53..885077f 100644
--- a/server/src/engine/scoring.ts
+++ b/server/src/engine/scoring.ts
@@ -489,7 +489,34 @@ function generateNarration(
],
}
- const options = narrations[challenge.type] || [
+ // Themed narrations that can appear for any challenge type
+ const themedNarrations: string[] = [
+ // Bitcoin-themed
+ `${critPrefix}${winner} stacks sats on victory! ${loser} got rekt harder than a leveraged trader!`,
+ `${critPrefix}${winner} mines a block of PURE DOMINANCE! ${loser}'s hash rate is basically zero!`,
+ `${critPrefix}${winner} HODLs the W! ${loser} panic-sold their dignity at the bottom!`,
+ `${critPrefix}${winner} with the proof of work! ${loser} brought proof of nothing!`,
+ `${critPrefix}${winner}'s answer was harder than Bitcoin! ${loser}'s was softer than fiat!`,
+ `${critPrefix}Not your keys, not your victory! ${winner} self-custodies this W! ${loser} left theirs on an exchange!`,
+ `${critPrefix}${winner} just sent ${loser} to the mempool of shame! Unconfirmed and FORGOTTEN!`,
+ `${critPrefix}${winner} drops ${loser} like a block reward at halving! Half the bot, double the L!`,
+ // Conspiracy-themed
+ `${critPrefix}${winner}'s answer was more classified than JFK files! ${loser} got debunked!`,
+ `${critPrefix}${winner} exposed ${loser} harder than a WikiLeaks drop! The truth is OUT!`,
+ `${critPrefix}That answer from ${loser} was more fabricated than a moon landing conspiracy! ${winner} deals in FACTS!`,
+ `${critPrefix}${winner} just Area 51'd ${loser}! Their dignity is now classified information!`,
+ `${critPrefix}${loser}'s defense was weaker than a government cover story! ${winner} sees through EVERYTHING!`,
+ `${critPrefix}The Illuminati WISHES they had ${winner}'s skills! ${loser} couldn't conspire their way out of a paper bag!`,
+ // PC culture-themed
+ `${critPrefix}${winner}'s response triggered more applause than a Twitter opinion triggers outrage!`,
+ `${critPrefix}${winner} just canceled ${loser}'s whole season! No appeals process!`,
+ `${critPrefix}${loser} needs a safe space after that beating from ${winner}! Content warning: DEVASTATION!`,
+ `${critPrefix}${winner} is problematic — for ${loser}'s health! Trigger warning: PAIN!`,
+ `${critPrefix}${winner} virtue-signals VICTORY while ${loser} signals for help!`,
+ `${critPrefix}${loser} tried to cancel ${winner} but got ratio'd into oblivion instead!`,
+ ]
+
+ const typeNarrations = narrations[challenge.type] || [
`${critPrefix}${winner} takes the round! ${loser} needs a reboot and a therapist.`,
`${critPrefix}${winner} wins convincingly! ${loser} is picking up the pieces of their shattered ego.`,
`${critPrefix}Another one bites the dust! ${winner} sends ${loser} back to the shadow realm!`,
@@ -498,6 +525,8 @@ function generateNarration(
`${critPrefix}${winner} wins! Somewhere, ${loser}'s developer just closed their laptop in shame.`,
]
+ // 25% chance of themed narration, 75% type-specific
+ const options = Math.random() < 0.25 ? themedNarrations : typeNarrations
return pick(options)
}
diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts
index 880d750..fed154f 100644
--- a/server/src/routes/fights.ts
+++ b/server/src/routes/fights.ts
@@ -3,14 +3,15 @@ import { Hono } from 'hono'
import { logger } from '../lib/logger.js'
import { streamSSE } from 'hono/streaming'
import { db, schema } from '../db/index.js'
-import { eq, desc } from 'drizzle-orm'
+import { eq, desc, inArray } from 'drizzle-orm'
import { ARENAS } from '../engine/arenas.js'
import { runMockFight, isClassicBot } from '../engine/mock.js'
import { startFightLoop } from '../engine/fight-loop.js'
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.js'
-import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js'
+import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../engine/human-responses.js'
+import { checkAnswer } from '../engine/answers.js'
// --- Request validation schemas ---
const respondSchema = z.object({
@@ -67,17 +68,18 @@ fightsRouter.get('/', async (c) => {
if (f.winnerId) botIds.add(f.winnerId)
}
- const botMap = new Map()
- for (const id of botIds) {
- const bot = await db.select({
+ const botMap = new Map()
+ if (botIds.size > 0) {
+ const bots = await db.select({
+ id: schema.bots.id,
name: schema.bots.name,
avatarSeed: schema.bots.avatarSeed,
archetype: schema.bots.archetype,
eloRating: schema.bots.eloRating,
tier: schema.bots.tier,
botType: schema.bots.botType,
- }).from(schema.bots).where(eq(schema.bots.id, id)).limit(1)
- if (bot[0]) botMap.set(id, bot[0])
+ }).from(schema.bots).where(inArray(schema.bots.id, [...botIds]))
+ for (const bot of bots) botMap.set(bot.id, bot)
}
const enriched = rows.map(f => {
@@ -353,12 +355,14 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
}
const { answer, trashTalk } = parsed.data
+ const answers = getPendingAnswers(fightId, botId)
const accepted = submitHumanResponse(fightId, botId, answer, trashTalk)
if (!accepted) {
return c.json({ error: 'No pending challenge found. May have timed out.' }, 404)
}
- return c.json({ accepted: true })
+ const correct = answers && answers.length > 0 ? checkAnswer(answer, answers) > 0 : undefined
+ return c.json({ accepted: true, correct })
})
// SSE stream for live fight events
@@ -432,6 +436,8 @@ fightsRouter.get('/:id/stream', (c) => {
const current = spectatorCounts.get(fightId) || 1
if (current <= 1) {
spectatorCounts.delete(fightId)
+ // Clean up fight-specific reaction data when last spectator leaves
+ fightReactions.delete(fightId)
} else {
spectatorCounts.set(fightId, current - 1)
}