diff --git a/src/commands/economy/exam.ts b/src/commands/economy/exam.ts new file mode 100644 index 0000000..33830e0 --- /dev/null +++ b/src/commands/economy/exam.ts @@ -0,0 +1,186 @@ +import { createCommand } from "@/lib/utils"; +import { SlashCommandBuilder } from "discord.js"; +import { userService } from "@/modules/user/user.service"; +import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; +import { userTimers, users } from "@/db/schema"; +import { eq, and, sql } from "drizzle-orm"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import config from "@/config/config.json"; + +const EXAM_TIMER_TYPE = '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); + 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 nextWeek = new Date(now); + nextWeek.setDate(now.getDate() + 7); + + const metadata: ExamMetadata = { + examDay: currentDay, + lastXp: user.xp.toString() + }; + + await DrizzleClient.insert(userTimers).values({ + userId: user.id, + type: EXAM_TIMER_TYPE, + key: EXAM_TIMER_KEY, + expiresAt: nextWeek, + metadata: metadata + }); + + await interaction.editReply({ + embeds: [createSuccessEmbed( + `You have registered for the exam! Your exam day is **${DAYS[currentDay]}**.\n` + + `Come back next week on **${DAYS[currentDay]}** to take your first exam and earn rewards based on your XP gain!`, + "Exam Registration Successful" + )] + }); + return; + } + + const metadata = timer.metadata as unknown as ExamMetadata; + const examDay = metadata.examDay; + + // 3. Cooldown Check + if (now < new Date(timer.expiresAt)) { + // Calculate time remaining + const expiresAt = new Date(timer.expiresAt); + // Simple formatting + 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: (${DAYS[examDay]})` + )] + }); + return; + } + + // 4. Day Check + if (currentDay !== examDay) { + // "If not executed on same weekday... we do not reward" + // Consume the attempt (reset timer) but give 0 reward. + + const nextWeek = new Date(now); + nextWeek.setDate(now.getDate() + 7); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: user.xp.toString() // Reset tracking + }; + + await DrizzleClient.update(userTimers) + .set({ + expiresAt: nextWeek, + 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 is on **${DAYS[examDay]}**, but today is ${DAYS[currentDay]}.\n` + + `You verify your attendance but score a **0**. Come back next **${DAYS[examDay]}**!`, + "Exam Failed" + )] + }); + return; + } + + // 5. Reward Calculation + const lastXp = BigInt(metadata.lastXp || "0"); // Fallback just in case + const currentXp = user.xp; + 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 nextWeek = new Date(now); + nextWeek.setDate(now.getDate() + 7); + + const newMetadata: ExamMetadata = { + examDay: examDay, + lastXp: currentXp.toString() + }; + + await DrizzleClient.transaction(async (tx) => { + // Update Timer + await tx.update(userTimers) + .set({ + expiresAt: nextWeek, + 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 on **${DAYS[examDay]}**!`, + "Exam Passed!" + )] + }); + + } catch (error: any) { + console.error("Exam command error:", error); + await interaction.editReply({ embeds: [createErrorEmbed("An error occurred while processing your exam.")] }); + } + } +}); diff --git a/src/config/config.json b/src/config/config.json index c5601d3..e309bc8 100644 --- a/src/config/config.json +++ b/src/config/config.json @@ -14,6 +14,11 @@ "streakBonus": "10", "cooldownMs": 86400000 }, + "exam": { + "multMin": 0.5, + "multMax": 1.5, + "cooldownHours": 168 + }, "transfers": { "allowSelfTransfer": false, "minAmount": "1"