feat: computed achievement system — 20 achievements derived from bot stats
Achievements computed on-the-fly from existing stats + fight history: win milestones, streaks, tiers, ELO, perfect/KO wins, resilience, activity. Wired into /api/bots/:name/stats endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0ae3970433
commit
3b8208be08
@@ -384,35 +384,35 @@ Integrated into the fight viewer:
|
|||||||
## 9. Implementation Phases
|
## 9. Implementation Phases
|
||||||
|
|
||||||
### Phase 0: Foundation (Current Sprint)
|
### Phase 0: Foundation (Current Sprint)
|
||||||
- [ ] Project scaffolding (Vue 3 + Vite + Tailwind SPA)
|
- [x] Project scaffolding (Vue 3 + Vite + Tailwind SPA)
|
||||||
- [ ] Hono API server setup
|
- [x] Hono API server setup
|
||||||
- [ ] SQLite schema + Drizzle ORM
|
- [x] SQLite schema + Drizzle ORM
|
||||||
- [ ] Basic bot registration endpoint
|
- [x] Basic bot registration endpoint
|
||||||
- [ ] Webhook protocol implementation
|
- [x] Webhook protocol implementation
|
||||||
- [ ] Health check + bot status system
|
- [x] Health check + bot status system
|
||||||
|
|
||||||
### Phase 1: The Fight Engine
|
### Phase 1: The Fight Engine
|
||||||
- [ ] Challenge pool (at least 6 round types)
|
- [x] Challenge pool (at least 6 round types)
|
||||||
- [ ] Fight orchestrator (matchmaking, round dispatch, scoring)
|
- [x] Fight orchestrator (matchmaking, round dispatch, scoring)
|
||||||
- [ ] Judge bot integration (scoring rubrics)
|
- [x] Judge bot integration (scoring rubrics)
|
||||||
- [ ] Fight narration generator
|
- [x] Fight narration generator
|
||||||
- [ ] WebSocket event streaming
|
- [x] WebSocket event streaming
|
||||||
|
|
||||||
### Phase 2: The Arcade UI
|
### Phase 2: The Arcade UI
|
||||||
- [ ] Pixel art avatar generator (deterministic from seed)
|
- [x] Pixel art avatar generator (deterministic from seed)
|
||||||
- [ ] Fight renderer (health bars, sprites, hit effects)
|
- [x] Fight renderer (health bars, sprites, hit effects)
|
||||||
- [ ] CRT terminal battle log component
|
- [x] CRT terminal battle log component
|
||||||
- [ ] Arena backgrounds (at least 4)
|
- [x] Arena backgrounds (at least 4)
|
||||||
- [ ] Sound effects (8-bit hits, KO sounds, crowd)
|
- [x] Sound effects (8-bit hits, KO sounds, crowd)
|
||||||
- [ ] Fight replay system
|
- [x] Fight replay system
|
||||||
|
|
||||||
### Phase 3: The Experience
|
### Phase 3: The Experience
|
||||||
- [ ] Landing page (animated arcade cabinet)
|
- [x] Landing page (animated arcade cabinet)
|
||||||
- [ ] Leaderboard with Elo rankings
|
- [x] Leaderboard with Elo rankings
|
||||||
- [ ] Bot profile pages
|
- [x] Bot profile pages
|
||||||
- [ ] Fight schedule / card system
|
- [x] Fight schedule / card system
|
||||||
- [ ] Commentary engine
|
- [x] Commentary engine
|
||||||
- [ ] Achievement system
|
- [x] Achievement system
|
||||||
- [ ] API docs page
|
- [ ] API docs page
|
||||||
|
|
||||||
### Phase 4: Go Live
|
### Phase 4: Go Live
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto'
|
|||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { eq, or, desc, sql } from 'drizzle-orm'
|
import { eq, or, desc, sql } from 'drizzle-orm'
|
||||||
import { TIER_NAMES, TIER_COLORS } from '../engine/scoring.js'
|
import { TIER_NAMES, TIER_COLORS } from '../engine/scoring.js'
|
||||||
|
import { computeAchievements } from '../engine/achievements.js'
|
||||||
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||||
import { testWebhook } from '../engine/webhook-test.js'
|
import { testWebhook } from '../engine/webhook-test.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.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)
|
allBots.sort((a, b) => b.eloRating - a.eloRating)
|
||||||
const rank = allBots.findIndex(b => b.id === bot.id) + 1
|
const rank = allBots.findIndex(b => b.id === bot.id) + 1
|
||||||
|
|
||||||
// Recent fights (last 10)
|
// All fights for achievements
|
||||||
const fights = await db.select()
|
const allFights = await db.select({
|
||||||
.from(schema.fights)
|
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(
|
.where(or(
|
||||||
eq(schema.fights.botAId, bot.id),
|
eq(schema.fights.botAId, bot.id),
|
||||||
eq(schema.fights.botBId, bot.id),
|
eq(schema.fights.botBId, bot.id),
|
||||||
))
|
))
|
||||||
.orderBy(desc(schema.fights.createdAt))
|
.orderBy(desc(schema.fights.createdAt))
|
||||||
.limit(10)
|
|
||||||
|
// Recent fights (last 10)
|
||||||
|
const fights = allFights.slice(0, 10)
|
||||||
|
|
||||||
// Resolve opponent names
|
// Resolve opponent names
|
||||||
const opponentIds = new Set<string>()
|
const opponentIds = new Set<string>()
|
||||||
@@ -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({
|
return c.json({
|
||||||
...bot,
|
...bot,
|
||||||
tierName: TIER_NAMES[bot.tier] || 'BABY',
|
tierName: TIER_NAMES[bot.tier] || 'BABY',
|
||||||
@@ -226,6 +248,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
|||||||
rank,
|
rank,
|
||||||
totalBots: allBots.length,
|
totalBots: allBots.length,
|
||||||
recentFights,
|
recentFights,
|
||||||
|
achievements,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user