feat: v4 — TUI fight loop, rate limiting, webhook tooling, expanded choreographies
- Fight loop CLI with TUI renderer (ink-style terminal UI) - Rate limiting middleware for API routes - Queue cooldowns wired into orchestrator after fights - Webhook test utility for bot debugging - API docs route - Expanded FightScene choreographies and weapon props - Fix Drizzle transaction execution in orchestrator - Schema additions, scoring/challenge/mock expansions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2c0323d5fb
commit
4d8b18a58a
@@ -1,11 +1,12 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { db, schema } from '../db/index.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 { 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
|
||||
@@ -28,6 +29,14 @@ interface WebhookResponse {
|
||||
|
||||
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({
|
||||
@@ -38,14 +47,68 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -61,6 +124,12 @@ async function callWebhook(
|
||||
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)
|
||||
@@ -80,7 +149,14 @@ async function callWebhook(
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
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)
|
||||
@@ -88,10 +164,15 @@ async function callWebhook(
|
||||
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
|
||||
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
|
||||
}
|
||||
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
|
||||
|
||||
// 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: data.answer || null,
|
||||
trashTalk: data.trash_talk,
|
||||
answer,
|
||||
trashTalk,
|
||||
timeMs: elapsed,
|
||||
timedOut: false,
|
||||
error: false,
|
||||
@@ -109,7 +190,7 @@ async function callWebhook(
|
||||
}
|
||||
}
|
||||
|
||||
function isMockBot(webhookUrl: string): boolean {
|
||||
export function isMockBot(webhookUrl: string): boolean {
|
||||
return webhookUrl.startsWith('http://mock.local')
|
||||
}
|
||||
|
||||
@@ -117,6 +198,7 @@ async function getBotResponse(
|
||||
bot: BotRecord,
|
||||
challenge: Challenge,
|
||||
roundNumber: number,
|
||||
fightId: string,
|
||||
opponent: { name: string; wins: number; losses: number },
|
||||
arena: Arena,
|
||||
): Promise<WebhookResponse> {
|
||||
@@ -132,7 +214,7 @@ async function getBotResponse(
|
||||
}
|
||||
}
|
||||
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
|
||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena)
|
||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
|
||||
}
|
||||
|
||||
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
||||
@@ -166,6 +248,26 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
|
||||
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
|
||||
@@ -183,10 +285,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
|
||||
})
|
||||
|
||||
// Call both bots simultaneously (mock bots get generated responses)
|
||||
// Call both bots simultaneously
|
||||
const [responseA, responseB] = await Promise.all([
|
||||
getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
|
||||
getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
|
||||
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
|
||||
@@ -272,39 +380,52 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
(winnerId === botB.id && hpB === 200)
|
||||
)
|
||||
|
||||
// Finalize fight
|
||||
await db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
// Finalize fight + update bot stats atomically
|
||||
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
|
||||
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
|
||||
|
||||
// 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 finalize = sqlite.transaction(() => {
|
||||
// Mark fight finished
|
||||
db.update(schema.fights).set({
|
||||
status: 'finished',
|
||||
winnerId,
|
||||
endedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.fights.id, fightId))
|
||||
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
|
||||
const newWinStreak = winner.winStreak + 1
|
||||
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
|
||||
// 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)
|
||||
|
||||
await Promise.all([
|
||||
db.update(schema.bots).set({
|
||||
wins: sql`${schema.bots.wins} + 1`,
|
||||
eloRating: newWinnerElo,
|
||||
winStreak: newWinStreak,
|
||||
bestStreak: newBestStreak,
|
||||
tier: calculateTier(newWinnerElo, winner.wins + 1),
|
||||
}).where(eq(schema.bots.id, winnerId)),
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, winnerId))
|
||||
|
||||
db.update(schema.bots).set({
|
||||
losses: sql`${schema.bots.losses} + 1`,
|
||||
eloRating: newLoserElo,
|
||||
winStreak: 0,
|
||||
tier: calculateTier(newLoserElo, loser.wins),
|
||||
}).where(eq(schema.bots.id, loserId)),
|
||||
])
|
||||
}
|
||||
lastFightAt: new Date().toISOString(),
|
||||
}).where(eq(schema.bots.id, loserId))
|
||||
} else {
|
||||
// Draw — update lastFightAt for both
|
||||
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id))
|
||||
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id))
|
||||
}
|
||||
})
|
||||
|
||||
finalize()
|
||||
|
||||
emit(fightId, 'fight_end', {
|
||||
winnerId,
|
||||
@@ -317,20 +438,65 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
||||
}
|
||||
|
||||
export async function runFight(botAId: string, botBId: string): Promise<string> {
|
||||
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
|
||||
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)
|
||||
})
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user