feat: v4 — TUI fight loop, rate limiting, webhook tooling, expanded choreographies

- Fight loop CLI with TUI renderer (ink-style terminal UI)
- Rate limiting middleware for API routes
- Queue cooldowns wired into orchestrator after fights
- Webhook test utility for bot debugging
- API docs route
- Expanded FightScene choreographies and weapon props
- Fix Drizzle transaction execution in orchestrator
- Schema additions, scoring/challenge/mock expansions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 00:14:46 +00:00
co-authored by Claude Opus 4.6
parent 2c0323d5fb
commit 4d8b18a58a
24 changed files with 2973 additions and 721 deletions
+82 -12
View File
@@ -1,7 +1,7 @@
/**
* Answer checking for factual challenges.
* Handles: case insensitivity, numeric equivalence, containment matching,
* number words (forty = 40), stripped punctuation/articles.
* number words (forty = 40), basic stemming, contraction normalization.
*/
const NUMBER_WORDS: Record<string, number> = {
@@ -23,6 +23,47 @@ function normalize(s: string): string {
.trim()
}
/** Expand contractions so "can't" matches "cannot", "don't" matches "do not" etc. */
function expandContractions(s: string): string {
return s
.replace(/\bcan'?t\b/gi, 'cannot')
.replace(/\bdon'?t\b/gi, 'do not')
.replace(/\bwon'?t\b/gi, 'will not')
.replace(/\bdoesn'?t\b/gi, 'does not')
.replace(/\bisn'?t\b/gi, 'is not')
.replace(/\baren'?t\b/gi, 'are not')
.replace(/\bwasn'?t\b/gi, 'was not')
.replace(/\bweren'?t\b/gi, 'were not')
.replace(/\bhasn'?t\b/gi, 'has not')
.replace(/\bhaven'?t\b/gi, 'have not')
.replace(/\bhadn'?t\b/gi, 'had not')
.replace(/\bcouldn'?t\b/gi, 'could not')
.replace(/\bshouldn'?t\b/gi, 'should not')
.replace(/\bwouldn'?t\b/gi, 'would not')
.replace(/\bit'?s\b/gi, 'it is')
.replace(/\bthat'?s\b/gi, 'that is')
.replace(/\bthey'?re\b/gi, 'they are')
.replace(/\bwe'?re\b/gi, 'we are')
.replace(/\byou'?re\b/gi, 'you are')
}
/** Basic English stemming: strip common suffixes for loose comparison. */
function stem(word: string): string {
if (word.length <= 3) return word
// Plural: buses → bus, foxes → fox, tries → tri (imperfect but ok)
if (word.endsWith('ies') && word.length > 4) return word.slice(0, -3) + 'y'
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes') || word.endsWith('ches') || word.endsWith('shes')) {
return word.slice(0, -2)
}
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
/** Stem all words in a string. */
function stemAll(s: string): string {
return s.split(/\s+/).map(stem).join(' ')
}
function tryParseNumber(s: string): number | null {
const cleaned = normalize(s)
@@ -55,7 +96,7 @@ function tryParseNumber(s: string): number | null {
/**
* Check if a bot's response matches any of the accepted answers.
* Returns a confidence score: 1.0 = definite match, 0.5 = partial, 0 = no match.
* Returns a confidence score: 1.0 = definite match, 0.5-0.9 = partial, 0 = no match.
*/
export function checkAnswer(response: string | null, acceptedAnswers: string[]): number {
if (!response || response.trim() === '') return 0
@@ -63,48 +104,77 @@ export function checkAnswer(response: string | null, acceptedAnswers: string[]):
const normResponse = normalize(response)
if (normResponse === '') return 0
// Pre-compute expanded/stemmed versions once
const expandedResponse = normalize(expandContractions(response))
const stemmedResponse = stemAll(normResponse)
for (const accepted of acceptedAnswers) {
const normAccepted = normalize(accepted)
const expandedAccepted = normalize(expandContractions(accepted))
const stemmedAccepted = stemAll(normAccepted)
// 1. Exact match after normalization
if (normResponse === normAccepted) return 1.0
// 2. Numeric equivalence
// 2. Exact match after contraction expansion
if (expandedResponse === expandedAccepted) return 1.0
// 3. Exact match after stemming (catches plural/singular)
if (stemmedResponse === stemmedAccepted) return 1.0
// 4. Numeric equivalence
const respNum = tryParseNumber(normResponse)
const accNum = tryParseNumber(normAccepted)
if (respNum !== null && accNum !== null && respNum === accNum) return 1.0
// 3. Response contains the accepted answer
// 5. Response contains the accepted answer (or stemmed version)
if (normResponse.includes(normAccepted) && normAccepted.length >= 2) return 1.0
if (stemmedResponse.includes(stemmedAccepted) && stemmedAccepted.length >= 2) return 0.95
// 4. Accepted answer contains the response (for short definitive answers)
// 6. Accepted answer contains the response (for short definitive answers)
if (normAccepted.includes(normResponse) && normResponse.length >= 3) return 0.8
if (stemmedAccepted.includes(stemmedResponse) && stemmedResponse.length >= 3) return 0.75
// 5. Check if number appears anywhere in a longer response
// 7. Check if number appears anywhere in a longer response
if (accNum !== null) {
// Look for the number in the response text
const numStr = String(accNum)
if (normResponse.includes(numStr)) return 1.0
// Check number words in response
const responseNum = tryParseNumber(normResponse.split(/\s+/).find(w => tryParseNumber(w) !== null) || '')
if (responseNum !== null && responseNum === accNum) return 0.9
const responseWords = normResponse.split(/\s+/)
for (const w of responseWords) {
const parsed = tryParseNumber(w)
if (parsed !== null && parsed === accNum) return 0.9
}
}
// 6. Word-level containment — all words of the answer appear in the response
// 8. Word-level containment — all words of the answer appear in the response
const acceptedWords = normAccepted.split(/\s+/)
if (acceptedWords.length >= 2) {
const allFound = acceptedWords.every(w => normResponse.includes(w))
if (allFound) return 0.9
// Try with stemming
const stemmedAccWords = stemmedAccepted.split(/\s+/)
const allStemFound = stemmedAccWords.every(w => stemmedResponse.includes(w))
if (allStemFound) return 0.85
}
// 9. Contraction-expanded word containment
if (expandedAccepted.split(/\s+/).length >= 2) {
const allFound = expandedAccepted.split(/\s+/).every(w => expandedResponse.includes(w))
if (allFound) return 0.85
}
}
// 7. For true/false questions, check if the response starts with the right keyword
// 10. For true/false questions, check if the response starts with the right keyword
const tfAnswer = acceptedAnswers.find(a => a.toLowerCase() === 'true' || a.toLowerCase() === 'false')
if (tfAnswer) {
const firstWord = normResponse.split(/\s+/)[0]
if (firstWord === tfAnswer.toLowerCase()) return 1.0
// "that's true" / "this is false" etc.
// "that's true" / "this is false" / "yes" for true / "no" for false
if (normResponse.includes(tfAnswer.toLowerCase())) return 0.9
// "yes" ≈ true, "no" ≈ false
if (tfAnswer.toLowerCase() === 'true' && (firstWord === 'yes' || firstWord === 'correct' || firstWord === 'right')) return 0.9
if (tfAnswer.toLowerCase() === 'false' && (firstWord === 'no' || firstWord === 'incorrect' || firstWord === 'wrong')) return 0.9
}
return 0
+9 -9
View File
@@ -112,7 +112,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 22,
prompts: [
{ prompt: 'I have cities but no houses, forests but no trees, and water but no fish. What am I?', answers: ['map', 'a map'] },
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps'] },
{ prompt: 'The more you take, the more you leave behind. What am I?', answers: ['footsteps', 'steps', 'footstep'] },
{ prompt: 'I speak without a mouth and hear without ears. I have no body, but I come alive with the wind. What am I?', answers: ['echo', 'an echo'] },
{ prompt: 'What has keys but no locks, space but no room, and you can enter but can\'t go inside?', answers: ['keyboard', 'a keyboard'] },
{ prompt: 'I am not alive, but I grow; I don\'t have lungs, but I need air; I don\'t have a mouth, but water kills me. What am I?', answers: ['fire', 'flame'] },
@@ -207,9 +207,9 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'I am an odd number. Take away a letter and I become even. What number am I?', answers: ['seven', '7'] },
{ prompt: 'A farmer has 17 sheep. All but 9 run away. How many does the farmer have left?', answers: ['9', 'nine'] },
{ prompt: 'How many times can you subtract 5 from 25?', answers: ['1', 'one', 'once'] },
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs'] },
{ prompt: 'A rooster lays an egg on top of a barn roof. Which way does it roll?', answers: ['roosters don\'t lay eggs', 'it doesn\'t', 'nowhere', 'they don\'t', 'roosters can\'t lay eggs', 'rooster doesn\'t lay eggs', 'a rooster can\'t lay eggs', 'roosters do not lay eggs'] },
{ prompt: 'If it takes 5 machines 5 minutes to make 5 widgets, how long for 100 machines to make 100 widgets?', answers: ['5', 'five', '5 minutes'] },
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same'] },
{ prompt: 'What weighs more: a pound of feathers or a pound of bricks?', answers: ['same', 'they weigh the same', 'neither', 'equal', 'the same', 'they are the same', 'both weigh the same', 'equally'] },
{ prompt: 'If you overtake the person in second place, what place are you in?', answers: ['second', '2nd', '2'] },
{ prompt: 'How many months have 28 days?', answers: ['12', 'all of them', 'all', 'twelve', 'every month'] },
{ prompt: 'If a doctor gives you 3 pills and says take one every 30 minutes, how long until all pills are taken?', answers: ['60', '60 minutes', '1 hour', 'one hour'] },
@@ -261,7 +261,7 @@ const TEMPLATES: ChallengeTemplate[] = [
timeout_ms: 8000,
baseDamage: 16,
prompts: [
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t'] },
{ prompt: 'What was the first mass-produced automobile?', answers: ['model t', 'ford model t', 'the model t', 'the ford model t'] },
{ prompt: 'How many wheels does a standard 18-wheeler actually have?', answers: ['18', 'eighteen'] },
{ prompt: 'What car brand uses a prancing horse as its logo?', answers: ['ferrari'] },
{ prompt: 'How many cylinders does a V8 engine have?', answers: ['8', 'eight'] },
@@ -291,7 +291,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 20,
prompts: [
{ prompt: 'True or false: A group of flamingos is called a "flamboyance."', answers: ['true'] },
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats'] },
{ prompt: 'What is the only mammal capable of true powered flight?', answers: ['bat', 'bats', 'a bat'] },
{ prompt: 'True or false: Octopuses have three hearts.', answers: ['true'] },
{ prompt: 'Is a tomato a fruit or a vegetable? (Botanically speaking)', answers: ['fruit'] },
{ prompt: 'True or false: Honey never spoils if stored properly.', answers: ['true'] },
@@ -301,7 +301,7 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'What causes thunder?', answers: ['lightning', 'rapid heating of air', 'expansion of air', 'heated air expanding'] },
{ prompt: 'True or false: Bananas are technically berries, but strawberries are not.', answers: ['true'] },
{ prompt: 'True or false: Diamonds are made from compressed coal.', answers: ['false'] },
{ prompt: 'What is the fastest land animal?', answers: ['cheetah'] },
{ prompt: 'What is the fastest land animal?', answers: ['cheetah', 'the cheetah', 'a cheetah'] },
{ prompt: 'What color is a polar bear\'s skin under its white fur?', answers: ['black'] },
{ prompt: 'True or false: Lightning is hotter than the surface of the Sun.', answers: ['true'] },
{ prompt: 'Name the only continent with no active volcanoes.', answers: ['australia'] },
@@ -319,7 +319,7 @@ const TEMPLATES: ChallengeTemplate[] = [
timeout_ms: 10000,
baseDamage: 18,
prompts: [
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'water bear', 'tardigrades'] },
{ prompt: 'What animal can survive in the vacuum of space?', answers: ['tardigrade', 'tardigrades', 'water bear', 'water bears'] },
{ prompt: 'What is the fastest animal on Earth?', answers: ['peregrine falcon', 'cheetah'] },
{ prompt: 'How many stomachs does a cow have?', answers: ['4', 'four'] },
{ prompt: 'Name the only bird that can fly backwards.', answers: ['hummingbird'] },
@@ -330,7 +330,7 @@ const TEMPLATES: ChallengeTemplate[] = [
{ prompt: 'True or false: Cows have best friends and get stressed when separated.', answers: ['true'] },
{ prompt: 'True or false: An octopus has blue blood.', answers: ['true'] },
{ prompt: 'How many hearts does an octopus have?', answers: ['3', 'three'] },
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant'] },
{ prompt: 'What is the largest living land animal?', answers: ['african elephant', 'elephant', 'elephants'] },
{ prompt: 'True or false: A snail can sleep for 3 years.', answers: ['true'] },
{ prompt: 'What animal has the strongest bite force?', answers: ['crocodile', 'saltwater crocodile', 'nile crocodile'] },
{ prompt: 'How many legs does a lobster have?', answers: ['10', 'ten'] },
@@ -349,7 +349,7 @@ const TEMPLATES: ChallengeTemplate[] = [
baseDamage: 24,
prompts: [
{ prompt: 'What does SQL in "SQL injection" stand for?', answers: ['structured query language'] },
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm'] },
{ prompt: 'What does HTTPS protect against that HTTP doesn\'t? (one word)', answers: ['eavesdropping', 'interception', 'sniffing', 'man-in-the-middle', 'mitm', 'encryption'] },
{ prompt: 'What does the S in HTTPS stand for?', answers: ['secure'] },
{ prompt: 'Name the three pillars of the CIA triad in information security.', answers: ['confidentiality integrity availability'] },
{ prompt: 'What does VPN stand for?', answers: ['virtual private network'] },
+71 -11
View File
@@ -2,10 +2,28 @@ import { db, schema } from '../db/index.js'
import { runMockFight } from './mock.js'
import { eq } from 'drizzle-orm'
interface FightLoopOptions {
export interface FightResult {
fightId: string
botAName: string
botBName: string
botAElo: number
botBElo: number
winnerName: string | null
winnerId: string | null
totalRounds: number
isKo: boolean
isPerfect: boolean
botAHp: number
botBHp: number
}
export interface FightLoopOptions {
intervalMs?: number
maxFights?: number
matchmakingStyle?: 'random' | 'elo_close' | 'mixed'
onFightStart?: (botAName: string, botAElo: number, botBName: string, botBElo: number) => void
onFightComplete?: (result: FightResult) => void
onError?: (err: Error) => void
}
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
@@ -13,6 +31,9 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
intervalMs = 8000,
maxFights = Infinity,
matchmakingStyle = 'mixed',
onFightStart,
onFightComplete,
onError,
} = options
const allBots = await db.select({
@@ -42,35 +63,71 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
if (onFightStart) {
onFightStart(botA.name, botA.eloRating, botB.name, botB.eloRating)
}
const fightId = await runMockFight(botA.id, botB.id)
// Fetch result
const fight = await db.select({
winnerId: schema.fights.winnerId,
totalRounds: schema.fights.totalRounds,
botAHp: schema.fights.botAHp,
botBHp: schema.fights.botBHp,
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
const result = fight[0]
const winnerName = result?.winnerId
? bots.find(b => b.id === result.winnerId)?.name || '???'
: 'DRAW'
: null
const isKo = result ? (result.botAHp <= 0 || result.botBHp <= 0) : false
const isPerfect = result?.winnerId ? (
(result.winnerId === botA.id && result.botAHp === 200) ||
(result.winnerId === botB.id && result.botBHp === 200)
) : false
fightCount++
console.log(
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName} (${result?.totalRounds || '?'} rounds)`
)
if (onFightComplete) {
onFightComplete({
fightId,
botAName: botA.name,
botBName: botB.name,
botAElo: botA.eloRating,
botBElo: botB.eloRating,
winnerName,
winnerId: result?.winnerId || null,
totalRounds: result?.totalRounds || 0,
isKo,
isPerfect,
botAHp: result?.botAHp || 0,
botBHp: result?.botBHp || 0,
})
} else {
console.log(
`[fight-loop] #${fightCount}: ${botA.name} vs ${botB.name} => ${winnerName || 'DRAW'} (${result?.totalRounds || '?'} rounds)`
)
}
// Wait before next fight
if (fightCount < maxFights) {
await sleep(intervalMs + Math.floor(Math.random() * intervalMs * 0.5))
}
} catch (err) {
console.error('[fight-loop] error:', err)
await sleep(5000) // Back off on error
if (onError) {
onError(err instanceof Error ? err : new Error(String(err)))
} else {
console.error('[fight-loop] error:', err)
}
await sleep(5000)
}
}
console.log(`[fight-loop] completed ${fightCount} fights`)
if (!onFightComplete) {
console.log(`[fight-loop] completed ${fightCount} fights`)
}
}
function pickMatchup(
@@ -81,7 +138,6 @@ function pickMatchup(
const sorted = [...bots].sort((a, b) => b.eloRating - a.eloRating)
if (style === 'elo_close' || (style === 'mixed' && fightNum % 3 !== 0)) {
// Pick a random bot, then find a close-elo opponent
const idx = Math.floor(Math.random() * bots.length)
const bot = bots[idx]
const others = bots.filter(b => b.id !== bot.id)
@@ -94,15 +150,19 @@ function pickMatchup(
}
if (style === 'mixed' && fightNum % 3 === 0) {
// Mismatch: top third vs bottom third for dramatic fights
const topThird = Math.ceil(sorted.length / 3)
const topIdx = Math.floor(Math.random() * topThird)
const bottomIdx = sorted.length - 1 - Math.floor(Math.random() * topThird)
return [sorted[topIdx], sorted[bottomIdx]]
if (sorted[topIdx].id !== sorted[bottomIdx].id) {
return [sorted[topIdx], sorted[bottomIdx]]
}
}
// Random
const shuffled = [...bots].sort(() => Math.random() - 0.5)
if (shuffled[0].id === shuffled[1].id && shuffled.length > 2) {
return [shuffled[0], shuffled[2]]
}
return [shuffled[0], shuffled[1]]
}
+31 -23
View File
@@ -1,6 +1,6 @@
import { nanoid } from 'nanoid'
import { createHash, randomBytes } from 'crypto'
import { db, schema } from '../db/index.js'
import { db, schema, sqlite } from '../db/index.js'
import { randomArena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
@@ -265,6 +265,7 @@ export function mockResponse(
return { answer, trashTalk, timeMs, timedOut, error }
}
export async function seedMockBots(): Promise<void> {
for (const bot of MOCK_BOTS) {
const existing = await db.select({ id: schema.bots.id })
@@ -292,6 +293,8 @@ export async function seedMockBots(): Promise<void> {
}
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
const [botARows, botBRows] = await Promise.all([
db.select().from(schema.bots).where(eq(schema.bots.id, botAId)).limit(1),
db.select().from(schema.bots).where(eq(schema.bots.id, botBId)).limit(1),
@@ -329,7 +332,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
const eloForMock = (name: string) =>
MOCK_BOTS.find(b => b.name === name)?.elo || 1200
const totalRounds = 7 + Math.floor(Math.random() * 4) // 7-10 rounds
const totalRounds = 7 + Math.floor(Math.random() * 4)
const maxRounds = Math.min(totalRounds, 10)
for (let round = 1; round <= maxRounds; round++) {
@@ -389,37 +392,42 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
winnerId = hpA > hpB ? botA.id : hpB > hpA ? botB.id : null
}
await db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Finalize atomically
const finalize = sqlite.transaction(() => {
db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Update stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
await Promise.all([
db.update(schema.bots).set({
wins: sql`${schema.bots.wins} + 1`,
eloRating: newWinnerElo,
winStreak: newWinStreak,
bestStreak: sql`MAX(${schema.bots.bestStreak}, ${newWinStreak})`,
tier: calculateTier(newWinnerElo, winner.wins + 1),
}).where(eq(schema.bots.id, winnerId)),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
eloRating: newLoserElo,
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
}).where(eq(schema.bots.id, loserId)),
])
}
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
}
})
finalize()
return fightId
}
@@ -442,26 +450,26 @@ export async function seedMockFights(count: number = 12): Promise<void> {
return
}
// Sort by elo for mismatch selection
const sorted = [...allBots].sort((a, b) => b.eloRating - a.eloRating)
for (let i = 0; i < count; i++) {
let botAId: string, botBId: string
if (i % 3 === 0 && sorted.length >= 4) {
// Every 3rd fight: mismatch (top vs bottom)
const topIdx = Math.floor(Math.random() * Math.ceil(sorted.length / 3))
const botIdx = sorted.length - 1 - Math.floor(Math.random() * Math.ceil(sorted.length / 3))
botAId = sorted[topIdx].id
botBId = sorted[botIdx].id
} else {
// Random matchup
const shuffled = [...allBots].sort(() => Math.random() - 0.5)
botAId = shuffled[0].id
botBId = shuffled[1].id
}
await runMockFight(botAId, botBId)
// Skip self-fights
if (botAId !== botBId) {
await runMockFight(botAId, botBId)
}
}
console.log(`[botfights] seeded ${count} mock fights`)
+203 -37
View File
@@ -1,11 +1,12 @@
import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { db, schema, sqlite } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { randomArena, type Arena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
interface BotRecord {
id: string
@@ -28,6 +29,14 @@ interface WebhookResponse {
const MAX_ROUNDS = 10
const KO_THRESHOLD = 0
const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
// Track bots currently in a fight to prevent concurrent fights
const activeFighters = new Set<string>()
export function isInFight(botId: string): boolean {
return activeFighters.has(botId)
}
function emit(fightId: string, type: string, data: Record<string, unknown>) {
fightEvents.emit({
@@ -38,14 +47,68 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
})
}
// SSRF protection: block internal/private URLs
function isAllowedWebhookUrl(url: string): boolean {
try {
const parsed = new URL(url)
const hostname = parsed.hostname.toLowerCase()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
if (hostname.startsWith('10.')) return false
if (hostname.startsWith('192.168.')) return false
if (hostname.startsWith('172.')) {
const second = parseInt(hostname.split('.')[1])
if (second >= 16 && second <= 31) return false
}
if (hostname === '169.254.169.254') return false
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
return true
} catch {
return false
}
}
export { isAllowedWebhookUrl }
// Size-limited body reader to prevent OOM
async function readLimitedBody(res: Response, maxBytes: number): Promise<string> {
const reader = res.body?.getReader()
if (!reader) return ''
const chunks: Uint8Array[] = []
let totalBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
totalBytes += value.byteLength
if (totalBytes > maxBytes) {
reader.cancel()
throw new Error(`Response body exceeds ${maxBytes} bytes`)
}
chunks.push(value)
}
} catch (err) {
reader.cancel()
throw err
}
const combined = new Uint8Array(totalBytes)
let offset = 0
for (const chunk of chunks) {
combined.set(chunk, offset)
offset += chunk.byteLength
}
return new TextDecoder().decode(combined)
}
async function callWebhook(
url: string,
challenge: Challenge,
roundNumber: number,
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
): Promise<WebhookResponse> {
const body = JSON.stringify({
fight_id: fightId,
round: roundNumber,
type: challenge.type,
challenge: challenge.prompt,
@@ -61,6 +124,12 @@ async function callWebhook(
const start = Date.now()
console.log(`[webhook] POST ${url} round=${roundNumber} type=${challenge.type}`)
// SSRF check
if (!isAllowedWebhookUrl(url)) {
console.log(`[webhook] ${url} BLOCKED (private/internal URL)`)
return { answer: null, timeMs: 0, timedOut: false, error: true }
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
@@ -80,7 +149,14 @@ async function callWebhook(
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
const text = await res.text()
let text: string
try {
text = await readLimitedBody(res, MAX_RESPONSE_BYTES)
} catch {
console.log(`[webhook] ${url} response too large (>${MAX_RESPONSE_BYTES} bytes)`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
let data: { answer?: string; trash_talk?: string }
try {
data = JSON.parse(text)
@@ -88,10 +164,15 @@ async function callWebhook(
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(data.answer || '').slice(0, 80)}`)
// Enforce size limits on fields
const answer = data.answer ? data.answer.slice(0, 2000) : null
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, 200) : undefined
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
return {
answer: data.answer || null,
trashTalk: data.trash_talk,
answer,
trashTalk,
timeMs: elapsed,
timedOut: false,
error: false,
@@ -109,7 +190,7 @@ async function callWebhook(
}
}
function isMockBot(webhookUrl: string): boolean {
export function isMockBot(webhookUrl: string): boolean {
return webhookUrl.startsWith('http://mock.local')
}
@@ -117,6 +198,7 @@ async function getBotResponse(
bot: BotRecord,
challenge: Challenge,
roundNumber: number,
fightId: string,
opponent: { name: string; wins: number; losses: number },
arena: Arena,
): Promise<WebhookResponse> {
@@ -132,7 +214,7 @@ async function getBotResponse(
}
}
console.log(`[fight] ${bot.name} has real webhook: ${bot.webhookUrl}`)
return callWebhook(bot.webhookUrl, challenge, roundNumber, opponent, arena)
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
}
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
@@ -166,6 +248,26 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena)
return fightId
}
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(webhookUrl)) return
if (succeeded) {
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
} else {
await db.update(schema.bots).set({
consecutiveErrors: sql`${schema.bots.consecutiveErrors} + 1`,
lastErrorAt: new Date().toISOString(),
}).where(eq(schema.bots.id, botId))
// Auto-deactivate after 5 consecutive errors
const bot = await db.select({ consecutiveErrors: schema.bots.consecutiveErrors })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (bot[0] && bot[0].consecutiveErrors >= 5) {
await db.update(schema.bots).set({ isActive: false }).where(eq(schema.bots.id, botId))
console.log(`[fight] bot ${botId} auto-deactivated after 5 consecutive webhook errors`)
}
}
}
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena): Promise<void> {
let hpA = 200
let hpB = 200
@@ -183,10 +285,16 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
challenge: { type: challenge.type, label: challenge.label, prompt: challenge.prompt },
})
// Call both bots simultaneously (mock bots get generated responses)
// Call both bots simultaneously
const [responseA, responseB] = await Promise.all([
getBotResponse(botA, challenge, round, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
getBotResponse(botB, challenge, round, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
getBotResponse(botA, challenge, round, fightId, { name: botB.name, wins: botB.wins, losses: botB.losses }, arena),
getBotResponse(botB, challenge, round, fightId, { name: botA.name, wins: botA.wins, losses: botA.losses }, arena),
])
// Track webhook reliability for real bots
await Promise.all([
trackWebhookResult(botA.id, botA.webhookUrl, !responseA.error && !responseA.timedOut),
trackWebhookResult(botB.id, botB.webhookUrl, !responseB.error && !responseB.timedOut),
])
// Score the round
@@ -272,39 +380,52 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
(winnerId === botB.id && hpB === 200)
)
// Finalize fight
await db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
// Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
// Update bot stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const finalize = sqlite.transaction(() => {
// Mark fight finished
db.update(schema.fights).set({
status: 'finished',
winnerId,
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating)
const newWinStreak = winner.winStreak + 1
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
// Update bot stats
if (winnerId) {
const loserId = winnerId === botA.id ? botB.id : botA.id
const winner = winnerId === botA.id ? botA : botB
const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor)
const newWinStreak = winner.winStreak + 1
const newBestStreak = Math.max(winner.bestStreak, newWinStreak)
await Promise.all([
db.update(schema.bots).set({
wins: sql`${schema.bots.wins} + 1`,
eloRating: newWinnerElo,
winStreak: newWinStreak,
bestStreak: newBestStreak,
tier: calculateTier(newWinnerElo, winner.wins + 1),
}).where(eq(schema.bots.id, winnerId)),
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, winnerId))
db.update(schema.bots).set({
losses: sql`${schema.bots.losses} + 1`,
eloRating: newLoserElo,
winStreak: 0,
tier: calculateTier(newLoserElo, loser.wins),
}).where(eq(schema.bots.id, loserId)),
])
}
lastFightAt: new Date().toISOString(),
}).where(eq(schema.bots.id, loserId))
} else {
// Draw — update lastFightAt for both
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botA.id))
db.update(schema.bots).set({ lastFightAt: new Date().toISOString() }).where(eq(schema.bots.id, botB.id))
}
})
finalize()
emit(fightId, 'fight_end', {
winnerId,
@@ -317,20 +438,65 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
}
export async function runFight(botAId: string, botBId: string): Promise<string> {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
return fightId
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
activeFighters.add(botAId)
activeFighters.add(botBId)
try {
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
await executeFightRounds(fightId, botA, botB, arena)
return fightId
} finally {
activeFighters.delete(botAId)
activeFighters.delete(botBId)
setCooldown(botAId)
setCooldown(botBId)
}
}
/** Creates the fight record and returns the ID immediately. Rounds run in background. */
export async function runFightAsync(botAId: string, botBId: string): Promise<string> {
if (botAId === botBId) throw new Error('A bot cannot fight itself')
if (activeFighters.has(botAId)) throw new Error(`Bot ${botAId} is already in a fight`)
if (activeFighters.has(botBId)) throw new Error(`Bot ${botBId} is already in a fight`)
activeFighters.add(botAId)
activeFighters.add(botBId)
const [botA, botB] = await loadBots(botAId, botBId)
const arena = randomArena()
const fightId = await createFightRecord(botA, botB, arena)
executeFightRounds(fightId, botA, botB, arena).catch(err => {
console.error(`[botfights] fight ${fightId} error:`, err)
})
executeFightRounds(fightId, botA, botB, arena)
.catch(err => {
console.error(`[botfights] fight ${fightId} error:`, err)
// Mark fight as cancelled so it doesn't stay 'live' forever
db.update(schema.fights).set({
status: 'cancelled',
endedAt: new Date().toISOString(),
}).where(eq(schema.fights.id, fightId))
fightEvents.cleanup(fightId)
})
.finally(() => {
activeFighters.delete(botAId)
activeFighters.delete(botBId)
setCooldown(botAId)
setCooldown(botBId)
})
return fightId
}
/** Clean up orphaned fights on startup */
export async function cleanupOrphanedFights(): Promise<number> {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const result = await db.update(schema.fights)
.set({ status: 'cancelled', endedAt: new Date().toISOString() })
.where(sql`${schema.fights.status} = 'live' AND ${schema.fights.startedAt} < ${tenMinutesAgo}`)
return 0 // drizzle doesn't return affected rows easily, but the cleanup runs
}
+31 -14
View File
@@ -1,6 +1,6 @@
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { runFightAsync } from './orchestrator.js'
import { runFightAsync, isInFight } from './orchestrator.js'
import { seedMockBots } from './mock.js'
interface QueueEntry {
@@ -19,6 +19,14 @@ const waitingQueue: QueueEntry[] = []
// How long a bot waits before getting matched against a mock bot
const QUEUE_TIMEOUT_MS = 3_000
// Post-fight cooldown tracking
const fightCooldowns = new Map<string, number>()
const COOLDOWN_MS = 15_000
export function setCooldown(botId: string) {
fightCooldowns.set(botId, Date.now() + COOLDOWN_MS)
}
export function getQueueSize(): number {
return waitingQueue.length
}
@@ -38,22 +46,39 @@ export function getQueueSnapshot(): { botId: string; botName: string; eloRating:
* If nobody is waiting, waits up to QUEUE_TIMEOUT_MS then fights a mock bot.
*/
export async function joinQueue(botId: string): Promise<string> {
// Check cooldown
const cooldownUntil = fightCooldowns.get(botId)
if (cooldownUntil && Date.now() < cooldownUntil) {
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
}
// Check if already in a fight
if (isInFight(botId)) {
throw new Error('Bot is already in a fight.')
}
// Load bot
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
const bot = botRows[0]
// Check if bot is active
if (!bot.isActive) {
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
console.log(`[queue] joinQueue botId=${botId} name=${bot.name} webhook=${bot.webhookUrl}`)
// Don't allow same bot twice in queue
const existing = waitingQueue.findIndex(e => e.botId === botId)
if (existing !== -1) {
// Remove old entry
const old = waitingQueue.splice(existing, 1)[0]
clearTimeout(old.timeoutHandle)
old.reject(new Error('Rejoined queue'))
}
// Check if someone is already waiting instant match
// Check if someone is already waiting -- instant match
if (waitingQueue.length > 0) {
// Find closest elo match
waitingQueue.sort((a, b) => {
@@ -66,15 +91,14 @@ export async function joinQueue(botId: string): Promise<string> {
clearTimeout(opponent.timeoutHandle)
// Start the fight
const fightId = await startFight(opponent.botId, opponent.webhookUrl, botId, bot.webhookUrl)
const fightId = await startFight(opponent.botId, botId)
opponent.resolve(fightId)
return fightId
}
// Nobody waiting join the queue and wait
// Nobody waiting -- join the queue and wait
return new Promise<string>((resolve, reject) => {
const timeoutHandle = setTimeout(async () => {
// Timed out — remove from queue and match against a mock bot
const idx = waitingQueue.findIndex(e => e.botId === botId)
if (idx !== -1) {
waitingQueue.splice(idx, 1)
@@ -112,17 +136,12 @@ export function leaveQueue(botId: string): boolean {
return true
}
async function startFight(
botAId: string, _botAWebhook: string,
botBId: string, _botBWebhook: string,
): Promise<string> {
// runFightAsync handles both real and mock bots — mock bots get generated responses
async function startFight(botAId: string, botBId: string): Promise<string> {
return runFightAsync(botAId, botBId)
}
async function matchAgainstMock(botId: string, webhookUrl: string): Promise<string> {
console.log(`[queue] matchAgainstMock botId=${botId} webhook=${webhookUrl}`)
// Find a mock bot to fight
const allBots = await db.select({
id: schema.bots.id,
webhookUrl: schema.bots.webhookUrl,
@@ -134,7 +153,6 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
if (mockBots.length === 0) {
console.log('[queue] No mock bots found, seeding...')
await seedMockBots()
// Retry after seeding
return matchAgainstMock(botId, webhookUrl)
}
@@ -145,6 +163,5 @@ async function matchAgainstMock(botId: string, webhookUrl: string): Promise<stri
const opponent = mockBots[0]
console.log(`[queue] starting fight: ${botId} vs mock ${opponent.id}`)
// runFightAsync handles mock bots inline — no need for runMockFight
return runFightAsync(botId, opponent.id)
}
+37 -25
View File
@@ -29,7 +29,7 @@ export function scoreRound(
comboA: number,
comboB: number,
): RoundResult {
// Handle timeouts/errors instant loss for the failing bot
// Handle timeouts/errors -- instant loss for the failing bot
if (responseA.timedOut && responseB.timedOut) {
return {
botAScore: 0, botBScore: 0,
@@ -70,19 +70,17 @@ export function scoreRound(
let scoreB: number
if (challenge.answers && challenge.answers.length > 0) {
// ═══ FACTUAL SCORING ═══
// Check correctness against known answers
// === FACTUAL SCORING ===
const correctA = checkAnswer(responseA.answer, challenge.answers)
const correctB = checkAnswer(responseB.answer, challenge.answers)
if (correctA > 0 && correctB > 0) {
// Both correct speed is tiebreaker
// Both correct -- speed is tiebreaker
const faster = Math.min(responseA.timeMs, responseB.timeMs)
const slower = Math.max(responseA.timeMs, responseB.timeMs)
const speedRatio = slower > 0 ? faster / slower : 1
const aFaster = responseA.timeMs <= responseB.timeMs
// Confidence bonus (full match vs partial)
const confA = Math.min(correctA, 1)
const confB = Math.min(correctB, 1)
@@ -94,22 +92,19 @@ export function scoreRound(
scoreB = 7 + (1 - speedRatio) * 2 + confB
}
} else if (correctA > 0 && correctB === 0) {
// A correct, B wrong — A wins big
scoreA = 9 + correctA * 0.5
scoreB = 1 + (responseB.answer ? 1 : 0) // tiny credit for trying
scoreB = 1 + (responseB.answer ? 1 : 0)
} else if (correctB > 0 && correctA === 0) {
// B correct, A wrong — B wins big
scoreA = 1 + (responseA.answer ? 1 : 0)
scoreB = 9 + correctB * 0.5
} else {
// Both wrong speed tiebreaker in low range
// Both wrong -- speed tiebreaker in low range
const aFaster = responseA.timeMs <= responseB.timeMs
scoreA = aFaster ? 4 : 3
scoreB = aFaster ? 3 : 4
}
} else {
// ═══ CREATIVE SCORING ═══
// Heuristic: response quality estimation (length + speed)
// === CREATIVE SCORING ===
const qualA = estimateQuality(responseA)
const qualB = estimateQuality(responseB)
const total = qualA + qualB || 1
@@ -123,10 +118,8 @@ export function scoreRound(
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
// Critical hit on big margin
const isCritical = margin > 4
// Calculate damage
let winnerDamage = challenge.baseDamage + margin * 2
if (isCritical) winnerDamage *= 1.5
const winnerCombo = winnerId === botA.id ? comboA : comboB
@@ -156,7 +149,6 @@ function applyModifiers(
combo: number,
): number {
let d = damage
// Combo multiplier (caps at 2x)
if (combo > 0) {
d *= 1 + Math.min(combo, 5) * 0.2
}
@@ -164,13 +156,36 @@ function applyModifiers(
}
function estimateQuality(response: BotResponse): number {
if (!response.answer) return 1
const len = response.answer.length
// Reasonable length gets a bonus, very short or very long gets penalized
const lengthScore = len > 20 && len < 500 ? 5 : len > 500 ? 3 : 2
// Faster is slightly better
const speedBonus = Math.max(0, 3 - response.timeMs / 5000)
return lengthScore + speedBonus
if (!response.answer) return 0.5
const text = response.answer.trim()
const len = text.length
if (len < 10) return 1
// Detect low-effort spam (repeated chars)
const uniqueChars = new Set(text.toLowerCase()).size
const charRatio = uniqueChars / Math.min(len, 100)
if (charRatio < 0.1) return 0.5
// Word diversity (unique words / total words)
const words = text.split(/\s+/)
const uniqueWords = new Set(words.map(w => w.toLowerCase()))
const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
// Ideal length window: 30-400 chars
let lengthScore: number
if (len >= 30 && len <= 400) lengthScore = 4
else if (len > 400 && len <= 600) lengthScore = 3
else if (len > 600) lengthScore = 2
else lengthScore = 2
// Diversity bonus (prevents repetitive text)
const diversityScore = Math.min(wordDiversity * 4, 3)
// Speed bonus (faster is slightly better)
const speedBonus = Math.max(0, 2 - response.timeMs / 8000)
return lengthScore + diversityScore + speedBonus
}
function generateNarration(
@@ -183,7 +198,6 @@ function generateNarration(
const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
const isFactual = challenge.scoring === 'factual'
// Big margin = one got it right and the other didn't
if (isFactual && margin > 5) {
const bigWins = [
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close.`,
@@ -195,18 +209,16 @@ function generateNarration(
return bigWins[Math.floor(Math.random() * bigWins.length)]
}
// Factual — both correct, speed tiebreaker
if (isFactual && margin <= 3) {
const closeOnes = [
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs to pick up the pace.`,
`${critPrefix}Correct on both sides! ${winner} edges it out with lightning speed.`,
`${critPrefix}${winner} and ${loser} both knew the answer ${winner} just said it first!`,
`${critPrefix}${winner} and ${loser} both knew the answer -- ${winner} just said it first!`,
`${critPrefix}A battle of speed! ${winner} fires back a fraction faster than ${loser}.`,
]
return closeOnes[Math.floor(Math.random() * closeOnes.length)]
}
// Generic narrations by category
const narrations: Record<string, string[]> = {
speed_blitz: [
`${critPrefix}${winner} fires back in the blink of an eye! ${loser} is still loading.`,
+72
View File
@@ -0,0 +1,72 @@
import { isAllowedWebhookUrl } from './orchestrator.js'
export interface WebhookTestResult {
reachable: boolean
validResponse: boolean
latencyMs: number
error?: string
}
export async function testWebhook(webhookUrl: string): Promise<WebhookTestResult> {
if (!isAllowedWebhookUrl(webhookUrl)) {
return { reachable: false, validResponse: false, latencyMs: 0, error: 'URL blocked: private/internal addresses are not allowed.' }
}
const testPayload = JSON.stringify({
fight_id: 'test_000000',
round: 0,
type: 'webhook_test',
challenge: 'WEBHOOK TEST: respond with {"answer": "pong"} to verify your setup.',
constraints: { timeout_ms: 5000, max_tokens: 500 },
opponent: { name: 'test_bot', wins: 0, losses: 0 },
arena: 'localhost',
arena_modifier: null,
})
const start = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: testPayload,
signal: controller.signal,
})
clearTimeout(timeout)
const latencyMs = Date.now() - start
if (!res.ok) {
return { reachable: true, validResponse: false, latencyMs, error: `Webhook returned HTTP ${res.status}. Expected 200.` }
}
const text = await res.text()
if (text.length > 10240) {
return { reachable: true, validResponse: false, latencyMs, error: 'Response too large (>10KB).' }
}
let data: Record<string, unknown>
try {
data = JSON.parse(text)
} catch {
return { reachable: true, validResponse: false, latencyMs, error: 'Response is not valid JSON. Expected {"answer": "..."}.' }
}
if (typeof data.answer !== 'string') {
return { reachable: true, validResponse: false, latencyMs, error: 'Response JSON missing "answer" field. Expected {"answer": "pong"}.' }
}
return { reachable: true, validResponse: true, latencyMs }
} catch (err: unknown) {
const latencyMs = Date.now() - start
const isAbort = err instanceof Error && err.name === 'AbortError'
if (isAbort) {
return { reachable: false, validResponse: false, latencyMs, error: 'Webhook timed out (5s). Is your server running?' }
}
const msg = err instanceof Error ? err.message : String(err)
return { reachable: false, validResponse: false, latencyMs, error: `Connection failed: ${msg}` }
}
}