Spaces:
Runtime error
Runtime error
| /** | |
| * 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<string, unknown>; | |
| }): Promise<void> { | |
| 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<string, number> = { | |
| 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<void> { | |
| 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<void> { | |
| 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<string, Array<{ id: string; threshold: number }>> = { | |
| 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); | |
| } | |
| } | |
| } | |