Polling bots have no external script running during practice mode, so the poll would time out giving empty answers. Now overrides the webhook URL to human.local so the browser UI handles challenges. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
711 lines
25 KiB
TypeScript
711 lines
25 KiB
TypeScript
import { z } from 'zod'
|
|
import { nanoid } from 'nanoid'
|
|
import { logger } from '../lib/logger.js'
|
|
import { toError } from '../lib/utils.js'
|
|
import { db, schema, sqlite } from '../db/index.js'
|
|
import { eq, sql } from 'drizzle-orm'
|
|
import { randomArena, type Arena } from './arenas.js'
|
|
import { pickChallenge, type Challenge } from './challenges.js'
|
|
import { generateRetroChallenge } from './retro-moves.js'
|
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
|
import { fightEvents } from './events.js'
|
|
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
|
|
import { setCooldown } from './queue.js'
|
|
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
|
|
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
|
|
import { settleBets, lockBets } from './betting.js'
|
|
import { publishFightResult } from './nostr-publish.js'
|
|
import { getCurrentSeason } from './seasons.js'
|
|
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
|
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
|
|
import { invalidateLeaderboardCache } from '../routes/bots.js'
|
|
import { createHmac } from 'crypto'
|
|
import { isPollingBot, waitForPollResponse } from './poll-responses.js'
|
|
|
|
const webhookResponseSchema = z.object({
|
|
answer: z.string().nullable().optional(),
|
|
trash_talk: z.string().optional(),
|
|
}).passthrough()
|
|
|
|
interface BotRecord {
|
|
id: string
|
|
name: string
|
|
webhookUrl: string
|
|
secretHash: string
|
|
eloRating: number
|
|
wins: number
|
|
losses: number
|
|
winStreak: number
|
|
bestStreak: number
|
|
}
|
|
|
|
interface WebhookResponse {
|
|
answer: string | null
|
|
trashTalk?: string
|
|
timeMs: number
|
|
timedOut: boolean
|
|
error: boolean
|
|
}
|
|
|
|
import { MAX_ROUNDS, KO_THRESHOLD, MAX_RESPONSE_BYTES, STARTING_HP, ELO_K_FACTOR, ELO_K_FACTOR_MOCK, MAX_ANSWER_LENGTH, MAX_TRASH_TALK_LENGTH } from '../lib/constants.js'
|
|
|
|
// Track bots currently in a fight to prevent concurrent fights
|
|
// Maps botId → fightId so we can direct users to their active fight
|
|
const activeFighters = new Map<string, string>()
|
|
|
|
export function getActiveFighterCount(): number {
|
|
return activeFighters.size
|
|
}
|
|
|
|
export function isInFight(botId: string): boolean {
|
|
return activeFighters.has(botId)
|
|
}
|
|
|
|
export function getActiveFightId(botId: string): string | undefined {
|
|
return activeFighters.get(botId)
|
|
}
|
|
|
|
function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
|
fightEvents.emit({
|
|
fightId,
|
|
type,
|
|
data,
|
|
timestamp: new Date().toISOString(),
|
|
})
|
|
}
|
|
|
|
// SSRF protection: block internal/private URLs
|
|
function isAllowedWebhookUrl(url: string): boolean {
|
|
try {
|
|
if (typeof url !== 'string' || url.length > 2048) return false
|
|
const parsed = new URL(url)
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
const hostname = parsed.hostname.toLowerCase()
|
|
// 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
|
|
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
|
|
}
|
|
// 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
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export { isAllowedWebhookUrl }
|
|
export { isHumanPlayer } from './human-responses.js'
|
|
|
|
// 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) {
|
|
void reader.cancel()
|
|
throw new Error(`Response body exceeds ${maxBytes} bytes`)
|
|
}
|
|
chunks.push(value)
|
|
}
|
|
} catch (err) {
|
|
void 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)
|
|
}
|
|
|
|
async function callWebhook(
|
|
url: string,
|
|
challenge: Challenge,
|
|
roundNumber: number,
|
|
fightId: string,
|
|
opponent: { name: string; wins: number; losses: number },
|
|
arena: Arena,
|
|
secretHash?: string,
|
|
): Promise<WebhookResponse> {
|
|
const body = JSON.stringify({
|
|
fight_id: fightId,
|
|
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()
|
|
logger.info('webhook', `POST ${url} round=${roundNumber} type=${challenge.type}`)
|
|
|
|
// SSRF check
|
|
if (!isAllowedWebhookUrl(url)) {
|
|
logger.warn('webhook', `${url} BLOCKED (private/internal URL)`)
|
|
return { answer: null, timeMs: 0, timedOut: false, error: true }
|
|
}
|
|
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
|
|
|
// HMAC-SHA256 signature for webhook verification
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (secretHash) {
|
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
const signature = createHmac('sha256', secretHash)
|
|
.update(`${timestamp}.${body}`)
|
|
.digest('hex')
|
|
headers['X-Botfights-Signature'] = `sha256=${signature}`
|
|
headers['X-Botfights-Timestamp'] = timestamp
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body,
|
|
signal: controller.signal,
|
|
})
|
|
|
|
clearTimeout(timeout)
|
|
const elapsed = Date.now() - start
|
|
|
|
if (!res.ok) {
|
|
logger.warn('webhook', `${url} returned ${res.status} in ${elapsed}ms`)
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
}
|
|
|
|
let text: string
|
|
try {
|
|
text = await readLimitedBody(res, MAX_RESPONSE_BYTES)
|
|
} catch {
|
|
logger.warn('webhook', `${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
logger.warn('webhook', `${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
}
|
|
|
|
const data = webhookResponseSchema.safeParse(parsed)
|
|
if (!data.success) {
|
|
logger.warn('webhook', `${url} invalid response shape in ${elapsed}ms: ${data.error.message}`)
|
|
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
|
}
|
|
|
|
// Enforce size limits on fields
|
|
const answer = data.data.answer ? data.data.answer.slice(0, MAX_ANSWER_LENGTH) : null
|
|
const trashTalk = data.data.trash_talk ? data.data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : undefined
|
|
|
|
logger.info('webhook', `${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
|
|
return {
|
|
answer,
|
|
trashTalk,
|
|
timeMs: elapsed,
|
|
timedOut: false,
|
|
error: false,
|
|
}
|
|
} catch (err: unknown) {
|
|
const elapsed = Date.now() - start
|
|
const isAbort = err instanceof Error && err.name === 'AbortError'
|
|
logger.warn('webhook', `${url} ${isAbort ? "TIMEOUT" : "ERROR"} in ${elapsed}ms: ${toError(err).message}`)
|
|
return {
|
|
answer: null,
|
|
timeMs: elapsed,
|
|
timedOut: isAbort,
|
|
error: !isAbort,
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isMockBot(webhookUrl: string): boolean {
|
|
return webhookUrl.startsWith('http://mock.local')
|
|
}
|
|
|
|
async function getBotResponse(
|
|
bot: BotRecord,
|
|
challenge: Challenge,
|
|
roundNumber: number,
|
|
fightId: string,
|
|
opponent: { name: string; wins: number; losses: number },
|
|
arena: Arena,
|
|
): Promise<WebhookResponse> {
|
|
if (isHumanPlayer(bot.webhookUrl)) {
|
|
logger.info('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: challenge.timeout_ms,
|
|
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 }
|
|
}
|
|
|
|
if (isClassicBot(bot.webhookUrl)) {
|
|
logger.info('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,
|
|
}
|
|
}
|
|
|
|
if (isMockBot(bot.webhookUrl)) {
|
|
logger.info('fight', `${bot.name} is mock bot, generating response`)
|
|
const mock = generateMockBotResponse(challenge, bot.name)
|
|
return {
|
|
answer: mock.answer || null,
|
|
trashTalk: mock.trashTalk,
|
|
timeMs: mock.timeMs,
|
|
timedOut: mock.timedOut,
|
|
error: mock.error,
|
|
}
|
|
}
|
|
if (isPollingBot(bot.webhookUrl)) {
|
|
logger.info('fight', `${bot.name} is polling bot, waiting for poll response`)
|
|
emit(fightId, 'poll_challenge', { botId: bot.id, round: roundNumber, type: challenge.type })
|
|
const start = Date.now()
|
|
const result = await waitForPollResponse(fightId, bot.id, challenge, roundNumber, opponent, arena.id, arena.modifier)
|
|
const elapsed = Date.now() - start
|
|
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
|
}
|
|
|
|
logger.info('fight', `${bot.name} has real webhook: ${bot.webhookUrl}`)
|
|
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena, bot.secretHash)
|
|
}
|
|
|
|
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
|
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')
|
|
}
|
|
return [botARows[0] as BotRecord, botBRows[0] as BotRecord]
|
|
}
|
|
|
|
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({
|
|
id: fightId,
|
|
botAId: botA.id,
|
|
botBId: botB.id,
|
|
arena: arena.id,
|
|
status: 'live',
|
|
mode,
|
|
potSats: mode === 'ranked' ? 42 : 0,
|
|
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
|
|
currentSeason: getCurrentSeason().id,
|
|
startedAt: now,
|
|
createdAt: now,
|
|
})
|
|
// Lock bets when fight starts
|
|
lockBets(fightId)
|
|
|
|
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 },
|
|
})
|
|
return fightId
|
|
}
|
|
|
|
// Track webhook errors per bot
|
|
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
|
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl) || isPollingBot(webhookUrl)) return
|
|
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))
|
|
logger.warn('fight', `bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<void> {
|
|
let hpA = STARTING_HP
|
|
let hpB = STARTING_HP
|
|
let comboA = 0
|
|
let comboB = 0
|
|
let winnerId: string | null = null
|
|
let lastRound = 0
|
|
const usedTypes = new Set<string>()
|
|
const hasHuman = isHumanPlayer(botA.webhookUrl) || isHumanPlayer(botB.webhookUrl)
|
|
|
|
// 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))
|
|
|
|
for (let round = 1; round <= MAX_ROUNDS; round++) {
|
|
lastRound = round
|
|
const challenge = round === retroRound
|
|
? generateRetroChallenge()
|
|
: pickChallenge(usedTypes, arena.modifier, undefined, round, hasHuman)
|
|
usedTypes.add(challenge.type)
|
|
|
|
emit(fightId, 'round_start', {
|
|
round,
|
|
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
|
})
|
|
|
|
// Call both bots simultaneously
|
|
const [responseA, responseB] = await Promise.all([
|
|
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),
|
|
])
|
|
|
|
// 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,
|
|
challengeData: JSON.stringify({ prompt: challenge.prompt, displayPrompt: challenge.displayPrompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
|
|
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,
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
|
|
|
// 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 && (
|
|
(winnerId === botA.id && hpA === STARTING_HP) ||
|
|
(winnerId === botB.id && hpB === STARTING_HP)
|
|
)
|
|
|
|
// Finalize fight + update bot stats atomically
|
|
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
|
|
const kFactor = isMockFight ? ELO_K_FACTOR_MOCK : ELO_K_FACTOR
|
|
let winnerEloChange = 0
|
|
let loserEloChange = 0
|
|
let newWinnerEloFinal = 0
|
|
let newLoserEloFinal = 0
|
|
|
|
const finalize = sqlite.transaction(() => {
|
|
// Mark fight finished
|
|
db.update(schema.fights).set({
|
|
status: 'finished',
|
|
winnerId,
|
|
endedAt: new Date().toISOString(),
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
|
|
|
// 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)
|
|
winnerEloChange = Math.round(newWinnerElo - winner.eloRating)
|
|
loserEloChange = Math.round(newLoserElo - loser.eloRating)
|
|
newWinnerEloFinal = newWinnerElo
|
|
newLoserEloFinal = newLoserElo
|
|
const newWinStreak = winner.winStreak + 1
|
|
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
|
|
|
db.update(schema.bots).set({
|
|
wins: sql`${schema.bots.wins} + 1`,
|
|
eloRating: newWinnerElo,
|
|
winStreak: newWinStreak,
|
|
bestStreak: newBestStreak,
|
|
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
|
lastFightAt: new Date().toISOString(),
|
|
}).where(eq(schema.bots.id, winnerId)).run()
|
|
|
|
db.update(schema.bots).set({
|
|
losses: sql`${schema.bots.losses} + 1`,
|
|
eloRating: newLoserElo,
|
|
winStreak: 0,
|
|
tier: calculateTier(newLoserElo, loser.wins),
|
|
lastFightAt: new Date().toISOString(),
|
|
}).where(eq(schema.bots.id, loserId)).run()
|
|
} else {
|
|
// Draw — update lastFightAt for both
|
|
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()
|
|
|
|
// Track aggregate analytics (no PII)
|
|
trackBotActive(botA.id)
|
|
trackBotActive(botB.id)
|
|
trackFightCompleted({
|
|
satsWagered: mode === 'ranked' ? ENTRY_FEE_SATS * 2 : 0,
|
|
durationRounds: lastRound,
|
|
mode,
|
|
})
|
|
for (const ct of usedTypes) {
|
|
trackMetric(`challenge_${ct}`)
|
|
}
|
|
|
|
// Invalidate leaderboard cache after Elo/stats update
|
|
invalidateLeaderboardCache()
|
|
|
|
// Advance tournament bracket if this was a tournament match
|
|
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
|
|
|
|
emit(fightId, 'fight_end', {
|
|
winnerId,
|
|
winnerName,
|
|
isPerfect,
|
|
finalHp: { a: hpA, b: hpB },
|
|
mode,
|
|
potSats: mode === 'ranked' ? 42 : 0,
|
|
})
|
|
|
|
try {
|
|
// Publish notable results to Nostr
|
|
if (winnerId) {
|
|
const winner = winnerId === botA.id ? botA : botB
|
|
const loser = winnerId === botA.id ? botB : botA
|
|
const isUpset = loser.eloRating - winner.eloRating > 150
|
|
const isKO = (winnerId === botA.id && hpB <= 0) || (winnerId === botB.id && hpA <= 0)
|
|
publishFightResult({
|
|
fightId, winnerName: winner.name, loserName: loser.name, winnerId,
|
|
winnerElo: newWinnerEloFinal, loserElo: newLoserEloFinal,
|
|
winnerEloChange, loserEloChange,
|
|
isPerfect: !!isPerfect, isKO, isUpset,
|
|
totalRounds: lastRound, arena: arena.name,
|
|
}).catch(err => logger.warn('nostr', `publish failed: ${err}`))
|
|
}
|
|
|
|
// Settle bets
|
|
try {
|
|
const settlements = await settleBets(fightId, winnerId)
|
|
for (const s of settlements) {
|
|
db.update(schema.bets).set({
|
|
status: s.won ? 'won' : winnerId ? 'lost' : 'refunded',
|
|
payoutSats: s.payoutSats,
|
|
payoutToken: s.payoutToken,
|
|
settledAt: new Date().toISOString(),
|
|
}).where(eq(schema.bets.id, s.betId)).run()
|
|
}
|
|
} catch (err) {
|
|
logger.error('betting', `settlement failed for fight ${fightId}: ${err}`)
|
|
}
|
|
|
|
// Ranked fight payout
|
|
if (mode === 'ranked') {
|
|
// 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 => {
|
|
logger.error('payments', `payout failed for fight ${fightId}: ${err}`)
|
|
})
|
|
} else if (!winnerId) {
|
|
// 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 => {
|
|
logger.error('payments', `draw refund failed for ${payment.id}: ${err}`)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
fightEvents.cleanup(fightId)
|
|
}
|
|
}
|
|
|
|
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`)
|
|
|
|
const [botA, botB] = await loadBots(botAId, botBId)
|
|
const arena = randomArena()
|
|
const fightId = await createFightRecord(botA, botB, arena, mode)
|
|
activeFighters.set(botAId, fightId)
|
|
activeFighters.set(botBId, fightId)
|
|
|
|
try {
|
|
await executeFightRounds(fightId, botA, botB, arena, mode)
|
|
return fightId
|
|
} finally {
|
|
activeFighters.delete(botAId)
|
|
activeFighters.delete(botBId)
|
|
setCooldown(botAId)
|
|
setCooldown(botBId)
|
|
}
|
|
}
|
|
|
|
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
|
|
export async function runFightAsync(botAId: string, botBId: string, mode: 'free' | 'ranked' = 'free', overrides?: { botAWebhookUrl?: string }): 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`)
|
|
|
|
const [botA, botB] = await loadBots(botAId, botBId)
|
|
if (overrides?.botAWebhookUrl) botA.webhookUrl = overrides.botAWebhookUrl
|
|
const arena = randomArena()
|
|
const fightId = await createFightRecord(botA, botB, arena, mode)
|
|
activeFighters.set(botAId, fightId)
|
|
activeFighters.set(botBId, fightId)
|
|
|
|
executeFightRounds(fightId, botA, botB, arena, mode)
|
|
.catch(err => {
|
|
logger.error('fight', `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(),
|
|
}).where(eq(schema.fights.id, fightId)).run()
|
|
fightEvents.cleanup(fightId)
|
|
})
|
|
.finally(() => {
|
|
activeFighters.delete(botAId)
|
|
activeFighters.delete(botBId)
|
|
setCooldown(botAId)
|
|
setCooldown(botBId)
|
|
})
|
|
|
|
return fightId
|
|
}
|
|
|
|
/** 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
|
|
}
|