feat: trivia command!
This commit is contained in:
90
bot/commands/economy/trivia.ts
Normal file
90
bot/commands/economy/trivia.ts
Normal file
@@ -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 <t:${timestamp}:R>.`
|
||||
)],
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 ---
|
||||
{
|
||||
|
||||
116
bot/modules/trivia/README.md
Normal file
116
bot/modules/trivia/README.md
Normal file
@@ -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
|
||||
126
bot/modules/trivia/trivia.interaction.ts
Normal file
126
bot/modules/trivia/trivia.interaction.ts
Normal file
@@ -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
|
||||
});
|
||||
}
|
||||
334
bot/modules/trivia/trivia.view.ts
Normal file
334
bot/modules/trivia/trivia.view.ts
Normal file
@@ -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:** <t:${Math.floor(expiresAt.getTime() / 1000)}:R> (${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
|
||||
};
|
||||
}
|
||||
@@ -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<string, any>;
|
||||
}
|
||||
|
||||
@@ -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({}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
241
shared/modules/trivia/trivia.service.test.ts
Normal file
241
shared/modules/trivia/trivia.service.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
310
shared/modules/trivia/trivia.service.ts
Normal file
310
shared/modules/trivia/trivia.service.ts
Normal file
@@ -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<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();
|
||||
@@ -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() {
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="trivia" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/10 flex items-center justify-center text-purple-500">
|
||||
🎯
|
||||
</div>
|
||||
<span className="font-medium">Trivia</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trivia.entryFee"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Entry Fee (AU)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" className="bg-background/50" placeholder="50" />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">Cost to play (currency sink)</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trivia.rewardMultiplier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reward Multiplier</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" step="0.1" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">Prize = Entry × Multiplier</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trivia.timeoutSeconds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Answer Time Limit (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trivia.cooldownMs"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cooldown (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="number" className="bg-background/50" onChange={e => field.onChange(Number(e.target.value))} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trivia.difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-background/50">
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
<SelectItem value="random">Random</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs">Question difficulty level</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="moderation" className="border border-border/40 rounded-xl bg-card/30 px-4 transition-all data-[state=open]:border-primary/20 data-[state=open]:bg-card/50">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user