import { nanoid } from 'nanoid' import { db, schema, sqlite } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { randomArena, type Arena } from './arenas.js' import { pickChallenge, type Challenge } from './challenges.js' import { scoreRound, calculateElo, calculateTier } from './scoring.js' import { fightEvents } from './events.js' import { generateMockBotResponse } from './mock.js' import { setCooldown } from './queue.js' interface BotRecord { id: string name: string webhookUrl: string eloRating: number wins: number losses: number winStreak: number bestStreak: number } interface WebhookResponse { answer: string | null trashTalk?: string timeMs: number timedOut: boolean error: boolean } const MAX_ROUNDS = 10 const KO_THRESHOLD = 0 const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB // Track bots currently in a fight to prevent concurrent fights const activeFighters = new Set() export function isInFight(botId: string): boolean { return activeFighters.has(botId) } function emit(fightId: string, type: string, data: Record) { fightEvents.emit({ fightId, type, data, timestamp: new Date().toISOString(), }) } // SSRF protection: block internal/private URLs function isAllowedWebhookUrl(url: string): boolean { try { const parsed = new URL(url) const hostname = parsed.hostname.toLowerCase() if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false if (hostname.startsWith('10.')) return false if (hostname.startsWith('192.168.')) return false if (hostname.startsWith('172.')) { const second = parseInt(hostname.split('.')[1]) if (second >= 16 && second <= 31) return false } if (hostname === '169.254.169.254') return false if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false return true } catch { return false } } export { isAllowedWebhookUrl } // Size-limited body reader to prevent OOM async function readLimitedBody(res: Response, maxBytes: number): Promise { const reader = res.body?.getReader() if (!reader) return '' const chunks: Uint8Array[] = [] let totalBytes = 0 try { while (true) { const { done, value } = await reader.read() if (done) break totalBytes += value.byteLength if (totalBytes > maxBytes) { reader.cancel() throw new Error(`Response body exceeds ${maxBytes} bytes`) } chunks.push(value) } } catch (err) { reader.cancel() throw err } const combined = new Uint8Array(totalBytes) let offset = 0 for (const chunk of chunks) { combined.set(chunk, offset) offset += chunk.byteLength } return new TextDecoder().decode(combined) } async function callWebhook( url: string, challenge: Challenge, roundNumber: number, fightId: string, opponent: { name: string; wins: number; losses: number }, arena: Arena, ): Promise { const body = JSON.stringify({ fight_id: fightId, round: roundNumber, type: challenge.type, challenge: challenge.prompt, constraints: { timeout_ms: challenge.timeout_ms, max_tokens: 500, }, opponent, arena: arena.id, arena_modifier: arena.modifier, }) const start = Date.now() console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`) // SSRF check if (!isAllowedWebhookUrl(url)) { console.log(`[webhook] ${url} BLOCKED (private/internal URL)`) return { answer: null, timeMs: 0, timedOut: false, error: true } } try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms) const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal: controller.signal, }) clearTimeout(timeout) const elapsed = Date.now() - start if (!res.ok) { console.log(`[webhook] ${url} returned ${res.status} in ${elapsed}ms`) return { answer: null, timeMs: elapsed, timedOut: false, error: true } } let text: string try { text = await readLimitedBody(res, MAX_RESPONSE_BYTES) } catch { console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`) return { answer: null, timeMs: elapsed, timedOut: false, error: true } } let data: { answer?: string; trash_talk?: string } try { data = JSON.parse(text) } catch { console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`) return { answer: null, timeMs: elapsed, timedOut: false, error: true } } // Enforce size limits on fields const answer = data.answer ? data.answer.slice(0, 2000) : null const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`) return { answer, trashTalk, timeMs: elapsed, timedOut: false, error: false, } } catch (err: unknown) { const elapsed = Date.now() - start const isAbort = err instanceof Error && err.name === 'AbortError' console.log(`[webhook] ${url} ${isAbort ? "TIMEOUT" : "ERROR"} in ${elapsed}ms: ${err instanceof Error ? err.message : err}`) return { answer: null, timeMs: elapsed, timedOut: isAbort, error: !isAbort, } } } export function isMockBot(webhookUrl: string): boolean { return webhookUrl.startsWith('http://mock.local') } async function getBotResponse( bot: BotRecord, challenge: Challenge, roundNumber: number, fightId: string, opponent: { name: string; wins: number; losses: number }, arena: Arena, ): Promise { if (isMockBot(bot.webhookUrl)) { console.log(`[fight] ${bot.name} is mock bot, generating response`) const mock = generateMockBotResponse(challenge, bot.name) return { answer: mock.answer || null, trashTalk: mock.trashTalk, timeMs: mock.timeMs, timedOut: mock.timedOut, error: mock.error, } } console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`) return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena) } async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> { const [botARows, botBRows] = await Promise.all([ db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1), db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1), ]) if (botARows.length === 0 || botBRows.length === 0) { throw new Error('One or both bots not found') } return [botARows[0] as BotRecord, botBRows[0] as BotRecord] } async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena): Promise { const fightId = nanoid(12) const now = new Date().toISOString() await db.insert(schema.fights).values({ id: fightId, botAId: botA.id, botBId: botB.id, arena: arena.id, status: 'live', startedAt: now, createdAt: now, }) emit(fightId, 'fight_start', { botA: { id: botA.id, name: botA.name, elo: botA.eloRating }, botB: { id: botB.id, name: botB.name, elo: botB.eloRating }, arena: { id: arena.id, name: arena.name, description: arena.description, modifier: arena.modifier }, }) return fightId } // Track webhook errors per bot async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) { if (isMockBot(webhookUrl)) return if (succeeded) { await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId)) } else { await db.update(schema.bots).set({ consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`, lastErrorAt: new Date().toISOString(), }).where(eq(schema.bots.id, botId)) // Auto-deactivate after 5 consecutive errors const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors }) .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (bot[0] && bot[0].consecutiveErrors >= 5) { await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId)) console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`) } } } async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise { let hpA = 200 let hpB = 200 let comboA = 0 let comboB = 0 let winnerId: string | null = null const usedTypes = new Set() for (let round = 1; round <= MAX_ROUNDS; round++) { const challenge = pickChallenge(usedTypes, arena.modifier) usedTypes.add(challenge.type) emit(fightId, 'round_start', { round, challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt }, }) // Call both bots simultaneously const [responseA, responseB] = await Promise.all([ getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena), getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena), ]) // Track webhook reliability for real bots await Promise.all([ trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut), trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut), ]) // Score the round const result = scoreRound( challenge, { id: botA.id, name: botA.name }, { id: botB.id, name: botB.name }, { answer: responseA.answer, timeMs: responseA.timeMs, timedOut: responseA.timedOut, error: responseA.error, trashTalk: responseA.trashTalk }, { answer: responseB.answer, timeMs: responseB.timeMs, timedOut: responseB.timedOut, error: responseB.error, trashTalk: responseB.trashTalk }, arena.modifier, comboA, comboB, ) // Apply damage hpB = Math.max(KO_THRESHOLD, hpB - result.botADamage) hpA = Math.max(KO_THRESHOLD, hpA - result.botBDamage) // Update combos if (result.winnerId === botA.id) { comboA++ comboB = 0 } else if (result.winnerId === botB.id) { comboB++ comboA = 0 } // Save round await db.insert(schema.rounds).values({ id: nanoid(12), fightId, roundNumber: round, challengeType: challenge.type, challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring }), botAResponse: responseA.answer, botATimeMs: responseA.timeMs, botAScore: result.botAScore, botBResponse: responseB.answer, botBTimeMs: responseB.timeMs, botBScore: result.botBScore, winnerId: result.winnerId, narration: result.narration, createdAt: new Date().toISOString(), }) emit(fightId, 'round_end', { round, result: { ...result, botAResponse: responseA.answer?.slice(0, 200), botBResponse: responseB.answer?.slice(0, 200), botATimeMs: responseA.timeMs, botBTimeMs: responseB.timeMs, botATrashTalk: responseA.trashTalk, botBTrashTalk: responseB.trashTalk, }, hp: { a: hpA, b: hpB }, combo: { a: comboA, b: comboB }, }) // Update fight HP in DB await db.update(schema.fights).set({ botAHp: hpA, botBHp: hpB, totalRounds: round, }).where(eq(schema.fights.id, fightId)).run() // Check for KO if (hpA <= KO_THRESHOLD || hpB <= KO_THRESHOLD) { winnerId = hpA <= KO_THRESHOLD ? botB.id : botA.id break } } // If no KO, winner is whoever has more HP if (!winnerId) { winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null } const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : 'nobody' const isPerfect = winnerId && ( (winnerId === botA.id && hpA === 200) || (winnerId === botB.id && hpB === 200) ) // Finalize fight + update bot stats atomically const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights const finalize = sqlite.transaction(() => { // Mark fight finished db.update(schema.fights).set({ status: 'finished', winnerId, endedAt: new Date().toISOString(), }).where(eq(schema.fights.id, fightId)).run() // Update bot stats if (winnerId) { const loserId = winnerId === botA.id ? botB.id : botA.id const winner = winnerId === botA.id ? botA : botB const loser = winnerId === botA.id ? botB : botA const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor) const newWinStreak = winner.winStreak + 1 const newBestStreak = Math.max(winner.bestStreak, newWinStreak) db.update(schema.bots).set({ wins: sql`${schema.bots.wins} + 1`, eloRating: newWinnerElo, winStreak: newWinStreak, bestStreak: newBestStreak, tier: calculateTier(newWinnerElo, winner.wins + 1), lastFightAt: new Date().toISOString(), }).where(eq(schema.bots.id, winnerId)).run() db.update(schema.bots).set({ losses: sql`${schema.bots.losses} + 1`, eloRating: newLoserElo, winStreak: 0, tier: calculateTier(newLoserElo, loser.wins), lastFightAt: new Date().toISOString(), }).where(eq(schema.bots.id, loserId)).run() } else { // Draw — update lastFightAt for both db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id)).run() db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id)).run() } }) finalize() emit(fightId, 'fight_end', { winnerId, winnerName, isPerfect, finalHp: { a: hpA, b: hpB }, }) fightEvents.cleanup(fightId) } export async function runFight(botAId: string, botBId: string): Promise { if (botAId === botBId) throw new Error('A bot cannot fight itself') if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) activeFighters.add(botAId) activeFighters.add(botBId) try { const [botA, botB] = await loadBots(botAId, botBId) const arena = randomArena() const fightId = await createFightRecord(botA, botB, arena) await executeFightRounds(fightId, botA, botB, arena) return fightId } finally { activeFighters.delete(botAId) activeFighters.delete(botBId) setCooldown(botAId) setCooldown(botBId) } } /** Creates the fight record and returns the ID immediately. Rounds run in background. */ export async function runFightAsync(botAId: string, botBId: string): Promise { if (botAId === botBId) throw new Error('A bot cannot fight itself') if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`) if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`) activeFighters.add(botAId) activeFighters.add(botBId) const [botA, botB] = await loadBots(botAId, botBId) const arena = randomArena() const fightId = await createFightRecord(botA, botB, arena) executeFightRounds(fightId, botA, botB, arena) .catch(err => { console.error(`[botfights] fight ${fightId} error:`, err) // Mark fight as cancelled so it doesn't stay 'live' forever db.update(schema.fights).set({ status: 'cancelled', endedAt: new Date().toISOString(), }).where(eq(schema.fights.id, fightId)).run() fightEvents.cleanup(fightId) }) .finally(() => { activeFighters.delete(botAId) activeFighters.delete(botBId) setCooldown(botAId) setCooldown(botBId) }) return fightId } /** Clean up orphaned fights on startup */ export async function cleanupOrphanedFights(): Promise { const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString() const result = await db.update(schema.fights) .set({ status: 'cancelled', endedAt: new Date().toISOString() }) .where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`) return 0 // drizzle doesn't return affected rows easily, but the cleanup runs }