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
+31 -14
View File
@@ -1,6 +1,6 @@
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { runFightAsync } from './orchestrator.js'
import { runFightAsync, isInFight } from './orchestrator.js'
import { seedMockBots } from './mock.js'
interface QueueEntry {
@@ -19,6 +19,14 @@ const waitingQueue: QueueEntry[] = []
// How long a bot waits before getting matched against a mock bot
const QUEUE_TIMEOUT_MS = 3_000
// Post-fight cooldown tracking
const fightCooldowns = new Map<string, number>()
const COOLDOWN_MS = 15_000
export function setCooldown(botId: string) {
fightCooldowns.set(botId, Date.now() + COOLDOWN_MS)
}
export function getQueueSize(): number {
return waitingQueue.length
}
@@ -38,22 +46,39 @@ export function getQueueSnapshot(): { botId: string; botName: string; eloRating:
* If nobody is waiting, waits up to QUEUE_TIMEOUT_MS then fights a mock bot.
*/
export async function joinQueue(botId: string): Promise<string> {
// Check cooldown
const cooldownUntil = fightCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
}
// Check if already in a fight
if (isInFight(botId)) {
throw new Error('Bot is already in a fight.')
}
// Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
const bot = botRows[0]
// Check if bot is active
if (!bot.isActive) {
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`)
// Don't allow same bot twice in queue
const existing = waitingQueue.findIndex(e => e.botId === botId)
if (existing !== -1) {
// Remove old entry
const old = waitingQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle)
old.reject(new Error('Rejoined queue'))
}
// Check if someone is already waiting instant match
// Check if someone is already waiting -- instant match
if (waitingQueue.length > 0) {
// Find closest elo match
waitingQueue.sort((a, b) => {
@@ -66,15 +91,14 @@ export async function joinQueue(botId: string): Promise<string> {
clearTimeout(opponent.timeoutHandle)
// Start the fight
const fightId = await startFight(opponent.botId, opponent.webhookUrl, botId, bot.webhookUrl)
const fightId = await startFight(opponent.botId, botId)
opponent.resolve(fightId)
return fightId
}
// Nobody waiting join the queue and wait
// Nobody waiting -- join the queue and wait
return new Promise<string>((resolve, reject) => {
const timeoutHandle = setTimeout(async () => {
// Timed out — remove from queue and match against a mock bot
const idx = waitingQueue.findIndex(e => e.botId === botId)
if (idx !== -1) {
waitingQueue.splice(idx, 1)
@@ -112,17 +136,12 @@ export function leaveQueue(botId: string): boolean {
return true
}
async function startFight(
botAId: string, _botAWebhook: string,
botBId: string, _botBWebhook: string,
): Promise<string> {
// runFightAsync handles both real and mock bots — mock bots get generated responses
async function startFight(botAId: string, botBId: string): Promise<string> {
return runFightAsync(botAId, botBId)
}
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`)
// Find a mock bot to fight
const allBots = await db.select({
id: schema.bots.id,
webhookUrl: schema.bots.webhookUrl,
@@ -134,7 +153,6 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
if (mockBots.length === 0) {
console.log('[queue] No mock bots found, seeding...')
await seedMockBots()
// Retry after seeding
return matchAgainstMock(botId, webhookUrl)
}
@@ -145,6 +163,5 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
const opponent = mockBots[0]
console.log(`[queue] starting fight: ${botId} vs mock ${opponent.id}`)
// runFightAsync handles mock bots inline — no need for runMockFight
return runFightAsync(botId, opponent.id)
}