import { db, schema } from '../db/index.js' import { eq, sql } from 'drizzle-orm' import { runFightAsync, isInFight } from './orchestrator.js' import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js' interface RankedQueueEntry { botId: string botName: string webhookUrl: string eloRating: number joinedAt: number paymentId: string resolve: (fightId: string) => void reject: (error: Error) => void timeoutHandle: ReturnType } const rankedQueue: RankedQueueEntry[] = [] const RANKED_TIMEOUT_MS = 60_000 // Post-fight cooldown tracking (shared with free queue) const rankedCooldowns = new Map() const COOLDOWN_MS = 15_000 export function setRankedCooldown(botId: string): void { rankedCooldowns.set(botId, Date.now() + COOLDOWN_MS) } export function getRankedQueueStatus(): { waiting: number; estimatedWaitSec: number } { const waiting = rankedQueue.length // If someone is waiting, new joiner matches instantly; otherwise estimate ~15s const estimatedWaitSec = waiting > 0 ? 5 : 15 return { waiting, estimatedWaitSec } } /** * Join the ranked queue. Requires a confirmed payment. * Returns fightId when matched, or rejects with refund on timeout. * NEVER matches against mock bots. */ export async function joinRankedQueue(botId: string, paymentId: string): Promise { // Verify payment is confirmed (checks NWC if still pending) const status = await checkPaymentStatus(paymentId) if (status !== 'confirmed') { throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`) } // Consume the payment — prevents double-spend // Verifies payment is confirmed, unused, belongs to this bot const consumed = await consumePaymentForQueue(paymentId, botId) if (!consumed) { throw new Error('Payment already used or does not belong to this bot.') } // Check cooldown const cooldownUntil = rankedCooldowns.get(botId) if (cooldownUntil && Date.now() < cooldownUntil) { const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000) releasePayment(paymentId) throw new Error(`Cooldown active. Wait ${waitSec}s.`) } // Check if already in a fight if (isInFight(botId)) { releasePayment(paymentId) throw new Error('Bot is already in a fight.') } // Load bot const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (botRows.length === 0) { releasePayment(paymentId) throw new Error('Bot not found') } const bot = botRows[0] if (!bot.isActive) { releasePayment(paymentId) throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.') } // NEVER allow mock/classic bots in ranked if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) { releasePayment(paymentId) throw new Error('Practice bots cannot join ranked fights.') } console.log(`[ranked-queue] joinRankedQueue botId=${botId} name=${bot.name} paymentId=${paymentId}`) // Don't allow same bot twice const existing = rankedQueue.findIndex(e => e.botId === botId) if (existing !== -1) { const old = rankedQueue.splice(existing, 1)[0] clearTimeout(old.timeoutHandle) // Release the OLD payment (the new one is already consumed) releasePayment(old.paymentId) old.reject(new Error('Rejoined ranked queue')) } // Elo-bracket matchmaking: prefer opponents within bracket, widen over time if (rankedQueue.length > 0) { const now = Date.now() // Find best match: base bracket ±200, widens by 100 every 15s of waiting let bestMatch: RankedQueueEntry | null = null let bestDiff = Infinity for (const entry of rankedQueue) { const waitSec = (now - entry.joinedAt) / 1000 const bracket = 200 + Math.floor(waitSec / 15) * 100 const eloDiff = Math.abs(entry.eloRating - bot.eloRating) if (eloDiff <= bracket && eloDiff < bestDiff) { bestMatch = entry bestDiff = eloDiff } } // If no one in bracket, fall back to closest overall if (!bestMatch) { rankedQueue.sort((a, b) => Math.abs(a.eloRating - bot.eloRating) - Math.abs(b.eloRating - bot.eloRating) ) bestMatch = rankedQueue[0] } const idx = rankedQueue.indexOf(bestMatch) rankedQueue.splice(idx, 1) clearTimeout(bestMatch.timeoutHandle) const fightId = await runFightAsync(bestMatch.botId, botId, 'ranked') await linkPaymentsToFight(fightId, [bestMatch.paymentId, paymentId]) bestMatch.resolve(fightId) return fightId } // Dev mode: auto-match against a random mock bot if (process.env.NODE_ENV !== 'production') { const mockBots = await db.select().from(schema.bots) .where(sql`${schema.bots.webhookUrl} LIKE 'http://mock.local%'`) if (mockBots.length > 0) { const mock = mockBots[Math.floor(Math.random() * mockBots.length)] console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`) const fightId = await runFightAsync(botId, mock.id, 'ranked') await linkPaymentsToFight(fightId, [paymentId]) return fightId } } // Nobody waiting — join queue and wait up to 60s return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(async () => { const idx = rankedQueue.findIndex(e => e.botId === botId) if (idx !== -1) { rankedQueue.splice(idx, 1) // Release payment back to refundable state, then refund releasePayment(paymentId) try { await refundEntry(paymentId) reject(new Error('No ranked opponent found — entry fee refunded.')) } catch (err) { reject(new Error('No ranked opponent found. Refund attempted but may have failed.')) } } }, RANKED_TIMEOUT_MS) rankedQueue.push({ botId, botName: bot.name, webhookUrl: bot.webhookUrl, eloRating: bot.eloRating, joinedAt: Date.now(), paymentId, resolve, reject, timeoutHandle, }) }) } /** * Leave the ranked queue. Triggers refund of entry payment. */ export async function leaveRankedQueue(botId: string): Promise { const idx = rankedQueue.findIndex(e => e.botId === botId) if (idx === -1) return false const entry = rankedQueue.splice(idx, 1)[0] clearTimeout(entry.timeoutHandle) // Release payment, then refund try { releasePayment(entry.paymentId) await refundEntry(entry.paymentId) } catch (err) { console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err) } entry.reject(new Error('Left ranked queue')) return true }