discord-multipurpose-bot/commands/games/trivia.js
2024-09-10 12:30:08 +01:00

362 lines
11 KiB
JavaScript

const { SlashCommandBuilder, EmbedBuilder } = require("discord.js");
const axios = require("axios");
const { decode } = require("html-entities");
const TriviaQuestion = require("../../models/TriviaQuestion");
const Leaderboard = require("../../models/Leaderboard");
// WARNING: this code is by means not perfect, and it might have questionable implementation but its still a good starting point and under development. feel free to suggest improvements.
const API_INTERVAL = 5000; // 5 seconds
const QUESTION_EXPIRY = 30 * 24 * 60 * 60 * 1000; // 1 month
const ACTIVE_GAMES = new Set(); // Track users with ongoing trivia
const LAST_API_CALL = { time: 0 }; // Track last API call
const CATEGORY_MAP = {
15: "Video Games",
31: "Anime & Manga",
18: "Computers",
16: "Board Games",
29: "Comics",
32: "Cartoons & Animations",
11: "Film",
9: "General Knowledge",
17: "Science & Nature",
27: "Animals",
12: "Music",
23: "History",
22: "Geography",
20: "Mythology",
};
// Generate a new session token for each user
const generateSessionToken = async () => {
const response = await axios.get(
"https://opentdb.com/api_token.php?command=request"
);
return response.data.token;
};
const fetchTriviaQuestion = async (categoryId, categoryName) => {
try {
let triviaQuestion;
let source = "API"; // Default to API
// Generate a new session token
let sessionToken = await generateSessionToken();
// Attempt to find a question in the database that hasn't been served recently
triviaQuestion = await TriviaQuestion.findOne({
last_served: { $lt: new Date(Date.now() - QUESTION_EXPIRY) },
category: categoryName,
}).sort({ last_served: 1 });
if (!triviaQuestion || Date.now() - LAST_API_CALL.time >= API_INTERVAL) {
// If no question was found in the database or API cooldown is over, fetch from API
const response = await axios.get(
`https://opentdb.com/api.php?amount=1&category=${categoryId}&token=${sessionToken}`
);
const apiQuestion = response.data.results[0];
// Check if the token is exhausted (response code 3 indicates this)
if (response.data.response_code === 3) {
// Token exhausted, generate a new one
sessionToken = await generateSessionToken();
// Retry fetching the question
const retryResponse = await axios.get(
`https://opentdb.com/api.php?amount=1&category=${categoryId}&token=${sessionToken}`
);
const retryApiQuestion = retryResponse.data.results[0];
triviaQuestion = await TriviaQuestion.findOne({
question: decode(retryApiQuestion.question),
category: categoryName,
});
if (!triviaQuestion) {
await TriviaQuestion.create({
question: decode(retryApiQuestion.question),
correct_answer: decode(retryApiQuestion.correct_answer),
incorrect_answers: retryApiQuestion.incorrect_answers.map(decode),
category: categoryName,
last_served: null,
});
triviaQuestion = await TriviaQuestion.findOne({
question: decode(retryApiQuestion.question),
category: categoryName,
});
}
} else {
triviaQuestion = await TriviaQuestion.findOne({
question: decode(apiQuestion.question),
category: categoryName,
});
if (!triviaQuestion) {
await TriviaQuestion.create({
question: decode(apiQuestion.question),
correct_answer: decode(apiQuestion.correct_answer),
incorrect_answers: apiQuestion.incorrect_answers.map(decode),
category: categoryName,
last_served: null,
});
triviaQuestion = await TriviaQuestion.findOne({
question: decode(apiQuestion.question),
category: categoryName,
});
}
}
LAST_API_CALL.time = Date.now(); // Update the last API call time
} else {
// If found in the database, set source to "Database"
source = "Database";
}
if (triviaQuestion) {
// Update the `last_served` timestamp when serving the question
triviaQuestion.last_served = new Date();
await triviaQuestion.save();
}
return { triviaQuestion, source }; // Return both the question and its source
} catch (error) {
console.error("Error fetching or saving trivia question:", error);
throw new Error("Error fetching trivia question");
}
};
const createTriviaEmbed = (
categoryName,
question,
answerMap,
guild,
timeLimit,
source
) => {
return new EmbedBuilder()
.setColor("#0099ff")
.setTitle(`${categoryName} Trivia Question`)
.setDescription(question)
.addFields(
Object.entries(answerMap).map(([number, answer]) => ({
name: `Option ${number}`,
value: answer,
inline: true,
}))
)
.setTimestamp()
.setFooter({
text: `${guild.name} | Answer within ${
timeLimit / 1000
} seconds | Source: ${source}`,
iconURL: guild.iconURL(),
});
};
const handleAnswerCollection = async (
interaction,
triviaQuestion,
answerMap,
correctAnswer,
allAnswers,
timeLimit,
userId,
username
) => {
try {
const answerFilter = (response) => {
const userInput = response.content.trim();
const userAnswerNumber = parseInt(userInput, 10);
const userAnswerText =
allAnswers.includes(userInput) ||
(answerMap[userAnswerNumber] &&
answerMap[userAnswerNumber] === correctAnswer);
return (
response.author.id === userId &&
(userAnswerText || (userAnswerNumber >= 1 && userAnswerNumber <= 4))
);
};
const answerCollector = interaction.channel.createMessageCollector({
filter: answerFilter,
max: 1,
time: timeLimit,
});
answerCollector.on("collect", async (message) => {
try {
const userInput = message.content.trim();
const userAnswerNumber = parseInt(userInput, 10);
const userAnswer = answerMap[userAnswerNumber] || userInput;
let resultMessage =
userAnswer === correctAnswer
? "🎉 Correct!"
: `❌ Incorrect! the correct answer is **${correctAnswer}.**`;
let userScore = await Leaderboard.findOne({ userId });
if (!userScore) {
userScore = new Leaderboard({
userId,
username,
gamesPlayed: 1,
correctAnswers: userAnswer === correctAnswer ? 1 : 0,
streak: userAnswer === correctAnswer ? 1 : 0, // Start streak
});
} else {
userScore.gamesPlayed += 1;
if (userAnswer === correctAnswer) {
userScore.correctAnswers += 1;
userScore.streak += 1; // Increment streak
} else {
userScore.streak = 0; // Reset streak
}
}
await userScore.save();
await interaction.followUp(
`${resultMessage} <@${userId}> You've answered ${userScore.correctAnswers} questions correctly out of ${userScore.gamesPlayed} games. Your current streak is **${userScore.streak}**.`
);
ACTIVE_GAMES.delete(userId);
} catch (error) {
console.error("Error processing collected answer:", error);
await interaction.followUp({
content: "There was an error processing your answer.",
ephemeral: true,
});
ACTIVE_GAMES.delete(userId);
}
});
answerCollector.on("end", async (collected, reason) => {
if (reason === "time") {
// Reset the user's streak when time runs out
try {
let userScore = await Leaderboard.findOne({ userId });
if (userScore) {
userScore.streak = 0; // Reset streak
await userScore.save();
}
await interaction.followUp(
`⏰ <@${userId}> Time's up! The correct answer is **${correctAnswer}**. Your current streak is **${userScore.streak}**.`
);
} catch (error) {
console.error("Error resetting streak after time limit:", error);
await interaction.followUp({
content: "There was an error resetting your streak.",
ephemeral: true,
});
}
ACTIVE_GAMES.delete(userId);
}
});
} catch (error) {
console.error("Error handling answer collection:", error);
await interaction.followUp({
content: "There was an error handling your response.",
ephemeral: true,
});
ACTIVE_GAMES.delete(userId);
}
};
module.exports = {
data: new SlashCommandBuilder()
.setName("trivia")
.setDescription("Play a trivia game")
.addStringOption((option) =>
option
.setName("category")
.setDescription("Choose a trivia category")
.setRequired(true)
.addChoices(
...Object.entries(CATEGORY_MAP).map(([value, name]) => ({
name,
value,
}))
)
),
async execute(interaction, client) {
const userId = interaction.user.id;
const username = interaction.user.username;
const guild = interaction.guild;
const timeLimit = 30000; // Time limit for answering in milliseconds
if (ACTIVE_GAMES.has(userId)) {
return interaction.reply({
content:
"You already have an ongoing trivia game. Please finish it before starting a new one.",
ephemeral: true,
});
}
ACTIVE_GAMES.add(userId);
try {
const categoryId = interaction.options.getString("category");
const categoryName = CATEGORY_MAP[categoryId] || "Video Games";
const { triviaQuestion, source } = await fetchTriviaQuestion(
categoryId,
categoryName
);
if (!triviaQuestion) {
throw new Error("No questions available.");
}
const question = decode(triviaQuestion.question);
const correctAnswer = decode(triviaQuestion.correct_answer);
const incorrectAnswers = triviaQuestion.incorrect_answers.map(decode);
let allAnswers = [...incorrectAnswers, correctAnswer];
let answerMap = {};
if (triviaQuestion.type === "boolean") {
answerMap = { 1: "True", 2: "False" };
} else {
allAnswers = allAnswers.sort(() => Math.random() - 0.5);
answerMap = allAnswers.reduce((map, answer, index) => {
map[index + 1] = answer;
return map;
}, {});
}
const triviaEmbed = createTriviaEmbed(
categoryName,
question,
answerMap,
guild,
timeLimit,
source // Pass the source flag (API or Database)
);
await interaction.reply({ embeds: [triviaEmbed] });
await handleAnswerCollection(
interaction,
triviaQuestion,
answerMap,
correctAnswer,
allAnswers,
timeLimit,
userId,
username
);
} catch (error) {
console.error("Error executing trivia command:", error);
await interaction.reply({
content:
"Trivia API hit the rate limit or encountered an issue. Please try again in 5 seconds.",
ephemeral: true,
});
ACTIVE_GAMES.delete(userId);
}
},
};