diff --git a/bot/commands/economy/trivia.ts b/bot/commands/economy/trivia.ts new file mode 100644 index 0000000..d1be097 --- /dev/null +++ b/bot/commands/economy/trivia.ts @@ -0,0 +1,90 @@ +import { createCommand } from "@shared/lib/utils"; +import { SlashCommandBuilder } from "discord.js"; +import { triviaService } from "@shared/modules/trivia/trivia.service"; +import { getTriviaQuestionView } from "@/modules/trivia/trivia.view"; +import { createErrorEmbed } from "@lib/embeds"; +import { UserError } from "@/lib/errors"; +import { config } from "@shared/lib/config"; + +export const trivia = createCommand({ + data: new SlashCommandBuilder() + .setName("trivia") + .setDescription("Play trivia to win currency! Answer correctly within the time limit."), + execute: async (interaction) => { + try { + // Check if user can play BEFORE deferring + const canPlay = await triviaService.canPlayTrivia(interaction.user.id); + + if (!canPlay.canPlay) { + // Cooldown error - ephemeral + const timestamp = Math.floor(canPlay.nextAvailable!.getTime() / 1000); + await interaction.reply({ + embeds: [createErrorEmbed( + `You're on cooldown! Try again .` + )], + ephemeral: true + }); + return; + } + + // User can play - defer publicly for trivia question + await interaction.deferReply(); + + // Start trivia session (deducts entry fee) + const session = await triviaService.startTrivia( + interaction.user.id, + interaction.user.username + ); + + // Generate Components v2 message + const { components, flags } = getTriviaQuestionView(session, interaction.user.username); + + // Reply with Components v2 question + await interaction.editReply({ + components, + flags + }); + + // Set up automatic timeout cleanup + setTimeout(async () => { + const stillActive = triviaService.getSession(session.sessionId); + if (stillActive) { + // User didn't answer - clean up session with no reward + try { + await triviaService.submitAnswer(session.sessionId, interaction.user.id, false); + } catch (error) { + // Session already cleaned up, ignore + } + } + }, config.trivia.timeoutSeconds * 1000 + 5000); // 5 seconds grace period + + } catch (error: any) { + if (error instanceof UserError) { + // Check if we've already deferred + if (interaction.deferred) { + await interaction.editReply({ + embeds: [createErrorEmbed(error.message)] + }); + } else { + await interaction.reply({ + embeds: [createErrorEmbed(error.message)], + ephemeral: true + }); + } + } else { + console.error("Error in trivia command:", error); + // Check if we've already deferred + if (interaction.deferred) { + await interaction.editReply({ + embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")] + }); + } else { + await interaction.reply({ + embeds: [createErrorEmbed("An unexpected error occurred. Please try again later.")], + ephemeral: true + }); + } + } + } + } +}); diff --git a/bot/lib/interaction.routes.ts b/bot/lib/interaction.routes.ts index 9a4699b..577cb7f 100644 --- a/bot/lib/interaction.routes.ts +++ b/bot/lib/interaction.routes.ts @@ -37,6 +37,11 @@ export const interactionRoutes: InteractionRoute[] = [ handler: () => import("@/modules/economy/lootdrop.interaction"), method: 'handleLootdropInteraction' }, + { + predicate: (i) => i.isButton() && i.customId.startsWith("trivia_"), + handler: () => import("@/modules/trivia/trivia.interaction"), + method: 'handleTriviaInteraction' + }, // --- ADMIN MODULE --- { diff --git a/bot/modules/trivia/README.md b/bot/modules/trivia/README.md new file mode 100644 index 0000000..84a3239 --- /dev/null +++ b/bot/modules/trivia/README.md @@ -0,0 +1,116 @@ +# Trivia - Components v2 Implementation + +This trivia feature uses **Discord Components v2** for a premium visual experience. + +## šŸŽØ Visual Features + +### **Container with Accent Colors** +Each trivia question is displayed in a Container with a colored accent bar that changes based on difficulty: +- **🟢 Easy**: Green accent bar (`0x57F287`) +- **🟔 Medium**: Yellow accent bar (`0xFEE75C`) +- **šŸ”“ Hard**: Red accent bar (`0xED4245`) + +### **Modern Layout Components** +- **TextDisplay** - Rich markdown formatting for question text +- **Separator** - Visual spacing between sections +- **Container** - Groups all content with difficulty-based styling + +### **Interactive Features** +āœ… **Give Up Button** - Players can forfeit if they're unsure +āœ… **Disabled Answer Buttons** - After answering, buttons show: + - āœ… Green for correct answer + - āŒ Red for user's incorrect answer + - Gray for other options + +āœ… **Time Display** - Shows both relative time (`in 30s`) and seconds remaining +āœ… **Stakes Preview** - Clear display: `50 AU āžœ 100 AU` + +## šŸ“ File Structure + +``` +bot/modules/trivia/ +ā”œā”€ā”€ trivia.view.ts # Components v2 view functions +ā”œā”€ā”€ trivia.interaction.ts # Button interaction handler +└── README.md # This file + +bot/commands/economy/ +└── trivia.ts # /trivia slash command +``` + +## šŸ”§ Technical Details + +### Components v2 Requirements +- Uses `MessageFlags.IsComponentsV2` flag +- No `embeds` or `content` fields (uses TextDisplay instead) +- Numeric component types: + - `1` - Action Row + - `2` - Button + - `10` - Text Display + - `14` - Separator + - `17` - Container +- Max 40 components per message (vs 5 for legacy) + +### Button Styles +- **Secondary (2)**: Gray - Used for answer buttons +- **Success (3)**: Green - Used for "True" and correct answers +- **Danger (4)**: Red - Used for "False", incorrect answers, and "Give Up" + +## šŸŽ® User Experience Flow + +1. User runs `/trivia` +2. Sees question in a Container with difficulty-based accent color +3. Can choose to: + - Select an answer (A/B/C/D or True/False) + - Give up using the šŸ³ļø button +4. After answering, sees result with: + - Disabled buttons showing correct/incorrect answers + - Container with result-based accent color (green/red/yellow) + - Reward or penalty information + +## 🌟 Visual Examples + +### Question Display +``` +ā”Œā”€[GREEN]─────────────────────────┐ +│ # šŸŽÆ Trivia Challenge │ +│ 🟢 Easy • šŸ“š Geography │ +│ ─────────────────────────── │ +│ ### What is the capital of │ +│ France? │ +│ │ +│ ā±ļø Time: in 30s (30s) │ +│ šŸ’° Stakes: 50 AU āžœ 100 AU │ +│ šŸ‘¤ Player: Username │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +[šŸ‡¦ A: Paris] [šŸ‡§ B: London] +[šŸ‡Ø C: Berlin] [šŸ‡© D: Madrid] +[šŸ³ļø Give Up] +``` + +### Result Display (Correct) +``` +ā”Œā”€[GREEN]─────────────────────────┐ +│ # šŸŽ‰ Correct Answer! │ +│ ### What is the capital of │ +│ France? │ +│ ─────────────────────────── │ +│ āœ… Your answer: Paris │ +│ │ +│ šŸ’° Reward: +100 AU │ +│ │ +│ šŸ† Great job! Keep it up! │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +[āœ… A: Paris] [āŒ B: London] +[āŒ C: Berlin] [āŒ D: Madrid] +(all buttons disabled) +``` + +## šŸš€ Future Enhancements + +Potential improvements: +- [ ] Thumbnail images based on trivia category +- [ ] Progress bar for time remaining +- [ ] Streak counter display +- [ ] Category-specific accent colors +- [ ] Media Gallery for image-based questions +- [ ] Leaderboard integration in results diff --git a/bot/modules/trivia/trivia.interaction.ts b/bot/modules/trivia/trivia.interaction.ts new file mode 100644 index 0000000..17abac4 --- /dev/null +++ b/bot/modules/trivia/trivia.interaction.ts @@ -0,0 +1,126 @@ +import { ButtonInteraction } from "discord.js"; +import { triviaService } from "@shared/modules/trivia/trivia.service"; +import { getTriviaResultView, getTriviaTimeoutView } from "./trivia.view"; +import { UserError } from "@/lib/errors"; + +export async function handleTriviaInteraction(interaction: ButtonInteraction) { + const parts = interaction.customId.split('_'); + + // Check for "Give Up" button + if (parts.length >= 3 && parts[0] === 'trivia' && parts[1] === 'giveup') { + const sessionId = `${parts[2]}_${parts[3]}`; + const session = triviaService.getSession(sessionId); + + if (!session) { + await interaction.reply({ + content: 'āŒ This trivia question has expired or already been answered.', + ephemeral: true + }); + return; + } + + // Validate ownership + if (session.userId !== interaction.user.id) { + await interaction.reply({ + content: 'āŒ This isn\'t your trivia question!', + ephemeral: true + }); + return; + } + + await interaction.deferUpdate(); + + // Process as incorrect (user gave up) + const result = await triviaService.submitAnswer(sessionId, interaction.user.id, false); + + // Show timeout view (since they gave up) + const { components, flags } = getTriviaTimeoutView( + session.question.question, + session.question.correctAnswer, + session.allAnswers + ); + + await interaction.editReply({ + components, + flags + }); + return; + } + + // Handle answer button + if (parts.length < 5 || parts[0] !== 'trivia' || parts[1] !== 'answer') { + return; + } + + const sessionId = `${parts[2]}_${parts[3]}`; + const answerIndexStr = parts[4]; + + if (!answerIndexStr) { + throw new UserError('Invalid answer format.'); + } + + const answerIndex = parseInt(answerIndexStr); + + // Get session BEFORE deferring to check ownership + const session = triviaService.getSession(sessionId); + + if (!session) { + // Session doesn't exist or expired + await interaction.reply({ + content: 'āŒ This trivia question has expired or already been answered.', + ephemeral: true + }); + return; + } + + // Validate ownership BEFORE deferring + if (session.userId !== interaction.user.id) { + // Wrong user trying to answer - send ephemeral error + await interaction.reply({ + content: 'āŒ This isn\'t your trivia question! Use `/trivia` to start your own game.', + ephemeral: true + }); + return; + } + + // Only defer if ownership is valid + await interaction.deferUpdate(); + + // Check timeout + if (new Date() > session.expiresAt) { + const { components, flags } = getTriviaTimeoutView( + session.question.question, + session.question.correctAnswer, + session.allAnswers + ); + + await interaction.editReply({ + components, + flags + }); + + // Clean up session + await triviaService.submitAnswer(sessionId, interaction.user.id, false); + return; + } + + // Check if correct + const isCorrect = answerIndex === session.correctIndex; + const userAnswer = session.allAnswers[answerIndex]; + + // Process result + const result = await triviaService.submitAnswer(sessionId, interaction.user.id, isCorrect); + + // Update message with enhanced visual feedback + const { components, flags } = getTriviaResultView( + result, + session.question.question, + userAnswer, + session.allAnswers + ); + + await interaction.editReply({ + components, + flags + }); +} diff --git a/bot/modules/trivia/trivia.view.ts b/bot/modules/trivia/trivia.view.ts new file mode 100644 index 0000000..3b13caf --- /dev/null +++ b/bot/modules/trivia/trivia.view.ts @@ -0,0 +1,334 @@ +import { MessageFlags } from "discord.js"; +import type { TriviaSession, TriviaResult } from "@shared/modules/trivia/trivia.service"; + +/** + * Get color based on difficulty level + */ +function getDifficultyColor(difficulty: string): number { + switch (difficulty.toLowerCase()) { + case 'easy': + return 0x57F287; // Green + case 'medium': + return 0xFEE75C; // Yellow + case 'hard': + return 0xED4245; // Red + default: + return 0x5865F2; // Blurple + } +} + +/** + * Get emoji for difficulty level + */ +function getDifficultyEmoji(difficulty: string): string { + switch (difficulty.toLowerCase()) { + case 'easy': + return '🟢'; + case 'medium': + return '🟔'; + case 'hard': + return 'šŸ”“'; + default: + return '⭐'; + } +} + +/** + * Generate Components v2 message for a trivia question + */ +export function getTriviaQuestionView(session: TriviaSession, username: string): { + components: any[]; + flags: number; +} { + const { question, allAnswers, entryFee, potentialReward, expiresAt, sessionId } = session; + + // Calculate time remaining + const now = Date.now(); + const timeLeft = Math.max(0, expiresAt.getTime() - now); + const secondsLeft = Math.floor(timeLeft / 1000); + + const difficultyEmoji = getDifficultyEmoji(question.difficulty); + const difficultyText = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1); + const accentColor = getDifficultyColor(question.difficulty); + + const components: any[] = []; + + // Main Container with difficulty accent color + components.push({ + type: 17, // Container + accent_color: accentColor, + components: [ + // Title and metadata section + { + type: 10, // Text Display + content: `# šŸŽÆ Trivia Challenge\n**${difficultyEmoji} ${difficultyText}** • šŸ“š ${question.category}` + }, + // Separator + { + type: 14, // Separator + spacing: 1, + divider: true + }, + // Question + { + type: 10, // Text Display + content: `### ${question.question}` + }, + // Stats section + { + type: 14, // Separator + spacing: 1, + divider: false + }, + { + type: 10, // Text Display + content: `ā±ļø **Time:** (${secondsLeft}s)\nšŸ’° **Stakes:** ${entryFee} AU āžœ ${potentialReward} AU\nšŸ‘¤ **Player:** ${username}` + } + ] + }); + + // Answer buttons + if (question.type === 'boolean') { + const trueIndex = allAnswers.indexOf('True'); + const falseIndex = allAnswers.indexOf('False'); + + components.push({ + type: 1, // Action Row + components: [ + { + type: 2, // Button + custom_id: `trivia_answer_${sessionId}_${trueIndex}`, + label: 'True', + style: 3, // Success + emoji: { name: 'āœ…' } + }, + { + type: 2, // Button + custom_id: `trivia_answer_${sessionId}_${falseIndex}`, + label: 'False', + style: 4, // Danger + emoji: { name: 'āŒ' } + } + ] + }); + } else { + const labels = ['A', 'B', 'C', 'D']; + const emojis = ['šŸ‡¦', 'šŸ‡§', 'šŸ‡Ø', 'šŸ‡©']; + + const buttonRow: any = { + type: 1, // Action Row + components: [] + }; + + for (let i = 0; i < allAnswers.length && i < 4; i++) { + const label = labels[i]; + const emoji = emojis[i]; + const answer = allAnswers[i]; + + if (!label || !emoji || !answer) continue; + + buttonRow.components.push({ + type: 2, // Button + custom_id: `trivia_answer_${sessionId}_${i}`, + label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`, + style: 2, // Secondary + emoji: { name: emoji } + }); + } + + components.push(buttonRow); + } + + // Give Up button in separate row + components.push({ + type: 1, // Action Row + components: [ + { + type: 2, // Button + custom_id: `trivia_giveup_${sessionId}`, + label: 'Give Up', + style: 4, // Danger + emoji: { name: 'šŸ³ļø' } + } + ] + }); + + return { + components, + flags: MessageFlags.IsComponentsV2 + }; +} + +/** + * Generate Components v2 result message + */ +export function getTriviaResultView( + result: TriviaResult, + question: string, + userAnswer?: string, + allAnswers?: string[] +): { + components: any[]; + flags: number; +} { + const { correct, reward, correctAnswer } = result; + const components: any[] = []; + + if (correct) { + // Success container + components.push({ + type: 17, // Container + accent_color: 0x57F287, // Green + components: [ + { + type: 10, // Text Display + content: `# šŸŽ‰ Correct Answer!\n### ${question}` + }, + { + type: 14, // Separator + spacing: 1, + divider: true + }, + { + type: 10, // Text Display + content: `āœ… **Your answer:** ${correctAnswer}\n\nšŸ’° **Reward:** +${reward} AU\n\nšŸ† Great job! Keep it up!` + } + ] + }); + } else { + const answerDisplay = userAnswer + ? `āŒ **Your answer:** ${userAnswer}\nāœ… **Correct answer:** ${correctAnswer}` + : `āœ… **Correct answer:** ${correctAnswer}`; + + // Error container + components.push({ + type: 17, // Container + accent_color: 0xED4245, // Red + components: [ + { + type: 10, // Text Display + content: `# āŒ Incorrect Answer\n### ${question}` + }, + { + type: 14, // Separator + spacing: 1, + divider: true + }, + { + type: 10, // Text Display + content: `${answerDisplay}\n\nšŸ’ø **Entry fee lost:** ${reward} AU\n\nšŸ“š Better luck next time!` + } + ] + }); + } + + // Show disabled buttons with visual feedback + if (allAnswers && allAnswers.length > 0) { + const buttonRow: any = { + type: 1, // Action Row + components: [] + }; + + const labels = ['A', 'B', 'C', 'D']; + const emojis = ['šŸ‡¦', 'šŸ‡§', 'šŸ‡Ø', 'šŸ‡©']; + + for (let i = 0; i < allAnswers.length && i < 4; i++) { + const label = labels[i]; + const emoji = emojis[i]; + const answer = allAnswers[i]; + + if (!label || !emoji || !answer) continue; + + const isCorrect = answer === correctAnswer; + const wasUserAnswer = answer === userAnswer; + + buttonRow.components.push({ + type: 2, // Button + custom_id: `trivia_result_${i}`, + label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`, + style: isCorrect ? 3 : wasUserAnswer ? 4 : 2, // Success : Danger : Secondary + emoji: { name: isCorrect ? 'āœ…' : wasUserAnswer ? 'āŒ' : emoji }, + disabled: true + }); + } + + components.push(buttonRow); + } + + return { + components, + flags: MessageFlags.IsComponentsV2 + }; +} + +/** + * Generate Components v2 timeout message + */ +export function getTriviaTimeoutView( + question: string, + correctAnswer: string, + allAnswers?: string[] +): { + components: any[]; + flags: number; +} { + const components: any[] = []; + + // Timeout container + components.push({ + type: 17, // Container + accent_color: 0xFEE75C, // Yellow + components: [ + { + type: 10, // Text Display + content: `# ā±ļø Time's Up!\n### ${question}` + }, + { + type: 14, // Separator + spacing: 1, + divider: true + }, + { + type: 10, // Text Display + content: `ā° **You ran out of time!**\nāœ… **Correct answer:** ${correctAnswer}\n\nšŸ’ø Entry fee lost\n\n⚔ Be faster next time!` + } + ] + }); + + // Show disabled buttons with correct answer highlighted + if (allAnswers && allAnswers.length > 0) { + const buttonRow: any = { + type: 1, // Action Row + components: [] + }; + + const labels = ['A', 'B', 'C', 'D']; + const emojis = ['šŸ‡¦', 'šŸ‡§', 'šŸ‡Ø', 'šŸ‡©']; + + for (let i = 0; i < allAnswers.length && i < 4; i++) { + const label = labels[i]; + const emoji = emojis[i]; + const answer = allAnswers[i]; + + if (!label || !emoji || !answer) continue; + + const isCorrect = answer === correctAnswer; + + buttonRow.components.push({ + type: 2, // Button + custom_id: `trivia_timeout_${i}`, + label: `${label}: ${answer.substring(0, 30)}${answer.length > 30 ? '...' : ''}`, + style: isCorrect ? 3 : 2, // Success : Secondary + emoji: { name: isCorrect ? 'āœ…' : emoji }, + disabled: true + }); + } + + components.push(buttonRow); + } + + return { + components, + flags: MessageFlags.IsComponentsV2 + }; +} diff --git a/shared/lib/config.ts b/shared/lib/config.ts index 087f31c..0d11def 100644 --- a/shared/lib/config.ts +++ b/shared/lib/config.ts @@ -70,6 +70,14 @@ export interface GameConfigType { autoTimeoutThreshold?: number; }; }; + trivia: { + entryFee: bigint; + rewardMultiplier: number; + timeoutSeconds: number; + cooldownMs: number; + categories: number[]; + difficulty: 'easy' | 'medium' | 'hard' | 'random'; + }; system: Record; } @@ -163,6 +171,21 @@ const configSchema = z.object({ dmOnWarn: true } }), + trivia: z.object({ + entryFee: bigIntSchema, + rewardMultiplier: z.number().min(0).max(10), + timeoutSeconds: z.number().min(5).max(300), + cooldownMs: z.number().min(0), + categories: z.array(z.number()).default([]), + difficulty: z.enum(['easy', 'medium', 'hard', 'random']).default('random'), + }).default({ + entryFee: 50n, + rewardMultiplier: 1.8, + timeoutSeconds: 30, + cooldownMs: 60000, + categories: [], + difficulty: 'random' + }), system: z.record(z.string(), z.any()).default({}), }); diff --git a/shared/lib/constants.ts b/shared/lib/constants.ts index d022988..1df0de0 100644 --- a/shared/lib/constants.ts +++ b/shared/lib/constants.ts @@ -7,6 +7,7 @@ export enum TimerType { EFFECT = 'EFFECT', ACCESS = 'ACCESS', EXAM_SYSTEM = 'EXAM_SYSTEM', + TRIVIA_COOLDOWN = 'TRIVIA_COOLDOWN', } export enum EffectType { @@ -30,6 +31,8 @@ export enum TransactionType { TRADE_IN = 'TRADE_IN', TRADE_OUT = 'TRADE_OUT', QUEST_REWARD = 'QUEST_REWARD', + TRIVIA_ENTRY = 'TRIVIA_ENTRY', + TRIVIA_WIN = 'TRIVIA_WIN', } export enum ItemTransactionType { diff --git a/shared/modules/trivia/trivia.service.test.ts b/shared/modules/trivia/trivia.service.test.ts new file mode 100644 index 0000000..fe2f98c --- /dev/null +++ b/shared/modules/trivia/trivia.service.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { triviaService } from "./trivia.service"; +import { DrizzleClient } from "@shared/db/DrizzleClient"; +import { users, userTimers } from "@db/schema"; +import { eq, and } from "drizzle-orm"; +import { config } from "@shared/lib/config"; +import { TimerType } from "@shared/lib/constants"; + +// Mock fetch for OpenTDB API +const mockFetch = mock(() => Promise.resolve({ + json: () => Promise.resolve({ + response_code: 0, + results: [{ + category: Buffer.from('General Knowledge').toString('base64'), + type: 'multiple', + difficulty: Buffer.from('medium').toString('base64'), + question: Buffer.from('What is 2 + 2?').toString('base64'), + correct_answer: Buffer.from('4').toString('base64'), + incorrect_answers: [ + Buffer.from('3').toString('base64'), + Buffer.from('5').toString('base64'), + Buffer.from('22').toString('base64'), + ] + }] + }) +})); + +global.fetch = mockFetch as any; + +describe("TriviaService", () => { + const TEST_USER_ID = "999999999"; + const TEST_USERNAME = "testuser"; + + beforeEach(async () => { + // Clean up test data + await DrizzleClient.delete(userTimers) + .where(eq(userTimers.userId, BigInt(TEST_USER_ID))); + + // Ensure test user exists with sufficient balance + await DrizzleClient.insert(users) + .values({ + id: BigInt(TEST_USER_ID), + username: TEST_USERNAME, + balance: 1000n, + xp: 0n, + }) + .onConflictDoUpdate({ + target: [users.id], + set: { + balance: 1000n, + } + }); + }); + + afterEach(async () => { + // Clean up + await DrizzleClient.delete(userTimers) + .where(eq(userTimers.userId, BigInt(TEST_USER_ID))); + }); + + describe("fetchQuestion", () => { + it("should fetch and decode a trivia question", async () => { + const question = await triviaService.fetchQuestion(); + + expect(question).toBeDefined(); + expect(question.question).toBe('What is 2 + 2?'); + expect(question.correctAnswer).toBe('4'); + expect(question.incorrectAnswers).toHaveLength(3); + expect(question.type).toBe('multiple'); + }); + }); + + describe("canPlayTrivia", () => { + it("should allow playing when no cooldown exists", async () => { + const result = await triviaService.canPlayTrivia(TEST_USER_ID); + + expect(result.canPlay).toBe(true); + expect(result.nextAvailable).toBeUndefined(); + }); + + it("should prevent playing when on cooldown", async () => { + const futureDate = new Date(Date.now() + 60000); + + await DrizzleClient.insert(userTimers).values({ + userId: BigInt(TEST_USER_ID), + type: TimerType.TRIVIA_COOLDOWN, + key: 'default', + expiresAt: futureDate, + }); + + const result = await triviaService.canPlayTrivia(TEST_USER_ID); + + expect(result.canPlay).toBe(false); + expect(result.nextAvailable).toBeDefined(); + }); + + it("should allow playing when cooldown has expired", async () => { + const pastDate = new Date(Date.now() - 1000); + + await DrizzleClient.insert(userTimers).values({ + userId: BigInt(TEST_USER_ID), + type: TimerType.TRIVIA_COOLDOWN, + key: 'default', + expiresAt: pastDate, + }); + + const result = await triviaService.canPlayTrivia(TEST_USER_ID); + + expect(result.canPlay).toBe(true); + }); + }); + + describe("startTrivia", () => { + it("should start a trivia session and deduct entry fee", async () => { + const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + + expect(session).toBeDefined(); + expect(session.sessionId).toContain(TEST_USER_ID); + expect(session.userId).toBe(TEST_USER_ID); + expect(session.question).toBeDefined(); + expect(session.allAnswers).toHaveLength(4); + expect(session.entryFee).toBe(config.trivia.entryFee); + expect(session.potentialReward).toBeGreaterThan(0n); + + // Verify balance deduction + const user = await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(TEST_USER_ID)) + }); + + expect(user?.balance).toBe(1000n - config.trivia.entryFee); + + // Verify cooldown was set + const cooldown = await DrizzleClient.query.userTimers.findFirst({ + where: and( + eq(userTimers.userId, BigInt(TEST_USER_ID)), + eq(userTimers.type, TimerType.TRIVIA_COOLDOWN), + eq(userTimers.key, 'default') + ) + }); + + expect(cooldown).toBeDefined(); + }); + + it("should throw error if user has insufficient balance", async () => { + // Set balance to less than entry fee + await DrizzleClient.update(users) + .set({ balance: 10n }) + .where(eq(users.id, BigInt(TEST_USER_ID))); + + await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) + .rejects.toThrow('Insufficient funds'); + }); + + it("should throw error if user is on cooldown", async () => { + const futureDate = new Date(Date.now() + 60000); + + await DrizzleClient.insert(userTimers).values({ + userId: BigInt(TEST_USER_ID), + type: TimerType.TRIVIA_COOLDOWN, + key: 'default', + expiresAt: futureDate, + }); + + await expect(triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME)) + .rejects.toThrow('cooldown'); + }); + }); + + describe("submitAnswer", () => { + it("should award prize for correct answer", async () => { + const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + const balanceBefore = (await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(TEST_USER_ID)) + }))!.balance!; + + const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true); + + expect(result.correct).toBe(true); + expect(result.reward).toBe(session.potentialReward); + expect(result.correctAnswer).toBe(session.question.correctAnswer); + + // Verify balance increase + const user = await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(TEST_USER_ID)) + }); + + expect(user?.balance).toBe(balanceBefore + session.potentialReward); + }); + + it("should not award prize for incorrect answer", async () => { + const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + const balanceBefore = (await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(TEST_USER_ID)) + }))!.balance!; + + const result = await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, false); + + expect(result.correct).toBe(false); + expect(result.reward).toBe(0n); + expect(result.correctAnswer).toBe(session.question.correctAnswer); + + // Verify balance unchanged (already deducted at start) + const user = await DrizzleClient.query.users.findFirst({ + where: eq(users.id, BigInt(TEST_USER_ID)) + }); + + expect(user?.balance).toBe(balanceBefore); + }); + + it("should throw error if session doesn't exist", async () => { + await expect(triviaService.submitAnswer("invalid_session", TEST_USER_ID, true)) + .rejects.toThrow('Session not found'); + }); + + it("should prevent double submission", async () => { + const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + + await triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true); + + // Try to submit again + await expect(triviaService.submitAnswer(session.sessionId, TEST_USER_ID, true)) + .rejects.toThrow('Session not found'); + }); + }); + + describe("getSession", () => { + it("should retrieve active session", async () => { + const session = await triviaService.startTrivia(TEST_USER_ID, TEST_USERNAME); + const retrieved = triviaService.getSession(session.sessionId); + + expect(retrieved).toBeDefined(); + expect(retrieved?.sessionId).toBe(session.sessionId); + }); + + it("should return undefined for non-existent session", () => { + const retrieved = triviaService.getSession("invalid_session"); + + expect(retrieved).toBeUndefined(); + }); + }); +}); diff --git a/shared/modules/trivia/trivia.service.ts b/shared/modules/trivia/trivia.service.ts new file mode 100644 index 0000000..9a716a6 --- /dev/null +++ b/shared/modules/trivia/trivia.service.ts @@ -0,0 +1,310 @@ +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 = 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): 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: (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 { + 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(); diff --git a/web/src/components/settings-drawer.tsx b/web/src/components/settings-drawer.tsx index 1ca6815..2ca0cbd 100644 --- a/web/src/components/settings-drawer.tsx +++ b/web/src/components/settings-drawer.tsx @@ -88,6 +88,14 @@ const formSchema = z.object({ autoTimeoutThreshold: z.number().optional() }) }), + trivia: z.object({ + entryFee: bigIntStringSchema, + rewardMultiplier: z.number(), + timeoutSeconds: z.number(), + cooldownMs: z.number(), + categories: z.array(z.number()).default([]), + difficulty: z.enum(['easy', 'medium', 'hard', 'random']), + }).optional(), system: z.record(z.string(), z.any()).optional(), }); @@ -747,6 +755,98 @@ export function SettingsDrawer() { + + +
+
+ šŸŽÆ +
+ Trivia +
+
+ +
+ ( + + Entry Fee (AU) + + + + Cost to play (currency sink) + + )} + /> + ( + + Reward Multiplier + + field.onChange(Number(e.target.value))} /> + + Prize = Entry Ɨ Multiplier + + )} + /> +
+ +
+ ( + + Answer Time Limit (seconds) + + field.onChange(Number(e.target.value))} /> + + + )} + /> + ( + + Cooldown (ms) + + field.onChange(Number(e.target.value))} /> + + + )} + /> +
+ + ( + + Difficulty + + Question difficulty level + + )} + /> +
+
+