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
+223
View File
@@ -0,0 +1,223 @@
import chalk from 'chalk'
import type { TuiState } from './state.js'
import { TIER_NAMES } from '../engine/scoring.js'
const TIER_CHALK = [
chalk.gray, // Baby
chalk.hex('#cd7f32'), // Bronze
chalk.white, // Silver
chalk.yellow, // Gold
chalk.cyan, // Platinum
chalk.hex('#b83dff'), // Diamond
chalk.hex('#ff2d7b'), // Legend
]
function tierColor(tier: number): (s: string) => string {
return TIER_CHALK[tier] || chalk.gray
}
function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000)
const hours = Math.floor(totalSec / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
if (minutes > 0) return `${minutes}m ${seconds}s`
return `${seconds}s`
}
function hpBar(hp: number, maxHp: number = 200, width: number = 20): string {
const filled = Math.round((hp / maxHp) * width)
const empty = width - filled
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty)
const color = hp > 120 ? chalk.green : hp > 60 ? chalk.yellow : chalk.red
return color(bar) + chalk.gray(` ${hp}`)
}
function pad(str: string, len: number): string {
return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length)
}
function padLeft(str: string, len: number): string {
return str.length >= len ? str.slice(0, len) : ' '.repeat(len - str.length) + str
}
export class TuiRenderer {
private state: TuiState
private cols: number
constructor(state: TuiState) {
this.state = state
this.cols = Math.min(process.stdout.columns || 80, 80)
}
render(): void {
this.cols = Math.min(process.stdout.columns || 80, 80)
const lines: string[] = []
const w = this.cols - 2 // inner width
const s = this.state
const elapsed = formatDuration(Date.now() - s.startedAt)
const rate = s.completed > 0
? (s.completed / ((Date.now() - s.startedAt) / 60000)).toFixed(1)
: '0.0'
// Header
const title = ' BOTFIGHTS OVERNIGHT LOOP '
const headerPad = Math.max(0, Math.floor((w - title.length) / 2))
lines.push(chalk.hex('#ff2d7b').bold('\u2554' + '\u2550'.repeat(w) + '\u2557'))
lines.push(chalk.hex('#ff2d7b')('\u2551') + ' '.repeat(headerPad) + chalk.hex('#ff2d7b').bold(title) + ' '.repeat(w - headerPad - title.length) + chalk.hex('#ff2d7b')('\u2551'))
const targetStr = s.totalTarget === Infinity ? '\u221E' : String(s.totalTarget)
const statusLeft = ` Fight #${s.completed}${s.currentFight ? '+1' : ''} of ${targetStr}`
const statusRight = `Elapsed: ${elapsed} `
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(statusLeft, w - statusRight.length)) + chalk.gray(statusRight) + chalk.hex('#ff2d7b')('\u2551'))
const styleLeft = ` Style: ${s.style}`
const rateRight = `Rate: ${rate} fights/min `
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(styleLeft, w - rateRight.length)) + chalk.gray(rateRight) + chalk.hex('#ff2d7b')('\u2551'))
lines.push(chalk.hex('#ff2d7b')('\u2560' + '\u2550'.repeat(w) + '\u2563'))
// Current fight
if (s.currentFight) {
const f = s.currentFight
const vs = `${f.botA.name} (${Math.round(f.botA.elo)}) vs ${f.botB.name} (${Math.round(f.botB.elo)})`
lines.push(chalk.hex('#ff2d7b')('\u2551') + ' ' + chalk.cyan.bold(vs) + ' '.repeat(Math.max(0, w - 2 - vs.length)) + chalk.hex('#ff2d7b')('\u2551'))
const hpLine = ` ${hpBar(f.botA.hp)} vs ${hpBar(f.botB.hp)}`
lines.push(chalk.hex('#ff2d7b')('\u2551') + hpLine + ' '.repeat(Math.max(0, w - stripAnsi(hpLine).length)) + chalk.hex('#ff2d7b')('\u2551'))
const roundLine = ` Round ${f.round}/${f.maxRounds} -- ${f.challengeLabel}`
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(roundLine, w)) + chalk.hex('#ff2d7b')('\u2551'))
for (const event of f.events.slice(-3)) {
const evLine = ` >> ${event}`
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(evLine, w)) + chalk.hex('#ff2d7b')('\u2551'))
}
} else {
const waiting = ' Waiting for next fight...'
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.gray(pad(waiting, w)) + chalk.hex('#ff2d7b')('\u2551'))
}
// Stats
lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 7) / 2))) + chalk.hex('#ff2d7b').bold(' STATS ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 7) / 2))) + chalk.hex('#ff2d7b')('\u2563'))
const koRate = s.completed > 0 ? Math.round((s.kos / s.completed) * 100) : 0
const statsLine = ` Fights: ${s.completed} | KOs: ${s.kos} (${koRate}%) | Perfects: ${s.perfects} | Draws: ${s.draws} | Errors: ${s.errors}`
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.white(pad(statsLine, w)) + chalk.hex('#ff2d7b')('\u2551'))
if (s.biggestUpset) {
const upsetLine = ` Biggest upset: ${s.biggestUpset.winner} beat ${s.biggestUpset.loser} (${s.biggestUpset.eloDiff} elo diff)`
lines.push(chalk.hex('#ff2d7b')('\u2551') + chalk.yellow(pad(upsetLine, w)) + chalk.hex('#ff2d7b')('\u2551'))
}
// Leaderboard
if (s.leaderboard.length > 0) {
lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 13) / 2))) + chalk.hex('#ff2d7b').bold(' LEADERBOARD ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 13) / 2))) + chalk.hex('#ff2d7b')('\u2563'))
const top = s.leaderboard.slice(0, 8)
for (let i = 0; i < top.length; i++) {
const b = top[i]
const tierName = TIER_NAMES[b.tier] || 'BABY'
const colorFn = tierColor(b.tier)
const rank = padLeft(`#${i + 1}`, 4)
const name = pad(b.name, 24)
const elo = padLeft(String(Math.round(b.elo)), 5)
const record = `${b.wins}W-${b.losses}L`
const line = ` ${rank} ${name} ${elo} ${pad(record, 10)} ${tierName}`
lines.push(chalk.hex('#ff2d7b')('\u2551') + colorFn(pad(line, w)) + chalk.hex('#ff2d7b')('\u2551'))
}
}
// Recent fights
if (s.recentFights.length > 0) {
lines.push(chalk.hex('#ff2d7b')('\u2560') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.floor((w - 8) / 2))) + chalk.hex('#ff2d7b').bold(' RECENT ') + chalk.hex('#ff2d7b')('\u2550'.repeat(Math.ceil((w - 8) / 2))) + chalk.hex('#ff2d7b')('\u2563'))
const recent = s.recentFights.slice(-6)
for (const f of recent) {
const winner = f.winner || 'DRAW'
const line = ` #${pad(String(f.num), 4)} ${pad(f.botA, 18)} vs ${pad(f.botB, 18)} -> ${pad(winner, 18)} (${f.method})`
const color = f.winner ? chalk.white : chalk.gray
lines.push(chalk.hex('#ff2d7b')('\u2551') + color(pad(line, w)) + chalk.hex('#ff2d7b')('\u2551'))
}
}
lines.push(chalk.hex('#ff2d7b').bold('\u255A' + '\u2550'.repeat(w) + '\u255D'))
// Clear screen and render
process.stdout.write('\x1B[2J\x1B[H')
process.stdout.write(lines.join('\n') + '\n')
}
showFinalSummary(): void {
const s = this.state
const elapsed = formatDuration(Date.now() - s.startedAt)
const koRate = s.completed > 0 ? Math.round((s.kos / s.completed) * 100) : 0
const perfectRate = s.completed > 0 ? Math.round((s.perfects / s.completed) * 100) : 0
const lines: string[] = []
lines.push('')
lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60)))
lines.push(chalk.hex('#ff2d7b').bold(' BOTFIGHTS SESSION COMPLETE'))
lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60)))
lines.push('')
lines.push(chalk.white(` Duration: ${elapsed}`))
lines.push(chalk.white(` Fights: ${s.completed} completed, ${s.errors} errors`))
lines.push(chalk.white(` KOs: ${s.kos} (${koRate}%) | Perfects: ${s.perfects} (${perfectRate}%) | Draws: ${s.draws}`))
lines.push('')
// Top Elo movers
const movers: { name: string; delta: number; startElo: number; currentElo: number }[] = []
for (const entry of s.leaderboard) {
const startElo = s.eloSnapshots.get(entry.name)
if (startElo !== undefined) {
const delta = entry.elo - startElo
if (Math.abs(delta) > 5) {
movers.push({ name: entry.name, delta, startElo, currentElo: entry.elo })
}
}
}
movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
if (movers.length > 0) {
lines.push(chalk.cyan.bold(' TOP ELO MOVERS:'))
for (const m of movers.slice(0, 5)) {
const sign = m.delta > 0 ? '+' : ''
const color = m.delta > 0 ? chalk.green : chalk.red
lines.push(color(` ${pad(m.name, 24)} ${Math.round(m.startElo)} -> ${Math.round(m.currentElo)} (${sign}${Math.round(m.delta)})`))
}
lines.push('')
}
if (s.biggestUpset) {
lines.push(chalk.yellow.bold(` BIGGEST UPSET: ${s.biggestUpset.winner} beat ${s.biggestUpset.loser} (${s.biggestUpset.eloDiff} elo diff)`))
lines.push('')
}
// Most active
let mostActive = ''
let mostFights = 0
for (const [name, count] of s.fightCounts) {
if (count > mostFights) {
mostFights = count
mostActive = name
}
}
if (mostActive) {
lines.push(chalk.white(` Most active: ${mostActive} (${mostFights} fights)`))
}
lines.push('')
lines.push(chalk.hex('#ff2d7b').bold('\u2550'.repeat(60)))
lines.push('')
process.stdout.write('\x1B[2J\x1B[H')
process.stdout.write(lines.join('\n') + '\n')
}
}
// Strip ANSI codes for length calculation
function stripAnsi(str: string): string {
return str.replace(/\x1B\[[0-9;]*m/g, '')
}
+68
View File
@@ -0,0 +1,68 @@
export interface CurrentFight {
botA: { name: string; elo: number; hp: number }
botB: { name: string; elo: number; hp: number }
round: number
maxRounds: number
challengeType: string
challengeLabel: string
events: string[]
}
export interface RecentFight {
num: number
botA: string
botB: string
winner: string | null
method: string // 'KO R6' | 'PERFECT R3' | 'Decision' | 'DRAW'
}
export interface LeaderboardEntry {
name: string
elo: number
wins: number
losses: number
tier: number
}
export interface EloMover {
name: string
startElo: number
currentElo: number
delta: number
}
export interface TuiState {
startedAt: number
totalTarget: number
completed: number
errors: number
kos: number
perfects: number
draws: number
currentFight: CurrentFight | null
recentFights: RecentFight[]
biggestUpset: { winner: string; loser: string; eloDiff: number } | null
fightCounts: Map<string, number>
leaderboard: LeaderboardEntry[]
eloSnapshots: Map<string, number> // starting elo for each bot
style: string
}
export function createTuiState(totalTarget: number, style: string): TuiState {
return {
startedAt: Date.now(),
totalTarget,
completed: 0,
errors: 0,
kos: 0,
perfects: 0,
draws: 0,
currentFight: null,
recentFights: [],
biggestUpset: null,
fightCounts: new Map(),
leaderboard: [],
eloSnapshots: new Map(),
style,
}
}