2026-03-06 16:27:54 +00:00
|
|
|
import { nanoid } from 'nanoid'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { db, schema, sqlite } from '../db/index.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
import { eq, sql } from 'drizzle-orm'
|
|
|
|
|
import { randomArena, type Arena } from './arenas.js'
|
|
|
|
|
import { pickChallenge, type Challenge } from './challenges.js'
|
2026-03-08 14:13:47 +00:00
|
|
|
import { generateRetroChallenge } from './retro-moves.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
|
|
|
|
import { fightEvents } from './events.js'
|
2026-03-08 10:33:30 +00:00
|
|
|
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
2026-03-07 00:14:46 +00:00
|
|
|
import { setCooldown } from './queue.js'
|
2026-03-07 15:20:14 +00:00
|
|
|
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
|
2026-03-08 01:24:26 +00:00
|
|
|
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
interface BotRecord {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
webhookUrl: string
|
|
|
|
|
eloRating: number
|
|
|
|
|
wins: number
|
|
|
|
|
losses: number
|
|
|
|
|
winStreak: number
|
|
|
|
|
bestStreak: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WebhookResponse {
|
|
|
|
|
answer: string | null
|
|
|
|
|
trashTalk?: string
|
|
|
|
|
timeMs: number
|
|
|
|
|
timedOut: boolean
|
|
|
|
|
error: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
const MAX_ROUNDS = 10
|
2026-03-06 16:27:54 +00:00
|
|
|
const KO_THRESHOLD = 0
|
2026-03-07 00:14:46 +00:00
|
|
|
const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
|
|
|
|
|
|
|
|
|
|
// Track bots currently in a fight to prevent concurrent fights
|
|
|
|
|
const activeFighters = new Set<string>()
|
|
|
|
|
|
|
|
|
|
export function isInFight(botId: string): boolean {
|
|
|
|
|
return activeFighters.has(botId)
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
|
|
|
|
fightEvents.emit({
|
|
|
|
|
fightId,
|
|
|
|
|
type,
|
|
|
|
|
data,
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// SSRF protection: block internal/private URLs
|
|
|
|
|
function isAllowedWebhookUrl(url: string): boolean {
|
|
|
|
|
try {
|
2026-03-08 12:08:18 +00:00
|
|
|
if (typeof url !== 'string' || url.length > 2048) return false
|
2026-03-07 00:14:46 +00:00
|
|
|
const parsed = new URL(url)
|
2026-03-08 12:08:18 +00:00
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
2026-03-07 00:14:46 +00:00
|
|
|
const hostname = parsed.hostname.toLowerCase()
|
2026-03-08 12:08:18 +00:00
|
|
|
// Localhost variants
|
|
|
|
|
if (hostname === 'localhost' || hostname === '::1') return false
|
|
|
|
|
if (hostname.startsWith('127.')) return false
|
|
|
|
|
// IPv6-mapped IPv4 localhost
|
|
|
|
|
if (hostname.startsWith('::ffff:127.')) return false
|
|
|
|
|
// Private IPv4 ranges
|
2026-03-07 00:14:46 +00:00
|
|
|
if (hostname.startsWith('10.')) return false
|
|
|
|
|
if (hostname.startsWith('192.168.')) return false
|
|
|
|
|
if (hostname.startsWith('172.')) {
|
|
|
|
|
const second = parseInt(hostname.split('.')[1])
|
|
|
|
|
if (second >= 16 && second <= 31) return false
|
|
|
|
|
}
|
2026-03-08 12:08:18 +00:00
|
|
|
// Link-local and metadata
|
|
|
|
|
if (hostname.startsWith('169.254.')) return false
|
|
|
|
|
// IPv6 private (fc00::/7)
|
|
|
|
|
if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false
|
|
|
|
|
// IPv6 link-local (fe80::/10)
|
|
|
|
|
if (hostname.startsWith('fe80')) return false
|
|
|
|
|
// Reserved TLDs
|
|
|
|
|
if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false
|
|
|
|
|
// Null byte injection
|
|
|
|
|
if (hostname.includes('\0')) return false
|
2026-03-07 00:14:46 +00:00
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { isAllowedWebhookUrl }
|
2026-03-07 15:20:14 +00:00
|
|
|
export { isHumanPlayer } from './human-responses.js'
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
// Size-limited body reader to prevent OOM
|
|
|
|
|
async function readLimitedBody(res: Response, maxBytes: number): Promise<string> {
|
|
|
|
|
const reader = res.body?.getReader()
|
|
|
|
|
if (!reader) return ''
|
|
|
|
|
const chunks: Uint8Array[] = []
|
|
|
|
|
let totalBytes = 0
|
|
|
|
|
try {
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
if (done) break
|
|
|
|
|
totalBytes += value.byteLength
|
|
|
|
|
if (totalBytes > maxBytes) {
|
|
|
|
|
reader.cancel()
|
|
|
|
|
throw new Error(`Response body exceeds ${maxBytes} bytes`)
|
|
|
|
|
}
|
|
|
|
|
chunks.push(value)
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
reader.cancel()
|
|
|
|
|
throw err
|
|
|
|
|
}
|
|
|
|
|
const combined = new Uint8Array(totalBytes)
|
|
|
|
|
let offset = 0
|
|
|
|
|
for (const chunk of chunks) {
|
|
|
|
|
combined.set(chunk, offset)
|
|
|
|
|
offset += chunk.byteLength
|
|
|
|
|
}
|
|
|
|
|
return new TextDecoder().decode(combined)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
async function callWebhook(
|
|
|
|
|
url: string,
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
roundNumber: number,
|
2026-03-07 00:14:46 +00:00
|
|
|
fightId: string,
|
2026-03-06 16:27:54 +00:00
|
|
|
opponent: { name: string; wins: number; losses: number },
|
|
|
|
|
arena: Arena,
|
|
|
|
|
): Promise<WebhookResponse> {
|
|
|
|
|
const body = JSON.stringify({
|
2026-03-07 00:14:46 +00:00
|
|
|
fight_id: fightId,
|
2026-03-06 16:27:54 +00:00
|
|
|
round: roundNumber,
|
|
|
|
|
type: challenge.type,
|
|
|
|
|
challenge: challenge.prompt,
|
|
|
|
|
constraints: {
|
|
|
|
|
timeout_ms: challenge.timeout_ms,
|
|
|
|
|
max_tokens: 500,
|
|
|
|
|
},
|
|
|
|
|
opponent,
|
|
|
|
|
arena: arena.id,
|
|
|
|
|
arena_modifier: arena.modifier,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const start = Date.now()
|
2026-03-06 22:13:19 +00:00
|
|
|
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// SSRF check
|
|
|
|
|
if (!isAllowedWebhookUrl(url)) {
|
|
|
|
|
console.log(`[webhook] ${url} BLOCKED (private/internal URL)`)
|
|
|
|
|
return { answer: null, timeMs: 0, timedOut: false, error: true }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
try {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
|
|
|
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
clearTimeout(timeout)
|
|
|
|
|
const elapsed = Date.now() - start
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
2026-03-06 22:13:19 +00:00
|
|
|
console.log(`[webhook] ${url} returned ${res.status} in ${elapsed}ms`)
|
2026-03-06 16:27:54 +00:00
|
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
let text: string
|
|
|
|
|
try {
|
|
|
|
|
text = await readLimitedBody(res, MAX_RESPONSE_BYTES)
|
|
|
|
|
} catch {
|
|
|
|
|
console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
|
|
|
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 23:44:40 +00:00
|
|
|
let data: { answer?: string; trash_talk?: string }
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(text)
|
|
|
|
|
} catch {
|
|
|
|
|
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
|
|
|
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
|
|
|
}
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
// Enforce size limits on fields
|
|
|
|
|
const answer = data.answer ? data.answer.slice(0, 2000) : null
|
|
|
|
|
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined
|
|
|
|
|
|
|
|
|
|
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
|
2026-03-06 16:27:54 +00:00
|
|
|
return {
|
2026-03-07 00:14:46 +00:00
|
|
|
answer,
|
|
|
|
|
trashTalk,
|
2026-03-06 16:27:54 +00:00
|
|
|
timeMs: elapsed,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
error: false,
|
|
|
|
|
}
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const elapsed = Date.now() - start
|
|
|
|
|
const isAbort = err instanceof Error && err.name === 'AbortError'
|
2026-03-06 22:13:19 +00:00
|
|
|
console.log(`[webhook] ${url} ${isAbort ? "TIMEOUT" : "ERROR"} in ${elapsed}ms: ${err instanceof Error ? err.message : err}`)
|
2026-03-06 16:27:54 +00:00
|
|
|
return {
|
|
|
|
|
answer: null,
|
|
|
|
|
timeMs: elapsed,
|
|
|
|
|
timedOut: isAbort,
|
|
|
|
|
error: !isAbort,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
export function isMockBot(webhookUrl: string): boolean {
|
2026-03-06 22:13:19 +00:00
|
|
|
return webhookUrl.startsWith('http://mock.local')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getBotResponse(
|
|
|
|
|
bot: BotRecord,
|
|
|
|
|
challenge: Challenge,
|
|
|
|
|
roundNumber: number,
|
2026-03-07 00:14:46 +00:00
|
|
|
fightId: string,
|
2026-03-06 22:13:19 +00:00
|
|
|
opponent: { name: string; wins: number; losses: number },
|
|
|
|
|
arena: Arena,
|
|
|
|
|
): Promise<WebhookResponse> {
|
2026-03-07 15:20:14 +00:00
|
|
|
if (isHumanPlayer(bot.webhookUrl)) {
|
|
|
|
|
console.log(`[fight] ${bot.name} is human player, waiting for browser response`)
|
|
|
|
|
emit(fightId, 'human_challenge', {
|
|
|
|
|
botId: bot.id,
|
|
|
|
|
round: roundNumber,
|
|
|
|
|
type: challenge.type,
|
|
|
|
|
label: challenge.label,
|
|
|
|
|
prompt: challenge.prompt,
|
|
|
|
|
timeoutMs: 8000,
|
|
|
|
|
scoring: challenge.scoring,
|
|
|
|
|
})
|
|
|
|
|
const start = Date.now()
|
|
|
|
|
const result = await waitForHumanResponse(fightId, bot.id, challenge, roundNumber)
|
|
|
|
|
const elapsed = Date.now() - start
|
|
|
|
|
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 10:33:30 +00:00
|
|
|
if (isClassicBot(bot.webhookUrl)) {
|
|
|
|
|
console.log(`[fight] ${bot.name} is classic bot, generating response`)
|
|
|
|
|
const classic = generateClassicBotResponse(challenge, bot.name)
|
|
|
|
|
return {
|
|
|
|
|
answer: classic.answer || null,
|
|
|
|
|
trashTalk: '',
|
|
|
|
|
timeMs: classic.timeMs,
|
|
|
|
|
timedOut: classic.timedOut,
|
|
|
|
|
error: classic.error,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
if (isMockBot(bot.webhookUrl)) {
|
|
|
|
|
console.log(`[fight] ${bot.name} is mock bot, generating response`)
|
2026-03-06 23:44:40 +00:00
|
|
|
const mock = generateMockBotResponse(challenge, bot.name)
|
2026-03-06 22:13:19 +00:00
|
|
|
return {
|
|
|
|
|
answer: mock.answer || null,
|
|
|
|
|
trashTalk: mock.trashTalk,
|
|
|
|
|
timeMs: mock.timeMs,
|
|
|
|
|
timedOut: mock.timedOut,
|
|
|
|
|
error: mock.error,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
|
2026-03-07 00:14:46 +00:00
|
|
|
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
2026-03-06 16:27:54 +00:00
|
|
|
const [botARows, botBRows] = await Promise.all([
|
|
|
|
|
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
|
|
|
|
|
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
|
|
|
|
|
])
|
|
|
|
|
if (botARows.length === 0 || botBRows.length === 0) {
|
|
|
|
|
throw new Error('One or both bots not found')
|
|
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
return [botARows[0] as BotRecord, botBRows[0] as BotRecord]
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-08 01:24:26 +00:00
|
|
|
async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<string> {
|
2026-03-06 16:27:54 +00:00
|
|
|
const fightId = nanoid(12)
|
|
|
|
|
const now = new Date().toISOString()
|
|
|
|
|
await db.insert(schema.fights).values({
|
|
|
|
|
id: fightId,
|
|
|
|
|
botAId: botA.id,
|
|
|
|
|
botBId: botB.id,
|
|
|
|
|
arena: arena.id,
|
|
|
|
|
status: 'live',
|
2026-03-08 01:24:26 +00:00
|
|
|
mode,
|
|
|
|
|
potSats: mode === 'ranked' ? 42 : 0,
|
|
|
|
|
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
|
2026-03-06 16:27:54 +00:00
|
|
|
startedAt: now,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
})
|
|
|
|
|
emit(fightId, 'fight_start', {
|
|
|
|
|
botA: { id: botA.id, name: botA.name, elo: botA.eloRating },
|
|
|
|
|
botB: { id: botB.id, name: botB.name, elo: botB.eloRating },
|
|
|
|
|
arena: { id: arena.id, name: arena.name, description: arena.description, modifier: arena.modifier },
|
|
|
|
|
})
|
2026-03-06 22:13:19 +00:00
|
|
|
return fightId
|
|
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Track webhook errors per bot
|
|
|
|
|
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
2026-03-08 10:33:30 +00:00
|
|
|
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
2026-03-07 00:14:46 +00:00
|
|
|
if (succeeded) {
|
|
|
|
|
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
|
|
|
|
} else {
|
|
|
|
|
await db.update(schema.bots).set({
|
|
|
|
|
consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`,
|
|
|
|
|
lastErrorAt: new Date().toISOString(),
|
|
|
|
|
}).where(eq(schema.bots.id, botId))
|
|
|
|
|
// Auto-deactivate after 5 consecutive errors
|
|
|
|
|
const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors })
|
|
|
|
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
|
|
|
|
if (bot[0] && bot[0].consecutiveErrors >= 5) {
|
|
|
|
|
await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId))
|
|
|
|
|
console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 01:24:26 +00:00
|
|
|
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<void> {
|
2026-03-06 22:13:19 +00:00
|
|
|
let hpA = 200
|
|
|
|
|
let hpB = 200
|
2026-03-06 16:27:54 +00:00
|
|
|
let comboA = 0
|
|
|
|
|
let comboB = 0
|
|
|
|
|
let winnerId: string | null = null
|
|
|
|
|
const usedTypes = new Set<string>()
|
|
|
|
|
|
2026-03-08 14:13:47 +00:00
|
|
|
// Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late)
|
|
|
|
|
const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4))
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
2026-03-08 14:13:47 +00:00
|
|
|
const challenge = round === retroRound
|
|
|
|
|
? generateRetroChallenge()
|
|
|
|
|
: pickChallenge(usedTypes, arena.modifier)
|
2026-03-06 16:27:54 +00:00
|
|
|
usedTypes.add(challenge.type)
|
|
|
|
|
|
|
|
|
|
emit(fightId, 'round_start', {
|
|
|
|
|
round,
|
|
|
|
|
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Call both bots simultaneously
|
2026-03-06 16:27:54 +00:00
|
|
|
const [responseA, responseB] = await Promise.all([
|
2026-03-07 00:14:46 +00:00
|
|
|
getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
|
|
|
|
getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Track webhook reliability for real bots
|
|
|
|
|
await Promise.all([
|
|
|
|
|
trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut),
|
|
|
|
|
trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut),
|
2026-03-06 16:27:54 +00:00
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// Score the round
|
|
|
|
|
const result = scoreRound(
|
|
|
|
|
challenge,
|
|
|
|
|
{ id: botA.id, name: botA.name },
|
|
|
|
|
{ id: botB.id, name: botB.name },
|
|
|
|
|
{ answer: responseA.answer, timeMs: responseA.timeMs, timedOut: responseA.timedOut, error: responseA.error, trashTalk: responseA.trashTalk },
|
|
|
|
|
{ answer: responseB.answer, timeMs: responseB.timeMs, timedOut: responseB.timedOut, error: responseB.error, trashTalk: responseB.trashTalk },
|
|
|
|
|
arena.modifier,
|
|
|
|
|
comboA,
|
|
|
|
|
comboB,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Apply damage
|
|
|
|
|
hpB = Math.max(KO_THRESHOLD, hpB - result.botADamage)
|
|
|
|
|
hpA = Math.max(KO_THRESHOLD, hpA - result.botBDamage)
|
|
|
|
|
|
|
|
|
|
// Update combos
|
|
|
|
|
if (result.winnerId === botA.id) {
|
|
|
|
|
comboA++
|
|
|
|
|
comboB = 0
|
|
|
|
|
} else if (result.winnerId === botB.id) {
|
|
|
|
|
comboB++
|
|
|
|
|
comboA = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save round
|
|
|
|
|
await db.insert(schema.rounds).values({
|
|
|
|
|
id: nanoid(12),
|
|
|
|
|
fightId,
|
|
|
|
|
roundNumber: round,
|
|
|
|
|
challengeType: challenge.type,
|
2026-03-08 14:13:47 +00:00
|
|
|
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
|
2026-03-06 16:27:54 +00:00
|
|
|
botAResponse: responseA.answer,
|
|
|
|
|
botATimeMs: responseA.timeMs,
|
|
|
|
|
botAScore: result.botAScore,
|
|
|
|
|
botBResponse: responseB.answer,
|
|
|
|
|
botBTimeMs: responseB.timeMs,
|
|
|
|
|
botBScore: result.botBScore,
|
|
|
|
|
winnerId: result.winnerId,
|
|
|
|
|
narration: result.narration,
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
emit(fightId, 'round_end', {
|
|
|
|
|
round,
|
|
|
|
|
result: {
|
|
|
|
|
...result,
|
|
|
|
|
botAResponse: responseA.answer?.slice(0, 200),
|
|
|
|
|
botBResponse: responseB.answer?.slice(0, 200),
|
|
|
|
|
botATimeMs: responseA.timeMs,
|
|
|
|
|
botBTimeMs: responseB.timeMs,
|
|
|
|
|
botATrashTalk: responseA.trashTalk,
|
|
|
|
|
botBTrashTalk: responseB.trashTalk,
|
|
|
|
|
},
|
|
|
|
|
hp: { a: hpA, b: hpB },
|
|
|
|
|
combo: { a: comboA, b: comboB },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update fight HP in DB
|
|
|
|
|
await db.update(schema.fights).set({
|
|
|
|
|
botAHp: hpA,
|
|
|
|
|
botBHp: hpB,
|
|
|
|
|
totalRounds: round,
|
2026-03-07 00:53:15 +00:00
|
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
// Check for KO
|
|
|
|
|
if (hpA <= KO_THRESHOLD || hpB <= KO_THRESHOLD) {
|
|
|
|
|
winnerId = hpA <= KO_THRESHOLD ? botB.id : botA.id
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no KO, winner is whoever has more HP
|
|
|
|
|
if (!winnerId) {
|
|
|
|
|
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : 'nobody'
|
|
|
|
|
const isPerfect = winnerId && (
|
2026-03-06 22:13:19 +00:00
|
|
|
(winnerId === botA.id && hpA === 200) ||
|
|
|
|
|
(winnerId === botB.id && hpB === 200)
|
2026-03-06 16:27:54 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Finalize fight + update bot stats atomically
|
2026-03-08 10:33:30 +00:00
|
|
|
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
|
|
|
|
|
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
const finalize = sqlite.transaction(() => {
|
|
|
|
|
// Mark fight finished
|
|
|
|
|
db.update(schema.fights).set({
|
|
|
|
|
status: 'finished',
|
|
|
|
|
winnerId,
|
|
|
|
|
endedAt: new Date().toISOString(),
|
2026-03-07 00:53:15 +00:00
|
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-07 00:14:46 +00:00
|
|
|
// Update bot stats
|
|
|
|
|
if (winnerId) {
|
|
|
|
|
const loserId = winnerId === botA.id ? botB.id : botA.id
|
|
|
|
|
const winner = winnerId === botA.id ? botA : botB
|
|
|
|
|
const loser = winnerId === botA.id ? botB : botA
|
|
|
|
|
|
|
|
|
|
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor)
|
|
|
|
|
const newWinStreak = winner.winStreak + 1
|
|
|
|
|
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
db.update(schema.bots).set({
|
|
|
|
|
wins: sql`${schema.bots.wins} + 1`,
|
|
|
|
|
eloRating: newWinnerElo,
|
|
|
|
|
winStreak: newWinStreak,
|
|
|
|
|
bestStreak: newBestStreak,
|
|
|
|
|
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
2026-03-07 00:14:46 +00:00
|
|
|
lastFightAt: new Date().toISOString(),
|
2026-03-07 00:53:15 +00:00
|
|
|
}).where(eq(schema.bots.id, winnerId)).run()
|
2026-03-07 00:14:46 +00:00
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
db.update(schema.bots).set({
|
|
|
|
|
losses: sql`${schema.bots.losses} + 1`,
|
|
|
|
|
eloRating: newLoserElo,
|
|
|
|
|
winStreak: 0,
|
|
|
|
|
tier: calculateTier(newLoserElo, loser.wins),
|
2026-03-07 00:14:46 +00:00
|
|
|
lastFightAt: new Date().toISOString(),
|
2026-03-07 00:53:15 +00:00
|
|
|
}).where(eq(schema.bots.id, loserId)).run()
|
2026-03-07 00:14:46 +00:00
|
|
|
} else {
|
|
|
|
|
// Draw — update lastFightAt for both
|
2026-03-07 00:53:15 +00:00
|
|
|
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()
|
2026-03-07 00:14:46 +00:00
|
|
|
}
|
2026-03-08 01:24:26 +00:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
}
|
2026-03-07 00:14:46 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
finalize()
|
2026-03-06 16:27:54 +00:00
|
|
|
|
|
|
|
|
emit(fightId, 'fight_end', {
|
|
|
|
|
winnerId,
|
|
|
|
|
winnerName,
|
|
|
|
|
isPerfect,
|
|
|
|
|
finalHp: { a: hpA, b: hpB },
|
2026-03-08 01:24:26 +00:00
|
|
|
mode,
|
|
|
|
|
potSats: mode === 'ranked' ? 42 : 0,
|
2026-03-06 16:27:54 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-08 01:24:26 +00:00
|
|
|
// Ranked fight payout
|
|
|
|
|
if (mode === 'ranked') {
|
2026-03-08 10:33:30 +00:00
|
|
|
// Dev mode: always pay the human bot (not mock), regardless of win/loss
|
|
|
|
|
const devMode = process.env.NODE_ENV !== 'production'
|
|
|
|
|
const isMockA = botA.webhookUrl.startsWith('http://mock.local')
|
|
|
|
|
const isMockB = botB.webhookUrl.startsWith('http://mock.local')
|
|
|
|
|
const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId
|
|
|
|
|
|
|
|
|
|
if (humanBotId) {
|
|
|
|
|
payWinner(fightId, humanBotId).catch(err => {
|
2026-03-08 01:24:26 +00:00
|
|
|
console.error(`[payments] payout failed for fight ${fightId}:`, err)
|
|
|
|
|
})
|
2026-03-08 10:33:30 +00:00
|
|
|
} else if (!winnerId) {
|
2026-03-08 01:24:26 +00:00
|
|
|
// 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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
fightEvents.cleanup(fightId)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
2026-03-06 16:27:54 +00:00
|
|
|
|
2026-03-08 01:24:26 +00:00
|
|
|
export async function runFight(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise<string> {
|
2026-03-07 00:14:46 +00:00
|
|
|
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`)
|
|
|
|
|
|
|
|
|
|
activeFighters.add(botAId)
|
|
|
|
|
activeFighters.add(botBId)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const [botA, botB] = await loadBots(botAId, botBId)
|
|
|
|
|
const arena = randomArena()
|
2026-03-08 01:24:26 +00:00
|
|
|
const fightId = await createFightRecord(botA, botB, arena, mode)
|
|
|
|
|
await executeFightRounds(fightId, botA, botB, arena, mode)
|
2026-03-07 00:14:46 +00:00
|
|
|
return fightId
|
|
|
|
|
} finally {
|
|
|
|
|
activeFighters.delete(botAId)
|
|
|
|
|
activeFighters.delete(botBId)
|
|
|
|
|
setCooldown(botAId)
|
|
|
|
|
setCooldown(botBId)
|
|
|
|
|
}
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
|
2026-03-08 01:24:26 +00:00
|
|
|
export async function runFightAsync(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free'): Promise<string> {
|
2026-03-07 00:14:46 +00:00
|
|
|
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`)
|
|
|
|
|
|
|
|
|
|
activeFighters.add(botAId)
|
|
|
|
|
activeFighters.add(botBId)
|
|
|
|
|
|
2026-03-06 22:13:19 +00:00
|
|
|
const [botA, botB] = await loadBots(botAId, botBId)
|
|
|
|
|
const arena = randomArena()
|
2026-03-08 01:24:26 +00:00
|
|
|
const fightId = await createFightRecord(botA, botB, arena, mode)
|
2026-03-07 00:14:46 +00:00
|
|
|
|
2026-03-08 01:24:26 +00:00
|
|
|
executeFightRounds(fightId, botA, botB, arena, mode)
|
2026-03-07 00:14:46 +00:00
|
|
|
.catch(err => {
|
|
|
|
|
console.error(`[botfights] fight ${fightId} error:`, err)
|
|
|
|
|
// Mark fight as cancelled so it doesn't stay 'live' forever
|
|
|
|
|
db.update(schema.fights).set({
|
|
|
|
|
status: 'cancelled',
|
|
|
|
|
endedAt: new Date().toISOString(),
|
2026-03-07 00:53:15 +00:00
|
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
2026-03-07 00:14:46 +00:00
|
|
|
fightEvents.cleanup(fightId)
|
|
|
|
|
})
|
|
|
|
|
.finally(() => {
|
|
|
|
|
activeFighters.delete(botAId)
|
|
|
|
|
activeFighters.delete(botBId)
|
|
|
|
|
setCooldown(botAId)
|
|
|
|
|
setCooldown(botBId)
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-06 16:27:54 +00:00
|
|
|
return fightId
|
|
|
|
|
}
|
2026-03-07 00:14:46 +00:00
|
|
|
|
|
|
|
|
/** Clean up orphaned fights on startup */
|
|
|
|
|
export async function cleanupOrphanedFights(): Promise<number> {
|
|
|
|
|
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString()
|
|
|
|
|
const result = await db.update(schema.fights)
|
|
|
|
|
.set({ status: 'cancelled', endedAt: new Date().toISOString() })
|
|
|
|
|
.where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`)
|
|
|
|
|
return 0 // drizzle doesn't return affected rows easily, but the cleanup runs
|
|
|
|
|
}
|