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