import { users, userTimers, transactions } from "@db/schema"; import { eq, and, sql } 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 = 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 { 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, categoryId?: number): Promise { // 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 .`); } 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: sql`${users.balance} - ${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 let category = categoryId; if (!category) { 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 { 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();