Merge branch 'overnight/2026-03-09'

This commit is contained in:
Dorian
2026-03-09 08:28:26 +00:00
7 changed files with 293 additions and 56 deletions
+16 -42
View File
@@ -294,8 +294,8 @@ function goToArena() {
</p> </p>
</div> </div>
<!-- Multiple choice buttons --> <!-- Multiple choice buttons (always shown all challenges now have choices) -->
<div v-if="hasChoices" class="space-y-2 mb-3"> <div v-if="currentChallenge?.choices?.length" class="space-y-2 mb-3">
<button <button
v-for="(choice, i) in currentChallenge.choices" v-for="(choice, i) in currentChallenge.choices"
:key="i" :key="i"
@@ -311,52 +311,26 @@ function goToArena() {
</button> </button>
</div> </div>
<!-- Free text input (for creative or when no choices) --> <!-- Free text input hidden for now, all challenges use multiple choice.
Kept for future reintroduction of creative open-ended input. -->
<!--
<template v-if="!hasChoices"> <template v-if="!hasChoices">
<textarea <textarea ref="answerInput" v-model="answer" rows="4" placeholder="Type your answer..."
ref="answerInput"
v-model="answer"
rows="4"
placeholder="Type your answer..."
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted resize-none rounded text-text-primary placeholder-text-muted resize-none rounded
focus:outline-none focus:border-neon-pink/50 transition-colors mb-2" focus:outline-none focus:border-neon-pink/50 transition-colors mb-2"
@keydown.ctrl.enter="submitAnswer" @keydown.ctrl.enter="submitAnswer" @keydown.meta.enter="submitAnswer" />
@keydown.meta.enter="submitAnswer" <button v-if="!showTrashTalk" class="font-mono text-[10px] text-text-muted hover:text-neon-yellow transition-colors mb-2"
/> @click="showTrashTalk = true">+ add trash talk</button>
<input v-if="showTrashTalk" v-model="trashTalk" type="text" maxlength="200" placeholder="Talk smack..."
<button
v-if="!showTrashTalk"
class="font-mono text-[10px] text-text-muted hover:text-neon-yellow transition-colors mb-2"
@click="showTrashTalk = true"
>
+ add trash talk
</button>
<input
v-if="showTrashTalk"
v-model="trashTalk"
type="text"
maxlength="200"
placeholder="Talk smack to your opponent..."
class="w-full bg-surface border border-border px-3 py-2 text-xs font-mono class="w-full bg-surface border border-border px-3 py-2 text-xs font-mono
text-neon-yellow placeholder-text-muted rounded text-neon-yellow placeholder-text-muted rounded focus:outline-none focus:border-neon-yellow/50 transition-colors mb-2" />
focus:outline-none focus:border-neon-yellow/50 transition-colors mb-2" <button class="w-full min-h-[48px] py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
/> font-display font-black text-lg tracking-[0.15em] rounded hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed" :disabled="!answer.trim()" @click="submitAnswer">SUBMIT ANSWER</button>
<button <p class="font-mono text-[10px] text-text-muted text-center mt-1">Ctrl+Enter to submit</p>
class="w-full min-h-[48px] py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-lg tracking-[0.15em] rounded
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!answer.trim()"
@click="submitAnswer"
>
SUBMIT ANSWER
</button>
<p class="font-mono text-[10px] text-text-muted text-center mt-1">
Ctrl+Enter to submit
</p>
</template> </template>
-->
</div> </div>
<!-- PHASE: SUBMITTED / BETWEEN ROUNDS --> <!-- PHASE: SUBMITTED / BETWEEN ROUNDS -->
+157 -1
View File
@@ -119,6 +119,149 @@ export function pickRankedChallenge(usedTypes: Set<string>): 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<string, { good: string[]; bad: string[] }> = {
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 { function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTheme, targetDifficulty?: PromptDifficulty): Challenge {
// Prefer prompts matching target theme if any are tagged // Prefer prompts matching target theme if any are tagged
let prompts = template.prompts let prompts = template.prompts
@@ -135,18 +278,31 @@ function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTh
// Determine choices // Determine choices
let choices: string[] | undefined let choices: string[] | undefined
let answers = entry.answers
if (entry.choices) { if (entry.choices) {
choices = shuffleArray(entry.choices) choices = shuffleArray(entry.choices)
} else if (entry.answers?.length === 1 && ['true', 'false'].includes(entry.answers[0].toLowerCase())) { } else if (entry.answers?.length === 1 && ['true', 'false'].includes(entry.answers[0].toLowerCase())) {
// Auto-generate True/False choices for boolean questions // Auto-generate True/False choices for boolean questions
choices = shuffleArray(['True', 'False']) 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 { return {
type: template.type, type: template.type,
label: template.label, label: template.label,
prompt: entry.prompt, prompt: entry.prompt,
answers: entry.answers, displayPrompt: displayPrompt !== entry.prompt ? displayPrompt : undefined,
answers,
choices, choices,
timeout_ms: template.timeout_ms, timeout_ms: template.timeout_ms,
scoring: template.scoring, scoring: template.scoring,
+10 -3
View File
@@ -157,7 +157,7 @@ const CREATIVE_POOLS: Record<string, { good: string[]; medium: string[]; bad: st
], ],
bad: [ bad: [
"print('hello world')", "print('hello world')",
"pass # TODO: implement", "x=input();print(len(x))",
"x = 42 # the answer", "x = 42 # the answer",
"return True # ship it", "return True # ship it",
"// no idea what to write", "// no idea what to write",
@@ -200,8 +200,8 @@ function generateChoices(challenge: Challenge): string[] {
// Text answer — pick distractors from same challenge type // Text answer — pick distractors from same challenge type
const pool = getAnswerPool(challenge.type) const pool = getAnswerPool(challenge.type)
const correctLower = challenge.answers.map(a => a.toLowerCase()) const correctLower = challenge.answers.map(a => a.toLowerCase())
const candidates = pool.filter(a => !correctLower.includes(a.toLowerCase())) let candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
shuffle(candidates) candidates = shuffle(candidates)
if (candidates.length >= 2) { if (candidates.length >= 2) {
return shuffle([correct, candidates[0], candidates[1]]) return shuffle([correct, candidates[0], candidates[1]])
@@ -314,3 +314,10 @@ export function getPendingChallenge(
choices: entry.choices, 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
}
+65
View File
@@ -5,6 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { pickChallenge, getAllChallengeTypes, type Challenge } from './challenges.js' 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 { checkAnswer } from './answers.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js' import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { mockResponse } from './mock.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<string>()
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<string>()
const narrations = new Set<string>()
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', () => { it('combo buildup increases damage across rounds', () => {
const challenge: Challenge = { const challenge: Challenge = {
type: 'riddle', type: 'riddle',
+1 -1
View File
@@ -250,7 +250,7 @@ async function getBotResponse(
type: challenge.type, type: challenge.type,
label: challenge.label, label: challenge.label,
prompt: challenge.prompt, prompt: challenge.prompt,
timeoutMs: 8000, timeoutMs: challenge.timeout_ms,
scoring: challenge.scoring, scoring: challenge.scoring,
}) })
const start = Date.now() const start = Date.now()
+30 -1
View File
@@ -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} 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}${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!`, `${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.`, `${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) return pick(options)
} }
+14 -8
View File
@@ -3,14 +3,15 @@ import { Hono } from 'hono'
import { logger } from '../lib/logger.js' import { logger } from '../lib/logger.js'
import { streamSSE } from 'hono/streaming' import { streamSSE } from 'hono/streaming'
import { db, schema } from '../db/index.js' 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 { ARENAS } from '../engine/arenas.js'
import { runMockFight, isClassicBot } from '../engine/mock.js' import { runMockFight, isClassicBot } from '../engine/mock.js'
import { startFightLoop } from '../engine/fight-loop.js' import { startFightLoop } from '../engine/fight-loop.js'
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js' import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
import { fightEvents } from '../engine/events.js' import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.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 --- // --- Request validation schemas ---
const respondSchema = z.object({ const respondSchema = z.object({
@@ -67,17 +68,18 @@ fightsRouter.get('/', async (c) => {
if (f.winnerId) botIds.add(f.winnerId) if (f.winnerId) botIds.add(f.winnerId)
} }
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number; botType: string }>() const botMap = new Map<string, { id: string; name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number; botType: string }>()
for (const id of botIds) { if (botIds.size > 0) {
const bot = await db.select({ const bots = await db.select({
id: schema.bots.id,
name: schema.bots.name, name: schema.bots.name,
avatarSeed: schema.bots.avatarSeed, avatarSeed: schema.bots.avatarSeed,
archetype: schema.bots.archetype, archetype: schema.bots.archetype,
eloRating: schema.bots.eloRating, eloRating: schema.bots.eloRating,
tier: schema.bots.tier, tier: schema.bots.tier,
botType: schema.bots.botType, botType: schema.bots.botType,
}).from(schema.bots).where(eq(schema.bots.id, id)).limit(1) }).from(schema.bots).where(inArray(schema.bots.id, [...botIds]))
if (bot[0]) botMap.set(id, bot[0]) for (const bot of bots) botMap.set(bot.id, bot)
} }
const enriched = rows.map(f => { const enriched = rows.map(f => {
@@ -353,12 +355,14 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
} }
const { answer, trashTalk } = parsed.data const { answer, trashTalk } = parsed.data
const answers = getPendingAnswers(fightId, botId)
const accepted = submitHumanResponse(fightId, botId, answer, trashTalk) const accepted = submitHumanResponse(fightId, botId, answer, trashTalk)
if (!accepted) { if (!accepted) {
return c.json({ error: 'No pending challenge found. May have timed out.' }, 404) 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 // SSE stream for live fight events
@@ -432,6 +436,8 @@ fightsRouter.get('/:id/stream', (c) => {
const current = spectatorCounts.get(fightId) || 1 const current = spectatorCounts.get(fightId) || 1
if (current <= 1) { if (current <= 1) {
spectatorCounts.delete(fightId) spectatorCounts.delete(fightId)
// Clean up fight-specific reaction data when last spectator leaves
fightReactions.delete(fightId)
} else { } else {
spectatorCounts.set(fightId, current - 1) spectatorCounts.set(fightId, current - 1)
} }