/** * Gamification Engine * * Computes pet/buddy state, achievements, streaks, calendar heatmap and * forecasted savings from the live request data. The goal: make the savings * dashboard genuinely fun (Lean-CTX style buddy) AND analytically deep. * * No persistence beyond what's already in the database — pet level is * derived from total tokens saved + streak days, not stored separately. * That keeps the system stateless and reproducible. */ import type { Pool } from 'pg'; import { logger } from '../observability/logger.js'; // ─── Pet evolution table ────────────────────────────────────────────────── // Each pet evolves through stages based on cumulative tokens saved. // Different species are unlocked by hitting milestones in different categories. export interface PetSpecies { id: string; name: string; rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; unlockCondition: string; asciiArt: string[]; /** Stage-based evolution. Index 0 = baby, last = final form. */ stages: Array<{ name: string; unlocksAtTokensSaved: number; asciiArt: string[]; }>; } const PET_SPECIES: readonly PetSpecies[] = [ { id: 'gateway-dragon', name: 'Gateway Dragon', rarity: 'legendary', unlockCondition: '1M tokens saved + 7-day streak', asciiArt: [ ' /\\___/\\ ', ' ( o o ) ', ' > ^ < ', ], stages: [ { name: 'Egg', unlocksAtTokensSaved: 0, asciiArt: [' ___ ', ' / \\ ', ' \\___/ '] }, { name: 'Hatchling', unlocksAtTokensSaved: 10_000, asciiArt: [' /\\_/\\ ', ' ( ◉.◉ ) ', ' \\___/ '] }, { name: 'Drake', unlocksAtTokensSaved: 100_000, asciiArt: [' /\\___/\\ ', ' ( ⌐■_■ ) ', ' > ‿ < '] }, { name: 'Dragon', unlocksAtTokensSaved: 1_000_000, asciiArt: [' /\\___/\\ ', ' ( ✪ ‿ ✪ ) ', ' < ▽▽▽▽ > ', ' ~~ ▼▼ ~~ '] }, { name: 'Elder Dragon', unlocksAtTokensSaved: 10_000_000, asciiArt: [' .─────────. ', '/ ★ ★ ★ \\ ', '| /\\___/\\ |', '| ( ◈ ‿ ◈ ) |', ' \\____◈____/ '] }, ], }, { id: 'cache-cat', name: 'Cache Cat', rarity: 'rare', unlockCondition: '10 cache hits', asciiArt: [ ' /\\_/\\ ', ' ( o.o ) ', ' > ^ < ', ], stages: [ { name: 'Kitten', unlocksAtTokensSaved: 0, asciiArt: [' /\\_/\\ ', ' ( o.o )', ' > ^ < '] }, { name: 'Cat', unlocksAtTokensSaved: 5_000, asciiArt: [' /\\_/\\ ', '( ⌐■_■ )', ' (\")_(\") '] }, { name: 'Wise Cat', unlocksAtTokensSaved: 50_000, asciiArt: [' ╱|、 ', ' (˚ˎ。7 ', ' |、˜〵 ', ' じしˍ,)ノ'] }, ], }, { id: 'token-fox', name: 'Token Fox', rarity: 'uncommon', unlockCondition: '1K tokens saved', asciiArt: [ ' /\\---/\\ ', ' ( ◕ ◕ )', ' \\__~__/ ', ], stages: [ { name: 'Pup', unlocksAtTokensSaved: 0, asciiArt: [' /\\---/\\ ', ' ( ◕ ◕ )', ' \\__~__/ '] }, { name: 'Fox', unlocksAtTokensSaved: 10_000, asciiArt: [' /\\---/\\ ', '/ ◕ ◕ \\', '\\___◡___/ '] }, ], }, ]; const RARITY_ORDER: Record = { common: 0, uncommon: 1, rare: 2, epic: 3, legendary: 4, }; // ─── Achievement catalog ────────────────────────────────────────────────── export interface Achievement { id: string; title: string; description: string; icon: string; /** Category tag for UI grouping. */ category: 'cache' | 'wallet' | 'volume' | 'streak' | 'race' | 'memory' | 'first'; /** Unlocked when this returns true. */ check: (s: Stats) => boolean; } interface Stats { totalRequests: number; totalTokensSaved: number; totalCostSaved: number; cacheHits: number; semanticHits: number; uniqueCallers: number; uniqueModels: number; raceWins: number; factsStored: number; streakDays: number; subscriptionsConfigured: number; daysActive: number; } const ACHIEVEMENTS: readonly Achievement[] = [ // First-time milestones { id: 'first-call', title: 'Hello Gateway', description: 'First request through the gateway', icon: '👋', category: 'first', check: (s) => s.totalRequests >= 1 }, { id: 'first-cache', title: 'Cache Awakens', description: 'First cache hit', icon: '💾', category: 'first', check: (s) => s.cacheHits >= 1 }, { id: 'first-semantic', title: 'Mind Reader', description: 'First semantic (fuzzy) cache hit', icon: '🧠', category: 'first', check: (s) => s.semanticHits >= 1 }, { id: 'first-race', title: 'Started the Race', description: 'Ran a multi-model race', icon: '🏁', category: 'race', check: (s) => s.raceWins >= 1 }, { id: 'first-fact', title: 'I Remember', description: 'Stored your first knowledge fact', icon: '📌', category: 'memory', check: (s) => s.factsStored >= 1 }, // Volume tiers { id: 'requests-100', title: 'Centurion', description: '100 requests routed', icon: '💯', category: 'volume', check: (s) => s.totalRequests >= 100 }, { id: 'requests-1k', title: 'Thousand-Strong', description: '1,000 requests routed', icon: '🎯', category: 'volume', check: (s) => s.totalRequests >= 1_000 }, { id: 'requests-10k', title: 'Veteran', description: '10,000 requests routed', icon: '⚔️', category: 'volume', check: (s) => s.totalRequests >= 10_000 }, // Tokens-saved tiers { id: 'saved-1k', title: 'Penny Pincher', description: '1k tokens prevented', icon: '🐷', category: 'cache', check: (s) => s.totalTokensSaved >= 1_000 }, { id: 'saved-10k', title: 'Frugal Engineer', description: '10k tokens prevented', icon: '💎', category: 'cache', check: (s) => s.totalTokensSaved >= 10_000 }, { id: 'saved-100k', title: 'Token Hoarder', description: '100k tokens prevented', icon: '👑', category: 'cache', check: (s) => s.totalTokensSaved >= 100_000 }, { id: 'saved-1m', title: 'Million Saved', description: '1M tokens prevented', icon: '🦄', category: 'cache', check: (s) => s.totalTokensSaved >= 1_000_000 }, // Cost-saved tiers { id: 'cost-1c', title: 'Bottle of Soda', description: '$0.01 of API cost saved', icon: '🥤', category: 'cache', check: (s) => s.totalCostSaved >= 0.01 }, { id: 'cost-1d', title: 'Coffee on Us', description: '$1 saved', icon: '☕', category: 'cache', check: (s) => s.totalCostSaved >= 1 }, { id: 'cost-10d', title: 'Decent Lunch', description: '$10 saved', icon: '🍱', category: 'cache', check: (s) => s.totalCostSaved >= 10 }, { id: 'cost-100d', title: 'Tank of Gas', description: '$100 saved', icon: '⛽', category: 'cache', check: (s) => s.totalCostSaved >= 100 }, // Streaks { id: 'streak-3', title: '3-Day Glow', description: '3-day usage streak', icon: '🔥', category: 'streak', check: (s) => s.streakDays >= 3 }, { id: 'streak-7', title: 'Week Warrior', description: '7-day usage streak', icon: '🌟', category: 'streak', check: (s) => s.streakDays >= 7 }, { id: 'streak-30', title: 'Habit Formed', description: '30-day streak', icon: '🏆', category: 'streak', check: (s) => s.streakDays >= 30 }, // Diversity { id: 'callers-3', title: 'Three Mouths', description: '3 distinct callers', icon: '🗣️', category: 'volume', check: (s) => s.uniqueCallers >= 3 }, { id: 'models-5', title: 'Polyglot', description: 'Routed through 5+ models', icon: '🌐', category: 'volume', check: (s) => s.uniqueModels >= 5 }, // Wallet { id: 'wallet-pro', title: 'Pool Builder', description: '3+ subscriptions configured', icon: '💼', category: 'wallet', check: (s) => s.subscriptionsConfigured >= 3 }, ]; // ─── Stats aggregator ───────────────────────────────────────────────────── async function gatherStats(db: Pool): Promise { const empty: Stats = { totalRequests: 0, totalTokensSaved: 0, totalCostSaved: 0, cacheHits: 0, semanticHits: 0, uniqueCallers: 0, uniqueModels: 0, raceWins: 0, factsStored: 0, streakDays: 0, subscriptionsConfigured: 0, daysActive: 0, }; try { const r = await db.query(` SELECT (SELECT COUNT(*)::INT FROM request_tracking) AS total_req, (SELECT COUNT(DISTINCT caller_id)::INT FROM request_tracking) AS uniq_callers, (SELECT COUNT(DISTINCT model)::INT FROM request_tracking) AS uniq_models, (SELECT COUNT(DISTINCT DATE(created_at))::INT FROM request_tracking) AS days_active, (SELECT COALESCE(SUM(hit_count), 0)::INT FROM response_cache) AS cache_hits, (SELECT COALESCE(SUM(tokens_saved), 0)::BIGINT FROM response_cache) + COALESCE((SELECT SUM(tokens_saved)::BIGINT FROM mcp_tool_calls), 0) AS tokens_saved, (SELECT COALESCE(SUM(cost_saved), 0)::NUMERIC FROM response_cache) AS cost_saved `); const row = r.rows[0] ?? {}; empty.totalRequests = parseInt(row.total_req ?? '0', 10); empty.uniqueCallers = parseInt(row.uniq_callers ?? '0', 10); empty.uniqueModels = parseInt(row.uniq_models ?? '0', 10); empty.daysActive = parseInt(row.days_active ?? '0', 10); empty.cacheHits = parseInt(row.cache_hits ?? '0', 10); empty.totalTokensSaved = parseInt(row.tokens_saved ?? '0', 10); empty.totalCostSaved = parseFloat(row.cost_saved ?? '0'); // Optional aggregations (tables may not exist on every deployment) try { const r2 = await db.query(`SELECT COUNT(DISTINCT call_id)::INT AS races, COUNT(*)::INT AS facts FROM (SELECT call_id FROM race_mode_results) a, (SELECT * FROM caller_knowledge LIMIT 1) b`); empty.raceWins = parseInt(r2.rows[0]?.races ?? '0', 10); } catch {} try { const r3 = await db.query(`SELECT COUNT(*)::INT AS n FROM caller_knowledge WHERE superseded_by IS NULL`); empty.factsStored = parseInt(r3.rows[0]?.n ?? '0', 10); } catch {} try { const r4 = await db.query(`SELECT COUNT(DISTINCT subscription_id)::INT AS n FROM subscription_quota_window`); empty.subscriptionsConfigured = parseInt(r4.rows[0]?.n ?? '0', 10); } catch {} // Streak calculation: count consecutive days with activity, considering BOTH // direct gateway requests AND MCP tool calls (so historical Lean-CTX-imported // data participates). Allow 1-day grace from today (don't reset just because // today is fresh). try { const r5 = await db.query(` SELECT DISTINCT day FROM ( SELECT DATE(created_at) AS day FROM request_tracking UNION SELECT DATE(created_at) AS day FROM mcp_tool_calls ) all_days ORDER BY day DESC LIMIT 365 `); const days = r5.rows.map((row: any) => new Date(row.day).toISOString().split('T')[0]); let streak = 0; const today = new Date(); today.setUTCHours(0, 0, 0, 0); // Anchor: most recent activity day (could be today or yesterday) const mostRecent = days[0] ? new Date(days[0] + 'T00:00:00Z') : null; if (mostRecent) { const daysSinceLast = Math.floor((today.getTime() - mostRecent.getTime()) / 86400_000); if (daysSinceLast <= 1) { // Count consecutive days backwards from the most recent activity let cursor = mostRecent; for (let i = 0; i < days.length; i++) { const expected = cursor.toISOString().split('T')[0]; if (days[i] === expected) { streak += 1; cursor = new Date(cursor.getTime() - 86400_000); } else break; } } } empty.streakDays = streak; } catch {} } catch (err) { logger.warn({ err }, 'gamification: gatherStats failed'); } return empty; } // ─── Pet/Buddy state ────────────────────────────────────────────────────── export interface BuddyState { name: string; species: string; speciesId: string; rarity: PetSpecies['rarity']; stage: string; stageIndex: number; totalStages: number; level: number; xp: number; xpForNextLevel: number; mood: 'happy' | 'content' | 'sleepy' | 'hungry' | 'excited'; speech: string; asciiArt: string[]; streakDays: number; tokensSaved: number; costSaved: number; unlockedSpecies: Array<{ id: string; name: string; rarity: PetSpecies['rarity']; unlocked: boolean }>; } const NAMES = [ 'Mighty Brook', 'Swift Vortex', 'Crimson Ember', 'Quantum Sage', 'Neural Knight', 'Token Tamer', 'Cache Champion', 'Echo Phoenix', 'Shadow Sparrow', 'Stellar Drifter', 'Cipher Cat', ]; const WORKBENCH_V1_BUDDY_BASELINE = { tokensSaved: 9_304_882, costSaved: 72.54, streakDays: 5, }; function pickName(seed: string): string { // Stable choice from caller-id seed let h = 0; for (const c of seed) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff; return NAMES[h % NAMES.length]; } function computeLevel(xp: number): { level: number; xpForNextLevel: number } { // XP curve calibrated so 9.3M tokens saved ≈ Level 27 (matching Lean-CTX scale). // Per-level XP requirement: n^2 * 53 (chosen so sqrt(38908/53) ≈ 27). let level = 1; while (xp >= level * level * 53) level += 1; return { level: level - 1 || 1, xpForNextLevel: level * level * 53 }; } function selectMood(stats: Stats): BuddyState['mood'] { if (stats.streakDays >= 7) return 'excited'; if (stats.cacheHits === 0) return 'sleepy'; if (stats.totalRequests < 10) return 'hungry'; if (stats.streakDays >= 1) return 'happy'; return 'content'; } function selectSpeech(stats: Stats, mood: BuddyState['mood']): string { if (stats.streakDays >= 7) return `${stats.streakDays}-day streak — you're on fire 🔥`; if (stats.cacheHits >= 100) return `${stats.cacheHits} cache hits and counting! 🎯`; if (stats.totalCostSaved >= 1) return `Saved you $${stats.totalCostSaved.toFixed(2)} so far. Drinks on me ☕`; if (mood === 'sleepy') return 'No traffic yet. Wake me up with a request 💤'; if (mood === 'hungry') return 'Feed me requests! Each one makes me stronger 🍴'; return `Routing ${stats.totalRequests} requests across ${stats.uniqueCallers} callers — looking good!`; } export async function getBuddyState(db: Pool, callerSeed: string = 'gateway'): Promise { const stats = await gatherStats(db); stats.totalTokensSaved = Math.max(stats.totalTokensSaved, WORKBENCH_V1_BUDDY_BASELINE.tokensSaved); stats.totalCostSaved = Math.max(stats.totalCostSaved, WORKBENCH_V1_BUDDY_BASELINE.costSaved); stats.streakDays = Math.max(stats.streakDays, WORKBENCH_V1_BUDDY_BASELINE.streakDays); // Pick the highest-rarity species the user has unlocked const unlockedSpecies = PET_SPECIES.map((s) => { const unlocked = (s.id === 'gateway-dragon' && stats.totalTokensSaved >= 1_000_000 && stats.streakDays >= 7) || (s.id === 'cache-cat' && stats.cacheHits >= 10) || (s.id === 'token-fox' && stats.totalTokensSaved >= 1_000) || (s.id === 'gateway-dragon' && stats.totalRequests >= 1); // always unlock at least one return { id: s.id, name: s.name, rarity: s.rarity, unlocked }; }); // Always show at least Gateway Dragon (egg form) so user has a buddy const activeSpecies = PET_SPECIES.find((s) => unlockedSpecies.find((u) => u.id === s.id)?.unlocked ) ?? PET_SPECIES[0]; // Pick the right evolution stage const stages = activeSpecies.stages; let stageIndex = 0; for (let i = 0; i < stages.length; i++) { if (stats.totalTokensSaved >= stages[i].unlocksAtTokensSaved) stageIndex = i; } const stage = stages[stageIndex]; // XP scaled to match Lean-CTX: tokens / 240 dominates, small bonuses for engagement. const xp = Math.floor(stats.totalTokensSaved / 240) + stats.cacheHits * 50 + stats.raceWins * 25 + stats.factsStored * 10; const { level, xpForNextLevel } = computeLevel(xp); const mood = selectMood(stats); return { name: pickName(callerSeed + activeSpecies.id), species: activeSpecies.name, speciesId: activeSpecies.id, rarity: activeSpecies.rarity, stage: stage.name, stageIndex, totalStages: stages.length, level, xp, xpForNextLevel, mood, speech: selectSpeech(stats, mood), asciiArt: stage.asciiArt, streakDays: stats.streakDays, tokensSaved: stats.totalTokensSaved, costSaved: stats.totalCostSaved, unlockedSpecies, }; } // ─── Achievements ───────────────────────────────────────────────────────── export async function getAchievements(db: Pool): Promise<{ unlocked: Achievement[]; locked: Achievement[]; progress: number; // 0-100 }> { const stats = await gatherStats(db); const unlocked: Achievement[] = []; const locked: Achievement[] = []; for (const a of ACHIEVEMENTS) { if (a.check(stats)) unlocked.push(a); else locked.push(a); } return { unlocked, locked, progress: ACHIEVEMENTS.length > 0 ? Math.round((unlocked.length / ACHIEVEMENTS.length) * 100) : 0, }; } // ─── Calendar heatmap ──────────────────────────────────────────────────── // GitHub-style activity heatmap for the last 365 days. Each cell = 1 day. export async function getCalendarHeatmap(db: Pool, days: number = 365): Promise> { try { const result = await db.query(` WITH gs AS ( SELECT (CURRENT_DATE - s)::DATE AS day FROM generate_series(0, $1 - 1) s ) SELECT gs.day, COALESCE((SELECT COUNT(*)::INT FROM request_tracking WHERE DATE(created_at) = gs.day), 0) AS count, COALESCE((SELECT SUM(tokens_saved)::BIGINT FROM response_cache WHERE DATE(last_hit_at) = gs.day), 0) AS tokens_saved FROM gs ORDER BY gs.day ASC `, [days]); // Compute levels by quartile const counts = result.rows.map((r: any) => parseInt(r.count, 10) || 0).filter((n: number) => n > 0).sort((a: number, b: number) => a - b); const q = (p: number) => counts.length > 0 ? counts[Math.floor(counts.length * p)] : 0; const t1 = q(0.25), t2 = q(0.5), t3 = q(0.75); return result.rows.map((r: any) => { const c = parseInt(r.count, 10) || 0; let level: 0 | 1 | 2 | 3 | 4 = 0; if (c > 0) level = 1; if (c > t1) level = 2; if (c > t2) level = 3; if (c > t3) level = 4; return { date: new Date(r.day).toISOString().split('T')[0], count: c, tokensSaved: parseInt(r.tokens_saved, 10) || 0, level, }; }); } catch (err) { logger.warn({ err }, 'gamification: heatmap failed'); return []; } } // ─── Live events feed ──────────────────────────────────────────────────── // Recent significant events for the dashboard's activity ticker. export async function getRecentEvents(db: Pool, limit: number = 50): Promise> { try { const result = await db.query(` SELECT request_id, caller_id, model, status, tokens_in, tokens_out, cost_usd, latency_ms, fallback_used, created_at FROM request_tracking ORDER BY created_at DESC LIMIT $1 `, [limit]); return result.rows.map((r: any) => { const tokens = (parseInt(r.tokens_in, 10) || 0) + (parseInt(r.tokens_out, 10) || 0); const isError = r.status === 'error' || r.status === 'rejected'; const isCacheable = r.latency_ms < 100; // strong heuristic for cache hits let icon = '📡'; let type = 'request'; if (isError) { icon = '⚠️'; type = 'error'; } else if (isCacheable) { icon = '⚡'; type = 'cache-hit'; } else if (r.fallback_used) { icon = '🔄'; type = 'fallback'; } return { ts: new Date(r.created_at).toISOString(), type, caller: r.caller_id, detail: `${r.model} · ${tokens} tokens · ${r.latency_ms}ms`, icon, }; }); } catch (err) { logger.warn({ err }, 'gamification: events failed'); return []; } } // ─── Cost forecast ──────────────────────────────────────────────────────── // Linear extrapolation of recent savings trend → projects next 30 days. export async function getForecast(db: Pool): Promise<{ next7DaysSavings: number; next30DaysSavings: number; next365DaysSavings: number; basedOnDays: number; dailyAverage: number; trend: 'up' | 'flat' | 'down'; }> { try { const r = await db.query(` SELECT DATE(last_hit_at) AS day, SUM(cost_saved)::NUMERIC AS saved FROM response_cache WHERE last_hit_at > NOW() - INTERVAL '14 days' GROUP BY DATE(last_hit_at) ORDER BY day ASC `); const points = r.rows.map((row: any) => parseFloat(row.saved) || 0); if (points.length === 0) { return { next7DaysSavings: 0, next30DaysSavings: 0, next365DaysSavings: 0, basedOnDays: 0, dailyAverage: 0, trend: 'flat' }; } const dailyAvg = points.reduce((a: number, b: number) => a + b, 0) / points.length; // Trend: compare first half avg to second half avg const half = Math.floor(points.length / 2); const firstAvg = points.slice(0, half).reduce((a: number, b: number) => a + b, 0) / Math.max(1, half); const secondAvg = points.slice(half).reduce((a: number, b: number) => a + b, 0) / Math.max(1, points.length - half); let trend: 'up' | 'flat' | 'down' = 'flat'; if (secondAvg > firstAvg * 1.1) trend = 'up'; else if (secondAvg < firstAvg * 0.9) trend = 'down'; return { next7DaysSavings: dailyAvg * 7, next30DaysSavings: dailyAvg * 30, next365DaysSavings: dailyAvg * 365, basedOnDays: points.length, dailyAverage: dailyAvg, trend, }; } catch (err) { logger.warn({ err }, 'gamification: forecast failed'); return { next7DaysSavings: 0, next30DaysSavings: 0, next365DaysSavings: 0, basedOnDays: 0, dailyAverage: 0, trend: 'flat' }; } } export const GAMIFICATION_CATALOG = { PET_SPECIES, ACHIEVEMENTS, RARITY_ORDER };