import { db } from '../../db/client.js'; import { users } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; /** Combo bonus tiers: minimum combo count → bonus XP */ const COMBO_BONUSES: ReadonlyArray<{ minCombo: number; bonus: number }> = [ { minCombo: 10, bonus: 20 }, { minCombo: 5, bonus: 10 }, { minCombo: 3, bonus: 5 }, ]; const BASE_XP = 10; const DEFAULT_DAILY_GOAL = 50; function toDateString(value: Date | string | null): string | null { if (!value) return null; if (typeof value === 'string') return value.slice(0, 10); return value.toISOString().slice(0, 10); } export interface DailyXpStatus { earned: number; goal: number; date: string; } /** * Calculate total XP for a correct answer, including combo bonus. * Combo bonus is cumulative: 3-combo = +5, 5-combo = +10, 10-combo = +20. */ export function calculateXp(baseXp: number, comboCount: number): number { let bonus = 0; for (const tier of COMBO_BONUSES) { if (comboCount >= tier.minCombo) { bonus = tier.bonus; break; } } return baseXp + bonus; } /** * Add XP to a user. Handles daily XP reset if the date has changed. * Uses atomic SQL update to prevent race conditions. */ export async function addXp(userId: string, amount: number): Promise { const today = new Date().toISOString().slice(0, 10); // Atomically update total XP and handle daily reset await db .update(users) .set({ xpTotal: sql`COALESCE(xp_total, 0) + ${amount}`, dailyXpEarned: sql`CASE WHEN COALESCE(daily_xp_date, '') = ${today} THEN COALESCE(daily_xp_earned, 0) + ${amount} ELSE ${amount} END`, dailyXpDate: sql`CAST(${today} AS DATE)`, }) .where(eq(users.id, userId)); } /** * Get the user's daily XP status. */ export async function getDailyXpStatus(userId: string): Promise { const today = new Date().toISOString().slice(0, 10); const [user] = await db .select({ dailyXpEarned: users.dailyXpEarned, dailyXpDate: users.dailyXpDate, dailyXpGoal: users.dailyXpGoal, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user) { return { earned: 0, goal: DEFAULT_DAILY_GOAL, date: today }; } const isToday = toDateString(user.dailyXpDate) === today; return { earned: isToday ? (user.dailyXpEarned ?? 0) : 0, goal: user.dailyXpGoal ?? DEFAULT_DAILY_GOAL, date: today, }; } export { BASE_XP };