199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
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): 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<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'))
|
|
}
|
|
|
|
// 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<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
|
|
}
|