diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 2b870ba..2fbc6d8 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -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 { +async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise { 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 { +async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise { 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 { +export async function runFight(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise { 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 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 } /** Creates the fight record and returns the ID immediately. Rounds run in background. */ -export async function runFightAsync(botAId: string, botBId: string): Promise { +export async function runFightAsync(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise { 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 { console.error(`[botfights] fight ${fightId} error:`, err) // Mark fight as cancelled so it doesn't stay 'live' forever diff --git a/server/src/engine/ranked-queue.ts b/server/src/engine/ranked-queue.ts new file mode 100644 index 0000000..a034db7 --- /dev/null +++ b/server/src/engine/ranked-queue.ts @@ -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 +} + +const rankedQueue: RankedQueueEntry[] = [] +const RANKED_TIMEOUT_MS = 60_000 + +// Post-fight cooldown tracking (shared with free queue) +const rankedCooldowns = new Map() +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 { + // 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((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 { + 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 +}