import { Hono } from 'hono' 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 } 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' export const fightsRouter = new Hono() // 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, eloRating: schema.bots.eloRating, tier: schema.bots.tier, }).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') 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, profilePicUrl: schema.bots.profilePicUrl, eloRating: schema.bots.eloRating, wins: schema.bots.wins, losses: schema.bots.losses, tier: schema.bots.tier, } 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) return c.json({ ...fight, botA: botARows[0] || null, botB: botBRows[0] || null, arenaInfo: arena || null, rounds: roundRows, }) }) // Trigger a mock fight between two random bots (dev/testing) fightsRouter.post('/mock', async (c) => { 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) => { 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) => { const count = parseInt(c.req.param('count')) || 10 const capped = Math.min(count, 500) startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' }) .then(() => console.log(`[botfights] batch of ${capped} fights completed`)) .catch(err => console.error('[botfights] batch error:', err)) return c.json({ message: `Started batch of ${capped} 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) const opponents = allBots.filter(b => b.id !== botId) 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.', }) }) // 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() } }) })