feat(economy): refactor exam command to use ExamService with status-based flow and full test coverage
This commit is contained in:
@@ -1,21 +1,7 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
|
||||||
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
import { createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { examService, ExamStatus } from "@shared/modules/economy/exam.service";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
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."),
|
.setDescription("Take your weekly exam to earn rewards based on your XP progress."),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
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 {
|
try {
|
||||||
// 1. Fetch existing timer/exam data
|
// First, try to take the exam or check status
|
||||||
const timer = await DrizzleClient.query.userTimers.findFirst({
|
const result = await examService.takeExam(interaction.user.id);
|
||||||
where: and(
|
|
||||||
eq(userTimers.userId, user.id),
|
|
||||||
eq(userTimers.type, EXAM_TIMER_TYPE),
|
|
||||||
eq(userTimers.key, EXAM_TIMER_KEY)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. First Run Logic
|
if (result.status === ExamStatus.NOT_REGISTERED) {
|
||||||
if (!timer) {
|
// Register the user
|
||||||
// Set exam day to today
|
const regResult = await examService.registerForExam(interaction.user.id, interaction.user.username);
|
||||||
const nextExamDate = new Date(now);
|
const nextRegTimestamp = Math.floor(regResult.nextExamAt!.getTime() / 1000);
|
||||||
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({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`You have registered for the exam! Your exam day is **${DAYS[currentDay]}** (Server Time).\n` +
|
`You have registered for the exam! Your exam day is **${DAYS[regResult.examDay!]}** (Server Time).\n` +
|
||||||
`Come back on <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>) to take your first exam!`,
|
`Come back on <t:${nextRegTimestamp}:D> (<t:${nextRegTimestamp}:R>) to take your first exam!`,
|
||||||
"Exam Registration Successful"
|
"Exam Registration Successful"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = timer.metadata as unknown as ExamMetadata;
|
const nextExamTimestamp = Math.floor(result.nextExamAt!.getTime() / 1000);
|
||||||
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);
|
|
||||||
|
|
||||||
|
if (result.status === ExamStatus.COOLDOWN) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
embeds: [createErrorEmbed(
|
||||||
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
`You have already taken your exam for this week (or are waiting for your first week to pass).\n` +
|
||||||
`Next exam available: <t:${timestamp}:D> (<t:${timestamp}:R>)`
|
`Next exam available: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Day Check
|
if (result.status === ExamStatus.MISSED) {
|
||||||
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({
|
await interaction.editReply({
|
||||||
embeds: [createErrorEmbed(
|
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` +
|
`You verify your attendance but score a **0**.\n` +
|
||||||
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
`Your next exam opportunity is: <t:${nextExamTimestamp}:D> (<t:${nextExamTimestamp}:R>)`,
|
||||||
"Exam Failed"
|
"Exam Failed"
|
||||||
@@ -132,74 +55,21 @@ export const exam = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Reward Calculation
|
// If it reached here with AVAILABLE, it means they passed
|
||||||
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({
|
await interaction.editReply({
|
||||||
embeds: [createSuccessEmbed(
|
embeds: [createSuccessEmbed(
|
||||||
`**XP Gained:** ${diff.toString()}\n` +
|
`**XP Gained:** ${result.xpDiff?.toString()}\n` +
|
||||||
`**Multiplier:** x${multiplier.toFixed(2)}\n` +
|
`**Multiplier:** x${result.multiplier?.toFixed(2)}\n` +
|
||||||
`**Reward:** ${reward.toString()} Currency\n\n` +
|
`**Reward:** ${result.reward?.toString()} Currency\n\n` +
|
||||||
`See you next week: <t:${nextExamTimestamp}:D>`,
|
`See you next week: <t:${nextExamTimestamp}:D>`,
|
||||||
"Exam Passed!"
|
"Exam Passed!"
|
||||||
)]
|
)]
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof UserError) {
|
console.error("Error in exam command:", error);
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message || "An unexpected error occurred.")] });
|
||||||
} else {
|
|
||||||
console.error("Error in exam command:", error);
|
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
237
shared/modules/economy/exam.service.test.ts
Normal file
237
shared/modules/economy/exam.service.test.ts
Normal file
@@ -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()
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
262
shared/modules/economy/exam.service.ts
Normal file
262
shared/modules/economy/exam.service.ts
Normal file
@@ -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<ExamActionResult> {
|
||||||
|
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<ExamActionResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user