diff --git a/bot/commands/economy/exam.ts b/bot/commands/economy/exam.ts index 926b2aa..f7bfde2 100644 --- a/bot/commands/economy/exam.ts +++ b/bot/commands/economy/exam.ts @@ -1,21 +1,7 @@ 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 "@shared/lib/errors"; -import { userTimers, users } from "@db/schema"; -import { eq, and, sql } from "drizzle-orm"; -import { DrizzleClient } from "@shared/db/DrizzleClient"; -import { config } from "@shared/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; -} +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -25,105 +11,42 @@ export const exam = createCommand({ .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) - ) - }); + // First, try to take the exam or check status + const result = await examService.takeExam(interaction.user.id); - // 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 - }); + if (result.status === ExamStatus.NOT_REGISTERED) { + // Register the user + const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username); + const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000); 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!`, + `You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (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); + const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000); + if (result.status === ExamStatus.COOLDOWN) { 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: ()` + `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) - )); - + if (result.status === ExamStatus.MISSED) { await interaction.editReply({ embeds: [createErrorEmbed( - `You missed your exam day! Your exam day is **${DAYS[examDay]}** (Server Time).\n` + + `You missed your exam day! Your exam day is **${DAYS[result.examDay!]}** (Server Time).\n` + `You verify your attendance but score a **0**.\n` + `Your next exam opportunity is: ()`, "Exam Failed" @@ -132,74 +55,21 @@ export const exam = createCommand({ 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)); - } - }); - + // If it reached here with AVAILABLE, it means they passed await interaction.editReply({ embeds: [createSuccessEmbed( - `**XP Gained:** ${diff.toString()}\n` + - `**Multiplier:** x${multiplier.toFixed(2)}\n` + - `**Reward:** ${reward.toString()} Currency\n\n` + + `**XP Gained:** ${result.xpDiff?.toString()}\n` + + `**Multiplier:** x${result.multiplier?.toFixed(2)}\n` + + `**Reward:** ${result.reward?.toString()} Currency\n\n` + `See you next week: `, "Exam Passed!" )] }); } catch (error: any) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Error in exam command:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); - } + console.error("Error in exam command:", error); + await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] }); } } }); + diff --git a/shared/modules/economy/exam.service.test.ts b/shared/modules/economy/exam.service.test.ts new file mode 100644 index 0000000..692955b --- /dev/null +++ b/shared/modules/economy/exam.service.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, mock, beforeEach, setSystemTime } from "bun:test"; +import { examService, ExamStatus } from "@shared/modules/economy/exam.service"; +import { users, userTimers, transactions } from "@db/schema"; + +// Define mock functions +const mockFindFirst = mock(); +const mockInsert = mock(); +const mockUpdate = mock(); +const mockValues = mock(); +const mockReturning = mock(); +const mockSet = mock(); +const mockWhere = mock(); + +// Chainable mock setup +mockInsert.mockReturnValue({ values: mockValues }); +mockValues.mockReturnValue({ returning: mockReturning }); +mockUpdate.mockReturnValue({ set: mockSet }); +mockSet.mockReturnValue({ where: mockWhere }); +mockWhere.mockReturnValue({ returning: mockReturning }); + +// Mock DrizzleClient +mock.module("@shared/db/DrizzleClient", () => { + const createMockTx = () => ({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + + return { + DrizzleClient: { + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + transaction: async (cb: any) => { + return cb(createMockTx()); + } + }, + }; +}); + +// Mock withTransaction +mock.module("@/lib/db", () => ({ + withTransaction: async (cb: any, tx?: any) => { + if (tx) return cb(tx); + return cb({ + query: { + users: { findFirst: mockFindFirst }, + userTimers: { findFirst: mockFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }); + } +})); + +// Mock Config +mock.module("@shared/lib/config", () => ({ + config: { + economy: { + exam: { + multMin: 1.0, + multMax: 2.0, + } + } + } +})); + +// Mock User Service +mock.module("@shared/modules/user/user.service", () => ({ + userService: { + getOrCreateUser: mock() + } +})); + +// Mock Dashboard Service +mock.module("@shared/modules/dashboard/dashboard.service", () => ({ + dashboardService: { + recordEvent: mock() + } +})); + +describe("ExamService", () => { + beforeEach(() => { + mockFindFirst.mockReset(); + mockInsert.mockClear(); + mockUpdate.mockClear(); + mockValues.mockClear(); + mockReturning.mockClear(); + mockSet.mockClear(); + mockWhere.mockClear(); + }); + + describe("getExamStatus", () => { + it("should return NOT_REGISTERED if no timer exists", async () => { + mockFindFirst.mockResolvedValue(undefined); + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should return COOLDOWN if now < expiresAt", async () => { + const now = new Date("2024-01-10T12:00:00Z"); + setSystemTime(now); + const future = new Date("2024-01-11T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: future, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.COOLDOWN); + expect(status.nextExamAt?.getTime()).toBe(future.setHours(0,0,0,0)); + }); + + it("should return MISSED if it is the wrong day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); // Wednesday (3) last week + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } // Registered for Wednesday + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.MISSED); + expect(status.examDay).toBe(3); + }); + + it("should return AVAILABLE if it is the correct day", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValue({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "100" } + }); + + const status = await examService.getExamStatus("1"); + expect(status.status).toBe(ExamStatus.AVAILABLE); + expect(status.examDay).toBe(3); + expect(status.lastXp).toBe(100n); + }); + }); + + describe("registerForExam", () => { + it("should create user and timer correctly", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + + const { userService } = await import("@shared/modules/user/user.service"); + (userService.getOrCreateUser as any).mockResolvedValue({ id: 1n, xp: 500n }); + + const result = await examService.registerForExam("1", "testuser"); + + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + expect(result.examDay).toBe(1); + + expect(mockInsert).toHaveBeenCalledWith(userTimers); + expect(mockInsert).toHaveBeenCalledTimes(1); + }); + }); + + describe("takeExam", () => { + it("should return NOT_REGISTERED if not registered", async () => { + mockFindFirst.mockResolvedValueOnce({ id: 1n }) // user check + .mockResolvedValueOnce(undefined); // timer check + + const result = await examService.takeExam("1"); + expect(result.status).toBe(ExamStatus.NOT_REGISTERED); + }); + + it("should handle missed exam and schedule for next exam day", async () => { + const now = new Date("2024-01-15T12:00:00Z"); // Monday (1) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, xp: 600n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } // Registered for Wednesday + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.MISSED); + expect(result.examDay).toBe(3); + + // Should set next exam to next Wednesday + // Monday (1) + 2 days = Wednesday (3) + const expected = new Date("2024-01-17T00:00:00Z"); + expect(result.nextExamAt!.getTime()).toBe(expected.getTime()); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + }); + + it("should calculate rewards and update state when passed", async () => { + const now = new Date("2024-01-17T12:00:00Z"); // Wednesday (3) + setSystemTime(now); + const past = new Date("2024-01-10T00:00:00Z"); + + mockFindFirst.mockResolvedValueOnce({ id: 1n, username: "testuser", xp: 1000n, balance: 0n }) // user + .mockResolvedValueOnce({ + expiresAt: past, + metadata: { examDay: 3, lastXp: "500" } + }); // timer + + const result = await examService.takeExam("1"); + + expect(result.status).toBe(ExamStatus.AVAILABLE); + expect(result.xpDiff).toBe(500n); + // Multiplier is between 1.0 and 2.0 based on mock config + expect(result.multiplier).toBeGreaterThanOrEqual(1.0); + expect(result.multiplier).toBeLessThanOrEqual(2.0); + expect(result.reward).toBeGreaterThanOrEqual(500n); + expect(result.reward).toBeLessThanOrEqual(1000n); + + expect(mockUpdate).toHaveBeenCalledWith(userTimers); + expect(mockUpdate).toHaveBeenCalledWith(users); + + // Verify transaction + expect(mockInsert).toHaveBeenCalledWith(transactions); + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ + amount: result.reward, + userId: 1n, + type: expect.anything() + })); + }); + }); +}); diff --git a/shared/modules/economy/exam.service.ts b/shared/modules/economy/exam.service.ts new file mode 100644 index 0000000..5f01fe1 --- /dev/null +++ b/shared/modules/economy/exam.service.ts @@ -0,0 +1,262 @@ +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); + } +};