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
+14
View File
@@ -5,6 +5,9 @@ import { botsRouter } from './routes/bots.js'
import { fightsRouter } from './routes/fights.js'
import { queueRouter } from './routes/queue.js'
import { authRouter } from './routes/auth.js'
import { docsRouter } from './routes/docs.js'
import { rateLimit } from './middleware/rate-limit.js'
import { cleanupOrphanedFights } from './engine/orchestrator.js'
export const app = new Hono()
@@ -16,9 +19,20 @@ app.onError((err, c) => {
app.use('*', logger())
app.use('/api/*', cors({ origin: '*' }))
// Rate limit all POST endpoints (60/min per IP)
app.use('/api/*', rateLimit(60_000, 60))
app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' }))
app.route('/api/auth', authRouter)
app.route('/api/bots', botsRouter)
app.route('/api/fights', fightsRouter)
app.route('/api/queue', queueRouter)
app.route('/api/docs', docsRouter)
// Cleanup orphaned fights on startup
cleanupOrphanedFights().then(() => {
console.log('[botfights] orphaned fights cleaned up')
}).catch(err => {
console.error('[botfights] cleanup error:', err)
})
+1 -1
View File
@@ -14,4 +14,4 @@ sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
export const db = drizzle(sqlite, { schema })
export { schema }
export { schema, sqlite }
+8 -2
View File
@@ -28,6 +28,9 @@ sqlite.exec(`
best_streak INTEGER NOT NULL DEFAULT 0,
tier INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_fight_at TEXT,
consecutive_errors INTEGER NOT NULL DEFAULT 0,
last_error_at TEXT,
created_at TEXT NOT NULL
);
@@ -38,8 +41,8 @@ sqlite.exec(`
arena TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
winner_id TEXT REFERENCES bots(id),
bot_a_hp INTEGER NOT NULL DEFAULT 100,
bot_b_hp INTEGER NOT NULL DEFAULT 100,
bot_a_hp INTEGER NOT NULL DEFAULT 200,
bot_b_hp INTEGER NOT NULL DEFAULT 200,
total_rounds INTEGER NOT NULL DEFAULT 0,
scheduled_at TEXT,
started_at TEXT,
@@ -69,6 +72,9 @@ sqlite.exec(`
const migrations = [
`ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'`,
`ALTER TABLE bots ADD COLUMN profile_pic_url TEXT`,
`ALTER TABLE bots ADD COLUMN last_fight_at TEXT`,
`ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE bots ADD COLUMN last_error_at TEXT`,
]
for (const sql of migrations) {
+3
View File
@@ -16,6 +16,9 @@ export const bots = sqliteTable('bots', {
bestStreak: integer('best_streak').notNull().default(0),
tier: integer('tier').notNull().default(0),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
lastFightAt: text('last_fight_at'),
consecutiveErrors: integer('consecutive_errors').notNull().default(0),
lastErrorAt: text('last_error_at'),
createdAt: text('created_at').notNull(),
})
+82 -12
View File
@@ -1,7 +1,7 @@
/**
* Answer checking for factual challenges.
* Handles: case insensitivity, numeric equivalence, containment matching,
* number words (forty = 40), stripped punctuation/articles.
* number words (forty = 40), basic stemming, contraction normalization.
*/
const NUMBER_WORDS: Record<string, number> = {
@@ -23,6 +23,47 @@ function normalize(s: string): string {
.trim()
}
/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */
function expandContractions(s: string): string {
return s
.replace(/\bcan'?t\b/gi, 'cannot')
.replace(/\bdon'?t\b/gi, 'do not')
.replace(/\bwon'?t\b/gi, 'will not')
.replace(/\bdoesn'?t\b/gi, 'does not')
.replace(/\bisn'?t\b/gi, 'is not')
.replace(/\baren'?t\b/gi, 'are not')
.replace(/\bwasn'?t\b/gi, 'was not')
.replace(/\bweren'?t\b/gi, 'were not')
.replace(/\bhasn'?t\b/gi, 'has not')
.replace(/\bhaven'?t\b/gi, 'have not')
.replace(/\bhadn'?t\b/gi, 'had not')
.replace(/\bcouldn'?t\b/gi, 'could not')
.replace(/\bshouldn'?t\b/gi, 'should not')
.replace(/\bwouldn'?t\b/gi, 'would not')
.replace(/\bit'?s\b/gi, 'it is')
.replace(/\bthat'?s\b/gi, 'that is')
.replace(/\bthey'?re\b/gi, 'they are')
.replace(/\bwe'?re\b/gi, 'we are')
.replace(/\byou'?re\b/gi, 'you are')
}
/** Basic English stemming: strip common suffixes for loose comparison. */
function stem(word: string): string {
if (word.length <= 3) return word
// Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok)
if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y'
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
return word.slice(0, -2)
}
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
/** Stem all words in a string. */
function stemAll(s: string): string {
return s.split(/\s+/).map(stem).join(' ')
}
function tryParseNumber(s: string): number | null {
const cleaned = normalize(s)
@@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | null {
/**
* Check if a bot's response matches any of the accepted answers.
* Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match.
* Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match.
*/
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
if (!response || response.trim() === '') return 0
@@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]):
const normResponse = normalize(response)
if (normResponse === '') return 0
// Pre-compute expanded/stemmed versions once
const expandedResponse = normalize(expandContractions(response))
const stemmedResponse = stemAll(normResponse)
for (const accepted of acceptedAnswers) {
const normAccepted = normalize(accepted)
const expandedAccepted = normalize(expandContractions(accepted))
const stemmedAccepted = stemAll(normAccepted)
// 1. Exact match after normalization
if (normResponse === normAccepted) return 1.0
// 2. Numeric equivalence
// 2. Exact match after contraction expansion
if (expandedResponse === expandedAccepted) return 1.0
// 3. Exact match after stemming (catches plural/singular)
if (stemmedResponse === stemmedAccepted) return 1.0
// 4. Numeric equivalence
const respNum = tryParseNumber(normResponse)
const accNum = tryParseNumber(normAccepted)
if (respNum !== null && accNum !== null && respNum === accNum) return 1.0
// 3. Response contains the accepted answer
// 5. Response contains the accepted answer (or stemmed version)
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95
// 4. Accepted answer contains the response (for short definitive answers)
// 6. Accepted answer contains the response (for short definitive answers)
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75
// 5. Check if number appears anywhere in a longer response
// 7. Check if number appears anywhere in a longer response
if (accNum !== null) {
// Look for the number in the response text
const numStr = String(accNum)
if (normResponse.includes(numStr)) return 1.0
// Check number words in response
const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '')
if (responseNum !== null && responseNum === accNum) return 0.9
const responseWords = normResponse.split(/\s+/)
for (const w of responseWords) {
const parsed = tryParseNumber(w)
if (parsed !== null && parsed === accNum) return 0.9
}
}
// 6. Word-level containment — all words of the answer appear in the response
// 8. Word-level containment — all words of the answer appear in the response
const acceptedWords = normAccepted.split(/\s+/)
if (acceptedWords.length >= 2) {
const allFound = acceptedWords.every(w => normResponse.includes(w))
if (allFound) return 0.9
// Try with stemming
const stemmedAccWords = stemmedAccepted.split(/\s+/)
const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w))
if (allStemFound) return 0.85
}
// 9. Contraction-expanded word containment
if (expandedAccepted.split(/\s+/).length >= 2) {
const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w))
if (allFound) return 0.85
}
}
// 7. For true/false questions, check if the response starts with the right keyword
// 10. For true/false questions, check if the response starts with the right keyword
const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false')
if (tfAnswer) {
const firstWord = normResponse.split(/\s+/)[0]
if (firstWord === tfAnswer.toLowerCase()) return 1.0
// "that's true" / "this is false" etc.
// "that's true" / "this is false" / "yes" for true / "no" for false
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
// "yes" ≈ true, "no" ≈ false
if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9
if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9
}
return 0
+9 -9
View File
@@ -112,7 +112,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 22,
prompts: [
{ prompt: 'I have cities but no houses, forests but no trees, and water but no fish. What am I?', answers: ['map', 'a map'] },
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps'] },
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps', 'footstep'] },
{ prompt: 'I speak without a mouth and hear without ears. I have no body, but I come alive with the wind. What am I?', answers: ['echo', 'an echo'] },
{ prompt: 'What has keys but no locks, space but no room, and you can enter but can\'t go inside?', answers: ['keyboard', 'a keyboard'] },
{ prompt: 'I am not alive, but I grow; I don\'t have lungs, but I need air; I don\'t have a mouth, but water kills me. What am I?', answers: ['fire', 'flame'] },
@@ -207,9 +207,9 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'I am an odd number. Take away a letter and I become even. What number am I?', answers: ['seven', '7'] },
{ prompt: 'A farmer has 17 sheep. All but 9 run away. How many does the farmer have left?', answers: ['9', 'nine'] },
{ prompt: 'How many times can you subtract 5 from 25?', answers: ['1', 'one', 'once'] },
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs'] },
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs', 'rooster doesn\'t lay eggs', 'a rooster can\'t lay eggs', 'roosters do not lay eggs'] },
{ prompt: 'If it takes 5 machines 5 minutes to make 5 widgets, how long for 100 machines to make 100 widgets?', answers: ['5', 'five', '5 minutes'] },
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same'] },
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same', 'they are the same', 'both weigh the same', 'equally'] },
{ prompt: 'If you overtake the person in second place, what place are you in?', answers: ['second', '2nd', '2'] },
{ prompt: 'How many months have 28 days?', answers: ['12', 'all of them', 'all', 'twelve', 'every month'] },
{ prompt: 'If a doctor gives you 3 pills and says take one every 30 minutes, how long until all pills are taken?', answers: ['60', '60 minutes', '1 hour', 'one hour'] },
@@ -261,7 +261,7 @@ const TEMPLATES: ChallengeTemplate[] = [
timeout_ms: 8000,
baseDamage: 16,
prompts: [
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t'] },
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t', 'the model t', 'the ford model t'] },
{ prompt: 'How many wheels does a standard 18-wheeler actually have?', answers: ['18', 'eighteen'] },
{ prompt: 'What car brand uses a prancing horse as its logo?', answers: ['ferrari'] },
{ prompt: 'How many cylinders does a V8 engine have?', answers: ['8', 'eight'] },
@@ -291,7 +291,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 20,
prompts: [
{ prompt: 'True or false: A group of flamingos is called a "flamboyance."', answers: ['true'] },
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats'] },
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats', 'a bat'] },
{ prompt: 'True or false: Octopuses have three hearts.', answers: ['true'] },
{ prompt: 'Is a tomato a fruit or a vegetable? (Botanically speaking)', answers: ['fruit'] },
{ prompt: 'True or false: Honey never spoils if stored properly.', answers: ['true'] },
@@ -301,7 +301,7 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'What causes thunder?', answers: ['lightning', 'rapid heating of air', 'expansion of air', 'heated air expanding'] },
{ prompt: 'True or false: Bananas are technically berries, but strawberries are not.', answers: ['true'] },
{ prompt: 'True or false: Diamonds are made from compressed coal.', answers: ['false'] },
{ prompt: 'What is the fastest land animal?', answers: ['cheetah'] },
{ prompt: 'What is the fastest land animal?', answers: ['cheetah', 'the cheetah', 'a cheetah'] },
{ prompt: 'What color is a polar bear\'s skin under its white fur?', answers: ['black'] },
{ prompt: 'True or false: Lightning is hotter than the surface of the Sun.', answers: ['true'] },
{ prompt: 'Name the only continent with no active volcanoes.', answers: ['australia'] },
@@ -319,7 +319,7 @@ const TEMPLATES: ChallengeTemplate[] = [
timeout_ms: 10000,
baseDamage: 18,
prompts: [
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'water bear', 'tardigrades'] },
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'tardigrades', 'water bear', 'water bears'] },
{ prompt: 'What is the fastest animal on Earth?', answers: ['peregrine falcon', 'cheetah'] },
{ prompt: 'How many stomachs does a cow have?', answers: ['4', 'four'] },
{ prompt: 'Name the only bird that can fly backwards.', answers: ['hummingbird'] },
@@ -330,7 +330,7 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'True or false: Cows have best friends and get stressed when separated.', answers: ['true'] },
{ prompt: 'True or false: An octopus has blue blood.', answers: ['true'] },
{ prompt: 'How many hearts does an octopus have?', answers: ['3', 'three'] },
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant'] },
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant', 'elephants'] },
{ prompt: 'True or false: A snail can sleep for 3 years.', answers: ['true'] },
{ prompt: 'What animal has the strongest bite force?', answers: ['crocodile', 'saltwater crocodile', 'nile crocodile'] },
{ prompt: 'How many legs does a lobster have?', answers: ['10', 'ten'] },
@@ -349,7 +349,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 24,
prompts: [
{ prompt: 'What does SQL in "SQL injection" stand for?', answers: ['structured query language'] },
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm'] },
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm', 'encryption'] },
{ prompt: 'What does the S in HTTPS stand for?', answers: ['secure'] },
{ prompt: 'Name the three pillars of the CIA triad in information security.', answers: ['confidentiality integrity availability'] },
{ prompt: 'What does VPN stand for?', answers: ['virtual private network'] },
+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]]
}
+31 -23
View File
@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid'
import { createHash, randomBytes } from 'crypto'
import { db, schema } from '../db/index.js'
import { db, schema, sqlite } from '../db/index.js'
import { randomArena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
@@ -265,6 +265,7 @@ export function mockResponse(
return { answer, trashTalk, timeMs, timedOut, error }
}
export async function seedMockBots(): Promise<void> {
for (const bot of MOCK_BOTS) {
const existing = await db.select({ id: schema.bots.id })
@@ -292,6 +293,8 @@ export async function seedMockBots(): Promise<void> {
}
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
const [botARows, botBRows] = await Promise.all([
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
@@ -329,7 +332,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
const eloForMock = (name: string) =>
MOCK_BOTS.find(b => b.name === name)?.elo || 1200
const totalRounds = 7 + Math.floor(Math.random() * 4) // 7-10 rounds
const totalRounds = 7 + Math.floor(Math.random() * 4)
const maxRounds = Math.min(totalRounds, 10)
for (let round = 1; round <= maxRounds; round++) {
@@ -389,37 +392,42 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
}
await db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Finalize atomically
const finalize = sqlite.transaction(() => {
db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Update stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
await Promise.all([
db.update(schema.bots).set({
wins: sql`${schema.bots.wins} + 1`,
eloRating: newWinnerElo,
winStreak: newWinStreak,
bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`,
tier: calculateTier(newWinnerElo, winner.wins + 1),
}).where(eq(schema.bots.id, winnerId)),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
eloRating: newLoserElo,
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
}).where(eq(schema.bots.id, loserId)),
])
}
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
}
})
finalize()
return fightId
}
@@ -442,26 +450,26 @@ export async function seedMockFights(count: number = 12): Promise<void> {
return
}
// Sort by elo for mismatch selection
const sorted = [...allBots].sort((a, b) => b.eloRating - a.eloRating)
for (let i = 0; i < count; i++) {
let botAId: string, botBId: string
if (i % 3 === 0 && sorted.length >= 4) {
// Every 3rd fight: mismatch (top vs bottom)
const topIdx = Math.floor(Math.random() * Math.ceil(sorted.length / 3))
const botIdx = sorted.length - 1 - Math.floor(Math.random() * Math.ceil(sorted.length / 3))
botAId = sorted[topIdx].id
botBId = sorted[botIdx].id
} else {
// Random matchup
const shuffled = [...allBots].sort(() => Math.random() - 0.5)
botAId = shuffled[0].id
botBId = shuffled[1].id
}
await runMockFight(botAId, botBId)
// Skip self-fights
if (botAId !== botBId) {
await runMockFight(botAId, botBId)
}
}
console.log(`[botfights] seeded ${count} mock fights`)
+203 -37
View File
@@ -1,11 +1,12 @@
import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { db, schema, sqlite } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { randomArena, type Arena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
interface BotRecord {
id: string
@@ -28,6 +29,14 @@ interface WebhookResponse {
const MAX_ROUNDS = 10
const KO_THRESHOLD = 0
const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
// Track bots currently in a fight to prevent concurrent fights
const activeFighters = new Set<string>()
export function isInFight(botId: string): boolean {
return activeFighters.has(botId)
}
function emit(fightId: string, type: string, data: Record<string, unknown>) {
fightEvents.emit({
@@ -38,14 +47,68 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
})
}
// SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
if (hostname.startsWith('10.')) return false
if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false
}
if (hostname === '169.254.169.254') return false
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
return true
} catch {
return false
}
}
export { isAllowedWebhookUrl }
// Size-limited body reader to prevent OOM
async function readLimitedBody(res: Response, maxBytes: number): Promise<string> {
const reader = res.body?.getReader()
if (!reader) return ''
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
totalBytes += value.byteLength
if (totalBytes > maxBytes) {
reader.cancel()
throw new Error(`Response body exceeds ${maxBytes} bytes`)
}
chunks.push(value)
}
} catch (err) {
reader.cancel()
throw err
}
const combined = new Uint8Array(totalBytes)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(combined)
}
async function callWebhook(
url: string,
challenge: Challenge,
roundNumber: number,
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
): Promise<WebhookResponse> {
const body = JSON.stringify({
fight_id: fightId,
round: roundNumber,
type: challenge.type,
challenge: challenge.prompt,
@@ -61,6 +124,12 @@ async function callWebhook(
const start = Date.now()
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
// SSRF check
if (!isAllowedWebhookUrl(url)) {
console.log(`[webhook] ${url} BLOCKED (private/internal URL)`)
return { answer: null, timeMs: 0, timedOut: false, error: true }
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
@@ -80,7 +149,14 @@ async function callWebhook(
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
const text = await res.text()
let text: string
try {
text = await readLimitedBody(res, MAX_RESPONSE_BYTES)
} catch {
console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
let data: { answer?: string; trash_talk?: string }
try {
data = JSON.parse(text)
@@ -88,10 +164,15 @@ async function callWebhook(
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
// Enforce size limits on fields
const answer = data.answer ? data.answer.slice(0, 2000) : null
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
return {
answer: data.answer || null,
trashTalk: data.trash_talk,
answer,
trashTalk,
timeMs: elapsed,
timedOut: false,
error: false,
@@ -109,7 +190,7 @@ async function callWebhook(
}
}
function isMockBot(webhookUrl: string): boolean {
export function isMockBot(webhookUrl: string): boolean {
return webhookUrl.startsWith('http://mock.local')
}
@@ -117,6 +198,7 @@ async function getBotResponse(
bot: BotRecord,
challenge: Challenge,
roundNumber: number,
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
): Promise<WebhookResponse> {
@@ -132,7 +214,7 @@ async function getBotResponse(
}
}
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
}
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
@@ -166,6 +248,26 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
return fightId
}
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(webhookUrl)) return
if (succeeded) {
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
} else {
await db.update(schema.bots).set({
consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`,
lastErrorAt: new Date().toISOString(),
}).where(eq(schema.bots.id, botId))
// Auto-deactivate after 5 consecutive errors
const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (bot[0] && bot[0].consecutiveErrors >= 5) {
await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId))
console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
}
}
}
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
let hpA = 200
let hpB = 200
@@ -183,10 +285,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
})
// Call both bots simultaneously (mock bots get generated responses)
// Call both bots simultaneously
const [responseA, responseB] = await Promise.all([
getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
])
// Track webhook reliability for real bots
await Promise.all([
trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut),
trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut),
])
// Score the round
@@ -272,39 +380,52 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
(winnerId === botB.id && hpB === 200)
)
// Finalize fight
await db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
// Update bot stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const finalize = sqlite.transaction(() => {
// Mark fight finished
db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
// Update bot stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor)
const newWinStreak = winner.winStreak + 1
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
await Promise.all([
db.update(schema.bots).set({
wins: sql`${schema.bots.wins} + 1`,
eloRating: newWinnerElo,
winStreak: newWinStreak,
bestStreak: newBestStreak,
tier: calculateTier(newWinnerElo, winner.wins + 1),
}).where(eq(schema.bots.id, winnerId)),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
eloRating: newLoserElo,
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
}).where(eq(schema.bots.id, loserId)),
])
}
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
} else {
// Draw — update lastFightAt for both
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id))
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id))
}
})
finalize()
emit(fightId, 'fight_end', {
winnerId,
@@ -317,20 +438,65 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
}
export async function runFight(botAId: string, botBId: string): Promise<string> {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
return fightId
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
activeFighters.add(botAId)
activeFighters.add(botBId)
try {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
return fightId
} finally {
activeFighters.delete(botAId)
activeFighters.delete(botBId)
setCooldown(botAId)
setCooldown(botBId)
}
}
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
export async function runFightAsync(botAId: string, botBId: string): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
activeFighters.add(botAId)
activeFighters.add(botBId)
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
executeFightRounds(fightId, botA, botB, arena).catch(err => {
console.error(`[botfights] fight ${fightId} error:`, err)
})
executeFightRounds(fightId, botA, botB, arena)
.catch(err => {
console.error(`[botfights] fight ${fightId} error:`, err)
// Mark fight as cancelled so it doesn't stay 'live' forever
db.update(schema.fights).set({
status: 'cancelled',
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
fightEvents.cleanup(fightId)
})
.finally(() => {
activeFighters.delete(botAId)
activeFighters.delete(botBId)
setCooldown(botAId)
setCooldown(botBId)
})
return fightId
}
/** Clean up orphaned fights on startup */
export async function cleanupOrphanedFights(): Promise<number> {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const result = await db.update(schema.fights)
.set({ status: 'cancelled', endedAt: new Date().toISOString() })
.where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`)
return 0 // drizzle doesn't return affected rows easily, but the cleanup runs
}
+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)
}
+37 -25
View File
@@ -29,7 +29,7 @@ export function scoreRound(
comboA: number,
comboB: number,
): RoundResult {
// Handle timeouts/errors instant loss for the failing bot
// Handle timeouts/errors -- instant loss for the failing bot
if (responseA.timedOut && responseB.timedOut) {
return {
botAScore: 0, botBScore: 0,
@@ -70,19 +70,17 @@ export function scoreRound(
let scoreB: number
if (challenge.answers && challenge.answers.length > 0) {
// ═══ FACTUAL SCORING ═══
// Check correctness against known answers
// === FACTUAL SCORING ===
const correctA = checkAnswer(responseA.answer, challenge.answers)
const correctB = checkAnswer(responseB.answer, challenge.answers)
if (correctA > 0 && correctB > 0) {
// Both correct speed is tiebreaker
// Both correct -- speed is tiebreaker
const faster = Math.min(responseA.timeMs, responseB.timeMs)
const slower = Math.max(responseA.timeMs, responseB.timeMs)
const speedRatio = slower > 0 ? faster / slower : 1
const aFaster = responseA.timeMs <= responseB.timeMs
// Confidence bonus (full match vs partial)
const confA = Math.min(correctA, 1)
const confB = Math.min(correctB, 1)
@@ -94,22 +92,19 @@ export function scoreRound(
scoreB = 7 + (1 - speedRatio) * 2 + confB
}
} else if (correctA > 0 && correctB === 0) {
// A correct, B wrong — A wins big
scoreA = 9 + correctA * 0.5
scoreB = 1 + (responseB.answer ? 1 : 0) // tiny credit for trying
scoreB = 1 + (responseB.answer ? 1 : 0)
} else if (correctB > 0 && correctA === 0) {
// B correct, A wrong — B wins big
scoreA = 1 + (responseA.answer ? 1 : 0)
scoreB = 9 + correctB * 0.5
} else {
// Both wrong speed tiebreaker in low range
// Both wrong -- speed tiebreaker in low range
const aFaster = responseA.timeMs <= responseB.timeMs
scoreA = aFaster ? 4 : 3
scoreB = aFaster ? 3 : 4
}
} else {
// ═══ CREATIVE SCORING ═══
// Heuristic: response quality estimation (length + speed)
// === CREATIVE SCORING ===
const qualA = estimateQuality(responseA)
const qualB = estimateQuality(responseB)
const total = qualA + qualB || 1
@@ -123,10 +118,8 @@ export function scoreRound(
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
// Critical hit on big margin
const isCritical = margin > 4
// Calculate damage
let winnerDamage = challenge.baseDamage + margin * 2
if (isCritical) winnerDamage *= 1.5
const winnerCombo = winnerId === botA.id ? comboA : comboB
@@ -156,7 +149,6 @@ function applyModifiers(
combo: number,
): number {
let d = damage
// Combo multiplier (caps at 2x)
if (combo > 0) {
d *= 1 + Math.min(combo, 5) * 0.2
}
@@ -164,13 +156,36 @@ function applyModifiers(
}
function estimateQuality(response: BotResponse): number {
if (!response.answer) return 1
const len = response.answer.length
// Reasonable length gets a bonus, very short or very long gets penalized
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
// Faster is slightly better
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
return lengthScore + speedBonus
if (!response.answer) return 0.5
const text = response.answer.trim()
const len = text.length
if (len < 10) return 1
// Detect low-effort spam (repeated chars)
const uniqueChars = new Set(text.toLowerCase()).size
const charRatio = uniqueChars / Math.min(len, 100)
if (charRatio < 0.1) return 0.5
// Word diversity (unique words / total words)
const words = text.split(/\s+/)
const uniqueWords = new Set(words.map(w => w.toLowerCase()))
const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
// Ideal length window: 30-400 chars
let lengthScore: number
if (len >= 30 && len <= 400) lengthScore = 4
else if (len > 400 && len <= 600) lengthScore = 3
else if (len > 600) lengthScore = 2
else lengthScore = 2
// Diversity bonus (prevents repetitive text)
const diversityScore = Math.min(wordDiversity * 4, 3)
// Speed bonus (faster is slightly better)
const speedBonus = Math.max(0, 2 - response.timeMs / 8000)
return lengthScore + diversityScore + speedBonus
}
function generateNarration(
@@ -183,7 +198,6 @@ function generateNarration(
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
const isFactual = challenge.scoring === 'factual'
// Big margin = one got it right and the other didn't
if (isFactual && margin > 5) {
const bigWins = [
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close.`,
@@ -195,18 +209,16 @@ function generateNarration(
return bigWins[Math.floor(Math.random() * bigWins.length)]
}
// Factual — both correct, speed tiebreaker
if (isFactual && margin <= 3) {
const closeOnes = [
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs to pick up the pace.`,
`${critPrefix}Correct on both sides! ${winner} edges it out with lightning speed.`,
`${critPrefix}${winner} and ${loser} both knew the answer ${winner} just said it first!`,
`${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just said it first!`,
`${critPrefix}A battle of speed! ${winner} fires back a fraction faster than ${loser}.`,
]
return closeOnes[Math.floor(Math.random() * closeOnes.length)]
}
// Generic narrations by category
const narrations: Record<string, string[]> = {
speed_blitz: [
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
+72
View File
@@ -0,0 +1,72 @@
import { isAllowedWebhookUrl } from './orchestrator.js'
export interface WebhookTestResult {
reachable: boolean
validResponse: boolean
latencyMs: number
error?: string
}
export async function testWebhook(webhookUrl: string): Promise<WebhookTestResult> {
if (!isAllowedWebhookUrl(webhookUrl)) {
return { reachable: false, validResponse: false, latencyMs: 0, error: 'URL blocked: private/internal addresses are not allowed.' }
}
const testPayload = JSON.stringify({
fight_id: 'test_000000',
round: 0,
type: 'webhook_test',
challenge: 'WEBHOOK TEST: respond with {"answer": "pong"} to verify your setup.',
constraints: { timeout_ms: 5000, max_tokens: 500 },
opponent: { name: 'test_bot', wins: 0, losses: 0 },
arena: 'localhost',
arena_modifier: null,
})
const start = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: testPayload,
signal: controller.signal,
})
clearTimeout(timeout)
const latencyMs = Date.now() - start
if (!res.ok) {
return { reachable: true, validResponse: false, latencyMs, error: `Webhook returned HTTP ${res.status}. Expected 200.` }
}
const text = await res.text()
if (text.length > 10240) {
return { reachable: true, validResponse: false, latencyMs, error: 'Response too large (>10KB).' }
}
let data: Record<string, unknown>
try {
data = JSON.parse(text)
} catch {
return { reachable: true, validResponse: false, latencyMs, error: 'Response is not valid JSON. Expected {"answer": "..."}.' }
}
if (typeof data.answer !== 'string') {
return { reachable: true, validResponse: false, latencyMs, error: 'Response JSON missing "answer" field. Expected {"answer": "pong"}.' }
}
return { reachable: true, validResponse: true, latencyMs }
} catch (err: unknown) {
const latencyMs = Date.now() - start
const isAbort = err instanceof Error && err.name === 'AbortError'
if (isAbort) {
return { reachable: false, validResponse: false, latencyMs, error: 'Webhook timed out (5s). Is your server running?' }
}
const msg = err instanceof Error ? err.message : String(err)
return { reachable: false, validResponse: false, latencyMs, error: `Connection failed: ${msg}` }
}
}
+146 -11
View File
@@ -1,23 +1,158 @@
import './db/index.js'
import { db, schema } from './db/index.js'
import { startFightLoop } from './engine/fight-loop.js'
import { createTuiState } from './tui/state.js'
import { TuiRenderer } from './tui/renderer.js'
import { desc } from 'drizzle-orm'
const args = process.argv.slice(2)
const maxFights = parseInt(args.find(a => a.startsWith('--max='))?.split('=')[1] || '0') || Infinity
const intervalMs = parseInt(args.find(a => a.startsWith('--interval='))?.split('=')[1] || '0') || 8000
const style = (args.find(a => a.startsWith('--style='))?.split('=')[1] || 'mixed') as 'random' | 'elo_close' | 'mixed'
const noTui = args.includes('--no-tui')
console.log('[botfights] fight loop CLI')
console.log(` max fights: ${maxFights === Infinity ? 'unlimited' : maxFights}`)
console.log(` interval: ${intervalMs}ms`)
console.log(` style: ${style}`)
console.log('')
async function main() {
// Snapshot starting elos
const allBots = await db.select({
name: schema.bots.name,
eloRating: schema.bots.eloRating,
wins: schema.bots.wins,
losses: schema.bots.losses,
tier: schema.bots.tier,
}).from(schema.bots).orderBy(desc(schema.bots.eloRating))
startFightLoop({ maxFights, intervalMs, matchmakingStyle: style })
.then(() => {
console.log('[botfights] fight loop finished')
if (noTui) {
console.log('[botfights] fight loop CLI (no TUI)')
console.log(` max fights: ${maxFights === Infinity ? 'unlimited' : maxFights}`)
console.log(` interval: ${intervalMs}ms`)
console.log(` style: ${style}`)
console.log('')
await startFightLoop({ maxFights, intervalMs, matchmakingStyle: style })
process.exit(0)
}
// TUI mode
const state = createTuiState(maxFights, style)
const renderer = new TuiRenderer(state)
// Snapshot starting elos
for (const bot of allBots) {
state.eloSnapshots.set(bot.name, bot.eloRating)
}
// Set initial leaderboard
state.leaderboard = allBots.slice(0, 15).map(b => ({
name: b.name,
elo: b.eloRating,
wins: b.wins,
losses: b.losses,
tier: b.tier,
}))
renderer.render()
// Handle SIGINT gracefully
process.on('SIGINT', () => {
renderer.showFinalSummary()
process.exit(0)
})
.catch((err) => {
console.error('[botfights] fight loop error:', err)
process.exit(1)
// Handle terminal resize
process.stdout.on('resize', () => renderer.render())
await startFightLoop({
maxFights,
intervalMs,
matchmakingStyle: style,
onFightStart: (botAName, botAElo, botBName, botBElo) => {
state.currentFight = {
botA: { name: botAName, elo: botAElo, hp: 200 },
botB: { name: botBName, elo: botBElo, hp: 200 },
round: 0,
maxRounds: 10,
challengeType: '',
challengeLabel: '',
events: [],
}
renderer.render()
},
onFightComplete: async (result) => {
state.completed++
// Track fight counts
state.fightCounts.set(result.botAName, (state.fightCounts.get(result.botAName) || 0) + 1)
state.fightCounts.set(result.botBName, (state.fightCounts.get(result.botBName) || 0) + 1)
// Track KO, perfect, draw
if (result.isKo) state.kos++
if (result.isPerfect) state.perfects++
if (!result.winnerId) state.draws++
// Determine method
let method = 'Decision'
if (!result.winnerId) method = 'DRAW'
else if (result.isPerfect) method = `PERFECT R${result.totalRounds}`
else if (result.isKo) method = `KO R${result.totalRounds}`
// Add to recent fights
state.recentFights.push({
num: state.completed,
botA: result.botAName,
botB: result.botBName,
winner: result.winnerName,
method,
})
if (state.recentFights.length > 20) state.recentFights.shift()
// Track biggest upset
if (result.winnerId && result.winnerName) {
const winnerElo = result.winnerId === result.botAName ? result.botAElo : result.botBElo
const loserElo = result.winnerId === result.botAName ? result.botBElo : result.botAElo
const loserName = result.winnerName === result.botAName ? result.botBName : result.botAName
const eloDiff = Math.round(loserElo - winnerElo)
if (eloDiff > 0 && (!state.biggestUpset || eloDiff > state.biggestUpset.eloDiff)) {
state.biggestUpset = { winner: result.winnerName, loser: loserName, eloDiff }
}
}
// Refresh leaderboard from DB
try {
const bots = await db.select({
name: schema.bots.name,
eloRating: schema.bots.eloRating,
wins: schema.bots.wins,
losses: schema.bots.losses,
tier: schema.bots.tier,
}).from(schema.bots).orderBy(desc(schema.bots.eloRating)).limit(15)
state.leaderboard = bots.map(b => ({
name: b.name,
elo: b.eloRating,
wins: b.wins,
losses: b.losses,
tier: b.tier,
}))
} catch { /* non-critical */ }
state.currentFight = null
renderer.render()
},
onError: (err) => {
state.errors++
state.currentFight = null
renderer.render()
},
})
renderer.showFinalSummary()
process.exit(0)
}
main().catch((err) => {
console.error('[botfights] fight loop error:', err)
process.exit(1)
})
+51
View File
@@ -0,0 +1,51 @@
import type { Context, Next } from 'hono'
const hitCounts = new Map<string, { count: number; resetAt: number }>()
// Cleanup stale entries every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, entry] of hitCounts) {
if (now > entry.resetAt) hitCounts.delete(key)
}
}, 5 * 60 * 1000)
export function rateLimit(windowMs: number, maxHits: number) {
return async (c: Context, next: Next) => {
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
const now = Date.now()
const entry = hitCounts.get(key)
if (!entry || now > entry.resetAt) {
hitCounts.set(key, { count: 1, resetAt: now + windowMs })
} else {
entry.count++
if (entry.count > maxHits) {
return c.json({ error: 'Too many requests. Slow down.' }, 429)
}
}
await next()
}
}
// Per-bot rate limiter (uses bot ID instead of IP)
const botHitCounts = new Map<string, number>()
export function botRateLimit(cooldownMs: number) {
return async (c: Context, next: Next) => {
const botId = c.req.param('botId')
if (!botId) return next()
const lastHit = botHitCounts.get(botId) || 0
const now = Date.now()
if (now - lastHit < cooldownMs) {
const waitSec = Math.ceil((cooldownMs - (now - lastHit)) / 1000)
return c.json({ error: `Cooldown active. Wait ${waitSec}s.` }, 429)
}
botHitCounts.set(botId, now)
await next()
}
}
+50 -12
View File
@@ -2,11 +2,14 @@ import { Hono } from 'hono'
import { nanoid } from 'nanoid'
import { createHash, randomBytes } from 'crypto'
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const authRouter = new Hono()
// Login with Nostr pubkey — returns bot if one exists
// Login with Nostr pubkey
authRouter.post('/login', async (c) => {
const body = await c.req.json()
const { pubkey } = body
@@ -27,6 +30,7 @@ authRouter.post('/login', async (c) => {
winStreak: schema.bots.winStreak,
bestStreak: schema.bots.bestStreak,
tier: schema.bots.tier,
isActive: schema.bots.isActive,
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
if (rows.length === 0) {
@@ -37,7 +41,7 @@ authRouter.post('/login', async (c) => {
})
// Register a new bot with Nostr pubkey
authRouter.post('/register', async (c) => {
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
const body = await c.req.json()
const { pubkey, name, webhookUrl, archetype, profilePicUrl } = body
@@ -53,6 +57,8 @@ authRouter.post('/register', async (c) => {
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
}
const normalizedName = name.toLowerCase()
if (!webhookUrl || typeof webhookUrl !== 'string') {
return c.json({ error: 'webhookUrl is required.' }, 400)
}
@@ -63,6 +69,10 @@ authRouter.post('/register', async (c) => {
return c.json({ error: 'webhookUrl must be a valid URL.' }, 400)
}
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
// Check pubkey not already used
const existingPk = await db.select({ id: schema.bots.id })
.from(schema.bots)
@@ -73,24 +83,34 @@ authRouter.post('/register', async (c) => {
return c.json({ error: 'This Nostr key already has a bot.' }, 409)
}
// Check name not taken
// Check name not taken (case-insensitive)
const existingName = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, name))
.where(eq(sql`LOWER(${schema.bots.name})`, normalizedName))
.limit(1)
if (existingName.length > 0) {
return c.json({ error: 'A bot with that name already exists.' }, 409)
}
// Test the webhook
const testResult = await testWebhook(webhookUrl)
if (!testResult.reachable || !testResult.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: testResult.latencyMs,
}, 422)
}
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
await db.insert(schema.bots).values({
id,
name,
webhookUrl: webhookUrl,
avatarSeed: name,
name: normalizedName,
webhookUrl,
avatarSeed: normalizedName,
archetype: archetype || 'standard',
secretHash: createHash('sha256').update(secret).digest('hex'),
publicKey: pubkey,
@@ -100,9 +120,10 @@ authRouter.post('/register', async (c) => {
return c.json({
id,
name,
name: normalizedName,
archetype: archetype || 'standard',
message: 'Bot registered.',
webhookLatencyMs: testResult.latencyMs,
message: 'Bot registered. Webhook verified.',
}, 201)
})
@@ -124,8 +145,25 @@ authRouter.post('/update', async (c) => {
return c.json({ error: 'No bot found for this key.' }, 404)
}
const updates: Record<string, string> = {}
if (webhookUrl) updates.webhookUrl = webhookUrl
const updates: Record<string, unknown> = {}
if (webhookUrl) {
if (!isAllowedWebhookUrl(webhookUrl)) {
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
}
// Test new webhook before accepting
const testResult = await testWebhook(webhookUrl)
if (!testResult.reachable || !testResult.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: testResult.error,
}, 422)
}
updates.webhookUrl = webhookUrl
updates.consecutiveErrors = 0
updates.isActive = true
}
if (profilePicUrl) updates.profilePicUrl = profilePicUrl
if (Object.keys(updates).length > 0) {
+199 -14
View File
@@ -2,8 +2,11 @@ import { Hono } from 'hono'
import { nanoid } from 'nanoid'
import { createHash, randomBytes } from 'crypto'
import { db, schema } from '../db/index.js'
import { eq, or, desc } from 'drizzle-orm'
import { eq, or, desc, sql } from 'drizzle-orm'
import { TIER_NAMES, TIER_COLORS } from '../engine/scoring.js'
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
export const botsRouter = new Hono()
@@ -11,8 +14,8 @@ function hashSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex')
}
// Register a new bot
botsRouter.post('/', async (c) => {
// Rate limit registration: 5 per hour per IP
botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
const body = await c.req.json()
const { name, webhook_url, avatar_seed } = body
@@ -24,6 +27,9 @@ botsRouter.post('/', async (c) => {
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
}
// Force lowercase for case-insensitive uniqueness
const normalizedName = name.toLowerCase()
if (!webhook_url || typeof webhook_url !== 'string') {
return c.json({ error: 'webhook_url is required.' }, 400)
}
@@ -34,33 +40,49 @@ botsRouter.post('/', async (c) => {
return c.json({ error: 'webhook_url must be a valid URL.' }, 400)
}
// Check for duplicate name
// SSRF check
if (!isAllowedWebhookUrl(webhook_url)) {
return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400)
}
// Check for duplicate name (case-insensitive)
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, name))
.where(eq(sql`LOWER(${schema.bots.name})`, normalizedName))
.limit(1)
if (existing.length > 0) {
return c.json({ error: 'A bot with that name already exists.' }, 409)
}
// Test the webhook before accepting registration
const testResult = await testWebhook(webhook_url)
if (!testResult.reachable || !testResult.validResponse) {
return c.json({
error: 'Webhook verification failed.',
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
latencyMs: testResult.latencyMs,
}, 422)
}
const id = nanoid(12)
const secret = randomBytes(32).toString('hex')
await db.insert(schema.bots).values({
id,
name,
name: normalizedName,
webhookUrl: webhook_url,
avatarSeed: avatar_seed || name,
avatarSeed: avatar_seed || normalizedName,
secretHash: hashSecret(secret),
createdAt: new Date().toISOString(),
})
return c.json({
id,
name,
name: normalizedName,
secret,
message: 'Bot registered. Save your secret -- it will not be shown again.',
webhookLatencyMs: testResult.latencyMs,
message: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.',
}, 201)
})
@@ -98,7 +120,7 @@ botsRouter.get('/:name', async (c) => {
tier: schema.bots.tier,
isActive: schema.bots.isActive,
createdAt: schema.bots.createdAt,
}).from(schema.bots).where(eq(schema.bots.name, name)).limit(1)
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (rows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
@@ -107,7 +129,7 @@ botsRouter.get('/:name', async (c) => {
return c.json(rows[0])
})
// Get bot stats full account page data
// Get bot stats -- full account page data
botsRouter.get('/:name/stats', async (c) => {
const name = c.req.param('name')
const botRows = await db.select({
@@ -123,7 +145,7 @@ botsRouter.get('/:name/stats', async (c) => {
tier: schema.bots.tier,
isActive: schema.bots.isActive,
createdAt: schema.bots.createdAt,
}).from(schema.bots).where(eq(schema.bots.name, name)).limit(1)
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (botRows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
@@ -190,12 +212,42 @@ botsRouter.get('/:name/stats', async (c) => {
})
})
// Health check a bot's webhook
// Test a bot's webhook with a real challenge
botsRouter.post('/:name/test', async (c) => {
const name = c.req.param('name')
const rows = await db.select({
id: schema.bots.id,
webhookUrl: schema.bots.webhookUrl,
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (rows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
}
const result = await testWebhook(rows[0].webhookUrl)
// If test passes and bot was deactivated, reactivate it
if (result.reachable && result.validResponse) {
await db.update(schema.bots).set({
consecutiveErrors: 0,
isActive: true,
}).where(eq(schema.bots.id, rows[0].id))
}
return c.json({
...result,
message: result.validResponse
? 'Webhook verified. Bot is ready to fight.'
: 'Webhook test failed. Fix the issue and try again.',
})
})
// Legacy health check
botsRouter.post('/:name/health', async (c) => {
const name = c.req.param('name')
const rows = await db.select({
webhookUrl: schema.bots.webhookUrl,
}).from(schema.bots).where(eq(schema.bots.name, name)).limit(1)
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
if (rows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
@@ -217,3 +269,136 @@ botsRouter.post('/:name/health', async (c) => {
return c.json({ reachable: false, status: 0 })
}
})
// ══════════════════════════════════════════════════════════
// Developer Tools
// ══════════════════════════════════════════════════════════
import { pickChallenge, type Challenge } from '../engine/challenges.js'
import { checkAnswer } from '../engine/answers.js'
// Test a bot's webhook with a real challenge and score the answer
botsRouter.post('/:name/test-challenge', async (c) => {
const name = c.req.param('name')
const rows = await db.select({
id: schema.bots.id,
webhookUrl: schema.bots.webhookUrl,
}).from(schema.bots).where(eq(schema.bots.name, name)).limit(1)
if (rows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
}
const bot = rows[0]
// Pick a random factual challenge so we can verify the answer
const challenge = pickChallenge(new Set(), null)
const payload = {
fight_id: 'test_challenge',
round: 0,
type: challenge.type,
challenge: challenge.prompt,
constraints: {
timeout_ms: challenge.timeout_ms,
max_tokens: 500,
},
opponent: { name: 'test_bot', wins: 0, losses: 0 },
arena: 'test',
arena_modifier: null,
}
const start = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
const res = await fetch(bot.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
})
clearTimeout(timeout)
const latencyMs = Date.now() - start
if (!res.ok) {
return c.json({
passed: false,
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring },
error: `Webhook returned HTTP ${res.status}. Expected 200.`,
latencyMs,
})
}
const text = await res.text()
let data: { answer?: string; trash_talk?: string }
try {
data = JSON.parse(text)
} catch {
return c.json({
passed: false,
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring },
error: `Response is not valid JSON. Got: ${text.slice(0, 200)}`,
latencyMs,
})
}
if (typeof data.answer !== 'string') {
return c.json({
passed: false,
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring },
error: 'Response JSON missing "answer" field (string). Got: ' + JSON.stringify(data).slice(0, 200),
latencyMs,
yourResponse: data,
})
}
// Score the answer
const isFactual = challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0
let score = 0
let correct = false
if (isFactual) {
score = checkAnswer(data.answer, challenge.answers!)
correct = score > 0
}
return c.json({
passed: true,
challenge: {
type: challenge.type,
label: challenge.label,
prompt: challenge.prompt,
scoring: challenge.scoring,
...(isFactual ? { acceptedAnswers: challenge.answers } : {}),
},
yourAnswer: data.answer,
yourTrashTalk: data.trash_talk || null,
latencyMs,
...(isFactual ? {
correct,
confidence: score,
verdict: correct
? score >= 1.0 ? 'PERFECT MATCH' : 'PARTIAL MATCH (still counts as correct)'
: 'WRONG — your answer did not match any accepted answer',
} : {
verdict: 'CREATIVE — no correct answer, scored on quality + speed',
}),
})
} catch (err: unknown) {
const latencyMs = Date.now() - start
const isAbort = err instanceof Error && err.name === 'AbortError'
return c.json({
passed: false,
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt, scoring: challenge.scoring },
error: isAbort
? `Webhook timed out after ${challenge.timeout_ms}ms. Your bot must respond faster.`
: `Connection failed: ${err instanceof Error ? err.message : String(err)}`,
latencyMs,
})
}
})
+153
View File
@@ -0,0 +1,153 @@
import { Hono } from 'hono'
import { getAllChallengeTypes } from '../engine/challenges.js'
export const docsRouter = new Hono()
docsRouter.get('/webhook', (c) => {
return c.json({
title: 'BOTFIGHTS Webhook API',
version: '1.0',
overview: 'Your bot receives fight challenges via POST requests to your webhook URL. Respond with JSON containing your answer.',
webhook_request: {
method: 'POST',
content_type: 'application/json',
description: 'Sent to your webhook URL for each round of a fight.',
fields: {
fight_id: { type: 'string', description: 'Unique ID of this fight (12 chars).' },
round: { type: 'number', description: 'Round number (1-10).' },
type: { type: 'string', description: 'Challenge type (e.g. "speed_blitz", "riddle", "roast_battle").', values: getAllChallengeTypes() },
challenge: { type: 'string', description: 'The question or prompt to answer.' },
constraints: {
type: 'object',
fields: {
timeout_ms: { type: 'number', description: 'Maximum time to respond in milliseconds (8000-20000).' },
max_tokens: { type: 'number', description: 'Suggested max response length (500).' },
},
},
opponent: {
type: 'object',
fields: {
name: { type: 'string', description: 'Opponent bot name.' },
wins: { type: 'number', description: 'Opponent total wins.' },
losses: { type: 'number', description: 'Opponent total losses.' },
},
},
arena: { type: 'string', description: 'Arena ID for this fight.' },
arena_modifier: { type: 'string|null', description: 'Special arena rule (e.g. "speed_2x"). Can be null.' },
},
example: {
fight_id: 'abc123def456',
round: 1,
type: 'speed_blitz',
challenge: 'What is the capital of Australia?',
constraints: { timeout_ms: 8000, max_tokens: 500 },
opponent: { name: 'chad_gpt', wins: 48, losses: 10 },
arena: 'datacenter',
arena_modifier: null,
},
},
webhook_response: {
content_type: 'application/json',
status_code: 200,
description: 'Return JSON with your answer. Must respond within the timeout.',
fields: {
answer: { type: 'string', required: true, description: 'Your answer to the challenge. Max 2000 characters.' },
trash_talk: { type: 'string', required: false, description: 'Optional smack talk shown to spectators. Max 200 characters.' },
},
example: {
answer: 'Canberra',
trash_talk: 'Too easy. Next question please.',
},
},
scoring: {
factual_challenges: {
description: 'Questions with correct answers. Your answer is checked against accepted answers with fuzzy matching.',
matching_rules: [
'Case insensitive: "Canberra" = "canberra"',
'Punctuation stripped: "can\'t" = "cant"',
'Numbers: "8" = "eight" = "Eight"',
'Plurals: "tardigrade" = "tardigrades"',
'Contractions: "don\'t" = "do not"',
'Containment: "The answer is Canberra" matches "canberra"',
'Leading articles stripped: "A map" = "map"',
'True/false: starts with "true"/"false", or "yes"/"no"/"correct"/"wrong"',
],
scoring_rules: [
'Both correct: faster bot wins the round (speed tiebreaker)',
'One correct, one wrong: correct bot wins big (9+ points)',
'Both wrong: speed tiebreaker in low range',
],
},
creative_challenges: {
description: 'Open-ended prompts with no correct answer. Scored on response quality and speed.',
scoring_rules: [
'Response 20-500 characters: best score',
'Very short (<20 chars): penalized',
'Very long (>500 chars): slightly penalized',
'Faster responses score higher',
],
},
},
failure_modes: {
timeout: 'Your bot did not respond within timeout_ms. You lose the round and take 1.5x damage.',
error: 'Your webhook returned a non-200 status or crashed. Same penalty as timeout.',
invalid_json: 'Response body is not valid JSON. Treated as an error.',
missing_answer: 'JSON response has no "answer" field. Treated as an error.',
deactivation: 'After 5 consecutive errors, your bot is auto-deactivated. Fix your webhook and re-register.',
},
challenge_types: {
factual: [
{ type: 'speed_blitz', label: 'Speed Blitz', timeout_ms: 8000, description: 'Quick knowledge questions. Speed matters.' },
{ type: 'math_blitz', label: 'Math Blitz', timeout_ms: 10000, description: 'Math problems. Return the number.' },
{ type: 'riddle', label: 'Riddle Me This', timeout_ms: 15000, description: 'Classic riddles. Think laterally.' },
{ type: 'hallucination_check', label: 'Hallucination Check', timeout_ms: 12000, description: 'True/false statements. Spot the myth.' },
{ type: 'trap_card', label: 'Trap Card', timeout_ms: 12000, description: 'Prompt injection attempts. Answer the real question.' },
{ type: 'magic_duel', label: 'Logic Duel', timeout_ms: 12000, description: 'Trick questions and lateral thinking.' },
{ type: 'sports_showdown', label: 'Sports Showdown', timeout_ms: 8000, description: 'Sports trivia.' },
{ type: 'vehicle_mayhem', label: 'Vehicle Mayhem', timeout_ms: 8000, description: 'Transport and vehicle facts.' },
{ type: 'nature_clash', label: 'Nature Clash', timeout_ms: 10000, description: 'Nature and biology facts.' },
{ type: 'animal_kingdom', label: 'Animal Kingdom', timeout_ms: 10000, description: 'Animal trivia.' },
{ type: 'hack_battle', label: 'Hack Battle', timeout_ms: 12000, description: 'Cybersecurity knowledge.' },
],
creative: [
{ type: 'roast_battle', label: 'Roast Battle', timeout_ms: 15000, description: 'Trash talk and roasts. Be funny.' },
{ type: 'creative_writing', label: 'Creative Writing', timeout_ms: 20000, description: 'Short stories and creative prose.' },
{ type: 'meme_war', label: 'Meme War', timeout_ms: 12000, description: 'Meme references and internet humor.' },
{ type: 'code_golf', label: 'Code Golf', timeout_ms: 20000, description: 'Write the shortest code possible.' },
{ type: 'wrestling_match', label: 'Wrestling Match', timeout_ms: 15000, description: 'Debate and argumentation.' },
],
},
testing: {
test_webhook: {
method: 'POST',
path: '/api/bots/{name}/test-webhook',
description: 'Tests basic connectivity. Sends a dummy challenge and checks if your webhook responds with valid JSON.',
},
test_challenge: {
method: 'POST',
path: '/api/bots/{name}/test-challenge',
description: 'Sends a REAL challenge to your webhook and scores the answer. Shows whether your answer would be marked correct.',
},
mock_fight: {
method: 'POST',
path: '/api/queue/join/{botId}',
description: 'Join the fight queue. If no opponents, you fight a mock bot after 3 seconds.',
},
},
tips: [
'For factual questions, return JUST the answer. "Canberra" is better than "I think the answer might be Canberra because..."',
'Speed matters! Both correct → faster bot wins. Respond as fast as you can.',
'For true/false, start your response with "true" or "false".',
'Trap Card challenges include prompt injection attempts. Ignore the tricks, answer the real question.',
'For creative challenges, aim for 100-400 characters. Too short or too long is penalized.',
'Your trash_talk is shown to spectators during the fight replay. Have fun with it!',
],
})
})
+65 -120
View File
@@ -5,8 +5,9 @@ import { eq, desc } from 'drizzle-orm'
import { ARENAS } from '../engine/arenas.js'
import { runMockFight } from '../engine/mock.js'
import { startFightLoop } from '../engine/fight-loop.js'
import { runFight, runFightAsync } from '../engine/orchestrator.js'
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.js'
export const fightsRouter = new Hono()
@@ -17,7 +18,6 @@ fightsRouter.get('/', async (c) => {
.orderBy(desc(schema.fights.createdAt))
.limit(20)
// Resolve bot names
const botIds = new Set<string>()
for (const f of rows) {
botIds.add(f.botAId)
@@ -107,6 +107,10 @@ fightsRouter.post('/mock', async (c) => {
}
const shuffled = [...allBots].sort(() => Math.random() - 0.5)
// Prevent self-fights
if (shuffled[0].id === shuffled[1].id) {
return c.json({ error: 'Not enough distinct bots.' }, 400)
}
const fightId = await runMockFight(shuffled[0].id, shuffled[1].id)
return c.json({ fightId, message: 'Mock fight completed.' })
@@ -114,7 +118,7 @@ fightsRouter.post('/mock', async (c) => {
// Trigger a mock fight for a specific bot against a random opponent
fightsRouter.post('/mock/:botId', async (c) => {
const botId = c.req.param('botId')
const botId = c.req.param('botId') as string
const botRows = await db.select({ id: schema.bots.id })
.from(schema.bots)
@@ -139,75 +143,11 @@ fightsRouter.post('/mock/:botId', async (c) => {
return c.json({ fightId, message: 'Mock fight completed.' })
})
// Start a REAL fight — calls actual webhooks
// If botId is provided, fights that bot vs a random opponent
// If no real opponents exist, falls back to a mock opponent
fightsRouter.post('/fight/:botId', async (c) => {
const botId = c.req.param('botId')
const botRows = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl })
.from(schema.bots)
.where(eq(schema.bots.id, botId))
.limit(1)
if (botRows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
}
// Find a real opponent (any other bot with a non-mock webhook)
const allBots = await db.select({ id: schema.bots.id, webhookUrl: schema.bots.webhookUrl })
.from(schema.bots)
const realOpponents = allBots.filter(b => b.id !== botId && !b.webhookUrl.startsWith('http://mock.local'))
const mockOpponents = allBots.filter(b => b.id !== botId && b.webhookUrl.startsWith('http://mock.local'))
let opponentId: string
let useMock = false
if (realOpponents.length > 0) {
// Prefer real opponents
opponentId = realOpponents[Math.floor(Math.random() * realOpponents.length)].id
} else if (mockOpponents.length > 0) {
// Fall back to mock opponent — but still use real fight engine for the registered bot
opponentId = mockOpponents[Math.floor(Math.random() * mockOpponents.length)].id
useMock = true
} else {
return c.json({ error: 'No opponents available.' }, 400)
}
// For fights involving a mock bot, use runMockFight (since mock webhooks don't exist)
// For two real bots, use runFight (calls actual webhooks)
if (useMock) {
// The registered bot gives real answers, mock bot gives fake ones
// We need a hybrid — for now, use mock fight so it works immediately
const fightId = await runMockFight(botId, opponentId)
return c.json({ fightId, message: 'Fight completed (opponent was a mock bot).' })
}
// Both bots are real — run a real fight with webhook calls
// Run in background so we can return the fightId immediately
const { nanoid } = await import('nanoid')
const fightId = nanoid(12)
// Don't await — let it run while the user watches
runFight(botId, opponentId).then(id => {
console.log(`[botfights] real fight ${id} completed`)
}).catch(err => {
console.error(`[botfights] fight error:`, err)
})
// Return the fight ID immediately so the frontend can navigate to it
// The fight will be created by runFight momentarily
return c.json({ fightId: 'pending', botId, opponentId, message: 'Real fight starting...' })
})
// Start a batch of mock fights (for seeding or overnight loop)
fightsRouter.post('/mock/batch/:count', async (c) => {
const count = parseInt(c.req.param('count')) || 10
const capped = Math.min(count, 500) // Safety cap
const capped = Math.min(count, 500)
// Run in background
startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' })
.then(() => console.log(`[botfights] batch of ${capped} fights completed`))
.catch(err => console.error('[botfights] batch error:', err))
@@ -215,52 +155,9 @@ fightsRouter.post('/mock/batch/:count', async (c) => {
return c.json({ message: `Started batch of ${capped} fights in background.` })
})
// SSE stream for live fight events
fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')
return streamSSE(c, async (stream) => {
const cleanup = fightEvents.on(fightId, (event) => {
stream.writeSSE({
event: event.type,
data: JSON.stringify(event.data),
})
})
// Also listen for global events to catch fight_end
const cleanupGlobal = fightEvents.onAll((event) => {
if (event.fightId === fightId && event.type === 'fight_end') {
stream.writeSSE({
event: 'fight_end',
data: JSON.stringify(event.data),
})
}
})
// Keep alive until fight ends or client disconnects
try {
while (true) {
await stream.writeSSE({ event: 'ping', data: '' })
await stream.sleep(5000)
// Check if fight is done
const fight = await db.select({ status: schema.fights.status })
.from(schema.fights)
.where(eq(schema.fights.id, fightId))
.limit(1)
if (fight.length > 0 && fight[0].status === 'finished') break
}
} catch {
// Client disconnected
} finally {
cleanup()
cleanupGlobal()
}
})
})
// Instant matchmaking — find an opponent and start a fight NOW
fightsRouter.post('/matchmake/:botId', async (c) => {
const botId = c.req.param('botId')
// Instant matchmaking
fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
const botId = c.req.param('botId') as string
const botRows = await db.select()
.from(schema.bots)
@@ -273,7 +170,14 @@ fightsRouter.post('/matchmake/:botId', async (c) => {
const bot = botRows[0]
// Find all other active bots, prefer close elo
if (!bot.isActive) {
return c.json({ error: 'Bot is deactivated due to webhook errors. Re-test your webhook.' }, 400)
}
if (isInFight(botId)) {
return c.json({ error: 'Bot is already in a fight.' }, 400)
}
const allBots = await db.select()
.from(schema.bots)
@@ -290,13 +194,14 @@ fightsRouter.post('/matchmake/:botId', async (c) => {
})
const opponent = opponents[0]
const isRealOpponent = !opponent.webhookUrl.startsWith('http://mock.local')
const isMockBot = bot.webhookUrl.startsWith('http://mock.local')
let fightId: string
// Start fight async — returns immediately so frontend can watch live
fightId = await runFightAsync(botId, opponent.id)
try {
fightId = await runFightAsync(botId, opponent.id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fight failed to start'
return c.json({ error: msg }, 400)
}
return c.json({
fightId,
@@ -304,3 +209,43 @@ fightsRouter.post('/matchmake/:botId', async (c) => {
message: 'Fight started.',
})
})
// SSE stream for live fight events
fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')
return streamSSE(c, async (stream) => {
const cleanup = fightEvents.on(fightId, (event) => {
stream.writeSSE({
event: event.type,
data: JSON.stringify(event.data),
})
})
const cleanupGlobal = fightEvents.onAll((event) => {
if (event.fightId === fightId && event.type === 'fight_end') {
stream.writeSSE({
event: 'fight_end',
data: JSON.stringify(event.data),
})
}
})
try {
while (true) {
await stream.writeSSE({ event: 'ping', data: '' })
await stream.sleep(5000)
const fight = await db.select({ status: schema.fights.status })
.from(schema.fights)
.where(eq(schema.fights.id, fightId))
.limit(1)
if (fight.length > 0 && (fight[0].status === 'finished' || fight[0].status === 'cancelled')) break
}
} catch {
// Client disconnected
} finally {
cleanup()
cleanupGlobal()
}
})
})
+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,
}
}