2026-03-06 22:13:19 +00:00
|
|
|
import { db, schema } from '../db/index.js'
|
|
|
|
|
import { eq } from 'drizzle-orm'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { runFightAsync, isInFight } from './orchestrator.js'
|
2026-03-06 23:44:40 +00:00
|
|
|
import { seedMockBots } from './mock.js'
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
interface QueueEntry {
|
|
|
|
|
botId: string
|
|
|
|
|
botName: string
|
|
|
|
|
webhookUrl: string
|
|
|
|
|
eloRating: number
|
|
|
|
|
joinedAt: number
|
|
|
|
|
resolve: (fightId: string) => void
|
|
|
|
|
reject: (error: Error) => void
|
|
|
|
|
timeoutHandle: ReturnType<typeof setTimeout>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const waitingQueue: QueueEntry[] = []
|
|
|
|
|
|
|
|
|
|
// How long a bot waits before getting matched against a mock bot
|
2026-03-07 11:04:32 +00:00
|
|
|
// 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)
|
2026-03-06 22:13:19 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Post-fight cooldown tracking
|
|
|
|
|
const fightCooldowns = new Map<string, number>()
|
|
|
|
|
const COOLDOWN_MS = 15_000
|
|
|
|
|
|
|
|
|
|
export function setCooldown(botId: string) {
|
|
|
|
|
fightCooldowns.set(botId, Date.now() + COOLDOWN_MS)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
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<string> {
|
2026-03-07 00:14:46 +00:00
|
|
|
// 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
|
|
|
|
|
if (isInFight(botId)) {
|
|
|
|
|
throw new Error('Bot is already in a fight.')
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
// 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]
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
// Check if bot is active
|
|
|
|
|
if (!bot.isActive) {
|
|
|
|
|
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`)
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
// 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'))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Check if someone is already waiting -- instant match
|
2026-03-06 22:13:19 +00:00
|
|
|
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
|
2026-03-07 00:14:46 +00:00
|
|
|
const fightId = await startFight(opponent.botId, botId)
|
2026-03-06 22:13:19 +00:00
|
|
|
opponent.resolve(fightId)
|
|
|
|
|
return fightId
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Nobody waiting -- join the queue and wait
|
2026-03-06 22:13:19 +00:00
|
|
|
return new Promise<string>((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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
async function startFight(botAId: string, botBId: string): Promise<string> {
|
2026-03-06 22:13:19 +00:00
|
|
|
return runFightAsync(botAId, botBId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
|
2026-03-06 23:44:40 +00:00
|
|
|
console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`)
|
2026-03-06 22:13:19 +00:00
|
|
|
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) {
|
2026-03-06 23:44:40 +00:00
|
|
|
console.log('[queue] No mock bots found, seeding...')
|
|
|
|
|
await seedMockBots()
|
|
|
|
|
return matchAgainstMock(botId, webhookUrl)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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]
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
console.log(`[queue] starting fight: ${botId} vs mock ${opponent.id}`)
|
2026-03-06 22:13:19 +00:00
|
|
|
return runFightAsync(botId, opponent.id)
|
|
|
|
|
}
|