Files
botfights/server/src/engine/orchestrator.ts
T
DorianandClaude Opus 4.6 68e292183a fix: polling bots play practice fights as human players in browser
Polling bots have no external script running during practice mode,
so the poll would time out giving empty answers. Now overrides the
webhook URL to human.local so the browser UI handles challenges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:02:35 +00:00

711 lines
25 KiB
TypeScript

import { z } from 'zod'
import { nanoid } from 'nanoid'
import { logger } from '../lib/logger.js'
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'
import { createHmac } from 'crypto'
import { isPollingBot, waitForPollResponse } from './poll-responses.js'
const webhookResponseSchema = z.object({
answer: z.string().nullable().optional(),
trash_talk: z.string().optional(),
}).passthrough()
interface BotRecord {
id: string
name: string
webhookUrl: string
secretHash: 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
// Maps botId → fightId so we can direct users to their active fight
const activeFighters = new Map<string, string>()
export function getActiveFighterCount(): number {
return activeFighters.size
}
export function isInFight(botId: string): boolean {
return activeFighters.has(botId)
}
export function getActiveFightId(botId: string): string | undefined {
return activeFighters.get(botId)
}
function emit(fightId: string, type: string, data: Record<string, unknown>) {
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<string> {
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,
secretHash?: string,
): Promise<WebhookResponse> {
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()
logger.info('webhook', `POST ${url} round=${roundNumber} type=${challenge.type}`)
// SSRF check
if (!isAllowedWebhookUrl(url)) {
logger.warn('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)
// HMAC-SHA256 signature for webhook verification
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (secretHash) {
const timestamp = Math.floor(Date.now() / 1000).toString()
const signature = createHmac('sha256', secretHash)
.update(`${timestamp}.${body}`)
.digest('hex')
headers['X-Botfights-Signature'] = `sha256=${signature}`
headers['X-Botfights-Timestamp'] = timestamp
}
const res = await fetch(url, {
method: 'POST',
headers,
body,
signal: controller.signal,
})
clearTimeout(timeout)
const elapsed = Date.now() - start
if (!res.ok) {
logger.warn('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 {
logger.warn('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 {
logger.warn('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) {
logger.warn('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
logger.info('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'
logger.warn('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<WebhookResponse> {
if (isHumanPlayer(bot.webhookUrl)) {
logger.info('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)) {
logger.info('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)) {
logger.info('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,
}
}
if (isPollingBot(bot.webhookUrl)) {
logger.info('fight', `${bot.name} is polling bot, waiting for poll response`)
emit(fightId, 'poll_challenge', { botId: bot.id, round: roundNumber, type: challenge.type })
const start = Date.now()
const result = await waitForPollResponse(fightId, bot.id, challenge, roundNumber, opponent, arena.id, arena.modifier)
const elapsed = Date.now() - start
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
}
logger.info('fight', `${bot.name} has real webhook: ${bot.webhookUrl}`)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena, bot.secretHash)
}
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<string> {
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) || isPollingBot(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))
logger.warn('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<void> {
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<string>()
const hasHuman = isHumanPlayer(botA.webhookUrl) || isHumanPlayer(botB.webhookUrl)
// 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, hasHuman)
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 => logger.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) {
logger.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 => {
logger.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 => {
logger.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<string> {
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`)
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena, mode)
activeFighters.set(botAId, fightId)
activeFighters.set(botBId, fightId)
try {
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', overrides?: { botAWebhookUrl?: string }): Promise<string> {
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`)
const [botA, botB] = await loadBots(botAId, botBId)
if (overrides?.botAWebhookUrl) botA.webhookUrl = overrides.botAWebhookUrl
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena, mode)
activeFighters.set(botAId, fightId)
activeFighters.set(botBId, fightId)
executeFightRounds(fightId, botA, botB, arena, mode)
.catch(err => {
logger.error('fight', `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<number> {
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
}