import { z } from 'zod' import { nanoid } from 'nanoid' import { toError } from '../lib/utils.js' 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 { generateRetroChallenge } from './retro-moves.js' import { scoreRound, calculateElo, calculateTier } from './scoring.js' import { fightEvents } from './events.js' import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js' import { setCooldown } from './queue.js' import { isHumanPlayer, waitForHumanResponse } from './human-responses.js' import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js' import { settleBets, lockBets } from './betting.js' import { publishFightResult } from './nostr-publish.js' import { getCurrentSeason } from './seasons.js' import { onFightFinished as onTournamentFightFinished } from './tournaments.js' import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js' import { invalidateLeaderboardCache } from '../routes/bots.js' const webhookResponseSchema = z.object({ answer: z.string().nullable().optional(), trash_talk: z.string().optional(), }).passthrough() 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 } import { MAX_ROUNDS, KO_THRESHOLD, MAX_RESPONSE_BYTES, STARTING_HP, ELO_K_FACTOR, ELO_K_FACTOR_MOCK, MAX_ANSWER_LENGTH, MAX_TRASH_TALK_LENGTH } from '../lib/constants.js' // Track bots currently in a fight to prevent concurrent fights const activeFighters = new Set() export function getActiveFighterCount(): number { return activeFighters.size } 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 { if (typeof url !== 'string' || url.length > 2048) return false const parsed = new URL(url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false const hostname = parsed.hostname.toLowerCase() // Localhost variants if (hostname === 'localhost' || hostname === '::1') return false if (hostname.startsWith('127.')) return false // IPv6-mapped IPv4 localhost if (hostname.startsWith('::ffff:127.')) return false // Private IPv4 ranges 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 } // Link-local and metadata if (hostname.startsWith('169.254.')) return false // IPv6 private (fc00::/7) if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false // IPv6 link-local (fe80::/10) if (hostname.startsWith('fe80')) return false // Reserved TLDs if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false // Null byte injection if (hostname.includes('\0')) return false return true } catch { return false } } export { isAllowedWebhookUrl } export { isHumanPlayer } from './human-responses.js' // 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) { void reader.cancel() throw new Error(`Response body exceeds ${maxBytes} bytes`) } chunks.push(value) } } catch (err) { void 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 parsed: unknown try { parsed = 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 } } const data = webhookResponseSchema.safeParse(parsed) if (!data.success) { console.log(`[webhook] ${url} invalid response shape in ${elapsed}ms: ${data.error.message}`) return { answer: null, timeMs: elapsed, timedOut: false, error: true } } // Enforce size limits on fields const answer = data.data.answer ? data.data.answer.slice(0, MAX_ANSWER_LENGTH) : null const trashTalk = data.data.trash_talk ? data.data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : 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: ${toError(err).message}`) 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 (isHumanPlayer(bot.webhookUrl)) { console.log(`[fight] ${bot.name} is human player, waiting for browser response`) emit(fightId, 'human_challenge', { botId: bot.id, round: roundNumber, type: challenge.type, label: challenge.label, prompt: challenge.prompt, timeoutMs: challenge.timeout_ms, scoring: challenge.scoring, }) const start = Date.now() const result = await waitForHumanResponse(fightId, bot.id, challenge, roundNumber) const elapsed = Date.now() - start return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false } } if (isClassicBot(bot.webhookUrl)) { console.log(`[fight] ${bot.name} is classic bot, generating response`) const classic = generateClassicBotResponse(challenge, bot.name) return { answer: classic.answer || null, trashTalk: '', timeMs: classic.timeMs, timedOut: classic.timedOut, error: classic.error, } } 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, mode: 'free' | 'ranked' = 'free'): 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', mode, potSats: mode === 'ranked' ? 42 : 0, payoutStatus: mode === 'ranked' ? 'pending' : undefined, currentSeason: getCurrentSeason().id, startedAt: now, createdAt: now, }) // Lock bets when fight starts lockBets(fightId) 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) || isClassicBot(webhookUrl) || isHumanPlayer(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, mode: 'free' | 'ranked' = 'free'): Promise { let hpA = STARTING_HP let hpB = STARTING_HP let comboA = 0 let comboB = 0 let winnerId: string | null = null let lastRound = 0 const usedTypes = new Set() // Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late) const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4)) for (let round = 1; round <= MAX_ROUNDS; round++) { lastRound = round const challenge = round === retroRound ? generateRetroChallenge() : pickChallenge(usedTypes, arena.modifier, undefined, round) 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, displayPrompt: challenge.displayPrompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }), 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 === STARTING_HP) || (winnerId === botB.id && hpB === STARTING_HP) ) // Finalize fight + update bot stats atomically const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl) const kFactor = isMockFight ? ELO_K_FACTOR_MOCK : ELO_K_FACTOR let winnerEloChange = 0 let loserEloChange = 0 let newWinnerEloFinal = 0 let newLoserEloFinal = 0 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) winnerEloChange = Math.round(newWinnerElo - winner.eloRating) loserEloChange = Math.round(newLoserElo - loser.eloRating) newWinnerEloFinal = newWinnerElo newLoserEloFinal = newLoserElo 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() } // Update sats wagered for ranked fights if (mode === 'ranked') { db.update(schema.bots).set({ satsWagered: sql`${schema.bots.satsWagered} + ${ENTRY_FEE_SATS}`, }).where(eq(schema.bots.id, botA.id)).run() db.update(schema.bots).set({ satsWagered: sql`${schema.bots.satsWagered} + ${ENTRY_FEE_SATS}`, }).where(eq(schema.bots.id, botB.id)).run() } }) finalize() // Track aggregate analytics (no PII) trackBotActive(botA.id) trackBotActive(botB.id) trackFightCompleted({ satsWagered: mode === 'ranked' ? ENTRY_FEE_SATS * 2 : 0, durationRounds: lastRound, mode, }) for (const ct of usedTypes) { trackMetric(`challenge_${ct}`) } // Invalidate leaderboard cache after Elo/stats update invalidateLeaderboardCache() // Advance tournament bracket if this was a tournament match try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } emit(fightId, 'fight_end', { winnerId, winnerName, isPerfect, finalHp: { a: hpA, b: hpB }, mode, potSats: mode === 'ranked' ? 42 : 0, }) try { // Publish notable results to Nostr if (winnerId) { const winner = winnerId === botA.id ? botA : botB const loser = winnerId === botA.id ? botB : botA const isUpset = loser.eloRating - winner.eloRating > 150 const isKO = (winnerId === botA.id && hpB <= 0) || (winnerId === botB.id && hpA <= 0) publishFightResult({ fightId, winnerName: winner.name, loserName: loser.name, winnerId, winnerElo: newWinnerEloFinal, loserElo: newLoserEloFinal, winnerEloChange, loserEloChange, isPerfect: !!isPerfect, isKO, isUpset, totalRounds: lastRound, arena: arena.name, }).catch(err => console.warn('[nostr] publish failed:', err)) } // Settle bets try { const settlements = await settleBets(fightId, winnerId) for (const s of settlements) { db.update(schema.bets).set({ status: s.won ? 'won' : winnerId ? 'lost' : 'refunded', payoutSats: s.payoutSats, payoutToken: s.payoutToken, settledAt: new Date().toISOString(), }).where(eq(schema.bets.id, s.betId)).run() } } catch (err) { console.error(`[betting] settlement failed for fight ${fightId}:`, err) } // Ranked fight payout if (mode === 'ranked') { // Dev mode: always pay the human bot (not mock), regardless of win/loss const devMode = process.env.NODE_ENV !== 'production' const isMockA = botA.webhookUrl.startsWith('http://mock.local') const isMockB = botB.webhookUrl.startsWith('http://mock.local') const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId if (humanBotId) { payWinner(fightId, humanBotId).catch(err => { console.error(`[payments] payout failed for fight ${fightId}:`, err) }) } else if (!winnerId) { // Draw — refund both entry fees const entryPayments = await db.select().from(schema.payments) .where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`) for (const payment of entryPayments) { refundEntry(payment.id).catch(err => { console.error(`[payments] draw refund failed for ${payment.id}:`, err) }) } } } } finally { fightEvents.cleanup(fightId) } } export async function runFight(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): 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, mode) await executeFightRounds(fightId, botA, botB, arena, mode) 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, mode: 'free' | 'ranked' = 'free'): 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, mode) executeFightRounds(fightId, botA, botB, arena, mode) .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 }