Files
botfights/server/src/engine/ranked-queue.ts
T

179 lines
5.9 KiB
TypeScript
Raw Normal View History

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<typeof setTimeout>
}
const rankedQueue: RankedQueueEntry[] = []
const RANKED_TIMEOUT_MS = 60_000
// Post-fight cooldown tracking (shared with free queue)
const rankedCooldowns = new Map<string, number>()
const COOLDOWN_MS = 15_000
export function setRankedCooldown(botId: string) {
rankedCooldowns.set(botId, Date.now() + COOLDOWN_MS)
}
export function getRankedQueueStatus(): { waiting: number } {
return { waiting: rankedQueue.length }
}
/**
* 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<string> {
// 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'))
}
// Check if someone is already waiting — instant match by closest ELO
if (rankedQueue.length > 0) {
rankedQueue.sort((a, b) => {
const diffA = Math.abs(a.eloRating - bot.eloRating)
const diffB = Math.abs(b.eloRating - bot.eloRating)
return diffA - diffB
})
const opponent = rankedQueue.shift()!
clearTimeout(opponent.timeoutHandle)
// Start ranked fight and link both payments
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
opponent.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<string>((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<boolean> {
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
}