Files
aurorabot/shared/modules/leveling/leveling.service.ts
syntaxbullet 5863418ae9 refactor: replace dynamic imports with event bus pattern
Replace 12 dynamic `await import()` calls with domain events emitted
through the existing systemEvents bus, breaking circular dependencies
between services (economy/inventory/leveling -> quest, * -> dashboard).

- Add `emitAsync` to SystemEventEmitter for sequential listener awaiting,
  preserving DB transaction atomicity for quest progress tracking
- Add DOMAIN event constants (BALANCE_CHANGED, XP_GAINED, ITEM_COLLECTED,
  ITEM_USED, TRANSFER_COMPLETED, DAILY_CLAIMED, TRIVIA_*, EXAM_PASSED)
- Create shared/lib/eventWiring.ts to register all domain event listeners
- Convert quest event calls to `await systemEvents.emitAsync()` (5 calls)
- Convert dashboard event calls to `systemEvents.emit()` fire-and-forget (5 calls)
- Convert exam.service.ts userService import to static import (1 call)
- Convert dashboard.service.ts events import to static import (1 call)
- Leave inventory.service.ts validateAndExecuteEffect import unchanged (Task 3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:59:15 +01:00

136 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 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 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);
},
// 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, 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);
}
};