import { nanoid } from 'nanoid' import { db, schema } 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' 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 function emit(fightId: string, type: string, data: Record) { fightEvents.emit({ fightId, type, data, timestamp: new Date().toISOString(), }) } async function callWebhook( url: string, challenge: Challenge, roundNumber: number, opponent: { name: string; wins: number; losses: number }, arena: Arena, ): Promise { const body = JSON.stringify({ 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}`) 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 } } const text = await res.text() 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 } } console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`) return { answer: data.answer || null, trashTalk: data.trash_talk, 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, } } } function isMockBot(webhookUrl: string): boolean { return webhookUrl.startsWith('http://mock.local') } async function getBotResponse( bot: BotRecord, challenge: Challenge, roundNumber: number, 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, 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 } 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 (mock bots get generated responses) const [responseA, responseB] = await Promise.all([ getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena), getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena), ]) // 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)) // 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 await db.update(schema.fights).set({ status: 'finished', winnerId, endedAt: new Date().toISOString(), }).where(eq(schema.fights.id, fightId)) // 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) const newWinStreak = winner.winStreak + 1 const newBestStreak = Math.max(winner.bestStreak, newWinStreak) await Promise.all([ db.update(schema.bots).set({ wins: sql`${schema.bots.wins} + 1`, eloRating: newWinnerElo, winStreak: newWinStreak, bestStreak: newBestStreak, tier: calculateTier(newWinnerElo, winner.wins + 1), }).where(eq(schema.bots.id, winnerId)), db.update(schema.bots).set({ losses: sql`${schema.bots.losses} + 1`, eloRating: newLoserElo, winStreak: 0, tier: calculateTier(newLoserElo, loser.wins), }).where(eq(schema.bots.id, loserId)), ]) } emit(fightId, 'fight_end', { winnerId, winnerName, isPerfect, finalHp: { a: hpA, b: hpB }, }) fightEvents.cleanup(fightId) } export async function runFight(botAId: string, botBId: string): Promise { 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 } /** Creates the fight record and returns the ID immediately. Rounds run in background. */ export async function runFightAsync(botAId: string, botBId: string): Promise { 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) }) return fightId }