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:
Dorian
2026-03-07 00:14:46 +00:00
co-authored by Claude Opus 4.6
parent 2c0323d5fb
commit 4d8b18a58a
24 changed files with 2973 additions and 721 deletions
+71 -11
View File
@@ -2,10 +2,28 @@ import { db, schema } from '../db/index.js'
import { runMockFight } from './mock.js'
import { eq } from 'drizzle-orm'
interface FightLoopOptions {
export interface FightResult {
fightId: string
botAName: string
botBName: string
botAElo: number
botBElo: number
winnerName: string | null
winnerId: string | null
totalRounds: number
isKo: boolean
isPerfect: boolean
botAHp: number
botBHp: number
}
export interface FightLoopOptions {
intervalMs?: number
maxFights?: number
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void
onFightComplete?: (result: FightResult) => void
onError?: (err: Error) => void
}
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
@@ -13,6 +31,9 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
intervalMs = 8000,
maxFights = Infinity,
matchmakingStyle = 'mixed',
onFightStart,
onFightComplete,
onError,
} = options
const allBots = await db.select({
@@ -42,35 +63,71 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
if (onFightStart) {
onFightStart(botA.name, botA.eloRating, botB.name, botB.eloRating)
}
const fightId = await runMockFight(botA.id, botB.id)
// Fetch result
const fight = await db.select({
winnerId: schema.fights.winnerId,
totalRounds: schema.fights.totalRounds,
botAHp: schema.fights.botAHp,
botBHp: schema.fights.botBHp,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
const result = fight[0]
const winnerName = result?.winnerId
? bots.find(b => b.id === result.winnerId)?.name || '???'
: 'DRAW'
: null
const isKo = result ? (result.botAHp <= 0 || result.botBHp <= 0) : false
const isPerfect = result?.winnerId ? (
(result.winnerId === botA.id && result.botAHp === 200) ||
(result.winnerId === botB.id && result.botBHp === 200)
) : false
fightCount++
console.log(
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName} (${result?.totalRounds || '?'} rounds)`
)
if (onFightComplete) {
onFightComplete({
fightId,
botAName: botA.name,
botBName: botB.name,
botAElo: botA.eloRating,
botBElo: botB.eloRating,
winnerName,
winnerId: result?.winnerId || null,
totalRounds: result?.totalRounds || 0,
isKo,
isPerfect,
botAHp: result?.botAHp || 0,
botBHp: result?.botBHp || 0,
})
} else {
console.log(
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName || 'DRAW'} (${result?.totalRounds || '?'} rounds)`
)
}
// Wait before next fight
if (fightCount < maxFights) {
await sleep(intervalMs + Math.floor(Math.random() * intervalMs * 0.5))
}
} catch (err) {
console.error('[fight-loop] error:', err)
await sleep(5000) // Back off on error
if (onError) {
onError(err instanceof Error ? err : new Error(String(err)))
} else {
console.error('[fight-loop] error:', err)
}
await sleep(5000)
}
}
console.log(`[fight-loop] completed ${fightCount} fights`)
if (!onFightComplete) {
console.log(`[fight-loop] completed ${fightCount} fights`)
}
}
function pickMatchup(
@@ -81,7 +138,6 @@ function pickMatchup(
const sorted = [...bots].sort((a, b) => b.eloRating - a.eloRating)
if (style === 'elo_close' || (style === 'mixed' && fightNum % 3 !== 0)) {
// Pick a random bot, then find a close-elo opponent
const idx = Math.floor(Math.random() * bots.length)
const bot = bots[idx]
const others = bots.filter(b => b.id !== bot.id)
@@ -94,15 +150,19 @@ function pickMatchup(
}
if (style === 'mixed' && fightNum % 3 === 0) {
// Mismatch: top third vs bottom third for dramatic fights
const topThird = Math.ceil(sorted.length / 3)
const topIdx = Math.floor(Math.random() * topThird)
const bottomIdx = sorted.length - 1 - Math.floor(Math.random() * topThird)
return [sorted[topIdx], sorted[bottomIdx]]
if (sorted[topIdx].id !== sorted[bottomIdx].id) {
return [sorted[topIdx], sorted[bottomIdx]]
}
}
// Random
const shuffled = [...bots].sort(() => Math.random() - 0.5)
if (shuffled[0].id === shuffled[1].id && shuffled.length > 2) {
return [shuffled[0], shuffled[2]]
}
return [shuffled[0], shuffled[1]]
}