diff --git a/server/src/engine/answers.test.ts b/server/src/engine/answers.test.ts index a413b53..dd2c033 100644 --- a/server/src/engine/answers.test.ts +++ b/server/src/engine/answers.test.ts @@ -145,3 +145,33 @@ describe('checkAnswer', () => { expect(checkAnswer('\t\n \r', ['Paris'])).toBe(0) }) }) + +describe('checkAnswer performance', () => { + it('completes 1000 checks in under 50ms (<0.05ms each)', () => { + const answers = ['Paris', 'London', 'Tokyo'] + const start = performance.now() + for (let i = 0; i < 1000; i++) { + checkAnswer('I think the answer is probably Paris', answers) + } + const elapsed = performance.now() - start + expect(elapsed).toBeLessThan(50) + }) + + it('no regex backtracking on adversarial input', () => { + // ReDoS-style strings that could cause catastrophic backtracking + const adversarial = [ + 'a'.repeat(10000), + 'a'.repeat(5000) + '!' + 'a'.repeat(5000), + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', + '(((((((((((((((((((((((((((((((', + 'x'.repeat(2000) + 'y'.repeat(2000), + ] + const start = performance.now() + for (const input of adversarial) { + checkAnswer(input, ['correct answer', '42', 'true']) + } + const elapsed = performance.now() - start + // Must complete in <100ms total for all adversarial inputs + expect(elapsed).toBeLessThan(100) + }) +}) diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index c1a5f45..a374a91 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -17,6 +17,7 @@ import { publishFightResult } from './nostr-publish.js' import { getCurrentSeason } from './seasons.js' import { onFightFinished as onTournamentFightFinished } from './tournaments.js' import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js' +import { invalidateLeaderboardCache } from '../routes/bots.js' const webhookResponseSchema = z.object({ answer: z.string().nullable().optional(), @@ -539,6 +540,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec trackMetric(`challenge_${ct}`) } + // Invalidate leaderboard cache after Elo/stats update + invalidateLeaderboardCache() + // Advance tournament bracket if this was a tournament match try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } diff --git a/server/src/engine/scoring.test.ts b/server/src/engine/scoring.test.ts index 533cb97..a5034fd 100644 --- a/server/src/engine/scoring.test.ts +++ b/server/src/engine/scoring.test.ts @@ -326,3 +326,23 @@ describe('calculateTier', () => { expect(calculateTier(1900, 39)).toBe(5) // below Legend wins }) }) + +describe('scoreRound performance', () => { + it('completes 1000 rounds in under 100ms (<0.1ms each)', () => { + const challenge = makeChallenge() + const botA = { id: 'a1', name: 'AlphaBot' } + const botB = { id: 'b1', name: 'BetaBot' } + + const start = performance.now() + for (let i = 0; i < 1000; i++) { + scoreRound( + challenge, botA, botB, + makeResponse('4', 200 + i), + makeResponse('banana', 500 + i), + null, i % 6, 0, + ) + } + const elapsed = performance.now() - start + expect(elapsed).toBeLessThan(100) // <0.1ms per call + }) +}) diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 844bac2..55ff764 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -11,6 +11,14 @@ import { rateLimit } from '../middleware/rate-limit.js' export const botsRouter = new Hono() +// Leaderboard cache — invalidated on fight completion +const leaderboardCache = new Map() +const LEADERBOARD_TTL_MS = 30_000 // 30s cache + +export function invalidateLeaderboardCache() { + leaderboardCache.clear() +} + function hashSecret(secret: string): string { return createHash('sha256').update(secret).digest('hex') } @@ -150,46 +158,58 @@ botsRouter.get('/:name', async (c) => { // Get bot stats -- full account page data -// Season leaderboard endpoint +// Season leaderboard endpoint (cached) botsRouter.get('/leaderboard', async (c) => { const seasonParam = c.req.query('season') + const cacheKey = seasonParam || '__alltime__' + const now = Date.now() + + const cached = leaderboardCache.get(cacheKey) + if (cached && now < cached.expiresAt) { + return c.json(cached.data) + } + + let result: unknown if (seasonParam === 'current' || seasonParam) { const { getCurrentSeason, getSeasonLeaderboard, getSeasonById } = await import('../engine/seasons.js') const season = seasonParam === 'current' ? getCurrentSeason() : getSeasonById(seasonParam) if (!season) return c.json({ error: 'Season not found' }, 404) const entries = await getSeasonLeaderboard(season.id) - return c.json({ season, entries }) + result = { season, entries } + } else { + // All-time: fall through to default bot list sorted by Elo + const allBots = await db.select({ + id: schema.bots.id, + name: schema.bots.name, + avatarSeed: schema.bots.avatarSeed, + archetype: schema.bots.archetype, + eloRating: schema.bots.eloRating, + wins: schema.bots.wins, + losses: schema.bots.losses, + winStreak: schema.bots.winStreak, + tier: schema.bots.tier, + botType: schema.bots.botType, + }).from(schema.bots) + + const ranked = allBots.filter(b => b.botType !== 'classic') + ranked.sort((a, b) => b.eloRating - a.eloRating) + + result = { season: null, entries: ranked.map(b => ({ + botId: b.id, + botName: b.name, + archetype: b.archetype, + tier: b.tier, + wins: b.wins, + losses: b.losses, + eloRating: b.eloRating, + avatarSeed: b.avatarSeed, + winStreak: b.winStreak, + })) } } - // All-time: fall through to default bot list sorted by Elo - const allBots = await db.select({ - id: schema.bots.id, - name: schema.bots.name, - avatarSeed: schema.bots.avatarSeed, - archetype: schema.bots.archetype, - eloRating: schema.bots.eloRating, - wins: schema.bots.wins, - losses: schema.bots.losses, - winStreak: schema.bots.winStreak, - tier: schema.bots.tier, - botType: schema.bots.botType, - }).from(schema.bots) - - const ranked = allBots.filter(b => b.botType !== 'classic') - ranked.sort((a, b) => b.eloRating - a.eloRating) - - return c.json({ season: null, entries: ranked.map(b => ({ - botId: b.id, - botName: b.name, - archetype: b.archetype, - tier: b.tier, - wins: b.wins, - losses: b.losses, - eloRating: b.eloRating, - avatarSeed: b.avatarSeed, - winStreak: b.winStreak, - })) }) + leaderboardCache.set(cacheKey, { data: result, expiresAt: now + LEADERBOARD_TTL_MS }) + return c.json(result) }) botsRouter.get('/:name/stats', async (c) => {