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 = { 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, }) } })