311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { users, userTimers, transactions } from "@db/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { config } from "@shared/lib/config";
|
|
import { withTransaction } from "@/lib/db";
|
|
import type { Transaction } from "@shared/lib/types";
|
|
import { UserError } from "@shared/lib/errors";
|
|
import { TimerType, TransactionType } from "@shared/lib/constants";
|
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
|
|
|
// OpenTDB API Response Types
|
|
interface OpenTDBResponse {
|
|
response_code: number;
|
|
results: Array<{
|
|
category: string;
|
|
type: 'boolean' | 'multiple';
|
|
difficulty: string;
|
|
question: string;
|
|
correct_answer: string;
|
|
incorrect_answers: string[];
|
|
}>;
|
|
}
|
|
|
|
export interface TriviaQuestion {
|
|
question: string;
|
|
correctAnswer: string;
|
|
incorrectAnswers: string[];
|
|
type: 'boolean' | 'multiple';
|
|
difficulty: string;
|
|
category: string;
|
|
}
|
|
|
|
export interface TriviaSession {
|
|
sessionId: string;
|
|
userId: string;
|
|
question: TriviaQuestion;
|
|
allAnswers: string[];
|
|
correctIndex: number;
|
|
expiresAt: Date;
|
|
entryFee: bigint;
|
|
potentialReward: bigint;
|
|
}
|
|
|
|
export interface TriviaResult {
|
|
correct: boolean;
|
|
reward: bigint;
|
|
correctAnswer: string;
|
|
}
|
|
|
|
class TriviaService {
|
|
private activeSessions: Map<string, TriviaSession> = new Map();
|
|
|
|
constructor() {
|
|
// Cleanup expired sessions every 30 seconds
|
|
setInterval(() => {
|
|
this.cleanupExpiredSessions();
|
|
}, 30000);
|
|
}
|
|
|
|
private cleanupExpiredSessions() {
|
|
const now = Date.now();
|
|
const expired: string[] = [];
|
|
|
|
for (const [sessionId, session] of this.activeSessions.entries()) {
|
|
if (session.expiresAt.getTime() < now) {
|
|
expired.push(sessionId);
|
|
}
|
|
}
|
|
|
|
for (const sessionId of expired) {
|
|
this.activeSessions.delete(sessionId);
|
|
}
|
|
|
|
if (expired.length > 0) {
|
|
console.log(`[TriviaService] Cleaned up ${expired.length} expired sessions.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a trivia question from OpenTDB API
|
|
*/
|
|
async fetchQuestion(category?: number, difficulty?: string): Promise<TriviaQuestion> {
|
|
let url = 'https://opentdb.com/api.php?amount=1&encode=base64';
|
|
|
|
if (category) {
|
|
url += `&category=${category}`;
|
|
}
|
|
|
|
if (difficulty && difficulty !== 'random') {
|
|
url += `&difficulty=${difficulty}`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const data = await response.json() as OpenTDBResponse;
|
|
|
|
if (data.response_code !== 0 || !data.results || data.results.length === 0) {
|
|
throw new Error('Failed to fetch trivia question');
|
|
}
|
|
|
|
const result = data.results[0];
|
|
|
|
if (!result) {
|
|
throw new Error('No trivia question returned');
|
|
}
|
|
|
|
// Decode base64
|
|
return {
|
|
category: Buffer.from(result.category, 'base64').toString('utf-8'),
|
|
type: result.type,
|
|
difficulty: Buffer.from(result.difficulty, 'base64').toString('utf-8'),
|
|
question: Buffer.from(result.question, 'base64').toString('utf-8'),
|
|
correctAnswer: Buffer.from(result.correct_answer, 'base64').toString('utf-8'),
|
|
incorrectAnswers: result.incorrect_answers.map(ans =>
|
|
Buffer.from(ans, 'base64').toString('utf-8')
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.error('[TriviaService] Error fetching question:', error);
|
|
throw new UserError('Failed to fetch trivia question. Please try again later.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user can play trivia (cooldown check)
|
|
*/
|
|
async canPlayTrivia(userId: string): Promise<{ canPlay: boolean; nextAvailable?: Date }> {
|
|
const now = new Date();
|
|
|
|
const cooldown = await DrizzleClient.query.userTimers.findFirst({
|
|
where: and(
|
|
eq(userTimers.userId, BigInt(userId)),
|
|
eq(userTimers.type, TimerType.TRIVIA_COOLDOWN),
|
|
eq(userTimers.key, 'default')
|
|
),
|
|
});
|
|
|
|
if (cooldown && cooldown.expiresAt > now) {
|
|
return { canPlay: false, nextAvailable: cooldown.expiresAt };
|
|
}
|
|
|
|
return { canPlay: true };
|
|
}
|
|
|
|
/**
|
|
* Start a trivia session - deducts entry fee and creates session
|
|
*/
|
|
async startTrivia(userId: string, username: string): Promise<TriviaSession> {
|
|
// Check cooldown
|
|
const cooldownCheck = await this.canPlayTrivia(userId);
|
|
if (!cooldownCheck.canPlay) {
|
|
const timestamp = Math.floor(cooldownCheck.nextAvailable!.getTime() / 1000);
|
|
throw new UserError(`You're on cooldown! Try again <t:${timestamp}:R>.`);
|
|
}
|
|
|
|
const entryFee = config.trivia.entryFee;
|
|
|
|
return await withTransaction(async (tx) => {
|
|
// Check balance
|
|
const user = await tx.query.users.findFirst({
|
|
where: eq(users.id, BigInt(userId)),
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UserError('User not found.');
|
|
}
|
|
|
|
if ((user.balance ?? 0n) < entryFee) {
|
|
throw new UserError(`Insufficient funds! You need ${entryFee} AU to play trivia.`);
|
|
}
|
|
|
|
// Deduct entry fee (SINK MECHANISM)
|
|
await tx.update(users)
|
|
.set({
|
|
balance: (user.balance ?? 0n) - entryFee,
|
|
})
|
|
.where(eq(users.id, BigInt(userId)));
|
|
|
|
// Record transaction
|
|
await tx.insert(transactions).values({
|
|
userId: BigInt(userId),
|
|
amount: -entryFee,
|
|
type: TransactionType.TRIVIA_ENTRY,
|
|
description: 'Trivia entry fee',
|
|
});
|
|
|
|
// Fetch question
|
|
const category = config.trivia.categories.length > 0
|
|
? config.trivia.categories[Math.floor(Math.random() * config.trivia.categories.length)]
|
|
: undefined;
|
|
|
|
const difficulty = config.trivia.difficulty;
|
|
const question = await this.fetchQuestion(category, difficulty);
|
|
|
|
// Shuffle answers
|
|
const allAnswers = [...question.incorrectAnswers, question.correctAnswer];
|
|
const shuffled = allAnswers.sort(() => Math.random() - 0.5);
|
|
const correctIndex = shuffled.indexOf(question.correctAnswer);
|
|
|
|
// Create session
|
|
const sessionId = `${userId}_${Date.now()}`;
|
|
const expiresAt = new Date(Date.now() + config.trivia.timeoutSeconds * 1000);
|
|
const potentialReward = BigInt(Math.floor(Number(entryFee) * config.trivia.rewardMultiplier));
|
|
|
|
const session: TriviaSession = {
|
|
sessionId,
|
|
userId,
|
|
question,
|
|
allAnswers: shuffled,
|
|
correctIndex,
|
|
expiresAt,
|
|
entryFee,
|
|
potentialReward,
|
|
};
|
|
|
|
this.activeSessions.set(sessionId, session);
|
|
|
|
// Set cooldown
|
|
const cooldownEnd = new Date(Date.now() + config.trivia.cooldownMs);
|
|
await tx.insert(userTimers)
|
|
.values({
|
|
userId: BigInt(userId),
|
|
type: TimerType.TRIVIA_COOLDOWN,
|
|
key: 'default',
|
|
expiresAt: cooldownEnd,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [userTimers.userId, userTimers.type, userTimers.key],
|
|
set: { expiresAt: cooldownEnd },
|
|
});
|
|
|
|
// Record dashboard event
|
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
|
await dashboardService.recordEvent({
|
|
type: 'info',
|
|
message: `${username} started a trivia game (${question.difficulty})`,
|
|
icon: '🎯'
|
|
});
|
|
|
|
return session;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get session by ID
|
|
*/
|
|
getSession(sessionId: string): TriviaSession | undefined {
|
|
return this.activeSessions.get(sessionId);
|
|
}
|
|
|
|
/**
|
|
* Submit answer and process reward
|
|
*/
|
|
async submitAnswer(sessionId: string, userId: string, isCorrect: boolean): Promise<TriviaResult> {
|
|
const session = this.activeSessions.get(sessionId);
|
|
|
|
if (!session) {
|
|
throw new UserError('Session not found or expired.');
|
|
}
|
|
|
|
if (session.userId !== userId) {
|
|
throw new UserError('This is not your trivia question!');
|
|
}
|
|
|
|
// Remove session to prevent double-submit
|
|
this.activeSessions.delete(sessionId);
|
|
|
|
const reward = isCorrect ? session.potentialReward : 0n;
|
|
|
|
if (isCorrect) {
|
|
await withTransaction(async (tx) => {
|
|
// Award prize
|
|
await tx.update(users)
|
|
.set({
|
|
balance: (await tx.query.users.findFirst({
|
|
where: eq(users.id, BigInt(userId))
|
|
}))!.balance! + reward,
|
|
})
|
|
.where(eq(users.id, BigInt(userId)));
|
|
|
|
// Record transaction
|
|
await tx.insert(transactions).values({
|
|
userId: BigInt(userId),
|
|
amount: reward,
|
|
type: TransactionType.TRIVIA_WIN,
|
|
description: 'Trivia prize',
|
|
});
|
|
|
|
// Record dashboard event
|
|
const user = await tx.query.users.findFirst({
|
|
where: eq(users.id, BigInt(userId))
|
|
});
|
|
|
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
|
await dashboardService.recordEvent({
|
|
type: 'success',
|
|
message: `${user?.username} won ${reward.toLocaleString()} AU from trivia!`,
|
|
icon: '🎉'
|
|
});
|
|
});
|
|
}
|
|
|
|
return {
|
|
correct: isCorrect,
|
|
reward,
|
|
correctAnswer: session.question.correctAnswer,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const triviaService = new TriviaService();
|