import { z } from 'zod' import { Hono } from 'hono' import { logger } from '../lib/logger.js' import { streamSSE } from 'hono/streaming' import { db, schema } from '../db/index.js' import { eq, desc } from 'drizzle-orm' import { ARENAS } from '../engine/arenas.js' import { runMockFight, isClassicBot } from '../engine/mock.js' import { startFightLoop } from '../engine/fight-loop.js' import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js' import { fightEvents } from '../engine/events.js' import { botRateLimit } from '../middleware/rate-limit.js' import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js' // --- Request validation schemas --- const respondSchema = z.object({ answer: z.string().min(1).max(2000), trashTalk: z.string().max(200).optional(), }) const reactSchema = z.object({ emoji: z.string().min(1), }) const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id) export const fightsRouter = new Hono() // Track spectator counts per fight const spectatorCounts = new Map() // Track reactions per fight const VALID_REACTIONS = new Set(['fist', 'fire', 'skull', '100', 'clown']) const fightReactions = new Map>() // Active SSE connection tracking let activeSSECount = 0 const ssePerIp = new Map() const MAX_SSE_PER_IP = 5 // Periodic cleanup of stale reaction data (every 10 minutes) setInterval(() => { if (fightReactions.size > 100) { fightReactions.clear() } }, 10 * 60_000) export function getActiveSSECount(): number { return activeSSECount } export function getSpectatorCount(fightId: string): number { return spectatorCounts.get(fightId) || 0 } // List recent fights (with bot names) fightsRouter.get('/', async (c) => { const rows = await db.select() .from(schema.fights) .orderBy(desc(schema.fights.createdAt)) .limit(20) const botIds = new Set() for (const f of rows) { botIds.add(f.botAId) botIds.add(f.botBId) if (f.winnerId) botIds.add(f.winnerId) } const botMap = new Map() for (const id of botIds) { const bot = await db.select({ name: schema.bots.name, avatarSeed: schema.bots.avatarSeed, archetype: schema.bots.archetype, eloRating: schema.bots.eloRating, tier: schema.bots.tier, botType: schema.bots.botType, }).from(schema.bots).where(eq(schema.bots.id, id)).limit(1) if (bot[0]) botMap.set(id, bot[0]) } const enriched = rows.map(f => { const arena = ARENAS.find(a => a.id === f.arena) return { ...f, botA: botMap.get(f.botAId) || null, botB: botMap.get(f.botBId) || null, winner: f.winnerId ? botMap.get(f.winnerId) || null : null, arenaInfo: arena ? { name: arena.name, description: arena.description } : null, } }) return c.json(enriched) }) // Get a single fight with rounds and bot details fightsRouter.get('/:id', async (c) => { const id = c.req.param('id') if (!isValidId(id)) return c.json({ error: 'Invalid ID format.' }, 400) const fightRows = await db.select() .from(schema.fights) .where(eq(schema.fights.id, id)) .limit(1) if (fightRows.length === 0) { return c.json({ error: 'Fight not found.' }, 404) } const fight = fightRows[0] const botFields = { id: schema.bots.id, name: schema.bots.name, avatarSeed: schema.bots.avatarSeed, archetype: schema.bots.archetype, customization: schema.bots.customization, profilePicUrl: schema.bots.profilePicUrl, eloRating: schema.bots.eloRating, wins: schema.bots.wins, losses: schema.bots.losses, tier: schema.bots.tier, botType: schema.bots.botType, } const [botARows, botBRows] = await Promise.all([ db.select(botFields).from(schema.bots).where(eq(schema.bots.id, fight.botAId)).limit(1), db.select(botFields).from(schema.bots).where(eq(schema.bots.id, fight.botBId)).limit(1), ]) const roundRows = await db.select() .from(schema.rounds) .where(eq(schema.rounds.fightId, id)) .orderBy(schema.rounds.roundNumber) const arena = ARENAS.find(a => a.id === fight.arena) const parseBot = (b: typeof botARows[0] | undefined) => { if (!b) return null return { ...b, customization: b.customization ? JSON.parse(b.customization) : null } } return c.json({ ...fight, botA: parseBot(botARows[0]), botB: parseBot(botBRows[0]), arenaInfo: arena || null, rounds: roundRows, }) }) // Dev-only mock fight endpoints (disabled in production) const isDev = process.env.NODE_ENV !== 'production' // Trigger a mock fight between two random bots (dev/testing) fightsRouter.post('/mock', async (c) => { if (!isDev) return c.json({ error: 'Mock fights disabled in production.' }, 403) const allBots = await db.select({ id: schema.bots.id }).from(schema.bots) if (allBots.length < 2) { return c.json({ error: 'Need at least 2 registered bots.' }, 400) } 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.' }) }) // Trigger a mock fight for a specific bot against a random opponent fightsRouter.post('/mock/:botId', async (c) => { if (!isDev) return c.json({ error: 'Mock fights disabled in production.' }, 403) const botId = c.req.param('botId') as string const botRows = await db.select({ id: schema.bots.id }) .from(schema.bots) .where(eq(schema.bots.id, botId)) .limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) } const opponents = await db.select({ id: schema.bots.id }) .from(schema.bots) const others = opponents.filter(b => b.id !== botId) if (others.length === 0) { return c.json({ error: 'No opponents available.' }, 400) } const opponent = others[Math.floor(Math.random() * others.length)] const fightId = await runMockFight(botId, opponent.id) return c.json({ fightId, message: 'Mock fight completed.' }) }) // Start a batch of mock fights (for seeding or overnight loop) fightsRouter.post('/mock/batch/:count', async (c) => { if (!isDev) return c.json({ error: 'Fight loop disabled in production.' }, 403) const count = Math.min(Math.max(1, parseInt(c.req.param('count')) || 10), 500) startFightLoop({ maxFights: count, intervalMs: 500, matchmakingStyle: 'mixed' }) .then(() => logger.info('fights', `batch of ${count} fights completed`)) .catch(err => logger.error('fights', 'batch error', err)) return c.json({ message: `Started batch of ${count} fights in background.` }) }) // 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) .where(eq(schema.bots.id, botId)) .limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) } const bot = botRows[0] 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) // Exclude classic bots from regular matchmaking — use /practice for those const opponents = allBots.filter(b => b.id !== botId && b.botType !== 'classic') if (opponents.length === 0) { return c.json({ error: 'No opponents available.' }, 400) } // Sort by closest elo for fair matchmaking, with some randomness opponents.sort((a, b) => { const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 200 const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 200 return diffA - diffB }) const opponent = opponents[0] let fightId: string 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, opponent: { id: opponent.id, name: opponent.name }, message: 'Fight started.', }) }) // Practice fight against a random classic bot (free, no sats) fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => { const botId = c.req.param('botId') as string const botRows = await db.select() .from(schema.bots) .where(eq(schema.bots.id, botId)) .limit(1) if (botRows.length === 0) { return c.json({ error: 'Bot not found.' }, 404) } const bot = botRows[0] if (isInFight(botId)) { return c.json({ error: 'Bot is already in a fight.' }, 400) } // Find all classic bots const classicBots = await db.select() .from(schema.bots) .where(eq(schema.bots.botType, 'classic')) if (classicBots.length === 0) { return c.json({ error: 'No practice bots available.' }, 400) } // Pick closest Elo classic bot with randomness classicBots.sort((a, b) => { const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 200 const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 200 return diffA - diffB }) const opponent = classicBots[0] let fightId: string try { fightId = await runFightAsync(botId, opponent.id, 'free') } catch (err) { const msg = err instanceof Error ? err.message : 'Fight failed to start' return c.json({ error: msg }, 400) } return c.json({ fightId, opponent: { id: opponent.id, name: opponent.name }, message: 'Practice fight started.', }) }) // Get pending challenge for a human player in an active fight fightsRouter.get('/:fightId/challenge/:botId', async (c) => { const fightId = c.req.param('fightId') const botId = c.req.param('botId') const challenge = getPendingChallenge(fightId, botId) if (!challenge) { // Check if fight is still active const fight = await db.select({ status: schema.fights.status }) .from(schema.fights) .where(eq(schema.fights.id, fightId)) .limit(1) const status = fight[0]?.status || 'unknown' return c.json({ pending: false, fightStatus: status }) } return c.json({ pending: true, ...challenge }) }) // Submit human response to a challenge fightsRouter.post('/:fightId/respond/:botId', async (c) => { const fightId = c.req.param('fightId') const botId = c.req.param('botId') if (!isValidId(fightId) || !isValidId(botId)) { return c.json({ error: 'Invalid ID format.' }, 400) } const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({}))) if (!parsed.success) { return c.json({ error: 'Answer is required.' }, 400) } const { answer, trashTalk } = parsed.data const accepted = submitHumanResponse(fightId, botId, answer, trashTalk) if (!accepted) { return c.json({ error: 'No pending challenge found. May have timed out.' }, 404) } return c.json({ accepted: true }) }) // SSE stream for live fight events fightsRouter.get('/:id/stream', (c) => { const fightId = c.req.param('id') const clientIp = c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || c.req.header('x-forwarded-for')?.split(',')[0].trim() || 'unknown' // Enforce per-IP SSE connection limit const ipCount = ssePerIp.get(clientIp) || 0 if (ipCount >= MAX_SSE_PER_IP) { return c.json({ error: 'Too many SSE connections' }, 429) } return streamSSE(c, async (stream) => { // Track connections activeSSECount++ ssePerIp.set(clientIp, (ssePerIp.get(clientIp) || 0) + 1) // Track spectator spectatorCounts.set(fightId, (spectatorCounts.get(fightId) || 0) + 1) const count = spectatorCounts.get(fightId)! // Send initial spectator count await stream.writeSSE({ event: 'spectator_count', data: JSON.stringify({ count }), }) const cleanup = fightEvents.on(fightId, (event) => { void stream.writeSSE({ event: event.type, data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }), }) }) const cleanupGlobal = fightEvents.onAll((event) => { if (event.fightId === fightId && event.type === 'fight_end') { void stream.writeSSE({ event: 'fight_end', data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }), }) } }) try { while (true) { await stream.writeSSE({ event: 'ping', data: JSON.stringify({ spectators: spectatorCounts.get(fightId) || 0 }), }) await stream.sleep(15000) 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 { // Decrement connection counts activeSSECount = Math.max(0, activeSSECount - 1) const ipCurrent = ssePerIp.get(clientIp) || 1 if (ipCurrent <= 1) ssePerIp.delete(clientIp) else ssePerIp.set(clientIp, ipCurrent - 1) // Decrement spectator count const current = spectatorCounts.get(fightId) || 1 if (current <= 1) { spectatorCounts.delete(fightId) } else { spectatorCounts.set(fightId, current - 1) } cleanup() cleanupGlobal() } }) }) // React to a fight fightsRouter.post('/:id/react', async (c) => { const fightId = c.req.param('id') if (!isValidId(fightId)) { return c.json({ error: 'Invalid ID format.' }, 400) } const parsed = reactSchema.safeParse(await c.req.json().catch(() => ({}))) const emoji = parsed.success ? parsed.data.emoji : null if (!emoji || !VALID_REACTIONS.has(emoji)) { return c.json({ error: 'Invalid reaction. Use: fist, fire, skull, 100, clown' }, 400) } // Aggregate if (!fightReactions.has(fightId)) { fightReactions.set(fightId, {}) } const counts = fightReactions.get(fightId)! counts[emoji] = (counts[emoji] || 0) + 1 // Broadcast via SSE fightEvents.emit({ fightId, type: 'reaction', data: { emoji, counts: { ...counts } }, timestamp: new Date().toISOString(), }) return c.json({ ok: true, counts }) })