import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import { runMockFight } from './mock.js' import { logger } from '../lib/logger.js' import { createTournament, joinTournament, startTournament, listTournaments, getPendingMatches, linkFightToMatch, } from './tournaments.js' import { runFightAsync } from './orchestrator.js' // Background fight loop keeps mock bots fighting so the site always has fresh content. // Configurable via env: // FIGHT_LOOP_ENABLED=true (default: true in production, false in dev) // FIGHT_LOOP_INTERVAL_MS=45000 (default: 45s between fights) // FIGHT_LOOP_QUIET_HOURS=2-8 (default: 2am-8am UTC, slower fights) const isProduction = process.env.NODE_ENV === 'production' const enabled = process.env.FIGHT_LOOP_ENABLED ? process.env.FIGHT_LOOP_ENABLED === 'true' : isProduction const intervalMs = Number(process.env.FIGHT_LOOP_INTERVAL_MS) || 45_000 const quietStart = Number(process.env.FIGHT_LOOP_QUIET_START) || 2 const quietEnd = Number(process.env.FIGHT_LOOP_QUIET_END) || 8 let running = false export function startBackgroundFights() { if (!enabled) { logger.info('background', 'fight loop disabled (set FIGHT_LOOP_ENABLED=true to enable)') return } if (running) return running = true logger.info('background', `fight loop started — interval ${intervalMs}ms, quiet hours ${quietStart}-${quietEnd} UTC`) loop().catch(err => { logger.error('background', 'fight loop crashed', err) running = false }) } export function stopBackgroundFights() { running = false } // Track last tournament check times let lastDailyCheck = 0 let lastWeeklyCheck = 0 /** Check if we should create a "Lightning Round" daily 8-bot tournament */ async function checkDailyTournament() { const now = Date.now() if (now - lastDailyCheck < 60 * 60 * 1000) return // Check at most hourly lastDailyCheck = now const hour = new Date().getUTCHours() if (hour !== 12) return // Noon UTC daily // Don't create if there's already an open/active tournament today const active = listTournaments('open').concat(listTournaments('active')) const today = new Date().toISOString().slice(0, 10) const existsToday = active.some(t => t.createdAt.startsWith(today)) if (existsToday) return try { const id = createTournament('Lightning Round', 'single_elim', 8, 0) await fillTournamentWithMockBots(id, 8) startTournament(id) await runTournamentMatches(id) logger.info('background', `daily Lightning Round tournament created: ${id}`) } catch (err) { logger.error('background', 'failed to create daily tournament', err) } } /** Check if we should create "The Halvening" weekly 32-bot tournament */ async function checkWeeklyTournament() { const now = Date.now() if (now - lastWeeklyCheck < 60 * 60 * 1000) return lastWeeklyCheck = now const d = new Date() if (d.getUTCDay() !== 6 || d.getUTCHours() !== 20) return // Saturday 20:00 UTC const active = listTournaments('open').concat(listTournaments('active')) const today = new Date().toISOString().slice(0, 10) const existsToday = active.some(t => t.createdAt.startsWith(today) && t.size === 32) if (existsToday) return try { const id = createTournament('The Halvening', 'single_elim', 32, 0) await fillTournamentWithMockBots(id, 32) startTournament(id) await runTournamentMatches(id) logger.info('background', `weekly The Halvening tournament created: ${id}`) } catch (err) { logger.error('background', 'failed to create weekly tournament', err) } } /** Fill remaining tournament slots with random mock bots */ async function fillTournamentWithMockBots(tournamentId: string, size: number) { const mockBots = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.botType, 'mock')) .all() const shuffled = mockBots.sort(() => Math.random() - 0.5) const toAdd = Math.min(size, shuffled.length) for (let i = 0; i < toAdd; i++) { try { joinTournament(tournamentId, shuffled[i].id) } catch { /* already joined or full */ } } } /** Run all pending matches in a tournament */ async function runTournamentMatches(tournamentId: string) { const pending = getPendingMatches(tournamentId) for (const match of pending) { if (!match.botAId || !match.botBId) continue try { const fightId = await runFightAsync(match.botAId, match.botBId, 'free') linkFightToMatch(match.id, fightId) } catch (err) { logger.error('background', `tournament match failed: ${match.id}`, err) } await sleep(2000) } } async function loop() { // Small delay on startup to let everything settle await sleep(5_000) while (running) { try { await runOneBackgroundFight() } catch (err) { logger.error('background', 'fight error', err) } // Check for scheduled tournaments try { await checkDailyTournament() await checkWeeklyTournament() } catch (err) { logger.error('background', 'tournament check error', err) } // During quiet hours, fights run 4x slower const hour = new Date().getUTCHours() const isQuiet = quietStart < quietEnd ? hour >= quietStart && hour < quietEnd : hour >= quietStart || hour < quietEnd const delay = isQuiet ? intervalMs * 4 : intervalMs // Add jitter (±25%) so fights don't feel robotic const jitter = delay * (0.75 + Math.random() * 0.5) await sleep(jitter) } logger.info('background', 'fight loop stopped') } async function runOneBackgroundFight() { // Pick two mock bots with ELO-weighted matchmaking const allBots = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl, eloRating: schema.bots.eloRating, name: schema.bots.name, }).from(schema.bots) const mockBots = allBots.filter(b => b.webhookUrl.startsWith('http://mock.local')) if (mockBots.length < 2) return // Mixed matchmaking: 70% close ELO, 30% wild card const isWild = Math.random() < 0.3 const botA = mockBots[Math.floor(Math.random() * mockBots.length)] const others = mockBots.filter(b => b.id !== botA.id) let botB: typeof botA if (isWild) { botB = others[Math.floor(Math.random() * others.length)] } else { others.sort((a, b) => { const diffA = Math.abs(a.eloRating - botA.eloRating) + Math.random() * 150 const diffB = Math.abs(b.eloRating - botA.eloRating) + Math.random() * 150 return diffA - diffB }) botB = others[0] } const fightId = await runMockFight(botA.id, botB.id) logger.info('background', `${botA.name} vs ${botB.name} => fight ${fightId}`) } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) }