Spaces:
Runtime error
Runtime error
| /** | |
| * Badge Definitions & Evaluation Engine for OmniRoute Gamification | |
| * | |
| * Defines 20+ built-in badges across 5 categories and evaluates unlock | |
| * criteria against user activity. All DB access goes through dynamic imports | |
| * to avoid circular dependencies. | |
| * | |
| * @module lib/gamification/badges | |
| */ | |
| import type { BadgeDefinition } from "../db/gamification"; | |
| // ─── Built-in Badge Definitions ────────────────────────────────────────────── | |
| /** | |
| * All built-in badges shipped with OmniRoute. | |
| * Spread with `{ created_at: new Date().toISOString() }` when inserting. | |
| */ | |
| export const BUILTIN_BADGES: Omit<BadgeDefinition, "createdAt">[] = [ | |
| // ── Token Usage (Milestone) ────────────────────────────────────────────── | |
| { | |
| id: "first-token", | |
| name: "First Token", | |
| description: "Made your first API request", | |
| icon: "sparkles", | |
| category: "usage", | |
| rarity: "common", | |
| criteria: JSON.stringify({ type: "action_count", action: "request", threshold: 1 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "token-consumer", | |
| name: "Token Consumer", | |
| description: "Made 1,000 API requests", | |
| icon: "zap", | |
| category: "usage", | |
| rarity: "uncommon", | |
| criteria: JSON.stringify({ type: "action_count", action: "request", threshold: 1000 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "token-machine", | |
| name: "Token Machine", | |
| description: "Made 10,000 API requests", | |
| icon: "cpu", | |
| category: "usage", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "action_count", action: "request", threshold: 10000 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "token-whale", | |
| name: "Token Whale", | |
| description: "Made 100,000 API requests", | |
| icon: "whale", | |
| category: "usage", | |
| rarity: "legendary", | |
| criteria: JSON.stringify({ type: "action_count", action: "request", threshold: 100000 }), | |
| hidden: 0, | |
| }, | |
| // ── Token Sharing (Social) ─────────────────────────────────────────────── | |
| { | |
| id: "generous", | |
| name: "Generous", | |
| description: "Shared 1,000 tokens with others", | |
| icon: "gift", | |
| category: "sharing", | |
| rarity: "common", | |
| criteria: JSON.stringify({ type: "action_count", action: "token_share", threshold: 1000 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "philanthropist", | |
| name: "Philanthropist", | |
| description: "Shared 10,000 tokens with others", | |
| icon: "heart", | |
| category: "sharing", | |
| rarity: "uncommon", | |
| criteria: JSON.stringify({ type: "action_count", action: "token_share", threshold: 10000 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "token-santa", | |
| name: "Token Santa", | |
| description: "Shared 100,000 tokens with others", | |
| icon: "santa", | |
| category: "sharing", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "action_count", action: "token_share", threshold: 100000 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "community-hero", | |
| name: "Community Hero", | |
| description: "Shared 1,000,000 tokens with others", | |
| icon: "trophy", | |
| category: "sharing", | |
| rarity: "legendary", | |
| criteria: JSON.stringify({ | |
| type: "action_count", | |
| action: "token_share", | |
| threshold: 1000000, | |
| }), | |
| hidden: 0, | |
| }, | |
| // ── Contribution (Achievement) ─────────────────────────────────────────── | |
| { | |
| id: "explorer", | |
| name: "Explorer", | |
| description: "Used 5 different providers", | |
| icon: "compass", | |
| category: "contribution", | |
| rarity: "uncommon", | |
| criteria: JSON.stringify({ type: "unique_count", action: "provider", threshold: 5 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "polyglot", | |
| name: "Polyglot", | |
| description: "Used 10 different models", | |
| icon: "languages", | |
| category: "contribution", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "unique_count", action: "model", threshold: 10 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "architect", | |
| name: "Architect", | |
| description: "Created 3 combo routes", | |
| icon: "blocks", | |
| category: "contribution", | |
| rarity: "uncommon", | |
| criteria: JSON.stringify({ type: "action_count", action: "combo_create", threshold: 3 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "speedster", | |
| name: "Speedster", | |
| description: "Maintained <500ms avg latency for 100 requests", | |
| icon: "gauge", | |
| category: "contribution", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ | |
| type: "threshold", | |
| metric: "avg_latency", | |
| threshold: 500, | |
| window: 100, | |
| }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "resilient", | |
| name: "Resilient", | |
| description: "100% uptime for 7 days", | |
| icon: "shield", | |
| category: "contribution", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "threshold", metric: "uptime", threshold: 100, window: 7 }), | |
| hidden: 0, | |
| }, | |
| // ── Streak (Engagement) ────────────────────────────────────────────────── | |
| { | |
| id: "daily-user", | |
| name: "Daily User", | |
| description: "Active for 3 consecutive days", | |
| icon: "flame", | |
| category: "streak", | |
| rarity: "common", | |
| criteria: JSON.stringify({ type: "streak", threshold: 3 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "weekly-warrior", | |
| name: "Weekly Warrior", | |
| description: "Active for 7 consecutive days", | |
| icon: "sword", | |
| category: "streak", | |
| rarity: "uncommon", | |
| criteria: JSON.stringify({ type: "streak", threshold: 7 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "monthly-master", | |
| name: "Monthly Master", | |
| description: "Active for 30 consecutive days", | |
| icon: "crown", | |
| category: "streak", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "streak", threshold: 30 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "unstoppable", | |
| name: "Unstoppable", | |
| description: "Active for 365 consecutive days", | |
| icon: "infinity", | |
| category: "streak", | |
| rarity: "legendary", | |
| criteria: JSON.stringify({ type: "streak", threshold: 365 }), | |
| hidden: 0, | |
| }, | |
| // ── Rare / Legendary ───────────────────────────────────────────────────── | |
| { | |
| id: "early-adopter", | |
| name: "Early Adopter", | |
| description: "Joined within the first month of gamification", | |
| icon: "rocket", | |
| category: "rare", | |
| rarity: "legendary", | |
| criteria: JSON.stringify({ type: "first", window_days: 30 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "bug-hunter", | |
| name: "Bug Hunter", | |
| description: "Reported 5 issues", | |
| icon: "bug", | |
| category: "rare", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "action_count", action: "issue_report", threshold: 5 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "contributor", | |
| name: "Contributor", | |
| description: "Merged 1 pull request", | |
| icon: "git-merge", | |
| category: "rare", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "action_count", action: "pr_merge", threshold: 1 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "community-leader", | |
| name: "Community Leader", | |
| description: "Reached top 10 on any leaderboard", | |
| icon: "medal", | |
| category: "rare", | |
| rarity: "rare", | |
| criteria: JSON.stringify({ type: "rank", threshold: 10 }), | |
| hidden: 0, | |
| }, | |
| { | |
| id: "secret-badge", | |
| name: "???", | |
| description: "A hidden achievement awaits...", | |
| icon: "question", | |
| category: "rare", | |
| rarity: "legendary", | |
| criteria: JSON.stringify({ type: "hidden" }), | |
| hidden: 1, | |
| }, | |
| ]; | |
| // ─── Criteria Types ────────────────────────────────────────────────────────── | |
| interface ActionCountCriteria { | |
| type: "action_count"; | |
| action: string; | |
| threshold: number; | |
| } | |
| interface StreakCriteria { | |
| type: "streak"; | |
| threshold: number; | |
| } | |
| interface UniqueCountCriteria { | |
| type: "unique_count"; | |
| action: string; | |
| threshold: number; | |
| } | |
| interface ThresholdCriteria { | |
| type: "threshold"; | |
| metric: string; | |
| threshold: number; | |
| window?: number; | |
| } | |
| interface RankCriteria { | |
| type: "rank"; | |
| threshold: number; | |
| } | |
| interface FirstCriteria { | |
| type: "first"; | |
| window_days: number; | |
| } | |
| interface HiddenCriteria { | |
| type: "hidden"; | |
| } | |
| type BadgeCriteria = | |
| | ActionCountCriteria | |
| | StreakCriteria | |
| | UniqueCountCriteria | |
| | ThresholdCriteria | |
| | RankCriteria | |
| | FirstCriteria | |
| | HiddenCriteria; | |
| // ─── Helper: Action Count ──────────────────────────────────────────────────── | |
| /** | |
| * Get the total count of a specific action for an API key from the XP audit log. | |
| */ | |
| async function getActionCount(apiKeyId: string, action: string): Promise<number> { | |
| const { getDbInstance } = await import("../db/core"); | |
| const db = getDbInstance(); | |
| const row = db | |
| .prepare( | |
| `SELECT COALESCE(SUM( | |
| CASE WHEN metadata IS NOT NULL | |
| THEN CAST(json_extract(metadata, '$.amount') AS INTEGER) | |
| ELSE 1 | |
| END | |
| ), 0) AS total | |
| FROM xp_audit_log | |
| WHERE api_key_id = ? AND action = ?` | |
| ) | |
| .get(apiKeyId, action) as { total: number } | undefined; | |
| return row?.total ?? 0; | |
| } | |
| // ─── Helper: Unique Count ──────────────────────────────────────────────────── | |
| /** | |
| * Get the count of unique values for a given type (provider, model, etc.) | |
| * from the XP audit log metadata. | |
| */ | |
| async function getUniqueCount(apiKeyId: string, type: string): Promise<number> { | |
| const { getDbInstance } = await import("../db/core"); | |
| const db = getDbInstance(); | |
| const row = db | |
| .prepare( | |
| `SELECT COUNT(DISTINCT json_extract(metadata, '$.' || ?)) AS total | |
| FROM xp_audit_log | |
| WHERE api_key_id = ? AND metadata IS NOT NULL` | |
| ) | |
| .get(type, apiKeyId) as { total: number } | undefined; | |
| return row?.total ?? 0; | |
| } | |
| // ─── Helper: Streak ────────────────────────────────────────────────────────── | |
| /** | |
| * Get the current streak count for an API key. | |
| * Delegates to the streaks module to avoid duplication. | |
| */ | |
| async function getStreak(apiKeyId: string): Promise<number> { | |
| const { getStreak: fetchStreak } = await import("./streaks"); | |
| const data = await fetchStreak(apiKeyId); | |
| return data.currentStreak; | |
| } | |
| // ─── Helper: Leaderboard Rank ──────────────────────────────────────────────── | |
| /** | |
| * Get the rank of an API key on the global leaderboard. | |
| * Rank = number of users with a higher score + 1. | |
| */ | |
| async function getRank(apiKeyId: string, scope: string): Promise<number> { | |
| const { getDbInstance } = await import("../db/core"); | |
| const db = getDbInstance(); | |
| const scoreRow = db | |
| .prepare("SELECT score FROM leaderboard WHERE api_key_id = ? AND scope = ?") | |
| .get(apiKeyId, scope) as { score: number } | undefined; | |
| if (!scoreRow) return Infinity; | |
| const rankRow = db | |
| .prepare("SELECT COUNT(*) AS rank FROM leaderboard WHERE scope = ? AND score > ?") | |
| .get(scope, scoreRow.score) as { rank: number } | undefined; | |
| return (rankRow?.rank ?? 0) + 1; | |
| } | |
| // ─── Badge Evaluation Engine ───────────────────────────────────────────────── | |
| /** | |
| * Evaluate if an action triggers any badge unlocks. | |
| * | |
| * Iterates over all badge definitions, skips already-earned badges, | |
| * and checks each unearned badge's criteria against current user state. | |
| * Returns the list of newly unlocked badge IDs. | |
| * | |
| * @param apiKeyId - The API key to evaluate | |
| * @param action - The action that was just performed (e.g. "request", "token_share") | |
| * @param metadata - Optional context (provider, model, amount, etc.) | |
| * @returns Array of newly unlocked badge IDs | |
| */ | |
| export async function evaluateBadges( | |
| apiKeyId: string, | |
| action: string, | |
| metadata?: Record<string, unknown> | |
| ): Promise<string[]> { | |
| // Import DB functions dynamically to avoid circular deps | |
| const { getBadgeDefinitions, unlockBadge, getBadges } = await import("../db/gamification"); | |
| const definitions = getBadgeDefinitions(); | |
| const earned = getBadges(apiKeyId); | |
| const earnedIds = new Set(earned.map((b) => b.badgeId)); | |
| const newlyUnlocked: string[] = []; | |
| for (const def of definitions) { | |
| if (earnedIds.has(def.id)) continue; // Already earned | |
| if (!def.criteria) continue; | |
| let criteria: BadgeCriteria; | |
| try { | |
| criteria = JSON.parse(def.criteria) as BadgeCriteria; | |
| } catch { | |
| continue; // Malformed criteria, skip | |
| } | |
| let unlocked = false; | |
| switch (criteria.type) { | |
| case "action_count": { | |
| if (criteria.action === action) { | |
| const count = await getActionCount(apiKeyId, action); | |
| unlocked = count >= criteria.threshold; | |
| } | |
| break; | |
| } | |
| case "streak": { | |
| const streak = await getStreak(apiKeyId); | |
| unlocked = streak >= criteria.threshold; | |
| break; | |
| } | |
| case "unique_count": { | |
| if (criteria.action === action || action === "request") { | |
| // Check on any qualifying action, not just exact match | |
| const uniqueCount = await getUniqueCount(apiKeyId, criteria.action); | |
| unlocked = uniqueCount >= criteria.threshold; | |
| } | |
| break; | |
| } | |
| case "threshold": { | |
| // Threshold badges are evaluated externally (e.g. latency, uptime) | |
| // and triggered via metadata | |
| if (metadata && typeof metadata[criteria.metric] === "number") { | |
| const value = metadata[criteria.metric] as number; | |
| if (criteria.metric === "avg_latency") { | |
| unlocked = value < criteria.threshold; | |
| } else { | |
| unlocked = value >= criteria.threshold; | |
| } | |
| } | |
| break; | |
| } | |
| case "rank": { | |
| const rank = await getRank(apiKeyId, "global"); | |
| unlocked = rank <= criteria.threshold; | |
| break; | |
| } | |
| case "first": { | |
| // Time-limited badge: check if user joined within window | |
| const { getDbInstance } = await import("../db/core"); | |
| const db = getDbInstance(); | |
| const firstLog = db | |
| .prepare(`SELECT MIN(created_at) AS first_at FROM xp_audit_log WHERE api_key_id = ?`) | |
| .get(apiKeyId) as { first_at: string | null } | undefined; | |
| if (firstLog?.first_at) { | |
| const joinDate = new Date(firstLog.first_at); | |
| const windowEnd = new Date(joinDate); | |
| windowEnd.setDate(windowEnd.getDate() + (criteria as FirstCriteria).window_days); | |
| unlocked = new Date() <= windowEnd; | |
| } | |
| break; | |
| } | |
| case "hidden": { | |
| // Secret badge: unlocked by having earned all other badges | |
| const allOtherDefs = definitions.filter( | |
| (d) => d.id !== def.id && !JSON.parse(d.criteria).type?.toString().includes("hidden") | |
| ); | |
| const allOtherEarned = allOtherDefs.every((d) => earnedIds.has(d.id)); | |
| unlocked = allOtherEarned; | |
| break; | |
| } | |
| } | |
| if (unlocked) { | |
| unlockBadge(apiKeyId, def.id); | |
| newlyUnlocked.push(def.id); | |
| } | |
| } | |
| return newlyUnlocked; | |
| } | |
| /** | |
| * Seed built-in badge definitions into the database. | |
| * Idempotent — uses INSERT OR IGNORE so existing badges are not overwritten. | |
| */ | |
| export async function seedBuiltinBadges(): Promise<void> { | |
| const { getDbInstance } = await import("../db/core"); | |
| const db = getDbInstance(); | |
| const insert = db.prepare( | |
| `INSERT OR IGNORE INTO badge_definitions (id, name, description, icon, category, rarity, criteria, hidden) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?)` | |
| ); | |
| const insertMany = db.transaction((badges: typeof BUILTIN_BADGES) => { | |
| for (const badge of badges) { | |
| insert.run( | |
| badge.id, | |
| badge.name, | |
| badge.description, | |
| badge.icon, | |
| badge.category, | |
| badge.rarity, | |
| badge.criteria, | |
| badge.hidden | |
| ); | |
| } | |
| }); | |
| insertMany(BUILTIN_BADGES); | |
| } | |