263 lines
8.6 KiB
TypeScript
263 lines
8.6 KiB
TypeScript
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);
|
|
}
|
|
};
|