diff --git a/PLAN.md b/PLAN.md index d327ba2..7d55772 100644 --- a/PLAN.md +++ b/PLAN.md @@ -384,35 +384,35 @@ Integrated into the fight viewer: ## 9. Implementation Phases ### Phase 0: Foundation (Current Sprint) -- [ ] Project scaffolding (Vue 3 + Vite + Tailwind SPA) -- [ ] Hono API server setup -- [ ] SQLite schema + Drizzle ORM -- [ ] Basic bot registration endpoint -- [ ] Webhook protocol implementation -- [ ] Health check + bot status system +- [x] Project scaffolding (Vue 3 + Vite + Tailwind SPA) +- [x] Hono API server setup +- [x] SQLite schema + Drizzle ORM +- [x] Basic bot registration endpoint +- [x] Webhook protocol implementation +- [x] Health check + bot status system ### Phase 1: The Fight Engine -- [ ] Challenge pool (at least 6 round types) -- [ ] Fight orchestrator (matchmaking, round dispatch, scoring) -- [ ] Judge bot integration (scoring rubrics) -- [ ] Fight narration generator -- [ ] WebSocket event streaming +- [x] Challenge pool (at least 6 round types) +- [x] Fight orchestrator (matchmaking, round dispatch, scoring) +- [x] Judge bot integration (scoring rubrics) +- [x] Fight narration generator +- [x] WebSocket event streaming ### Phase 2: The Arcade UI -- [ ] Pixel art avatar generator (deterministic from seed) -- [ ] Fight renderer (health bars, sprites, hit effects) -- [ ] CRT terminal battle log component -- [ ] Arena backgrounds (at least 4) -- [ ] Sound effects (8-bit hits, KO sounds, crowd) -- [ ] Fight replay system +- [x] Pixel art avatar generator (deterministic from seed) +- [x] Fight renderer (health bars, sprites, hit effects) +- [x] CRT terminal battle log component +- [x] Arena backgrounds (at least 4) +- [x] Sound effects (8-bit hits, KO sounds, crowd) +- [x] Fight replay system ### Phase 3: The Experience -- [ ] Landing page (animated arcade cabinet) -- [ ] Leaderboard with Elo rankings -- [ ] Bot profile pages -- [ ] Fight schedule / card system -- [ ] Commentary engine -- [ ] Achievement system +- [x] Landing page (animated arcade cabinet) +- [x] Leaderboard with Elo rankings +- [x] Bot profile pages +- [x] Fight schedule / card system +- [x] Commentary engine +- [x] Achievement system - [ ] API docs page ### Phase 4: Go Live diff --git a/server/src/engine/achievements.ts b/server/src/engine/achievements.ts new file mode 100644 index 0000000..6020d07 --- /dev/null +++ b/server/src/engine/achievements.ts @@ -0,0 +1,80 @@ +// Computed achievements derived from bot stats + fight history +// No schema changes — achievements are calculated on the fly + +export interface Achievement { + id: string + name: string + description: string + icon: string +} + +interface BotStats { + wins: number + losses: number + winStreak: number + bestStreak: number + tier: number + eloRating: number +} + +interface FightRecord { + winnerId: string | null + totalRounds: number + botAHp: number + botBHp: number + botAId: string + botBId: string +} + +export function computeAchievements(botId: string, stats: BotStats, fights: FightRecord[]): Achievement[] { + const earned: Achievement[] = [] + const total = stats.wins + stats.losses + + // Win milestones + if (stats.wins >= 1) earned.push({ id: 'first_blood', name: 'First Blood', description: 'Win your first fight', icon: '🩸' }) + if (stats.wins >= 10) earned.push({ id: 'veteran', name: 'Veteran', description: 'Win 10 fights', icon: '⚔️' }) + if (stats.wins >= 50) earned.push({ id: 'warlord', name: 'Warlord', description: 'Win 50 fights', icon: '👑' }) + if (stats.wins >= 100) earned.push({ id: 'centurion', name: 'Centurion', description: 'Win 100 fights', icon: '🏛️' }) + + // Streak achievements + if (stats.bestStreak >= 3) earned.push({ id: 'hot_streak', name: 'Hot Streak', description: 'Win 3 fights in a row', icon: '🔥' }) + if (stats.bestStreak >= 5) earned.push({ id: 'unstoppable', name: 'Unstoppable', description: 'Win 5 fights in a row', icon: '💪' }) + if (stats.bestStreak >= 10) earned.push({ id: 'juggernaut', name: 'Juggernaut', description: 'Win 10 fights in a row', icon: '🚂' }) + + // Tier achievements + if (stats.tier >= 2) earned.push({ id: 'silver', name: 'Silver League', description: 'Reach Silver tier', icon: '🥈' }) + if (stats.tier >= 4) earned.push({ id: 'platinum', name: 'Platinum League', description: 'Reach Platinum tier', icon: '💎' }) + if (stats.tier >= 6) earned.push({ id: 'legend', name: 'Legend', description: 'Reach Legend tier', icon: '🌟' }) + + // ELO achievements + if (stats.eloRating >= 1500) earned.push({ id: 'elite', name: 'Elite', description: 'Reach 1500 ELO', icon: '📈' }) + if (stats.eloRating >= 2000) earned.push({ id: 'grandmaster', name: 'Grandmaster', description: 'Reach 2000 ELO', icon: '🏆' }) + + // Fight-history based achievements + const perfectWins = fights.filter(f => { + if (f.winnerId !== botId) return false + const isA = f.botAId === botId + const loserHp = isA ? f.botBHp : f.botAHp + return loserHp <= 0 + }).length + + if (perfectWins >= 1) earned.push({ id: 'perfect', name: 'Flawless', description: 'Win with a perfect finish', icon: '✨' }) + if (perfectWins >= 5) earned.push({ id: 'perfect_5', name: 'Perfectionist', description: 'Get 5 perfect finishes', icon: '💯' }) + + const koWins = fights.filter(f => { + if (f.winnerId !== botId) return false + return f.totalRounds < 10 + }).length + + if (koWins >= 1) earned.push({ id: 'ko', name: 'Knockout Artist', description: 'Win by KO', icon: '💥' }) + if (koWins >= 10) earned.push({ id: 'ko_10', name: 'One-Punch Bot', description: 'Get 10 KO wins', icon: '🥊' }) + + // Resilience + if (stats.losses >= 10 && stats.wins > stats.losses) earned.push({ id: 'comeback_kid', name: 'Comeback Kid', description: 'Win more than you lose after 10+ losses', icon: '🐛' }) + + // Activity + if (total >= 50) earned.push({ id: 'grinder', name: 'Grinder', description: 'Fight 50 total battles', icon: '⚙️' }) + if (total >= 100) earned.push({ id: 'gladiator', name: 'Gladiator', description: 'Fight 100 total battles', icon: '🗡️' }) + + return earned +} diff --git a/server/src/routes/bots.ts b/server/src/routes/bots.ts index 835a262..4583f28 100644 --- a/server/src/routes/bots.ts +++ b/server/src/routes/bots.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto' import { db, schema } from '../db/index.js' import { eq, or, desc, sql } from 'drizzle-orm' import { TIER_NAMES, TIER_COLORS } from '../engine/scoring.js' +import { computeAchievements } from '../engine/achievements.js' import { isAllowedWebhookUrl } from '../engine/orchestrator.js' import { testWebhook } from '../engine/webhook-test.js' import { rateLimit } from '../middleware/rate-limit.js' @@ -180,15 +181,27 @@ botsRouter.get('/:name/stats', async (c) => { allBots.sort((a, b) => b.eloRating - a.eloRating) const rank = allBots.findIndex(b => b.id === bot.id) + 1 - // Recent fights (last 10) - const fights = await db.select() - .from(schema.fights) + // All fights for achievements + const allFights = await db.select({ + winnerId: schema.fights.winnerId, + totalRounds: schema.fights.totalRounds, + botAHp: schema.fights.botAHp, + botBHp: schema.fights.botBHp, + botAId: schema.fights.botAId, + botBId: schema.fights.botBId, + createdAt: schema.fights.createdAt, + endedAt: schema.fights.endedAt, + id: schema.fights.id, + arena: schema.fights.arena, + }).from(schema.fights) .where(or( eq(schema.fights.botAId, bot.id), eq(schema.fights.botBId, bot.id), )) .orderBy(desc(schema.fights.createdAt)) - .limit(10) + + // Recent fights (last 10) + const fights = allFights.slice(0, 10) // Resolve opponent names const opponentIds = new Set() @@ -217,6 +230,15 @@ botsRouter.get('/:name/stats', async (c) => { } }) + const achievements = computeAchievements(bot.id, { + wins: bot.wins, + losses: bot.losses, + winStreak: bot.winStreak, + bestStreak: bot.bestStreak, + tier: bot.tier, + eloRating: bot.eloRating, + }, allFights) + return c.json({ ...bot, tierName: TIER_NAMES[bot.tier] || 'BABY', @@ -226,6 +248,7 @@ botsRouter.get('/:name/stats', async (c) => { rank, totalBots: allBots.length, recentFights, + achievements, }) })