mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|