One-line JSDoc on 82 methods across 11 service files for quick scanning without reading full implementations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { users, userTimers } from "@db/schema";
|
|
import { eq, sql, and } from "drizzle-orm";
|
|
import { withTransaction } from "@/lib/db";
|
|
import { config } from "@shared/lib/config";
|
|
import { systemEvents, EVENTS } from "@shared/lib/events";
|
|
import type { Transaction } from "@shared/lib/types";
|
|
import { TimerKey, TimerType } from "@shared/lib/constants";
|
|
import { UserError } from "@shared/lib/errors";
|
|
|
|
export const levelingService = {
|
|
/** Calculate the cumulative XP required to reach a specific level. */
|
|
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;
|
|
},
|
|
|
|
/** Derive a user's level from their total accumulated 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 the XP needed to advance from the given level to the next. */
|
|
getXpForNextLevel: (currentLevel: number) => {
|
|
return Math.floor(config.leveling.base * Math.pow(currentLevel, config.leveling.exponent));
|
|
},
|
|
|
|
/** Add XP to a user, recalculating their level and emitting a quest event. */
|
|
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 UserError("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();
|
|
|
|
// Trigger Quest Event
|
|
await systemEvents.emitAsync(EVENTS.DOMAIN.XP_GAINED, { userId: id, amount: Number(amount), tx: txFn });
|
|
|
|
return { user: updatedUser, levelUp, currentLevel: newLevel };
|
|
}, tx);
|
|
},
|
|
|
|
/** Award random chat XP if the user is not on cooldown; applies active XP boost multipliers. */
|
|
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, TimerKey.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: TimerKey.CHAT_XP,
|
|
expiresAt: nextReadyAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [userTimers.userId, userTimers.type, userTimers.key],
|
|
set: { expiresAt: nextReadyAt },
|
|
});
|
|
|
|
return { awarded: true, amount, ...result };
|
|
}, tx);
|
|
}
|
|
};
|