/** * Gamification event emitter — called from chat pipeline. * * @module lib/gamification/events */ import { logger } from "../../../open-sse/utils/logger.ts"; const log = logger("GAMIFICATION"); /** * Emit a gamification event. All gamification updates happen here. * Called from chatCore.ts after successful requests. * * This function is fire-and-forget — never blocks the request pipeline. * All errors are caught and logged, never thrown. */ export async function emitGamificationEvent(params: { apiKeyId: string; action: | "request" | "provider_switch" | "model_switch" | "combo_create" | "combo_use" | "token_share" | "invite_redeem" | "daily_login"; metadata?: Record; }): Promise { const { apiKeyId, action, metadata } = params; if (!apiKeyId) return; // Skip if no API key try { // 1. Award XP const xpAmount = getXpForAction(action); if (xpAmount > 0) { const { addXp } = await import("../db/gamification"); addXp(apiKeyId, action, xpAmount, metadata ? JSON.stringify(metadata) : undefined); // Update level const { getXp, updateLevel } = await import("../db/gamification"); const xp = getXp(apiKeyId); if (xp) { const { calculateLevel } = await import("./xp"); const newLevel = calculateLevel(xp.totalXp); if (newLevel !== xp.currentLevel) { updateLevel(apiKeyId, newLevel); log.info("events.level_up", { apiKeyId, oldLevel: xp.currentLevel, newLevel }); } } } // 2. Update streak if (action === "request") { const { updateStreak } = await import("./streaks"); const streak = await updateStreak(apiKeyId); // Check streak badges if (streak >= 365) { await checkAndUnlockBadge(apiKeyId, "unstoppable"); } else if (streak >= 30) { await checkAndUnlockBadge(apiKeyId, "monthly-master"); } else if (streak >= 7) { await checkAndUnlockBadge(apiKeyId, "weekly-warrior"); } else if (streak >= 3) { await checkAndUnlockBadge(apiKeyId, "daily-user"); } } // 3. Update leaderboard const { updateScore } = await import("./leaderboard"); await updateScore(apiKeyId, "global", xpAmount); // Update weekly/monthly await updateScore(apiKeyId, "weekly", xpAmount); await updateScore(apiKeyId, "monthly", xpAmount); // Update specific scopes if (action === "token_share") { await updateScore(apiKeyId, "tokens_shared", xpAmount); } // 4. Check action count badges await checkActionCountBadges(apiKeyId, action); } catch (err) { // Never throw — gamification must not break the request pipeline log.error("events.error", { apiKeyId, action, error: err instanceof Error ? err.message : String(err), }); } } /** * Get XP amount for an action. */ function getXpForAction(action: string): number { const rewards: Record = { request: 1, provider_switch: 5, model_switch: 3, combo_create: 10, combo_use: 2, token_share: 1, invite_redeem: 50, daily_login: 5, }; return rewards[action] || 0; } /** * Check and unlock a specific badge. */ async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise { const { unlockBadge, hasBadge } = await import("../db/gamification"); // #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is // empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on // every request. if (!hasBadge(apiKeyId, badgeId)) { unlockBadge(apiKeyId, badgeId); log.info("events.badge_unlocked", { apiKeyId, badgeId }); // Look up badge details from badge_definitions const { getDbInstance } = await import("../db/core"); const badgeRow = getDbInstance() .prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?") .get(badgeId) as | { name: string; description: string | null; icon: string | null; rarity: string } | undefined; // Record notification for SSE toast const { recordBadgeUnlock } = await import("./notifications"); recordBadgeUnlock(apiKeyId, { badgeId, badgeName: badgeRow?.name ?? badgeId, badgeDescription: badgeRow?.description ?? "", badgeIcon: badgeRow?.icon ?? "award", badgeRarity: badgeRow?.rarity ?? "common", unlockedAt: new Date().toISOString(), }); } } /** * Check action count badges after an action. */ async function checkActionCountBadges(apiKeyId: string, action: string): Promise { const { getDbInstance } = await import("../db/core"); const db = getDbInstance(); // Count total actions of this type const row = db .prepare( "SELECT COALESCE(COUNT(*), 0) AS count FROM xp_audit_log WHERE api_key_id = ? AND action = ?" ) .get(apiKeyId, action) as { count: number }; const count = row.count; // Badge thresholds const thresholds: Record> = { request: [ { id: "first-token", threshold: 1 }, { id: "token-consumer", threshold: 1000 }, { id: "token-machine", threshold: 10000 }, { id: "token-whale", threshold: 100000 }, ], token_share: [ { id: "generous", threshold: 1000 }, { id: "philanthropist", threshold: 10000 }, { id: "token-santa", threshold: 100000 }, { id: "community-hero", threshold: 1000000 }, ], }; const badges = thresholds[action]; if (!badges) return; for (const badge of badges) { if (count >= badge.threshold) { await checkAndUnlockBadge(apiKeyId, badge.id); } } }