feat: ranked queue + orchestrator payout wiring
Add ranked-queue.ts with 60s timeout, ELO matching, no-mock guard, and automatic refund on timeout. Wire orchestrator to accept mode param, track satsWagered in finalize transaction, trigger payWinner on ranked win and refundEntry on ranked draw. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
abe5040742
commit
e046cb13eb
@@ -0,0 +1,146 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { runFightAsync, isInFight } from './orchestrator.js'
|
||||
import { checkPaymentStatus, refundEntry } 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
|
||||
const status = await checkPaymentStatus(paymentId)
|
||||
if (status !== 'confirmed') {
|
||||
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`)
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const cooldownUntil = rankedCooldowns.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]
|
||||
|
||||
if (!bot.isActive) {
|
||||
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
||||
}
|
||||
|
||||
// NEVER allow mock bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local')) {
|
||||
throw new Error('Mock 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)
|
||||
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
|
||||
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
|
||||
opponent.resolve(fightId)
|
||||
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)
|
||||
// Refund the entry fee
|
||||
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)
|
||||
|
||||
// Refund entry fee
|
||||
try {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user