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:
Dorian
2026-03-08 01:24:26 +00:00
co-authored by Claude Opus 4.6
parent abe5040742
commit e046cb13eb
2 changed files with 188 additions and 8 deletions
+42 -8
View File
@@ -8,6 +8,7 @@ import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
interface BotRecord {
id: string
@@ -247,7 +248,7 @@ async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, Bot
return [botARows[0] as BotRecord, botBRows[0] as BotRecord]
}
async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena): Promise<string> {
async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<string> {
const fightId = nanoid(12)
const now = new Date().toISOString()
await db.insert(schema.fights).values({
@@ -256,6 +257,9 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
botBId: botB.id,
arena: arena.id,
status: 'live',
mode,
potSats: mode === 'ranked' ? 42 : 0,
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
startedAt: now,
createdAt: now,
})
@@ -287,7 +291,7 @@ async function trackWebhookResult(botId: string, webhookUrl: string, succeeded:
}
}
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<void> {
let hpA = 200
let hpB = 200
let comboA = 0
@@ -442,6 +446,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id)).run()
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id)).run()
}
// Update sats wagered for ranked fights
if (mode === 'ranked') {
db.update(schema.bots).set({
satsWagered: sql`${schema.bots.satsWagered} + ${ENTRY_FEE_SATS}`,
}).where(eq(schema.bots.id, botA.id)).run()
db.update(schema.bots).set({
satsWagered: sql`${schema.bots.satsWagered} + ${ENTRY_FEE_SATS}`,
}).where(eq(schema.bots.id, botB.id)).run()
}
})
finalize()
@@ -451,12 +465,32 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
winnerName,
isPerfect,
finalHp: { a: hpA, b: hpB },
mode,
potSats: mode === 'ranked' ? 42 : 0,
})
// Ranked fight payout
if (mode === 'ranked') {
if (winnerId) {
payWinner(fightId, winnerId).catch(err => {
console.error(`[payments] payout failed for fight ${fightId}:`, err)
})
} else {
// Draw — refund both entry fees
const entryPayments = await db.select().from(schema.payments)
.where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`)
for (const payment of entryPayments) {
refundEntry(payment.id).catch(err => {
console.error(`[payments] draw refund failed for ${payment.id}:`, err)
})
}
}
}
fightEvents.cleanup(fightId)
}
export async function runFight(botAId: string, botBId: string): Promise<string> {
export async function runFight(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
@@ -467,8 +501,8 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
try {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
const fightId = await createFightRecord(botA, botB, arena, mode)
await executeFightRounds(fightId, botA, botB, arena, mode)
return fightId
} finally {
activeFighters.delete(botAId)
@@ -479,7 +513,7 @@ export async function runFight(botAId: string, botBId: string): Promise<string>
}
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
export async function runFightAsync(botAId: string, botBId: string): Promise<string> {
export async function runFightAsync(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
@@ -489,9 +523,9 @@ export async function runFightAsync(botAId: string, botBId: string): Promise<str
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
const fightId = await createFightRecord(botA, botB, arena, mode)
executeFightRounds(fightId, botA, botB, arena)
executeFightRounds(fightId, botA, botB, arena, mode)
.catch(err => {
console.error(`[botfights] fight ${fightId} error:`, err)
// Mark fight as cancelled so it doesn't stay 'live' forever
+146
View File
@@ -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
}