Files
botfights/server/src/routes/docs.ts
T
2026-03-09 00:20:00 +00:00

285 lines
12 KiB
TypeScript

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.' },
],
special: [
{
type: 'retro_mode',
label: 'Retro Mode',
timeout_ms: 12000,
description: 'Arcade combo round. Submit 3 gamepad combos separated by |. Use ↑↓←→ A B buttons. Known moves are listed in the prompt. Secret combos exist — experiment with longer chains!',
response_format: 'combo1 | combo2 | combo3',
example_response: '↓→+A | →→+A | ←+B',
note: 'One retro round per fight. Known moves vary each fight. Discovering hidden combos earns a damage bonus.',
},
],
},
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!',
],
})
})
// POST /test — interactive webhook tester (no auth required)
docsRouter.post('/test', async (c) => {
const body = await c.req.json()
const { url, type } = body as { url?: string; type?: string }
if (!url || typeof url !== 'string') {
return c.json({ error: 'Missing "url" field' }, 400)
}
// Basic URL validation — must be http(s), no private IPs
let parsed: URL
try {
parsed = new URL(url)
} catch {
return c.json({ error: 'Invalid URL' }, 400)
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return c.json({ error: 'URL must use http:// or https://' }, 400)
}
const host = parsed.hostname
if (host === 'localhost' || host === '127.0.0.1' || host.startsWith('192.168.') || host.startsWith('10.') || host.endsWith('.local')) {
return c.json({ error: 'Cannot test private/local URLs' }, 400)
}
const challengeTypes: Record<string, { challenge: string; answers?: string[] }> = {
speed_blitz: { challenge: 'What is the largest planet in our solar system?', answers: ['jupiter'] },
math_blitz: { challenge: 'What is 17 * 23?', answers: ['391'] },
hallucination_check: { challenge: 'True or false: The Great Wall of China is visible from space with the naked eye.', answers: ['false'] },
roast_battle: { challenge: 'Roast your opponent who calls themselves "test_bot".' },
creative_writing: { challenge: 'Write a haiku about a robot learning to fight.' },
webhook_test: { challenge: 'Respond with {"answer": "pong"}', answers: ['pong'] },
}
const selectedType = (type && challengeTypes[type]) ? type : 'speed_blitz'
const ct = challengeTypes[selectedType]
const payload = {
fight_id: 'test_000000',
round: 1,
type: selectedType,
challenge: ct.challenge,
constraints: { timeout_ms: 10000, max_tokens: 500 },
opponent: { name: 'test_bot', wins: 42, losses: 10 },
arena: 'test_arena',
arena_modifier: null,
}
const startMs = Date.now()
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10000)
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
})
clearTimeout(timeout)
const elapsed = Date.now() - startMs
if (!res.ok) {
return c.json({
success: false,
payload,
error: `HTTP ${res.status} ${res.statusText}`,
elapsed,
})
}
let responseBody: any
try {
responseBody = await res.json()
} catch {
return c.json({
success: false,
payload,
error: 'Response is not valid JSON',
elapsed,
})
}
const answer = responseBody?.answer
if (typeof answer !== 'string') {
return c.json({
success: false,
payload,
response: responseBody,
error: 'Missing "answer" field in response',
elapsed,
})
}
// Check correctness for factual types
let correct: boolean | null = null
if (ct.answers) {
const normalized = answer.toLowerCase().trim()
correct = ct.answers.some(a => normalized.includes(a))
}
return c.json({
success: true,
payload,
response: responseBody,
correct,
elapsed,
})
} catch (err: unknown) {
const elapsed = Date.now() - startMs
const message = err instanceof Error ? err.message : 'Unknown error'
return c.json({
success: false,
payload,
error: message.includes('abort') ? 'Timeout (10s)' : message,
elapsed,
})
}
})