import { users, userTimers, transactions } from "@db/schema"; import { eq, and, sql } from "drizzle-orm"; import { TimerType, TransactionType } from "@shared/lib/constants"; import { config } from "@shared/lib/config"; import { withTransaction } from "@/lib/db"; import type { Transaction } from "@shared/lib/types"; import { UserError } from "@shared/lib/errors"; const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; const EXAM_TIMER_KEY = 'default'; export interface ExamMetadata { examDay: number; lastXp: string; } export enum ExamStatus { NOT_REGISTERED = 'NOT_REGISTERED', COOLDOWN = 'COOLDOWN', MISSED = 'MISSED', AVAILABLE = 'AVAILABLE', } export interface ExamActionResult { status: ExamStatus; nextExamAt?: Date; reward?: bigint; xpDiff?: bigint; multiplier?: number; examDay?: number; } export const examService = { /** * Get the current exam status for a user. */ async getExamStatus(userId: string, tx?: Transaction) { return await withTransaction(async (txFn) => { const timer = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) ) }); if (!timer) { return { status: ExamStatus.NOT_REGISTERED }; } const now = new Date(); const expiresAt = new Date(timer.expiresAt); expiresAt.setHours(0, 0, 0, 0); if (now < expiresAt) { return { status: ExamStatus.COOLDOWN, nextExamAt: expiresAt }; } const metadata = timer.metadata as unknown as ExamMetadata; const currentDay = now.getDay(); if (currentDay !== metadata.examDay) { return { status: ExamStatus.MISSED, nextExamAt: expiresAt, examDay: metadata.examDay }; } return { status: ExamStatus.AVAILABLE, examDay: metadata.examDay, lastXp: BigInt(metadata.lastXp || "0") }; }, tx); }, /** * Register a user for the first time. */ async registerForExam(userId: string, username: string, tx?: Transaction): Promise { return await withTransaction(async (txFn) => { // Ensure user exists const { userService } = await import("@shared/modules/user/user.service"); const user = await userService.getOrCreateUser(userId, username, txFn); if (!user) throw new Error("Failed to get or create user."); const now = new Date(); const currentDay = now.getDay(); // Set next exam to next week const nextExamDate = new Date(now); nextExamDate.setDate(now.getDate() + 7); nextExamDate.setHours(0, 0, 0, 0); const metadata: ExamMetadata = { examDay: currentDay, lastXp: (user.xp ?? 0n).toString() }; await txFn.insert(userTimers).values({ userId: BigInt(userId), type: EXAM_TIMER_TYPE, key: EXAM_TIMER_KEY, expiresAt: nextExamDate, metadata: metadata }); return { status: ExamStatus.NOT_REGISTERED, nextExamAt: nextExamDate, examDay: currentDay }; }, tx); }, /** * Take the exam. Handles missed exams and reward calculations. */ async takeExam(userId: string, tx?: Transaction): Promise { return await withTransaction(async (txFn) => { const user = await txFn.query.users.findFirst({ where: eq(users.id, BigInt(userId)) }); if (!user) throw new Error("User not found"); const timer = await txFn.query.userTimers.findFirst({ where: and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) ) }); if (!timer) { return { status: ExamStatus.NOT_REGISTERED }; } const now = new Date(); const expiresAt = new Date(timer.expiresAt); expiresAt.setHours(0, 0, 0, 0); if (now < expiresAt) { return { status: ExamStatus.COOLDOWN, nextExamAt: expiresAt }; } const metadata = timer.metadata as unknown as ExamMetadata; const examDay = metadata.examDay; const currentDay = now.getDay(); if (currentDay !== examDay) { // Missed exam logic let daysUntil = (examDay - currentDay + 7) % 7; if (daysUntil === 0) daysUntil = 7; const nextExamDate = new Date(now); nextExamDate.setDate(now.getDate() + daysUntil); nextExamDate.setHours(0, 0, 0, 0); const newMetadata: ExamMetadata = { examDay: examDay, lastXp: (user.xp ?? 0n).toString() }; await txFn.update(userTimers) .set({ expiresAt: nextExamDate, metadata: newMetadata }) .where(and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) )); return { status: ExamStatus.MISSED, nextExamAt: nextExamDate, examDay: examDay }; } // Reward Calculation const lastXp = BigInt(metadata.lastXp || "0"); const currentXp = user.xp ?? 0n; const diff = currentXp - lastXp; const multMin = config.economy.exam.multMin; const multMax = config.economy.exam.multMax; const multiplier = Math.random() * (multMax - multMin) + multMin; let reward = 0n; if (diff > 0n) { // Use scaled BigInt arithmetic to avoid precision loss with large XP values const scaledMultiplier = BigInt(Math.round(multiplier * 10000)); reward = (diff * scaledMultiplier) / 10000n; } const nextExamDate = new Date(now); nextExamDate.setDate(now.getDate() + 7); nextExamDate.setHours(0, 0, 0, 0); const newMetadata: ExamMetadata = { examDay: examDay, lastXp: currentXp.toString() }; // Update Timer await txFn.update(userTimers) .set({ expiresAt: nextExamDate, metadata: newMetadata }) .where(and( eq(userTimers.userId, BigInt(userId)), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) )); // Add Currency if (reward > 0n) { await txFn.update(users) .set({ balance: sql`${users.balance} + ${reward}` }) .where(eq(users.id, BigInt(userId))); // Add Transaction Record await txFn.insert(transactions).values({ userId: BigInt(userId), amount: reward, type: TransactionType.EXAM_REWARD, description: `Weekly exam reward (XP Diff: ${diff})`, }); } // Record dashboard event const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); await dashboardService.recordEvent({ type: 'success', message: `${user.username} passed their exam: ${reward.toLocaleString()} AU`, icon: '🎓' }); return { status: ExamStatus.AVAILABLE, nextExamAt: nextExamDate, reward, xpDiff: diff, multiplier, examDay }; }, tx); } };