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