feat: trivia command!

This commit is contained in:
syntaxbullet
2026-01-11 14:37:17 +01:00
parent 1cd3dbcd72
commit 35bd1f58dd
10 changed files with 1348 additions and 0 deletions

View 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
});
}
}
}
}
});

View File

@@ -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 ---
{

View 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

View 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
});
}

View 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
};
}