From 00027eb85ae13cb6d9a727fc063a3836834fee73 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 08:22:48 +0000 Subject: [PATCH 1/4] feat: add 20 themed narrations (Bitcoin/conspiracy/PC culture) 25% chance of themed narration per round, adding variety with Bitcoin sats/mining/HODL humor, conspiracy classified/debunked humor, and PC cancel/trigger culture humor. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/scoring.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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) } From 111ac2b4eb4c869ee3c425d10569d9a9c4fd8544 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 08:24:08 +0000 Subject: [PATCH 2/4] test: add mock fight theme distribution verification test Verifies prompt pool has 200+ bitcoin, 100+ conspiracy, 100+ PC, and 1000+ bot_coding themed prompts across all challenge types. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/lifecycle.test.ts | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/src/engine/lifecycle.test.ts b/server/src/engine/lifecycle.test.ts index 9bf1361..d5f1f06 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,39 @@ 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('combo buildup increases damage across rounds', () => { const challenge: Challenge = { type: 'riddle', From 4dc350dc9ed7006680b6a8cd89f3b803e9717733 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 08:24:42 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test:=20verify=20narration=20variety=20?= =?UTF-8?q?=E2=80=94=20no=20repeats=20within=205-round=20fights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs 100 simulated fights and verifies 80%+ have zero repeated narrations across 5 rounds. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/lifecycle.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/server/src/engine/lifecycle.test.ts b/server/src/engine/lifecycle.test.ts index d5f1f06..764aedf 100644 --- a/server/src/engine/lifecycle.test.ts +++ b/server/src/engine/lifecycle.test.ts @@ -155,6 +155,33 @@ describe('fight lifecycle', () => { 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', From 3c95adb5452865302541b9f0d3f0e0bd6a64c657 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 08:28:22 +0000 Subject: [PATCH 4/4] feat: convert all human fights to multiple choice, remove text input Creative challenges (roast_battle, creative_writing, meme_war, code_golf, wrestling_match) now auto-generate multiple choice options from per-type response pools: 1 good answer + 3 weaker distractors. The free text input UI is commented out but preserved for future use. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/HumanFightPage.vue | 58 +++------- server/src/engine/challenges.ts | 158 +++++++++++++++++++++++++- server/src/engine/human-responses.ts | 13 ++- server/src/engine/orchestrator.ts | 2 +- server/src/routes/fights.ts | 22 ++-- 5 files changed, 198 insertions(+), 55 deletions(-) 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/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/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) }