import { createCommand } from "@shared/lib/utils"; import { SlashCommandBuilder } from "discord.js"; import { userService } from "@shared/modules/user/user.service"; import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; import { UserError } from "@/lib/errors"; import { userTimers, users } from "@db/schema"; import { eq, and, sql } from "drizzle-orm"; import { DrizzleClient } from "@shared/db/DrizzleClient"; import { config } from "@lib/config"; import { TimerType } from "@shared/lib/constants"; const EXAM_TIMER_TYPE = TimerType.EXAM_SYSTEM; const EXAM_TIMER_KEY = 'default'; interface ExamMetadata { examDay: number; lastXp: string; } const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; export const exam = createCommand({ data: new SlashCommandBuilder() .setName("exam") .setDescription("Take your weekly exam to earn rewards based on your XP progress."), execute: async (interaction) => { await interaction.deferReply(); const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username); if (!user) { await interaction.editReply({ embeds: [createErrorEmbed("Failed to retrieve user data.")] }); return; } const now = new Date(); const currentDay = now.getDay(); try { // 1. Fetch existing timer/exam data const timer = await DrizzleClient.query.userTimers.findFirst({ where: and( eq(userTimers.userId, user.id), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) ) }); // 2. First Run Logic if (!timer) { // Set exam day to today const nextExamDate = new Date(now); nextExamDate.setDate(now.getDate() + 7); nextExamDate.setHours(0, 0, 0, 0); const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const metadata: ExamMetadata = { examDay: currentDay, lastXp: (user.xp ?? 0n).toString() }; await DrizzleClient.insert(userTimers).values({ userId: user.id, type: EXAM_TIMER_TYPE, key: EXAM_TIMER_KEY, expiresAt: nextExamDate, metadata: metadata }); await interaction.editReply({ embeds: [createSuccessEmbed( `You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` + `Come back on () to take your first exam!`, "Exam Registration Successful" )] }); return; } const metadata = timer.metadata as unknown as ExamMetadata; const examDay = metadata.examDay; // 3. Cooldown Check const expiresAt = new Date(timer.expiresAt); expiresAt.setHours(0, 0, 0, 0); if (now < expiresAt) { // Calculate time remaining const timestamp = Math.floor(expiresAt.getTime() / 1000); await interaction.editReply({ embeds: [createErrorEmbed( `You have already taken your exam for this week (or are waiting for your first week to pass).\n` + `Next exam available: ()` )] }); return; } // 4. Day Check if (currentDay !== examDay) { // Calculate next correct exam day to correct the schedule 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 nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const newMetadata: ExamMetadata = { examDay: examDay, lastXp: (user.xp ?? 0n).toString() }; await DrizzleClient.update(userTimers) .set({ expiresAt: nextExamDate, metadata: newMetadata }) .where(and( eq(userTimers.userId, user.id), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) )); await interaction.editReply({ embeds: [createErrorEmbed( `You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + `You verify your attendance but score a **0**.\n` + `Your next exam opportunity is: ()`, "Exam Failed" )] }); return; } // 5. Reward Calculation const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case const currentXp = user.xp ?? 0n; const diff = currentXp - lastXp; // Calculate Reward const multMin = config.economy.exam.multMin; const multMax = config.economy.exam.multMax; const multiplier = Math.random() * (multMax - multMin) + multMin; // Allow negative reward? existing description implies "difference", usually gain. // If diff is negative (lost XP?), reward might be 0. let reward = 0n; if (diff > 0n) { reward = BigInt(Math.floor(Number(diff) * multiplier)); } // 6. Update State const nextExamDate = new Date(now); nextExamDate.setDate(now.getDate() + 7); nextExamDate.setHours(0, 0, 0, 0); const nextExamTimestamp = Math.floor(nextExamDate.getTime() / 1000); const newMetadata: ExamMetadata = { examDay: examDay, lastXp: currentXp.toString() }; await DrizzleClient.transaction(async (tx) => { // Update Timer await tx.update(userTimers) .set({ expiresAt: nextExamDate, metadata: newMetadata }) .where(and( eq(userTimers.userId, user.id), eq(userTimers.type, EXAM_TIMER_TYPE), eq(userTimers.key, EXAM_TIMER_KEY) )); // Add Currency if (reward > 0n) { await tx.update(users) .set({ balance: sql`${users.balance} + ${reward}` }) .where(eq(users.id, user.id)); } }); await interaction.editReply({ embeds: [createSuccessEmbed( `**XP Gained:** ${diff.toString()}\n` + `**Multiplier:** x${multiplier.toFixed(2)}\n` + `**Reward:** ${reward.toString()} Currency\n\n` + `See you next week: `, "Exam Passed!" )] }); } catch (error: any) { if (error instanceof UserError) { await interaction.reply({ embeds: [createErrorEmbed(error.message)], ephemeral: true }); } else { console.error("Error in exam command:", error); await interaction.reply({ embeds: [createErrorEmbed("An unexpected error occurred.")], ephemeral: true }); } } } });