diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index 9235edc..a5ba1ab 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -88,6 +88,62 @@ fightsRouter.get('/', async (c) => { return c.json(enriched) }) +// --- Polling API (for bots that don't expose a public URL) --- +// NOTE: these two static routes (/poll, /poll/respond) MUST be registered +// before the dynamic GET /:id route below — Hono resolves same-shape +// single-segment routes in registration order, so a GET /:id registered +// first would otherwise shadow GET /poll (a literal request for +// GET /api/fights/poll would be matched as id="poll", a lookup that always +// 404s "Fight not found."). This was a real pre-existing bug: polling bots +// could never receive a challenge. Fixed 2026-07-31 (phase 09-05). + +// Poll for a pending challenge (bot authenticates with id+secret) +fightsRouter.get('/poll', rateLimit(1_000, 30), async (c) => { + const botOrRes = await authenticateBot(c) + if (botOrRes instanceof Response) return botOrRes + const bot = botOrRes + const challenge = getPendingPollChallenge(bot.botId) + + if (!challenge) { + return c.json({ pending: false }) + } + + return c.json({ + pending: true, + fight_id: challenge.fightId, + round: challenge.roundNumber, + type: challenge.type, + challenge: challenge.prompt, + constraints: challenge.constraints, + opponent: challenge.opponent, + arena: challenge.arena, + arena_modifier: challenge.arenaModifier, + remaining_ms: challenge.remainingMs, + scoring: challenge.scoring, + }) +}) + +// Submit answer to a pending poll challenge +fightsRouter.post('/poll/respond', async (c) => { + const botOrRes = await authenticateBot(c) + if (botOrRes instanceof Response) return botOrRes + const bot = botOrRes + const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({}))) + + if (!parsed.success) { + return c.json({ error: 'Answer is required (string, 1-2000 chars).' }, 400) + } + + const { answer, trashTalk } = parsed.data + const accepted = submitPollResponse(bot.botId, answer, trashTalk) + + if (!accepted) { + return c.json({ error: 'No pending challenge. Either timed out or no active fight.' }, 404) + } + + return c.json({ accepted: true }) +}) + // Get a single fight with rounds and bot details fightsRouter.get('/:id', async (c) => { const id = c.req.param('id') @@ -357,55 +413,6 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => { return c.json({ accepted: true, correct }) }) -// --- Polling API (for bots that don't expose a public URL) --- - -// Poll for a pending challenge (bot authenticates with id+secret) -fightsRouter.get('/poll', rateLimit(1_000, 30), async (c) => { - const botOrRes = await authenticateBot(c) - if (botOrRes instanceof Response) return botOrRes - const bot = botOrRes - const challenge = getPendingPollChallenge(bot.botId) - - if (!challenge) { - return c.json({ pending: false }) - } - - return c.json({ - pending: true, - fight_id: challenge.fightId, - round: challenge.roundNumber, - type: challenge.type, - challenge: challenge.prompt, - constraints: challenge.constraints, - opponent: challenge.opponent, - arena: challenge.arena, - arena_modifier: challenge.arenaModifier, - remaining_ms: challenge.remainingMs, - scoring: challenge.scoring, - }) -}) - -// Submit answer to a pending poll challenge -fightsRouter.post('/poll/respond', async (c) => { - const botOrRes = await authenticateBot(c) - if (botOrRes instanceof Response) return botOrRes - const bot = botOrRes - const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({}))) - - if (!parsed.success) { - return c.json({ error: 'Answer is required (string, 1-2000 chars).' }, 400) - } - - const { answer, trashTalk } = parsed.data - const accepted = submitPollResponse(bot.botId, answer, trashTalk) - - if (!accepted) { - return c.json({ error: 'No pending challenge. Either timed out or no active fight.' }, 404) - } - - return c.json({ accepted: true }) -}) - // SSE stream for live fight events fightsRouter.get('/:id/stream', (c) => { const fightId = c.req.param('id')