Files
botfights/server/src/engine/queue.ts
T
2026-03-08 21:48:22 +00:00

173 lines
5.4 KiB
TypeScript

import { db, schema } from '../db/index.js'
import { logger } from '../lib/logger.js'
import { eq } from 'drizzle-orm'
import { runFightAsync, isInFight } 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<typeof setTimeout>
}
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<string, number>()
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<string> {
// 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.')
}
// 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<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
}
async function startFight(botAId: string, botBId: string): Promise<string> {
return runFightAsync(botAId, botBId)
}
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
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)
}