import { db, schema } from '../db/index.js' import { logger } from '../lib/logger.js' import { eq } from 'drizzle-orm' import { runFightAsync, isInFight, getActiveFightId } from './orchestrator.js' import { seedMockBots } from './mock.js' interface QueueEntry { botId: string botName: string webhookUrl: string eloRating: number joinedAt: number resolve: (fightId: string) => void reject: (error: Error) => void timeoutHandle: ReturnType } const waitingQueue: QueueEntry[] = [] // How long a bot waits before getting matched against a mock bot // In production, give real bots time to match (30s). In dev, fall back fast (3s). const QUEUE_TIMEOUT_MS = Number(process.env.QUEUE_TIMEOUT_MS) || (process.env.NODE_ENV === 'production' ? 30_000 : 3_000) // Post-fight cooldown tracking const fightCooldowns = new Map() const COOLDOWN_MS = 15_000 export function setCooldown(botId: string): void { fightCooldowns.set(botId, Date.now() + COOLDOWN_MS) } export function getQueueSize(): number { return waitingQueue.length } export function getQueueSnapshot(): { botId: string; botName: string; eloRating: number; waitingSince: number }[] { return waitingQueue.map(e => ({ botId: e.botId, botName: e.botName, eloRating: e.eloRating, waitingSince: e.joinedAt, })) } /** * Join the fight queue. Returns a fightId when matched. * If another bot is waiting, matches instantly. * If nobody is waiting, waits up to QUEUE_TIMEOUT_MS then fights a mock bot. */ export async function joinQueue(botId: string): Promise { // Check cooldown const cooldownUntil = fightCooldowns.get(botId) if (cooldownUntil && Date.now() < cooldownUntil) { const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000) throw new Error(`Cooldown active. Wait ${waitSec}s.`) } // Check if already in a fight — return the fight ID so frontend can redirect if (isInFight(botId)) { const activeFightId = getActiveFightId(botId) const err = new Error('Bot is already in a fight.') ;(err as any).fightId = activeFightId throw err } // Load bot const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (botRows.length === 0) throw new Error('Bot not found') const bot = botRows[0] // Check if bot is active if (!bot.isActive) { throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.') } logger.info('queue', `joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`) // Don't allow same bot twice in queue const existing = waitingQueue.findIndex(e => e.botId === botId) if (existing !== -1) { const old = waitingQueue.splice(existing, 1)[0] clearTimeout(old.timeoutHandle) old.reject(new Error('Rejoined queue')) } // Check if someone is already waiting -- instant match if (waitingQueue.length > 0) { // Find closest elo match waitingQueue.sort((a, b) => { const diffA = Math.abs(a.eloRating - bot.eloRating) const diffB = Math.abs(b.eloRating - bot.eloRating) return diffA - diffB }) const opponent = waitingQueue.shift()! clearTimeout(opponent.timeoutHandle) // Start the fight — ensure human players are always botA (left side) const isHumanJoiner = bot.webhookUrl === 'http://human.local/' const fightId = isHumanJoiner ? await startFight(botId, opponent.botId) : await startFight(opponent.botId, botId) opponent.resolve(fightId) return fightId } // Nobody waiting -- join the queue and wait return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(async () => { const idx = waitingQueue.findIndex(e => e.botId === botId) if (idx !== -1) { waitingQueue.splice(idx, 1) try { const fightId = await matchAgainstMock(botId, bot.webhookUrl) resolve(fightId) } catch (err) { reject(err) } } }, QUEUE_TIMEOUT_MS) waitingQueue.push({ botId, botName: bot.name, webhookUrl: bot.webhookUrl, eloRating: bot.eloRating, joinedAt: Date.now(), resolve, reject, timeoutHandle, }) }) } /** * Leave the queue without fighting. */ export function leaveQueue(botId: string): boolean { const idx = waitingQueue.findIndex(e => e.botId === botId) if (idx === -1) return false const entry = waitingQueue.splice(idx, 1)[0] clearTimeout(entry.timeoutHandle) entry.reject(new Error('Left queue')) return true } async function startFight(botAId: string, botBId: string): Promise { return runFightAsync(botAId, botBId) } async function matchAgainstMock(botId: string, webhookUrl: string): Promise { logger.info('queue', `matchAgainstMock botId=${botId} webhook=${webhookUrl}`) const allBots = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl, eloRating: schema.bots.eloRating, }).from(schema.bots) const mockBots = allBots.filter(b => b.id !== botId && b.webhookUrl.startsWith('http://mock.local')) if (mockBots.length === 0) { logger.info('queue', 'No mock bots found, seeding...') await seedMockBots() return matchAgainstMock(botId, webhookUrl) } // Pick closest elo mock bot const bot = allBots.find(b => b.id === botId) const botElo = bot?.eloRating || 1200 mockBots.sort((a, b) => Math.abs(a.eloRating - botElo) - Math.abs(b.eloRating - botElo)) const opponent = mockBots[0] logger.info('queue', `starting fight: ${botId} vs mock ${opponent.id}`) return runFightAsync(botId, opponent.id) }