Files
botfights/server/src/engine/orchestrator.ts
T
DorianandClaude Opus 4.6 e49735002c fix: Drizzle transaction execution, canvas re-init, crowd removal
- Add .run() to all Drizzle queries inside sqlite.transaction() in
  orchestrator.ts and mock.ts — queries were building but never executing,
  leaving fights stuck as 'live' forever
- Remove spectator crowd from drawArenaDecor()
- Rename duplicate spawnProp to spawnWeaponProp (was crashing module load)
- Replace canvas element on re-init to avoid "KAPLAY already initialized"
- Add safeText() to strip brackets from k.text() calls (Kaplay treats
  [ ] as style markup, crashes on unclosed tags)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:53:15 +00:00

503 lines
16 KiB
TypeScript

import { nanoid } from 'nanoid'
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 { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
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
}
const MAX_ROUNDS = 10
const KO_THRESHOLD = 0
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)
}
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 {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
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
}
if (hostname === '169.254.169.254') return false
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
return true
} catch {
return false
}
}
export { isAllowedWebhookUrl }
// 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)
}
async function callWebhook(
url: string,
challenge: Challenge,
roundNumber: number,
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
): 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()
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
// SSRF check
if (!isAllowedWebhookUrl(url)) {
console.log(`[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)
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) {
console.log(`[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 {
console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
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 }
}
// 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)}`)
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'
console.log(`[webhook] ${url} ${isAbort ? "TIMEOUT" : "ERROR"} in ${elapsed}ms: ${err instanceof Error ? err.message : err}`)
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 (isMockBot(bot.webhookUrl)) {
console.log(`[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,
}
}
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
}
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): 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',
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 },
})
return fightId
}
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(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))
console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
}
}
}
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
let hpA = 200
let hpB = 200
let comboA = 0
let comboB = 0
let winnerId: string | null = null
const usedTypes = new Set<string>()
for (let round = 1; round <= MAX_ROUNDS; round++) {
const challenge = pickChallenge(usedTypes, arena.modifier)
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, scoring: challenge.scoring }),
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 === 200) ||
(winnerId === botB.id && hpB === 200)
)
// Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
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)
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()
}
})
finalize()
emit(fightId, 'fight_end', {
winnerId,
winnerName,
isPerfect,
finalHp: { a: hpA, b: hpB },
})
fightEvents.cleanup(fightId)
}
export async function runFight(botAId: string, botBId: 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`)
activeFighters.add(botAId)
activeFighters.add(botBId)
try {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
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): 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`)
activeFighters.add(botAId)
activeFighters.add(botBId)
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
executeFightRounds(fightId, botA, botB, arena)
.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(),
}).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
}