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, 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' export const fightsRouter = new Hono() // Track spectator counts per fight const spectatorCounts = new Map() 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') 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 = 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) // 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') const body = await c.req.json() const { answer, trashTalk } = body if (!answer || typeof answer !== 'string') { return c.json({ error: 'Answer is required.' }, 400) } 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') return streamSSE(c, async (stream) => { // 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) => { 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') { 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(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 { // Decrement spectator count const current = spectatorCounts.get(fightId) || 1 if (current <= 1) { spectatorCounts.delete(fightId) } else { spectatorCounts.set(fightId, current - 1) } cleanup() cleanupGlobal() } }) })