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
@@ -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, '')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user