import { users, userTimers } from "@db/schema"; import { eq, sql, and } from "drizzle-orm"; import { withTransaction } from "@/lib/db"; import { config } from "@/lib/config"; import type { Transaction } from "@shared/lib/types"; import { TimerType } from "@shared/lib/constants"; export const levelingService = { // Calculate total XP required to REACH a specific level (Cumulative) // Level 1 = 0 XP // Level 2 = Base * (1^Exp) // Level 3 = Level 2 + Base * (2^Exp) // ... getXpToReachLevel: (level: number) => { let total = 0; for (let l = 1; l < level; l++) { total += Math.floor(config.leveling.base * Math.pow(l, config.leveling.exponent)); } return total; }, // Calculate level from Total XP getLevelFromXp: (totalXp: bigint) => { let level = 1; let xp = Number(totalXp); while (true) { // XP needed to complete current level and reach next const xpForNext = Math.floor(config.leveling.base * Math.pow(level, config.leveling.exponent)); if (xp < xpForNext) { return level; } xp -= xpForNext; level++; } }, // Get XP needed to complete the current level (for calculating next level threshold in isolation) // Used internally or for display getXpForNextLevel: (currentLevel: number) => { return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent)); }, // Cumulative XP addition addXp: async (id: string, amount: bigint, tx?: Transaction) => { return await withTransaction(async (txFn) => { // Get current state const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(id)), }); if (!user) throw new Error("User not found"); const currentXp = user.xp ?? 0n; const newXp = currentXp + amount; // Calculate new level based on TOTAL accumulated XP const newLevel = levelingService.getLevelFromXp(newXp); const currentLevel = user.level ?? 1; const levelUp = newLevel > currentLevel; // Update user const [updatedUser] = await txFn.update(users) .set({ xp: newXp, level: newLevel, }) .where(eq(users.id, BigInt(id))) .returning(); return { user: updatedUser, levelUp, currentLevel: newLevel }; }, tx); }, // Handle chat XP with cooldowns processChatXp: async (id: string, tx?: Transaction) => { return await withTransaction(async (txFn) => { // check if an xp cooldown is in place const cooldown = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(id)), eq(userTimers.type, TimerType.COOLDOWN), eq(userTimers.key, 'chat_xp') ), }); const now = new Date(); if (cooldown && cooldown.expiresAt > now) { return { awarded: false, reason: 'cooldown' }; } // Calculate random XP let amount = BigInt(Math.floor(Math.random() * (config.leveling.chat.maxXp - config.leveling.chat.minXp + 1)) + config.leveling.chat.minXp); // Check for XP Boost const xpBoost = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(id)), eq(userTimers.type, TimerType.EFFECT), eq(userTimers.key, 'xp_boost') ) }); if (xpBoost && xpBoost.expiresAt > now) { const multiplier = (xpBoost.metadata as any)?.multiplier || 1; amount = BigInt(Math.floor(Number(amount) * multiplier)); } // Add XP const result = await levelingService.addXp(id, amount, txFn); // Update/Set Cooldown const nextReadyAt = new Date(now.getTime() + config.leveling.chat.cooldownMs); await txFn.insert(userTimers) .values({ userId: BigInt(id), type: TimerType.COOLDOWN, key: 'chat_xp', expiresAt: nextReadyAt, }) .onConflictDoUpdate({ target: [userTimers.userId, userTimers.type, userTimers.key], set: { expiresAt: nextReadyAt }, }); return { awarded: true, amount, ...result }; }, tx); } };