Spaces:
Running
Running
| ; | |
| /** Phase 5 — XP / level service. | |
| * | |
| * Level curve (per replit.md spec): | |
| * level = floor(sqrt(xp / 12)), capped at 1000. | |
| * | |
| * Rate-limit: chat XP is gated to 1 grant per user per minute. Other | |
| * actions (post, friend, payment) bypass the rate-limit because they | |
| * already have natural cooldowns. | |
| */ | |
| Object.defineProperty(exports, "__esModule", { value: true }); | |
| exports.XP_REASONS = void 0; | |
| exports.levelFromXp = levelFromXp; | |
| exports.addXp = addXp; | |
| exports.awardAchievement = awardAchievement; | |
| const drive_1 = require("./drive"); | |
| const achievements_1 = require("../utils/achievements"); | |
| const lastChatXpAt = new Map(); | |
| const CHAT_RL_MS = 60 * 1000; | |
| exports.XP_REASONS = { | |
| chat: 1, | |
| friend_accept: 5, | |
| post: 10, | |
| payment_approved: 50, | |
| }; | |
| function levelFromXp(xp) { | |
| if (!Number.isFinite(xp) || xp <= 0) | |
| return 0; | |
| return Math.min(1000, Math.floor(Math.sqrt(xp / 12))); | |
| } | |
| function levelMilestoneAchievements(newLevel) { | |
| const out = []; | |
| if (newLevel >= 10) | |
| out.push("level_10"); | |
| if (newLevel >= 50) | |
| out.push("level_50"); | |
| if (newLevel >= 100) | |
| out.push("level_100"); | |
| return out; | |
| } | |
| /** Awards XP + persists. Returns the updated user. If `reason === "chat"` | |
| * and the user already received chat XP within the last minute, returns a | |
| * zero-delta result (no write). */ | |
| async function addXp(user, reason, extraAchievements = []) { | |
| const baseXp = Number(user.xp || 0); | |
| const baseLevel = Number(user.level || 0); | |
| const baseAch = Array.isArray(user.achievements) ? user.achievements : []; | |
| let amount = exports.XP_REASONS[reason]; | |
| if (reason === "chat") { | |
| const last = lastChatXpAt.get(user.username) || 0; | |
| if (Date.now() - last < CHAT_RL_MS) | |
| amount = 0; | |
| else | |
| lastChatXpAt.set(user.username, Date.now()); | |
| } | |
| const newXp = baseXp + amount; | |
| const newLevel = levelFromXp(newXp); | |
| const levelUp = newLevel > baseLevel; | |
| // Merge + dedupe achievements. | |
| const candidate = new Set(baseAch); | |
| const newlyAwarded = []; | |
| for (const k of [...extraAchievements, ...levelMilestoneAchievements(newLevel)]) { | |
| if (!(0, achievements_1.isValidAchievement)(k)) | |
| continue; | |
| if (!candidate.has(k)) { | |
| candidate.add(k); | |
| newlyAwarded.push(k); | |
| } | |
| } | |
| if (amount === 0 && newlyAwarded.length === 0) { | |
| return { xpDelta: 0, xp: baseXp, level: baseLevel, levelUp: false, newAchievements: [], user }; | |
| } | |
| const updated = { | |
| ...user, | |
| xp: newXp, | |
| level: newLevel, | |
| achievements: [...candidate], | |
| }; | |
| try { | |
| const folderId = await (0, drive_1.getUserFolder)(user.username); | |
| if (folderId) | |
| await (0, drive_1.writeUserDb)(folderId, updated); | |
| } | |
| catch (e) { | |
| console.warn("[xp] persist failed:", e?.message); | |
| } | |
| return { | |
| xpDelta: amount, | |
| xp: newXp, | |
| level: newLevel, | |
| levelUp, | |
| newAchievements: newlyAwarded, | |
| user: updated, | |
| }; | |
| } | |
| /** Idempotently award an achievement without granting XP. */ | |
| async function awardAchievement(user, key) { | |
| if (!(0, achievements_1.isValidAchievement)(key)) | |
| return user; | |
| const existing = Array.isArray(user.achievements) ? user.achievements : []; | |
| if (existing.includes(key)) | |
| return user; | |
| const updated = { ...user, achievements: [...existing, key] }; | |
| try { | |
| const folderId = await (0, drive_1.getUserFolder)(user.username); | |
| if (folderId) | |
| await (0, drive_1.writeUserDb)(folderId, updated); | |
| } | |
| catch (e) { | |
| console.warn("[xp] achievement persist failed:", e?.message); | |
| } | |
| return updated; | |
| } | |