refactoring first steps

This commit is contained in:
Milo
2025-07-29 15:18:43 +02:00
parent 1bc578d17d
commit 0d05dd088a
37 changed files with 9759 additions and 5272 deletions

View File

@@ -1,120 +1,5 @@
import 'dotenv/config';
import { getTimesChoices } from './game.js';
import { capitalize, InstallGlobalCommands } from './utils.js';
import { registerCommands } from './src/config/commands.js';
function createTimesChoices() {
const choices = getTimesChoices();
const commandChoices = [];
for (let choice of choices) {
commandChoices.push({
name: capitalize(choice.name),
value: choice.value?.toString(),
});
}
return commandChoices;
}
// Simple test command
const TEST_COMMAND = {
name: 'test',
description: 'Basic command',
type: 1,
integration_types: [0, 1],
contexts: [0, 1, 2],
};
// Timeout vote command
const TIMEOUT_COMMAND = {
name: 'timeout',
description: 'Vote démocratique pour timeout un boug',
options: [
{
type: 6,
name: 'akhy',
description: 'Qui ?',
required: true,
},
{
type: 3,
name: 'temps',
description: 'Combien de temps ?',
required: true,
choices: createTimesChoices(),
}
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
// Valorant
const VALORANT_COMMAND = {
name: 'valorant',
description: `Ouvrir une caisse valorant (15€)`,
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
// Own inventory command
const INVENTORY_COMMAND = {
name: 'inventory',
description: 'Voir inventaire',
options: [
{
type: 6,
name: 'akhy',
description: 'Qui ?',
required: false,
},
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const INFO_COMMAND = {
name: 'info',
description: 'Qui est time out ?',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SKINS_COMMAND = {
name: 'skins',
description: 'Le top 10 des skins les plus chers.',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SITE_COMMAND = {
name: 'floposite',
description: 'Lien vers FlopoSite',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SEARCH_SKIN_COMMAND = {
name: 'search',
description: 'Chercher un skin',
options: [
{
type: 3,
name: 'recherche',
description: 'Tu cherches quoi ?',
required: true,
},
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const ALL_COMMANDS = [TIMEOUT_COMMAND, INVENTORY_COMMAND, VALORANT_COMMAND, INFO_COMMAND, SKINS_COMMAND, SEARCH_SKIN_COMMAND, SITE_COMMAND];
InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS);
console.log('Registering global commands...');
registerCommands();
console.log('Commands registered.');

BIN
flopobot.db-shm Normal file

Binary file not shown.

BIN
flopobot.db-wal Normal file

Binary file not shown.

5188
index.js

File diff suppressed because it is too large Load Diff

5167
old_index.js Normal file

File diff suppressed because it is too large Load Diff

60
src/api/discord.js Normal file
View File

@@ -0,0 +1,60 @@
import 'dotenv/config';
/**
* A generic function for making requests to the Discord API.
* It handles URL construction, authentication, and basic error handling.
*
* @param {string} endpoint - The API endpoint to request (e.g., 'channels/123/messages').
* @param {object} [options] - Optional fetch options (method, body, etc.).
* @returns {Promise<Response>} The raw fetch response object.
* @throws Will throw an error if the API request is not successful.
*/
export async function DiscordRequest(endpoint, options) {
// Construct the full API URL
const url = 'https://discord.com/api/v10/' + endpoint;
// Stringify the payload if it exists
if (options && options.body) {
options.body = JSON.stringify(options.body);
}
// Use fetch to make the request, automatically including required headers
const res = await fetch(url, {
headers: {
'Authorization': `Bot ${process.env.DISCORD_TOKEN}`,
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': 'DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)',
},
...options, // Spread the given options (e.g., method, body)
});
// If the request was not successful, throw a detailed error
if (!res.ok) {
const data = await res.json();
console.error(`Discord API Error on endpoint ${endpoint}:`, res.status, data);
throw new Error(JSON.stringify(data));
}
// Return the original response object for further processing
return res;
}
/**
* Installs or overwrites all global slash commands for the application.
*
* @param {string} appId - The application (client) ID.
* @param {Array<object>} commands - An array of command objects to install.
*/
export async function InstallGlobalCommands(appId, commands) {
// API endpoint for bulk overwriting global commands
const endpoint = `applications/${appId}/commands`;
console.log('Installing global commands...');
try {
// This uses the generic DiscordRequest function to make the API call
await DiscordRequest(endpoint, { method: 'PUT', body: commands });
console.log('Successfully installed global commands.');
} catch (err) {
console.error('Error installing global commands:', err);
}
}

11
src/api/valorant.js Normal file
View File

@@ -0,0 +1,11 @@
export async function getValorantSkins(locale='fr-FR') {
const response = await fetch(`https://valorant-api.com/v1/weapons/skins?language=${locale}`, { method: 'GET' });
const data = await response.json();
return data.data
}
export async function getSkinTiers(locale='fr-FR') {
const response = await fetch(`https://valorant-api.com/v1/contenttiers?language=${locale}`, { method: 'GET'});
const data = await response.json();
return data.data
}

28
src/bot/client.js Normal file
View File

@@ -0,0 +1,28 @@
import { Client, GatewayIntentBits } from 'discord.js';
/**
* The single, shared Discord.js Client instance for the entire application.
* It is configured with all the necessary intents to receive the events it needs.
*/
export const client = new Client({
// Define the events the bot needs to receive from Discord's gateway.
intents: [
// Required for basic guild information and events.
GatewayIntentBits.Guilds,
// Required to receive messages in guilds (e.g., in #general).
GatewayIntentBits.GuildMessages,
// A PRIVILEGED INTENT, required to read the content of messages.
// This is necessary for the AI handler, admin commands, and "quoi/feur".
GatewayIntentBits.MessageContent,
// Required to receive updates when members join, leave, or are updated.
// Crucial for fetching member details for commands like /timeout or /info.
GatewayIntentBits.GuildMembers,
// Required to receive member presence updates (online, idle, offline).
// Necessary for features like `getOnlineUsersWithRole`.
GatewayIntentBits.GuildPresences,
],
});

View File

@@ -0,0 +1,55 @@
import {
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
/**
* Handles the /floposite slash command.
* This command replies with a simple embed containing a link button to the main website.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
*/
export async function handleFlopoSiteCommand(req, res) {
// The URL for the link button. Consider moving to .env if it changes.
const siteUrl = process.env.FLOPOSITE_URL || 'https://floposite.com';
// The URL for the thumbnail image.
const thumbnailUrl = `${process.env.API_URL}/public/images/flopo.png`;
// Define the components (the link button)
const components = [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
label: 'Aller sur FlopoSite',
style: ButtonStyleTypes.LINK,
url: siteUrl,
},
],
},
];
// Define the embed message
const embeds = [
{
title: 'FlopoSite',
description: "L'officiel et très goatesque site de FlopoBot.",
color: 0x6571F3, // A custom blue color
thumbnail: {
url: thumbnailUrl,
},
},
];
// Send the response to Discord
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: embeds,
components: components,
},
});
}

71
src/bot/commands/info.js Normal file
View File

@@ -0,0 +1,71 @@
import { InteractionResponseType } from 'discord-interactions';
/**
* Handles the /info slash command.
* Fetches and displays a list of all members who are currently timed out in the guild.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleInfoCommand(req, res, client) {
const { guild_id } = req.body;
try {
// Fetch the guild object from the client
const guild = await client.guilds.fetch(guild_id);
// Fetch all members to ensure the cache is up to date
await guild.members.fetch();
// Filter the cached members to find those who are timed out
// A member is timed out if their `communicationDisabledUntil` property is a future date.
const timedOutMembers = guild.members.cache.filter(
(member) =>
member.communicationDisabledUntilTimestamp &&
member.communicationDisabledUntilTimestamp > Date.now()
);
// --- Case 1: No members are timed out ---
if (timedOutMembers.size === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: 'Membres Timeout',
description: "Aucun membre n'est actuellement timeout.",
color: 0x4F545C, // Discord's gray color
},
],
},
});
}
// --- Case 2: At least one member is timed out ---
// Format the list of timed-out members for the embed
const memberList = timedOutMembers
.map((member) => {
// toLocaleString provides a user-friendly date and time format
const expiration = new Date(member.communicationDisabledUntilTimestamp).toLocaleString('fr-FR');
return `▫️ **${member.user.globalName || member.user.username}** (jusqu'au ${expiration})`;
})
.join('\n');
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: 'Membres Actuellement Timeout',
description: memberList,
color: 0xED4245, // Discord's red color
},
],
},
});
} catch (error) {
console.error('Error handling /info command:', error);
return res.status(500).json({ error: 'Failed to fetch timeout information.' });
}
}

View File

@@ -0,0 +1,131 @@
import {
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from 'discord-interactions';
import { activeInventories, skins } from '../../game/state.js';
import { getUserInventory } from '../../database/index.js';
/**
* Handles the /inventory slash command.
* Displays a paginated, interactive embed of a user's Valorant skin inventory.
*
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
* @param {string} interactionId - The unique ID of the interaction.
*/
export async function handleInventoryCommand(req, res, client, interactionId) {
const { member, guild_id, token, data } = req.body;
const commandUserId = member.user.id;
// User can specify another member, otherwise it defaults to themself
const targetUserId = data.options && data.options.length > 0 ? data.options[0].value : commandUserId;
try {
// --- 1. Fetch Data ---
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(targetUserId);
const inventorySkins = getUserInventory.all({ user_id: targetUserId });
// --- 2. Handle Empty Inventory ---
if (inventorySkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
description: "Cet inventaire est vide.",
color: 0x4F545C, // Discord Gray
}],
},
});
}
// --- 3. Store Interactive Session State ---
// This state is crucial for the component handlers to know which inventory to update.
activeInventories[interactionId] = {
akhyId: targetUserId, // The inventory owner
userId: commandUserId, // The user who ran the command
page: 0,
amount: inventorySkins.length,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
inventorySkins: inventorySkins, // Cache the skins to avoid re-querying the DB on each page turn
};
// --- 4. Prepare Embed Content ---
const currentSkin = inventorySkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0);
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? '💠 ' : '◾ ';
}
return result || 'N/A';
};
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return 'Base';
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
// --- 5. Build Initial Components (Buttons) ---
const components = [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
];
const isUpgradable = currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length;
// Only show upgrade button if the skin is upgradable AND the command user owns the inventory
if (isUpgradable && targetUserId === commandUserId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)}€)`,
style: ButtonStyleTypes.PRIMARY,
});
}
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
footer: { text: `Page 1/${inventorySkins.length} | Valeur Totale : ${totalPrice.toFixed(2)}` },
fields: [{
name: `${currentSkin.displayName} | ${currentSkin.currentPrice.toFixed(2)}`,
value: `${currentSkin.tierText}\nChroma : ${getChromaText(currentSkin, skinData)} | ${getChromaName(currentSkin, skinData)}\nLvl : **${currentSkin.currentLvl}**/${skinData.levels.length}`,
}],
image: { url: getImageUrl(currentSkin, skinData) },
}],
components: [{ type: MessageComponentTypes.ACTION_ROW, components: components }],
},
});
} catch (error) {
console.error('Error handling /inventory command:', error);
return res.status(500).json({ error: 'Failed to generate inventory.' });
}
}

122
src/bot/commands/search.js Normal file
View File

@@ -0,0 +1,122 @@
import {
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { activeSearchs, skins } from '../../game/state.js';
import { getAllSkins } from '../../database/index.js';
/**
* Handles the /search slash command.
* Searches for skins by name or tier and displays them in a paginated embed.
*
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
* @param {string} interactionId - The unique ID of the interaction.
*/
export async function handleSearchCommand(req, res, client, interactionId) {
const { member, guild_id, token, data } = req.body;
const userId = member.user.id;
const searchValue = data.options[0].value.toLowerCase();
try {
// --- 1. Fetch and Filter Data ---
const allDbSkins = getAllSkins.all();
const resultSkins = allDbSkins.filter((skin) =>
skin.displayName.toLowerCase().includes(searchValue) ||
skin.tierText.toLowerCase().includes(searchValue)
);
// --- 2. Handle No Results ---
if (resultSkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Aucun skin ne correspond à votre recherche.',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Store Interactive Session State ---
activeSearchs[interactionId] = {
userId: userId,
page: 0,
amount: resultSkins.length,
resultSkins: resultSkins,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
searchValue: searchValue,
};
// --- 4. Prepare Initial Embed Content ---
const guild = await client.guilds.fetch(guild_id);
const currentSkin = resultSkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
// Fetch owner details if the skin is owned
let ownerText = '';
if (currentSkin.user_id) {
try {
const owner = await guild.members.fetch(currentSkin.user_id);
ownerText = `| **@${owner.user.globalName || owner.user.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
ownerText = '| Appartenant à un utilisateur inconnu';
}
}
// Helper to get the best possible image for the skin
const getImageUrl = (skinInfo) => {
const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1];
if (lastChroma?.fullRender) return lastChroma.fullRender;
if (lastChroma?.displayIcon) return lastChroma.displayIcon;
const lastLevel = skinInfo.levels[skinInfo.levels.length - 1];
if (lastLevel?.displayIcon) return lastLevel.displayIcon;
return skinInfo.displayIcon; // Fallback to base icon
};
// --- 5. Build Initial Components & Embed ---
const components = [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_search_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_search_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
],
},
];
const embed = {
title: 'Résultats de la recherche',
description: `🔎 _"${searchValue}"_`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
fields: [{
name: `**${currentSkin.displayName}**`,
value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice}€** ${ownerText}`,
}],
image: { url: getImageUrl(skinData) },
footer: { text: `Résultat 1/${resultSkins.length}` },
};
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [embed],
components: components,
},
});
} catch (error) {
console.error('Error handling /search command:', error);
return res.status(500).json({ error: 'Failed to execute search.' });
}
}

68
src/bot/commands/skins.js Normal file
View File

@@ -0,0 +1,68 @@
import { InteractionResponseType } from 'discord-interactions';
import { getTopSkins } from '../../database/index.js';
/**
* Handles the /skins slash command.
* Fetches and displays the top 10 most valuable skins from the database.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleSkinsCommand(req, res, client) {
const { guild_id } = req.body;
try {
// --- 1. Fetch Data ---
const topSkins = getTopSkins.all();
const guild = await client.guilds.fetch(guild_id);
const fields = [];
// --- 2. Build Embed Fields Asynchronously ---
// We use a for...of loop to handle the async fetch for each owner.
for (const [index, skin] of topSkins.entries()) {
let ownerText = 'Libre'; // Default text if the skin has no owner
// If the skin has an owner, fetch their details
if (skin.user_id) {
try {
const owner = await guild.members.fetch(skin.user_id);
// Use globalName if available, otherwise fallback to username
ownerText = `**@${owner.user.globalName || owner.user.username}** ✅`;
} catch (e) {
// This can happen if the user has left the server
console.warn(`Could not fetch owner for user ID: ${skin.user_id}`);
ownerText = 'Appartient à un utilisateur inconnu';
}
}
// Add the formatted skin info to our fields array
fields.push({
name: `#${index + 1} - **${skin.displayName}**`,
value: `Valeur Max: **${skin.maxPrice}€** | ${ownerText}`,
inline: false,
});
}
// --- 3. Send the Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: '🏆 Top 10 des Skins les Plus Chers',
description: 'Classement des skins par leur valeur maximale potentielle.',
fields: fields,
color: 0xFFD700, // Gold color for a leaderboard
footer: {
text: 'Utilisez /inventory pour voir vos propres skins.'
}
},
],
},
});
} catch (error) {
console.error('Error handling /skins command:', error);
return res.status(500).json({ error: 'Failed to fetch the skins leaderboard.' });
}
}

212
src/bot/commands/timeout.js Normal file
View File

@@ -0,0 +1,212 @@
import {
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { formatTime, getOnlineUsersWithRole } from '../../utils/index.js';
import { DiscordRequest } from '../../api/discord.js';
import { activePolls } from '../../game/state.js';
import { getSocketIo } from '../../server/socket.js';
import { getUser } from '../../database/index.js';
/**
* Handles the /timeout slash command.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleTimeoutCommand(req, res, client) {
const io = getSocketIo();
const { id, member, guild_id, channel_id, token, data } = req.body;
const { options } = data;
// Extract command options
const userId = member.user.id;
const targetUserId = options[0].value;
const time = options[1].value;
// Fetch member objects from Discord
const guild = await client.guilds.fetch(guild_id);
const fromMember = await guild.members.fetch(userId);
const toMember = await guild.members.fetch(targetUserId);
// --- Validation Checks ---
// 1. Check if a poll is already running for the target user
const existingPoll = Object.values(activePolls).find(poll => poll.toUserId === targetUserId);
if (existingPoll) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Impossible de lancer un vote pour **${toMember.user.globalName}**, un vote est déjà en cours.`,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// 2. Check if the user is already timed out
if (toMember.communicationDisabledUntilTimestamp > Date.now()) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `**${toMember.user.globalName}** est déjà timeout.`,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- Poll Initialization ---
const pollId = id; // Use the interaction ID as the unique poll ID
const webhookEndpoint = `webhooks/${process.env.APP_ID}/${token}/messages/@original`;
// Calculate required votes
const onlineEligibleUsers = await getOnlineUsersWithRole(guild, process.env.VOTING_ROLE_ID);
const requiredMajority = Math.max(
parseInt(process.env.MIN_VOTES, 10),
Math.floor(onlineEligibleUsers.size / (time >= 21600 ? 2 : 3)) + 1
);
// Store poll data in the active state
activePolls[pollId] = {
id: userId,
username: fromMember.user.globalName,
toUserId: targetUserId,
toUsername: toMember.user.globalName,
time: time,
time_display: formatTime(time),
for: 0,
against: 0,
voters: [],
channelId: channel_id,
endpoint: webhookEndpoint,
endTime: Date.now() + parseInt(process.env.POLL_TIME, 10) * 1000,
requiredMajority: requiredMajority,
};
// --- Set up Countdown Interval ---
const countdownInterval = setInterval(async () => {
const poll = activePolls[pollId];
// If poll no longer exists, clear the interval
if (!poll) {
clearInterval(countdownInterval);
return;
}
const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000));
const votesNeeded = Math.max(0, poll.requiredMajority - poll.for);
const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`;
// --- Poll Expiration Logic ---
if (remaining === 0) {
clearInterval(countdownInterval);
const votersList = poll.voters.map(voterId => {
const user = getUser.get(voterId);
return `- ${user?.globalName || 'Utilisateur Inconnu'}`;
}).join('\n');
try {
await DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: `Le vote pour timeout ${poll.toUsername} a échoué 😔`,
description: `Il manquait **${votesNeeded}** vote(s).`,
fields: [{
name: 'Pour',
value: `${poll.for}\n${votersList}`,
inline: true,
}],
color: 0xFF4444, // Red for failure
}],
components: [], // Remove buttons
},
});
} catch (err) {
console.error('Error updating failed poll message:', err);
}
// Clean up the poll from active state
delete activePolls[pollId];
io.emit('poll-update'); // Notify frontend
return;
}
// --- Periodic Update Logic ---
// Update the message every second with the new countdown
try {
const votersList = poll.voters.map(voterId => {
const user = getUser.get(voterId);
return `- ${user?.globalName || 'Utilisateur Inconnu'}`;
}).join('\n');
await DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Vote de Timeout',
description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`,
fields: [{
name: 'Pour',
value: `${poll.for}\n${votersList}`,
inline: true,
}, {
name: 'Temps restant',
value: `${countdownText}`,
inline: false,
}],
color: 0x5865F2, // Discord Blurple
}],
// Keep the components so people can still vote
components: [{
type: MessageComponentTypes.ACTION_ROW,
components: [
{ type: MessageComponentTypes.BUTTON, custom_id: `vote_for_${pollId}`, label: 'Oui ✅', style: ButtonStyleTypes.SUCCESS },
{ type: MessageComponentTypes.BUTTON, custom_id: `vote_against_${pollId}`, label: 'Non ❌', style: ButtonStyleTypes.DANGER },
],
}],
},
});
} catch (err) {
console.error('Error updating countdown:', err);
// If the message was deleted, stop trying to update it.
if (err.message.includes('Unknown Message')) {
clearInterval(countdownInterval);
delete activePolls[pollId];
io.emit('poll-update');
}
}
}, 2000); // Update every 2 seconds to avoid rate limits
// --- Send Initial Response ---
io.emit('poll-update'); // Notify frontend
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: 'Vote de Timeout',
description: `**${activePolls[pollId].username}** propose de timeout **${activePolls[pollId].toUsername}** pendant ${activePolls[pollId].time_display}.\nIl manque **${activePolls[pollId].requiredMajority}** vote(s).`,
fields: [{
name: 'Pour',
value: '✅ 0',
inline: true,
}, {
name: 'Temps restant',
value: `⏳ **${Math.floor((activePolls[pollId].endTime - Date.now()) / 60000)}m**`,
inline: false,
}],
color: 0x5865F2,
}],
components: [{
type: MessageComponentTypes.ACTION_ROW,
components: [
{ type: MessageComponentTypes.BUTTON, custom_id: `vote_for_${pollId}`, label: 'Oui ✅', style: ButtonStyleTypes.SUCCESS },
{ type: MessageComponentTypes.BUTTON, custom_id: `vote_against_${pollId}`, label: 'Non ❌', style: ButtonStyleTypes.DANGER },
],
}],
},
});
}

View File

@@ -0,0 +1,188 @@
import {
InteractionResponseType,
InteractionResponseFlags,
} from 'discord-interactions';
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
import { postAPOBuy } from '../../utils/index.js';
import { DiscordRequest } from '../../api/discord.js';
import { getAllAvailableSkins, updateSkin } from '../../database/index.js';
import { skins } from '../../game/state.js';
/**
* Handles the /valorant slash command for opening a "skin case".
*
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleValorantCommand(req, res, client) {
const { member, token } = req.body;
const userId = member.user.id;
const valoPrice = parseInt(process.env.VALO_PRICE, 10) || 150;
try {
// --- 1. Verify and process payment ---
const buyResponse = await postAPOBuy(userId, valoPrice);
if (!buyResponse.ok) {
const errorData = await buyResponse.json();
const errorMessage = errorData.message || `Tu n'as pas assez d'argent... Il te faut ${valoPrice}€.`;
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: errorMessage,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 2. Send Initial "Opening" Response ---
// Acknowledge the interaction immediately with a loading message.
const initialEmbed = new EmbedBuilder()
.setTitle('Ouverture de la caisse...')
.setImage('https://media.tenor.com/gIWab6ojBnYAAAAd/weapon-line-up-valorant.gif')
.setColor('#F2F3F3');
await res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { embeds: [initialEmbed] },
});
// --- 3. Run the skin reveal logic after a delay ---
setTimeout(async () => {
const webhookEndpoint = `webhooks/${process.env.APP_ID}/${token}/messages/@original`;
try {
// --- Skin Selection ---
const availableSkins = getAllAvailableSkins.all();
if (availableSkins.length === 0) {
throw new Error("No available skins to award.");
}
const dbSkin = availableSkins[Math.floor(Math.random() * availableSkins.length)];
const randomSkinData = skins.find((skin) => skin.uuid === dbSkin.uuid);
if (!randomSkinData) {
throw new Error(`Could not find skin data for UUID: ${dbSkin.uuid}`);
}
// --- Randomize Level and Chroma ---
const randomLevel = Math.floor(Math.random() * randomSkinData.levels.length) + 1;
let randomChroma = 1;
if (randomLevel === randomSkinData.levels.length && randomSkinData.chromas.length > 1) {
// Ensure chroma is at least 1 and not greater than the number of chromas
randomChroma = Math.floor(Math.random() * randomSkinData.chromas.length) + 1;
}
// --- Calculate Price ---
const calculatePrice = () => {
let result = parseFloat(dbSkin.basePrice);
result *= (1 + (randomLevel / Math.max(randomSkinData.levels.length, 2)));
result *= (1 + (randomChroma / 4));
return parseFloat(result.toFixed(2));
};
const finalPrice = calculatePrice();
// --- Update Database ---
await updateSkin.run({
uuid: randomSkinData.uuid,
user_id: userId,
currentLvl: randomLevel,
currentChroma: randomChroma,
currentPrice: finalPrice,
});
// --- Prepare Final Embed and Components ---
const finalEmbed = buildFinalEmbed(dbSkin, randomSkinData, randomLevel, randomChroma, finalPrice);
const components = buildComponents(randomSkinData, randomLevel, randomChroma);
// --- Edit the Original Message with the Result ---
await DiscordRequest(webhookEndpoint, {
method: 'PATCH',
body: {
embeds: [finalEmbed],
components: components,
},
});
} catch (revealError) {
console.error('Error during skin reveal:', revealError);
// Inform the user that something went wrong
await DiscordRequest(webhookEndpoint, {
method: 'PATCH',
body: {
content: "Oups, il y a eu un petit problème lors de l'ouverture de la caisse. L'administrateur a été notifié.",
embeds: [],
},
});
}
}, 5000); // 5-second delay for suspense
} catch (error) {
console.error('Error handling /valorant command:', error);
// This catches errors from the initial interaction, e.g., the payment API call.
return res.status(500).json({ error: 'Failed to initiate the case opening.' });
}
}
// --- Helper Functions ---
/** Builds the final embed to display the won skin. */
function buildFinalEmbed(dbSkin, skinData, level, chroma, price) {
const selectedChromaData = skinData.chromas[chroma - 1] || {};
const getChromaName = () => {
if (chroma > 1) {
const name = selectedChromaData.displayName?.replace(/[\r\n]+/g, ' ').replace(skinData.displayName, '').trim();
const match = name?.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : (name || 'Chroma Inconnu');
}
return 'Base';
};
const getImageUrl = () => {
if (level === skinData.levels.length) {
return selectedChromaData.fullRender || selectedChromaData.displayIcon || skinData.displayIcon;
}
const levelData = skinData.levels[level - 1];
return levelData?.displayIcon || skinData.displayIcon;
};
const lvlText = '1⃣'.repeat(level) + '◾'.repeat(skinData.levels.length - level);
const chromaText = '💠'.repeat(chroma) + '◾'.repeat(skinData.chromas.length - chroma);
return new EmbedBuilder()
.setTitle(`${skinData.displayName} | ${getChromaName()}`)
.setDescription(dbSkin.tierText)
.setColor(`#${dbSkin.tierColor}`)
.setImage(getImageUrl())
.setFields([
{ name: 'Lvl', value: lvlText || 'N/A', inline: true },
{ name: 'Chroma', value: chromaText || 'N/A', inline: true },
{ name: 'Prix', value: `**${price}** <:vp:1362964205808128122>`, inline: true },
])
.setFooter({ text: 'Skin ajouté à votre inventaire !' });
}
/** Builds the action row with a video button if a video is available. */
function buildComponents(skinData, level, chroma) {
const selectedLevelData = skinData.levels[level - 1] || {};
const selectedChromaData = skinData.chromas[chroma - 1] || {};
let videoUrl = null;
if (level === skinData.levels.length) {
videoUrl = selectedChromaData.streamedVideo;
}
videoUrl = videoUrl || selectedLevelData.streamedVideo;
if (videoUrl) {
return [
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setLabel('🎬 Aperçu Vidéo')
.setStyle(ButtonStyle.Link)
.setURL(videoUrl)
)
];
}
return []; // Return an empty array if no video is available
}

View File

@@ -0,0 +1,151 @@
import {
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from 'discord-interactions';
import { DiscordRequest } from '../../api/discord.js';
import { activeInventories, skins } from '../../game/state.js';
/**
* Handles navigation button clicks (Previous/Next) for the inventory embed.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleInventoryNav(req, res, client) {
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// Extract direction ('prev' or 'next') and the original interaction ID from the custom_id
const [direction, page, interactionId] = custom_id.split('_');
// --- 1. Retrieve the interactive session ---
const inventorySession = activeInventories[interactionId];
// --- 2. Validation Checks ---
if (!inventorySession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Oups, cet affichage d'inventaire a expiré. Veuillez relancer la commande `/inventory`.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Ensure the user clicking the button is the one who initiated the command
if (inventorySession.userId !== member.user.id) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas naviguer dans l'inventaire de quelqu'un d'autre.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Update Page Number ---
const { amount } = inventorySession;
if (direction === 'next') {
inventorySession.page = (inventorySession.page + 1) % amount;
} else if (direction === 'prev') {
inventorySession.page = (inventorySession.page - 1 + amount) % amount;
}
try {
// --- 4. Rebuild Embed with New Page Content ---
const { page, inventorySkins } = inventorySession;
const currentSkin = inventorySkins[page];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(inventorySession.akhyId);
const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0);
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? '💠 ' : '◾ ';
}
return result || 'N/A';
};
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return 'Base';
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
// --- 5. Rebuild Components (Buttons) ---
let components = [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
];
const isUpgradable = currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length;
// Conditionally add the upgrade button
if (isUpgradable && inventorySession.akhyId === inventorySession.userId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)}€)`,
style: ButtonStyleTypes.PRIMARY,
});
}
// --- 6. Send PATCH Request to Update the Message ---
await DiscordRequest(inventorySession.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
footer: { text: `Page ${page + 1}/${amount} | Valeur Totale : ${totalPrice.toFixed(2)}` },
fields: [{
name: `${currentSkin.displayName} | ${currentSkin.currentPrice.toFixed(2)}`,
value: `${currentSkin.tierText}\nChroma : ${getChromaText(currentSkin, skinData)} | ${getChromaName(currentSkin, skinData)}\nLvl : **${currentSkin.currentLvl}**/${skinData.levels.length}`,
}],
image: { url: getImageUrl(currentSkin, skinData) },
}],
components: [{ type: MessageComponentTypes.ACTION_ROW, components: components }],
},
});
// --- 7. Acknowledge the Interaction ---
// This tells Discord the interaction was received, and since the message is already updated,
// no further action is needed.
return res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE });
} catch (error) {
console.error('Error handling inventory navigation:', error);
// In case of an error, we should still acknowledge the interaction to prevent it from failing.
// We can send a silent, ephemeral error message.
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Une erreur est survenue lors de la mise à jour de l\'inventaire.',
flags: InteractionResponseFlags.EPHEMERAL,
}
});
}
}

View File

@@ -0,0 +1,176 @@
import {
InteractionResponseType,
InteractionResponseFlags,
} from 'discord-interactions';
import { DiscordRequest } from '../../api/discord.js';
import { activePolls } from '../../game/state.js';
import { getSocketIo } from '../../server/socket.js';
import { getUser } from '../../database/index.js';
/**
* Handles clicks on the 'Yes' or 'No' buttons of a timeout poll.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
*/
export async function handlePollVote(req, res) {
const io = getSocketIo();
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// --- 1. Parse Component ID ---
const [_, voteType, pollId] = custom_id.split('_'); // e.g., ['vote', 'for', '12345...']
const isVotingFor = voteType === 'for';
// --- 2. Retrieve Poll and Validate ---
const poll = activePolls[pollId];
const voterId = member.user.id;
if (!poll) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Ce sondage de timeout n'est plus actif.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Check if the voter has the required role
if (!member.roles.includes(process.env.VOTING_ROLE_ID)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous n'avez pas le rôle requis pour participer à ce vote.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent user from voting on themselves
if (poll.toUserId === voterId) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas voter pour vous-même.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent double voting
if (poll.voters.includes(voterId)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Vous avez déjà voté pour ce sondage.',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Record the Vote ---
poll.voters.push(voterId);
if (isVotingFor) {
poll.for++;
} else {
poll.against++;
}
io.emit('poll-update'); // Notify frontend clients of the change
const votersList = poll.voters.map(vId => `- ${getUser.get(vId)?.globalName || 'Utilisateur Inconnu'}`).join('\n');
// --- 4. Check for Majority ---
if (isVotingFor && poll.for >= poll.requiredMajority) {
// --- SUCCESS CASE: MAJORITY REACHED ---
// a. Update the poll message to show success
try {
await DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Vote Terminé - Timeout Appliqué !',
description: `La majorité a été atteinte. **${poll.toUsername}** a été timeout pendant ${poll.time_display}.`,
fields: [{ name: 'Votes Pour', value: `${poll.for}\n${votersList}`, inline: true }],
color: 0x22A55B, // Green for success
}],
components: [], // Remove buttons
},
});
} catch (err) {
console.error('Error updating final poll message:', err);
}
// b. Execute the timeout via Discord API
try {
const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString();
const endpointTimeout = `guilds/${guild_id}/members/${poll.toUserId}`;
await DiscordRequest(endpointTimeout, {
method: 'PATCH',
body: { communication_disabled_until: timeoutUntil },
});
// c. Send a public confirmation message and clean up
delete activePolls[pollId];
io.emit('poll-update');
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `💥 <@${poll.toUserId}> a été timeout pendant **${poll.time_display}** par décision démocratique !`,
},
});
} catch (err) {
console.error('Error timing out user:', err);
delete activePolls[pollId];
io.emit('poll-update');
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `La majorité a été atteinte, mais une erreur est survenue lors de l'application du timeout sur <@${poll.toUserId}>.`,
},
});
}
} else {
// --- PENDING CASE: NO MAJORITY YET ---
// a. Send an ephemeral acknowledgment to the voter
res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Votre vote a été enregistré ! ✅',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
// b. Update the original poll message asynchronously (no need to await)
// The main countdown interval will also handle this, but this provides a faster update.
const votesNeeded = Math.max(0, poll.requiredMajority - poll.for);
const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000));
const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`;
DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Vote de Timeout',
description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`,
fields: [{
name: 'Pour',
value: `${poll.for}\n${votersList}`,
inline: true,
}, {
name: 'Temps restant',
value: `${countdownText}`,
inline: false,
}],
color: 0x5865F2,
}],
// Keep the original components so people can still vote
components: req.body.message.components,
},
}).catch(err => console.error("Error updating poll after vote:", err));
}
}

View File

@@ -0,0 +1,121 @@
import {
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { DiscordRequest } from '../../api/discord.js';
import { activeSearchs, skins } from '../../game/state.js';
/**
* Handles navigation button clicks (Previous/Next) for the search results embed.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleSearchNav(req, res, client) {
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// Extract direction and the original interaction ID from the custom_id
const [direction, _, page, interactionId] = custom_id.split('_'); // e.g., ['next', 'search', 'page', '123...']
// --- 1. Retrieve the interactive session ---
const searchSession = activeSearchs[interactionId];
// --- 2. Validation Checks ---
if (!searchSession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Oups, cette recherche a expiré. Veuillez relancer la commande `/search`.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Ensure the user clicking the button is the one who initiated the command
if (searchSession.userId !== member.user.id) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas naviguer dans les résultats de recherche de quelqu'un d'autre.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Update Page Number ---
const { amount } = searchSession;
if (direction === 'next') {
searchSession.page = (searchSession.page + 1) % amount;
} else if (direction === 'prev') {
searchSession.page = (searchSession.page - 1 + amount) % amount;
}
try {
// --- 4. Rebuild Embed with New Page Content ---
const { page, resultSkins, searchValue } = searchSession;
const currentSkin = resultSkins[page];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
// Fetch owner details if the skin is owned
let ownerText = '';
if (currentSkin.user_id) {
try {
const owner = await client.users.fetch(currentSkin.user_id);
ownerText = `| **@${owner.globalName || owner.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
ownerText = '| Appartenant à un utilisateur inconnu';
}
}
// Helper to get the best possible image for the skin
const getImageUrl = (skinInfo) => {
const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1];
if (lastChroma?.fullRender) return lastChroma.fullRender;
if (lastChroma?.displayIcon) return lastChroma.displayIcon;
const lastLevel = skinInfo.levels[skinInfo.levels.length - 1];
if (lastLevel?.displayIcon) return lastLevel.displayIcon;
return skinInfo.displayIcon;
};
// --- 5. Send PATCH Request to Update the Message ---
// Note: The components (buttons) do not change, so we can reuse them from the original message.
await DiscordRequest(searchSession.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Résultats de la recherche',
description: `🔎 _"${searchValue}"_`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
fields: [{
name: `**${currentSkin.displayName}**`,
value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice}€** ${ownerText}`,
}],
image: { url: getImageUrl(skinData) },
footer: { text: `Résultat ${page + 1}/${amount}` },
}],
components: req.body.message.components, // Reuse existing components
},
});
// --- 6. Acknowledge the Interaction ---
return res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE });
} catch (error) {
console.error('Error handling search navigation:', error);
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Une erreur est survenue lors de la mise à jour de la recherche.',
flags: InteractionResponseFlags.EPHEMERAL,
}
});
}
}

View File

@@ -0,0 +1,196 @@
import {
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
import { DiscordRequest } from '../../api/discord.js';
import { postAPOBuy } from '../../utils/index.js';
import { activeInventories, skins } from '../../game/state.js';
import { getSkin, updateSkin } from '../../database/index.js';
/**
* Handles the click of the 'Upgrade' button on a skin in the inventory.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
*/
export async function handleUpgradeSkin(req, res) {
const { member, data } = req.body;
const { custom_id } = data;
const interactionId = custom_id.replace('upgrade_', '');
const userId = member.user.id;
// --- 1. Retrieve Session and Validate ---
const inventorySession = activeInventories[interactionId];
if (!inventorySession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: "Cet affichage d'inventaire a expiré.", flags: InteractionResponseFlags.EPHEMERAL },
});
}
// Ensure the user clicking is the inventory owner
if (inventorySession.akhyId !== userId || inventorySession.userId !== userId) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: "Vous ne pouvez pas améliorer un skin qui ne vous appartient pas.", flags: InteractionResponseFlags.EPHEMERAL },
});
}
const skinToUpgrade = inventorySession.inventorySkins[inventorySession.page];
const skinData = skins.find((s) => s.uuid === skinToUpgrade.uuid);
if (!skinData || (skinToUpgrade.currentLvl >= skinData.levels.length && skinToUpgrade.currentChroma >= skinData.chromas.length)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: "Ce skin est déjà au niveau maximum et ne peut pas être amélioré.", flags: InteractionResponseFlags.EPHEMERAL },
});
}
// --- 2. Handle Payment ---
const upgradePrice = parseFloat(process.env.VALO_UPGRADE_PRICE) || parseFloat(skinToUpgrade.maxPrice) / 10;
try {
const buyResponse = await postAPOBuy(userId, upgradePrice.toFixed(0));
if (!buyResponse.ok) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: `Il vous faut ${upgradePrice.toFixed(0)}€ pour tenter cette amélioration.`, flags: InteractionResponseFlags.EPHEMERAL },
});
}
} catch (paymentError) {
console.error("Payment API error:", paymentError);
return res.status(500).json({ error: "Payment service unavailable."});
}
// --- 3. Show Loading Animation ---
// Acknowledge the click immediately and then edit the message to show a loading state.
await res.send({ type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE });
await DiscordRequest(inventorySession.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Amélioration en cours...',
image: { url: 'https://media.tenor.com/HD8nVN2QP9MAAAAC/thoughts-think.gif' },
color: 0x4F545C,
}],
components: [],
},
});
// --- 4. Perform Upgrade Logic ---
let succeeded = false;
const isLevelUpgrade = skinToUpgrade.currentLvl < skinData.levels.length;
if (isLevelUpgrade) {
// Upgrading Level
const successProb = 1 - (skinToUpgrade.currentLvl / skinData.levels.length) * (skinToUpgrade.tierRank / 5 + 0.5);
if (Math.random() < successProb) {
succeeded = true;
skinToUpgrade.currentLvl++;
}
} else {
// Upgrading Chroma
const successProb = 1 - (skinToUpgrade.currentChroma / skinData.chromas.length) * (skinToUpgrade.tierRank / 5 + 0.5);
if (Math.random() < successProb) {
succeeded = true;
skinToUpgrade.currentChroma++;
}
}
// --- 5. Update Database if Successful ---
if (succeeded) {
const calculatePrice = () => {
let result = parseFloat(skinToUpgrade.basePrice);
result *= (1 + (skinToUpgrade.currentLvl / Math.max(skinData.levels.length, 2)));
result *= (1 + (skinToUpgrade.currentChroma / 4));
return parseFloat(result.toFixed(2));
};
skinToUpgrade.currentPrice = calculatePrice();
await updateSkin.run({
uuid: skinToUpgrade.uuid,
user_id: skinToUpgrade.user_id,
currentLvl: skinToUpgrade.currentLvl,
currentChroma: skinToUpgrade.currentChroma,
currentPrice: skinToUpgrade.currentPrice,
});
// Update the session cache
inventorySession.inventorySkins[inventorySession.page] = skinToUpgrade;
}
// --- 6. Send Final Result ---
setTimeout(async () => {
// Fetch the latest state of the skin from the database
const finalSkinState = getSkin.get(skinToUpgrade.uuid);
const finalEmbed = buildFinalEmbed(succeeded, finalSkinState, skinData);
const finalComponents = buildFinalComponents(succeeded, skinData, finalSkinState, interactionId);
await DiscordRequest(inventorySession.endpoint, {
method: 'PATCH',
body: {
embeds: [finalEmbed],
components: finalComponents,
},
});
}, 2000); // Delay for the result to feel more impactful
}
// --- Helper Functions ---
/** Builds the result embed (success or failure). */
function buildFinalEmbed(succeeded, skin, skinData) {
const embed = new EmbedBuilder()
.setTitle(succeeded ? 'Amélioration Réussie ! 🎉' : "L'amélioration a échoué... ❌")
.setDescription(`**${skin.displayName}**`)
.setImage(skin.displayIcon) // A static image is fine here
.setColor(succeeded ? 0x22A55B : 0xED4245);
if (succeeded) {
embed.addFields(
{ name: 'Nouveau Niveau', value: `${skin.currentLvl}/${skinData.levels.length}`, inline: true },
{ name: 'Nouveau Chroma', value: `${skin.currentChroma}/${skinData.chromas.length}`, inline: true },
{ name: 'Nouvelle Valeur', value: `**${skin.currentPrice}€**`, inline: true }
);
} else {
embed.addFields({ name: 'Statut', value: 'Aucun changement.' });
}
return embed;
}
/** Builds the result components (Retry button or Video link). */
function buildFinalComponents(succeeded, skinData, skin, interactionId) {
const isMaxed = skin.currentLvl >= skinData.levels.length && skin.currentChroma >= skinData.chromas.length;
if (isMaxed) return []; // No buttons if maxed out
const row = new ActionRowBuilder();
if (succeeded) {
// Check for video on the new level/chroma
const levelData = skinData.levels[skin.currentLvl - 1] || {};
const chromaData = skinData.chromas[skin.currentChroma - 1] || {};
const videoUrl = levelData.streamedVideo || chromaData.streamedVideo;
if (videoUrl) {
row.addComponents(new ButtonBuilder().setLabel('🎬 Aperçu Vidéo').setStyle(ButtonStyle.Link).setURL(videoUrl));
} else {
return []; // No button if no video
}
} else {
// Add a "Retry" button
row.addComponents(
new ButtonBuilder()
.setLabel('Réessayer 🔄️')
.setStyle(ButtonStyle.Primary)
.setCustomId(`upgrade_${interactionId}`)
);
}
return [row];
}

56
src/bot/events.js Normal file
View File

@@ -0,0 +1,56 @@
import { handleMessageCreate } from './handlers/messageCreate.js';
import { getAkhys, setupCronJobs } from '../utils/index.js';
/**
* Initializes and attaches all necessary event listeners to the Discord client.
* This function should be called once the client is ready.
*
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance for real-time communication.
*/
export function initializeEvents(client, io) {
// --- on 'ready' ---
// This event fires once the bot has successfully logged in and is ready to operate.
// It's a good place for setup tasks that require the bot to be online.
client.once('ready', async () => {
console.log(`Bot is ready and logged in as ${client.user.tag}!`);
// You can add any post-login setup tasks here if needed.
// For example, setting the bot's activity:
client.user.setActivity('FlopoSite.com', { type: 'WATCHING' });
console.log('[Startup] Bot is ready, performing initial data sync...');
await getAkhys(client);
console.log('[Startup] Setting up scheduled tasks...');
setupCronJobs(client, io);
console.log('--- FlopoBOT is fully operational ---');
});
// --- on 'messageCreate' ---
// This event fires every time a message is sent in a channel the bot can see.
// The logic is delegated to its own dedicated handler for cleanliness.
client.on('messageCreate', async (message) => {
// We pass the client and io instances to the handler so it has access to them
// without needing to import them, preventing potential circular dependencies.
await handleMessageCreate(message, client, io);
});
// --- on 'interactionCreate' (Alternative Method) ---
// While we handle interactions via the Express endpoint for scalability and statelessness,
// you could also listen for them via the gateway like this.
// It's commented out because our current architecture uses the webhook approach.
/*
client.on('interactionCreate', async (interaction) => {
// Logic to handle interactions would go here if not using a webhook endpoint.
});
*/
// You can add more event listeners here as your bot's functionality grows.
// For example, listening for new members joining the server:
// client.on('guildMemberAdd', (member) => {
// console.log(`Welcome to the server, ${member.user.tag}!`);
// const welcomeChannel = member.guild.channels.cache.get('YOUR_WELCOME_CHANNEL_ID');
// if (welcomeChannel) {
// welcomeChannel.send(`Please welcome <@${member.id}> to the server!`);
// }
// });
}

View File

@@ -0,0 +1,89 @@
import {
InteractionType,
InteractionResponseType,
} from 'discord-interactions';
// --- Command Handlers ---
import { handleTimeoutCommand } from '../commands/timeout.js';
import { handleInventoryCommand } from '../commands/inventory.js';
import { handleValorantCommand } from '../commands/valorant.js';
import { handleInfoCommand } from '../commands/info.js';
import { handleSkinsCommand } from '../commands/skins.js';
import { handleSearchCommand } from '../commands/search.js';
import { handleFlopoSiteCommand } from '../commands/floposite.js';
// --- Component Handlers ---
import { handlePollVote } from '../components/pollVote.js';
import { handleInventoryNav } from '../components/inventoryNav.js';
import { handleUpgradeSkin } from '../components/upgradeSkin.js';
import { handleSearchNav } from '../components/searchNav.js';
/**
* The main handler for all incoming interactions from Discord.
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
* @param {object} client - The Discord.js client instance.
*/
export async function handleInteraction(req, res, client) {
const { type, data, id } = req.body;
try {
if (type === InteractionType.PING) {
return res.send({ type: InteractionResponseType.PONG });
}
if (type === InteractionType.APPLICATION_COMMAND) {
const { name } = data;
switch (name) {
case 'timeout':
return await handleTimeoutCommand(req, res, client);
case 'inventory':
return await handleInventoryCommand(req, res, client, id);
case 'valorant':
return await handleValorantCommand(req, res, client);
case 'info':
return await handleInfoCommand(req, res, client);
case 'skins':
return await handleSkinsCommand(req, res, client);
case 'search':
return await handleSearchCommand(req, res, client, id);
case 'floposite':
return await handleFlopoSiteCommand(req, res);
default:
console.error(`Unknown command: ${name}`);
return res.status(400).json({ error: 'Unknown command' });
}
}
if (type === InteractionType.MESSAGE_COMPONENT) {
const componentId = data.custom_id;
if (componentId.startsWith('vote_')) {
return await handlePollVote(req, res, client);
}
if (componentId.startsWith('prev_page') || componentId.startsWith('next_page')) {
return await handleInventoryNav(req, res, client);
}
if (componentId.startsWith('upgrade_')) {
return await handleUpgradeSkin(req, res, client);
}
if (componentId.startsWith('prev_search_page') || componentId.startsWith('next_search_page')) {
return await handleSearchNav(req, res, client);
}
// Fallback for other potential components
console.error(`Unknown component ID: ${componentId}`);
return res.status(400).json({ error: 'Unknown component' });
}
// --- Fallback for Unknown Interaction Types ---
console.error('Unknown interaction type:', type);
return res.status(400).json({ error: 'Unknown interaction type' });
} catch (error) {
console.error('Error handling interaction:', error);
// Send a generic error response to Discord if something goes wrong
return res.status(500).json({ error: 'An internal error occurred' });
}
}

View File

@@ -0,0 +1,191 @@
import { sleep } from 'openai/core';
import { gork } from '../../utils/ai.js';
import { formatTime, postAPOBuy, getAPOUsers } from '../../utils/index.js';
import { channelPointsHandler, slowmodesHandler, randomSkinPrice, initTodaysSOTD } from '../../game/points.js';
import { requestTimestamps, activeSlowmodes, activePolls, skins } from '../../game/state.js';
import { flopoDB, getUser, getAllUsers, updateManyUsers } from '../../database/index.js';
// Constants for the AI rate limiter
const MAX_REQUESTS_PER_INTERVAL = parseInt(process.env.MAX_REQUESTS || "5");
const SPAM_INTERVAL = parseInt(process.env.SPAM_INTERVAL || "60000"); // 60 seconds default
/**
* Handles all logic for when a message is created.
* @param {object} message - The Discord.js message object.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
*/
export async function handleMessageCreate(message, client, io) {
// Ignore all messages from bots to prevent loops
if (message.author.bot) return;
// --- Specific User Gags ---
if (message.author.id === process.env.PATA_ID) {
if (message.content.toLowerCase().startsWith('feur') || message.content.toLowerCase().startsWith('rati')) {
await sleep(1000);
await message.delete().catch(console.error);
}
}
// --- Main Guild Features (Points & Slowmode) ---
if (message.guildId === process.env.GUILD_ID) {
// Award points for activity
const pointsAwarded = await channelPointsHandler(message);
if (pointsAwarded) {
io.emit('data-updated', { table: 'users', action: 'update' });
}
// Enforce active slowmodes
const wasSlowmoded = await slowmodesHandler(message, activeSlowmodes);
if (wasSlowmoded.deleted) {
io.emit('slowmode-update');
}
}
// --- AI Mention Handler ---
if (message.mentions.has(client.user) || message.mentions.repliedUser?.id === client.user.id) {
await handleAiMention(message, client, io);
return; // Stop further processing after AI interaction
}
// --- "Quoi/Feur" Gag ---
if (message.content.toLowerCase().includes("quoi")) {
const prob = Math.random();
if (prob < (parseFloat(process.env.FEUR_PROB) || 0.05)) {
message.channel.send('feur').catch(console.error);
}
return;
}
// --- Admin/Dev Guild Commands ---
if (message.guildId === process.env.DEV_GUILD_ID && message.author.id === process.env.DEV_ID) {
await handleAdminCommands(message);
}
}
// --- Sub-handler for AI Logic ---
async function handleAiMention(message, client, io) {
const authorId = message.author.id;
let authorDB = getUser.get(authorId);
if (!authorDB) return; // Should not happen if user is in DB, but good practice
// --- Rate Limiting ---
const now = Date.now();
const timestamps = (requestTimestamps.get(authorId) || []).filter(ts => now - ts < SPAM_INTERVAL);
if (timestamps.length >= MAX_REQUESTS_PER_INTERVAL) {
console.log(`Rate limit exceeded for ${authorDB.username}`);
if (!authorDB.warned) {
await message.reply(`T'abuses fréro, attends un peu ⏳`).catch(console.error);
}
// Update user's warn status
authorDB.warned = 1;
authorDB.warns += 1;
authorDB.allTimeWarns += 1;
updateManyUsers([authorDB]);
// Apply timeout if warn count is too high
if (authorDB.warns > (parseInt(process.env.MAX_WARNS) || 10)) {
try {
const member = await message.guild.members.fetch(authorId);
const time = parseInt(process.env.SPAM_TIMEOUT_TIME);
await member.timeout(time, 'Spam excessif du bot AI.');
message.channel.send(`Ce bouffon de <@${authorId}> a été timeout pendant ${formatTime(time / 1000)}, il me cassait les couilles 🤫`).catch(console.error);
} catch (e) {
console.error('Failed to apply timeout for AI spam:', e);
message.channel.send(`<@${authorId}>, tu as de la chance que je ne puisse pas te timeout...`).catch(console.error);
}
}
return;
}
timestamps.push(now);
requestTimestamps.set(authorId, timestamps);
// Reset warns if user is behaving, and increment their request count
authorDB.warned = 0;
authorDB.warns = 0;
authorDB.totalRequests += 1;
updateManyUsers([authorDB]);
// --- AI Processing ---
try {
message.channel.sendTyping();
// Fetch last 20 messages for context
const fetchedMessages = await message.channel.messages.fetch({ limit: 20 });
const messagesArray = Array.from(fetchedMessages.values()).reverse(); // Oldest to newest
const requestMessage = message.content.replace(`<@${client.user.id}>`, '').trim();
// Format the conversation for the AI
const messageHistory = messagesArray.map(msg => ({
role: msg.author.id === client.user.id ? 'assistant' : 'user',
content: `<@${msg.author.id}> a dit: ${msg.content}`
}));
// Add system prompts
messageHistory.unshift(
{ role: 'system', content: "Adopte une attitude détendue de membre du serveur. Réponds comme si tu participais à la conversation, pas trop long, pas de retour à la ligne. Utilise les emojis du serveur quand c'est pertinent. Ton id est <@132380758368780288>, ton nom est FlopoBot." },
{ role: 'system', content: `L'utilisateur qui s'adresse à toi est <@${authorId}>. Son message est une réponse à ${message.mentions.repliedUser ? `<@${message.mentions.repliedUser.id}>` : 'personne'}.` }
);
const reply = await gork(messageHistory);
await message.reply(reply);
} catch (err) {
console.error("Error processing AI mention:", err);
await message.reply("Oups, mon cerveau a grillé. Réessaie plus tard.").catch(console.error);
}
}
// --- Sub-handler for Admin Commands ---
async function handleAdminCommands(message) {
const prefix = process.env.DEV_SITE === 'true' ? 'dev' : 'flopo';
const [command, ...args] = message.content.split(' ');
switch(command) {
case '?u':
console.log(await getAPOUsers());
break;
case '?b':
console.log(await postAPOBuy('650338922874011648', args[0]));
break;
case '?v':
console.log('Active Polls:', activePolls);
break;
case '?sv':
const amount = parseInt(args[0], 10);
if (isNaN(amount)) return message.reply('Invalid amount.');
let sum = 0;
const start_at = Date.now();
for (let i = 0; i < amount; i++) {
sum += parseFloat(randomSkinPrice());
}
console.log(`Result for ${amount} skins: Avg: ~${(sum / amount).toFixed(2)}€ | Total: ${sum.toFixed(2)}€ | Elapsed: ${Date.now() - start_at}ms`);
break;
case `${prefix}:sotd`:
initTodaysSOTD();
message.reply('New Solitaire of the Day initialized.');
break;
case `${prefix}:users`:
console.log(getAllUsers.all());
break;
case `${prefix}:sql`:
const sqlCommand = args.join(' ');
try {
const stmt = flopoDB.prepare(sqlCommand);
const result = sqlCommand.trim().toUpperCase().startsWith('SELECT') ? stmt.all() : stmt.run();
console.log(result);
message.reply('```json\n' + JSON.stringify(result, null, 2).substring(0, 1900) + '\n```');
} catch (e) {
console.error(e);
message.reply(`SQL Error: ${e.message}`);
}
break;
}
}

113
src/config/commands.js Normal file
View File

@@ -0,0 +1,113 @@
import 'dotenv/config';
import { getTimesChoices } from '../game/various.js';
import { capitalize, InstallGlobalCommands } from '../utils/index.js';
function createTimesChoices() {
const choices = getTimesChoices();
const commandChoices = [];
for (let choice of choices) {
commandChoices.push({
name: capitalize(choice.name),
value: choice.value?.toString(),
});
}
return commandChoices;
}
// Timeout vote command
const TIMEOUT_COMMAND = {
name: 'timeout',
description: 'Vote démocratique pour timeout un boug',
options: [
{
type: 6,
name: 'akhy',
description: 'Qui ?',
required: true,
},
{
type: 3,
name: 'temps',
description: 'Combien de temps ?',
required: true,
choices: createTimesChoices(),
}
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
// Valorant
const VALORANT_COMMAND = {
name: 'valorant',
description: `Ouvrir une caisse valorant (15€)`,
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
// Own inventory command
const INVENTORY_COMMAND = {
name: 'inventory',
description: 'Voir inventaire',
options: [
{
type: 6,
name: 'akhy',
description: 'Qui ?',
required: false,
},
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const INFO_COMMAND = {
name: 'info',
description: 'Qui est time out ?',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SKINS_COMMAND = {
name: 'skins',
description: 'Le top 10 des skins les plus chers.',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SITE_COMMAND = {
name: 'floposite',
description: 'Lien vers FlopoSite',
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const SEARCH_SKIN_COMMAND = {
name: 'search',
description: 'Chercher un skin',
options: [
{
type: 3,
name: 'recherche',
description: 'Tu cherches quoi ?',
required: true,
},
],
type: 1,
integration_types: [0, 1],
contexts: [0, 2],
}
const ALL_COMMANDS = [TIMEOUT_COMMAND, INVENTORY_COMMAND, VALORANT_COMMAND, INFO_COMMAND, SKINS_COMMAND, SEARCH_SKIN_COMMAND, SITE_COMMAND];
export function registerCommands() {
InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS);
}

185
src/database/index.js Normal file
View File

@@ -0,0 +1,185 @@
import Database from "better-sqlite3";
export const flopoDB = new Database('flopobot.db');
export const stmtUsers = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
globalName TEXT,
warned BOOLEAN DEFAULT 0,
warns INTEGER DEFAULT 0,
allTimeWarns INTEGER DEFAULT 0,
totalRequests INTEGER DEFAULT 0,
coins INTEGER DEFAULT 0,
dailyQueried BOOLEAN DEFAULT 0
)
`);
stmtUsers.run();
export const stmtSkins = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS skins (
uuid TEXT PRIMARY KEY,
displayName TEXT,
contentTierUuid TEXT,
displayIcon TEXT,
user_id TEXT REFERENCES users,
tierRank TEXT,
tierColor TEXT,
tierText TEXT,
basePrice TEXT,
currentLvl INTEGER DEFAULT NULL,
currentChroma INTEGER DEFAULT NULL,
currentPrice INTEGER DEFAULT NULL,
maxPrice INTEGER DEFAULT NULL
)
`);
stmtSkins.run()
export const insertUser = flopoDB.prepare('INSERT INTO users (id, username, globalName, warned, warns, allTimeWarns, totalRequests) VALUES (@id, @username, @globalName, @warned, @warns, @allTimeWarns, @totalRequests)');
export const updateUser = flopoDB.prepare('UPDATE users SET warned = @warned, warns = @warns, allTimeWarns = @allTimeWarns, totalRequests = @totalRequests WHERE id = @id');
export const queryDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 1 WHERE id = ?`);
export const resetDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 0 WHERE id = ?`);
export const updateUserCoins = flopoDB.prepare('UPDATE users SET coins = @coins WHERE id = @id');
export const getUser = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id WHERE users.id = ?');
export const getAllUsers = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id ORDER BY coins DESC');
export const insertSkin = flopoDB.prepare('INSERT INTO skins (uuid, displayName, contentTierUuid, displayIcon, user_id, tierRank, tierColor, tierText, basePrice, currentLvl, currentChroma, currentPrice, maxPrice) VALUES (@uuid, @displayName, @contentTierUuid, @displayIcon, @user_id, @tierRank, @tierColor, @tierText, @basePrice, @currentLvl, @currentChroma, @currentPrice, @maxPrice)');
export const updateSkin = flopoDB.prepare('UPDATE skins SET user_id = @user_id, currentLvl = @currentLvl, currentChroma = @currentChroma, currentPrice = @currentPrice WHERE uuid = @uuid');
export const getSkin = flopoDB.prepare('SELECT * FROM skins WHERE uuid = ?');
export const getAllSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC');
export const getAllAvailableSkins = flopoDB.prepare('SELECT * FROM skins WHERE user_id IS NULL');
export const getUserInventory = flopoDB.prepare('SELECT * FROM skins WHERE user_id = @user_id ORDER BY currentPrice DESC');
export const getTopSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC LIMIT 10');
export const insertManyUsers = flopoDB.transaction(async (users) => {
for (const user of users) try { await insertUser.run(user) } catch (e) { /**/ }
});
export const updateManyUsers = flopoDB.transaction(async (users) => {
for (const user of users) try { await updateUser.run(user) } catch (e) { console.log('user update failed') }
});
export const insertManySkins = flopoDB.transaction(async (skins) => {
for (const skin of skins) try { await insertSkin.run(skin) } catch (e) {}
});
export const updateManySkins = flopoDB.transaction(async (skins) => {
for (const skin of skins) try { await updateSkin.run(skin) } catch (e) {}
});
export const stmtLogs = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS logs (
id PRIMARY KEY,
user_id TEXT REFERENCES users,
action TEXT,
target_user_id TEXT REFERENCES users,
coins_amount INTEGER,
user_new_amount INTEGER
)
`);
stmtLogs.run()
export const insertLog = flopoDB.prepare('INSERT INTO logs (id, user_id, action, target_user_id, coins_amount, user_new_amount) VALUES (@id, @user_id, @action, @target_user_id, @coins_amount, @user_new_amount)');
export const getLogs = flopoDB.prepare('SELECT * FROM logs');
export const getUserLogs = flopoDB.prepare('SELECT * FROM logs WHERE user_id = @user_id');
export const stmtGames = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS games (
id PRIMARY KEY,
p1 TEXT REFERENCES users,
p2 TEXT REFERENCES users,
p1_score INTEGER,
p2_score INTEGER,
p1_elo INTEGER,
p2_elo INTEGER,
p1_new_elo INTEGER,
p2_new_elo INTEGER,
type TEXT,
timestamp TIMESTAMP
)
`);
stmtGames.run()
export const insertGame = flopoDB.prepare('INSERT INTO games (id, p1, p2, p1_score, p2_score, p1_elo, p2_elo, p1_new_elo, p2_new_elo, type, timestamp) VALUES (@id, @p1, @p2, @p1_score, @p2_score, @p1_elo, @p2_elo, @p1_new_elo, @p2_new_elo, @type, @timestamp)');
export const getGames = flopoDB.prepare('SELECT * FROM games');
export const getUserGames = flopoDB.prepare('SELECT * FROM games WHERE p1 = @user_id OR p2 = @user_id');
export const stmtElos = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS elos (
id PRIMARY KEY REFERENCES users,
elo INTEGER
)
`);
stmtElos.run()
export const insertElos = flopoDB.prepare(`INSERT INTO elos (id, elo) VALUES (@id, @elo)`);
export const getElos = flopoDB.prepare(`SELECT * FROM elos`);
export const getUserElo = flopoDB.prepare(`SELECT * FROM elos WHERE id = @id`);
export const updateElo = flopoDB.prepare('UPDATE elos SET elo = @elo WHERE id = @id');
export const getUsersByElo = flopoDB.prepare('SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC')
export const stmtSOTD = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS sotd (
id INT PRIMARY KEY,
tableauPiles TEXT,
foundationPiles TEXT,
stockPile TEXT,
wastePile TEXT,
isDone BOOLEAN DEFAULT false,
seed TEXT
)
`);
stmtSOTD.run()
export const getSOTD = flopoDB.prepare(`SELECT * FROM sotd WHERE id = '0'`)
export const insertSOTD = flopoDB.prepare(`INSERT INTO sotd (id, tableauPiles, foundationPiles, stockPile, wastePile, seed) VALUES (@id, @tableauPiles, @foundationPiles, @stockPile, @wastePile, @seed)`)
export const deleteSOTD = flopoDB.prepare(`DELETE FROM sotd WHERE id = '0'`)
export const stmtSOTDStats = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS sotd_stats (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users,
time INTEGER,
moves INTEGER,
score INTEGER
)
`);
stmtSOTDStats.run()
export const getAllSOTDStats = flopoDB.prepare(`SELECT sotd_stats.*, users.globalName FROM sotd_stats JOIN users ON users.id = sotd_stats.user_id ORDER BY score DESC, moves ASC, time ASC`);
export const getUserSOTDStats = flopoDB.prepare(`SELECT * FROM sotd_stats WHERE user_id = ?`);
export const insertSOTDStats = flopoDB.prepare(`INSERT INTO sotd_stats (id, user_id, time, moves, score) VALUES (@id, @user_id, @time, @moves, @score)`);
export const clearSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats`);
export const deleteUserSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats WHERE user_id = ?`);
export async function pruneOldLogs() {
const users = flopoDB.prepare(`
SELECT user_id
FROM logs
GROUP BY user_id
HAVING COUNT(*) > ${process.env.LOGS_BY_USER}
`).all();
const transaction = flopoDB.transaction(() => {
for (const { user_id } of users) {
flopoDB.prepare(`
DELETE FROM logs
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (ORDER BY id DESC) AS rn
FROM logs
WHERE user_id = ?
)
WHERE rn > ${process.env.LOGS_BY_USER}
)
`).run(user_id);
}
});
transaction()
}

144
src/game/elo.js Normal file
View File

@@ -0,0 +1,144 @@
import {
getUser,
getUserElo,
insertElos,
updateElo,
insertGame,
} from '../database/index.js';
/**
* Handles Elo calculation for a standard 1v1 game.
* @param {string} p1Id - The ID of player 1.
* @param {string} p2Id - The ID of player 2.
* @param {number} p1Score - The score for player 1 (1 for win, 0.5 for draw, 0 for loss).
* @param {number} p2Score - The score for player 2.
* @param {string} type - The type of game being played (e.g., 'TICTACTOE', 'CONNECT4').
*/
export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
// --- 1. Fetch Player Data ---
const p1DB = getUser.get(p1Id);
const p2DB = getUser.get(p2Id);
if (!p1DB || !p2DB) {
console.error(`Elo Handler: Could not find user data for ${p1Id} or ${p2Id}.`);
return;
}
let p1EloData = getUserElo.get({ id: p1Id });
let p2EloData = getUserElo.get({ id: p2Id });
// --- 2. Initialize Elo if it doesn't exist ---
if (!p1EloData) {
await insertElos.run({ id: p1Id, elo: 1000 });
p1EloData = { id: p1Id, elo: 1000 };
}
if (!p2EloData) {
await insertElos.run({ id: p2Id, elo: 1000 });
p2EloData = { id: p2Id, elo: 1000 };
}
const p1CurrentElo = p1EloData.elo;
const p2CurrentElo = p2EloData.elo;
// --- 3. Calculate Elo Change ---
// The K-factor determines how much the Elo rating changes after a game.
const K_FACTOR = 32;
// Calculate expected scores
const expectedP1 = 1 / (1 + Math.pow(10, (p2CurrentElo - p1CurrentElo) / 400));
const expectedP2 = 1 / (1 + Math.pow(10, (p1CurrentElo - p2CurrentElo) / 400));
// Calculate new Elo ratings
const p1NewElo = Math.round(p1CurrentElo + K_FACTOR * (p1Score - expectedP1));
const p2NewElo = Math.round(p2CurrentElo + K_FACTOR * (p2Score - expectedP2));
// Ensure Elo doesn't drop below a certain threshold (e.g., 100)
const finalP1Elo = Math.max(100, p1NewElo);
const finalP2Elo = Math.max(100, p2NewElo);
console.log(`Elo Update (${type}) for ${p1DB.globalName}: ${p1CurrentElo} -> ${finalP1Elo}`);
console.log(`Elo Update (${type}) for ${p2DB.globalName}: ${p2CurrentElo} -> ${finalP2Elo}`);
// --- 4. Update Database ---
updateElo.run({ id: p1Id, elo: finalP1Elo });
updateElo.run({ id: p2Id, elo: finalP2Elo });
insertGame.run({
id: `${p1Id}-${p2Id}-${Date.now()}`,
p1: p1Id,
p2: p2Id,
p1_score: p1Score,
p2_score: p2Score,
p1_elo: p1CurrentElo,
p2_elo: p2CurrentElo,
p1_new_elo: finalP1Elo,
p2_new_elo: finalP2Elo,
type: type,
timestamp: Date.now(),
});
}
/**
* Handles Elo calculation for a multi-player poker game.
* @param {object} room - The poker room object containing player and winner info.
*/
export async function pokerEloHandler(room) {
if (room.fakeMoney) {
console.log("Skipping Elo update for fake money poker game.");
return;
}
const playerIds = Object.keys(room.players);
if (playerIds.length < 2) return; // Not enough players to calculate Elo
// Fetch all players' Elo data at once
const dbPlayers = playerIds.map(id => {
const user = getUser.get(id);
const elo = getUserElo.get({ id })?.elo || 1000;
return { ...user, elo };
});
const winnerIds = new Set(room.winners);
const playerCount = dbPlayers.length;
const K_BASE = 16; // A lower K-factor is often used for multi-player games
const averageElo = dbPlayers.reduce((sum, p) => sum + p.elo, 0) / playerCount;
dbPlayers.forEach(player => {
// Expected score is the chance of winning against an "average" player from the field
const expectedScore = 1 / (1 + Math.pow(10, (averageElo - player.elo) / 400));
// Determine actual score
let actualScore;
if (winnerIds.has(player.id)) {
// Winners share the "win" points
actualScore = 1 / winnerIds.size;
} else {
actualScore = 0;
}
// Dynamic K-factor: higher impact for more significant results
const kFactor = K_BASE * playerCount;
const eloChange = kFactor * (actualScore - expectedScore);
const newElo = Math.max(100, Math.round(player.elo + eloChange));
if (!isNaN(newElo)) {
console.log(`Elo Update (POKER) for ${player.globalName}: ${player.elo} -> ${newElo} (Δ: ${eloChange.toFixed(2)})`);
updateElo.run({ id: player.id, elo: newElo });
insertGame.run({
id: `${player.id}-poker-${Date.now()}`,
p1: player.id,
p2: null, // No single opponent
p1_score: actualScore,
p2_score: null,
p1_elo: player.elo,
p2_elo: Math.round(averageElo), // Log the average opponent Elo for context
p1_new_elo: newElo,
p2_new_elo: null,
type: 'POKER_ROUND',
});
} else {
console.error(`Error calculating new Elo for ${player.globalName}.`);
}
});
}

191
src/game/points.js Normal file
View File

@@ -0,0 +1,191 @@
import {
getUser,
updateUserCoins,
insertLog,
getAllSkins,
insertSOTD,
clearSOTDStats,
getAllSOTDStats,
} from '../database/index.js';
import { messagesTimestamps, activeSlowmodes, skins } from './state.js';
import { deal, createSeededRNG, seededShuffle, createDeck } from './solitaire.js';
/**
* Handles awarding points (coins) to users for their message activity.
* Limits points to 10 messages within a 15-minute window.
* @param {object} message - The Discord.js message object.
* @returns {boolean} True if points were awarded, false otherwise.
*/
export async function channelPointsHandler(message) {
const author = message.author;
const authorDB = getUser.get(author.id);
if (!authorDB) {
// User not in our database, do nothing.
return false;
}
// Ignore short messages or commands that might be spammed
if (message.content.length < 3 || message.content.startsWith('?')) {
return false;
}
const now = Date.now();
const userTimestamps = messagesTimestamps.get(author.id) || [];
// Filter out timestamps older than 15 minutes (900,000 ms)
const recentTimestamps = userTimestamps.filter(ts => now - ts < 900000);
// If the user has already sent 10 messages in the last 15 mins, do nothing
if (recentTimestamps.length >= 10) {
return false;
}
// Add the new message timestamp
recentTimestamps.push(now);
messagesTimestamps.set(author.id, recentTimestamps);
// Award 50 coins for the 10th message, 10 for others
const coinsToAdd = recentTimestamps.length === 10 ? 50 : 10;
const newCoinTotal = authorDB.coins + coinsToAdd;
updateUserCoins.run({
id: author.id,
coins: newCoinTotal,
});
insertLog.run({
id: `${author.id}-${now}`,
user_id: author.id,
action: 'AUTO_COINS',
target_user_id: null,
coins_amount: coinsToAdd,
user_new_amount: newCoinTotal,
});
return true; // Indicate that points were awarded
}
/**
* Handles message deletion for users currently under a slowmode effect.
* @param {object} message - The Discord.js message object.
* @returns {object} An object indicating if a message was deleted or a slowmode expired.
*/
export async function slowmodesHandler(message) {
const author = message.author;
const authorSlowmode = activeSlowmodes[author.id];
if (!authorSlowmode) {
return { deleted: false, expired: false };
}
const now = Date.now();
// Check if the slowmode duration has passed
if (now > authorSlowmode.endAt) {
console.log(`Slowmode for ${author.username} has expired.`);
delete activeSlowmodes[author.id];
return { deleted: false, expired: true };
}
// Check if the user is messaging too quickly (less than 1 minute between messages)
if (authorSlowmode.lastMessage && (now - authorSlowmode.lastMessage < 60 * 1000)) {
try {
await message.delete();
console.log(`Deleted a message from slowmoded user: ${author.username}`);
return { deleted: true, expired: false };
} catch (err) {
console.error(`Failed to delete slowmode message:`, err);
return { deleted: false, expired: false };
}
} else {
// Update the last message timestamp for the user
authorSlowmode.lastMessage = now;
return { deleted: false, expired: false };
}
}
/**
* Calculates a random price for a skin based on its properties.
* Used for testing and simulations.
* @returns {string} The calculated random price as a string.
*/
export function randomSkinPrice() {
const dbSkins = getAllSkins.all();
if (dbSkins.length === 0) return '0.00';
const randomDbSkin = dbSkins[Math.floor(Math.random() * dbSkins.length)];
const randomSkinData = skins.find((skin) => skin.uuid === randomDbSkin.uuid);
if (!randomSkinData) return '0.00';
// Generate random level and chroma
const randomLevel = Math.floor(Math.random() * randomSkinData.levels.length) + 1;
let randomChroma = 1;
if (randomLevel === randomSkinData.levels.length && randomSkinData.chromas.length > 1) {
randomChroma = Math.floor(Math.random() * randomSkinData.chromas.length) + 1;
}
// Calculate price based on these random values
let result = parseFloat(randomDbSkin.basePrice);
result *= (1 + (randomLevel / Math.max(randomSkinData.levels.length, 2)));
result *= (1 + (randomChroma / 4));
return result.toFixed(2);
}
/**
* Initializes the Solitaire of the Day.
* This function clears previous stats, awards the winner, and generates a new daily seed.
*/
export function initTodaysSOTD() {
console.log('Initializing new Solitaire of the Day...');
// 1. Award previous day's winner
const rankings = getAllSOTDStats.all();
if (rankings.length > 0) {
const winnerId = rankings[0].user_id;
const winnerUser = getUser.get(winnerId);
if (winnerUser) {
const reward = 1000;
const newCoinTotal = winnerUser.coins + reward;
updateUserCoins.run({ id: winnerId, coins: newCoinTotal });
insertLog.run({
id: `${winnerId}-sotd-win-${Date.now()}`,
user_id: winnerId,
action: 'SOTD_FIRST_PLACE',
coins_amount: reward,
user_new_amount: newCoinTotal,
});
console.log(`${winnerUser.globalName || winnerUser.username} won the previous SOTD and received ${reward} coins.`);
}
}
// 2. Generate a new seeded deck for today
const newRandomSeed = Date.now().toString(36) + Math.random().toString(36).substr(2);
let numericSeed = 0;
for (let i = 0; i < newRandomSeed.length; i++) {
numericSeed = (numericSeed + newRandomSeed.charCodeAt(i)) & 0xFFFFFFFF;
}
const rng = createSeededRNG(numericSeed);
const deck = createDeck();
const shuffledDeck = seededShuffle(deck, rng);
const todaysSOTD = deal(shuffledDeck);
// 3. Clear old stats and save the new game state to the database
try {
clearSOTDStats.run();
insertSOTD.run({
tableauPiles: JSON.stringify(todaysSOTD.tableauPiles),
foundationPiles: JSON.stringify(todaysSOTD.foundationPiles),
stockPile: JSON.stringify(todaysSOTD.stockPile),
wastePile: JSON.stringify(todaysSOTD.wastePile),
seed: newRandomSeed,
});
console.log("Today's SOTD is ready with a new seed.");
} catch(e) {
console.error("Error saving new SOTD to database:", e);
}
}

139
src/game/poker.js Normal file
View File

@@ -0,0 +1,139 @@
import pkg from 'pokersolver';
const { Hand } = pkg;
// An array of all 52 standard playing cards.
export const initialCards = [
'Ad', '2d', '3d', '4d', '5d', '6d', '7d', '8d', '9d', 'Td', 'Jd', 'Qd', 'Kd',
'As', '2s', '3s', '4s', '5s', '6s', '7s', '8s', '9s', 'Ts', 'Js', 'Qs', 'Ks',
'Ac', '2c', '3c', '4c', '5c', '6c', '7c', '8c', '9c', 'Tc', 'Jc', 'Qc', 'Kc',
'Ah', '2h', '3h', '4h', '5h', '6h', '7h', '8h', '9h', 'Th', 'Jh', 'Qh', 'Kh',
];
/**
* Creates a shuffled copy of the initial card deck.
* @returns {Array<string>} A new array containing all 52 cards in a random order.
*/
export function initialShuffledCards() {
// Create a copy and sort it randomly
return [...initialCards].sort(() => 0.5 - Math.random());
}
/**
* Finds the first active player to act after the dealer.
* This is used to start betting rounds after the flop, turn, and river.
* @param {object} room - The poker room object.
* @returns {string|null} The ID of the next player, or null if none is found.
*/
export function getFirstActivePlayerAfterDealer(room) {
const players = Object.values(room.players);
const dealerPosition = players.findIndex((p) => p.id === room.dealer);
// Loop through players starting from the one after the dealer
for (let i = 1; i <= players.length; i++) {
const nextPos = (dealerPosition + i) % players.length;
const nextPlayer = players[nextPos];
// Player must not be folded or all-in to be able to act
if (nextPlayer && !nextPlayer.folded && !nextPlayer.allin) {
return nextPlayer.id;
}
}
return null; // Should not happen in a normal game
}
/**
* Finds the next active player in turn order.
* @param {object} room - The poker room object.
* @returns {string|null} The ID of the next player, or null if none is found.
*/
export function getNextActivePlayer(room) {
const players = Object.values(room.players);
const currentPlayerPosition = players.findIndex((p) => p.id === room.current_player);
// Loop through players starting from the one after the current player
for (let i = 1; i <= players.length; i++) {
const nextPos = (currentPlayerPosition + i) % players.length;
const nextPlayer = players[nextPos];
if (nextPlayer && !nextPlayer.folded && !nextPlayer.allin) {
return nextPlayer.id;
}
}
return null;
}
/**
* Checks if the current betting round should end and what the next phase should be.
* @param {object} room - The poker room object.
* @returns {object} An object with `endRound`, `winner`, and `nextPhase` properties.
*/
export function checkEndOfBettingRound(room) {
const activePlayers = Object.values(room.players).filter((p) => !p.folded);
// --- Scenario 1: Only one player left (everyone else folded) ---
if (activePlayers.length === 1) {
return { endRound: true, winner: activePlayers[0].id, nextPhase: 'showdown' };
}
// --- Scenario 2: All remaining players are all-in ---
// The hand goes immediately to a "progressive showdown".
const allInPlayers = activePlayers.filter(p => p.allin);
if (allInPlayers.length >= 2 && allInPlayers.length === activePlayers.length) {
return { endRound: true, winner: null, nextPhase: 'progressive-showdown' };
}
// --- Scenario 3: All active players have acted and bets are equal ---
const allBetsMatched = activePlayers.every(p =>
p.allin || // Player is all-in
(p.bet === room.highest_bet && p.last_played_turn === room.current_turn) // Or their bet matches the highest and they've acted this turn
);
if (allBetsMatched) {
let nextPhase;
switch (room.current_turn) {
case 0: nextPhase = 'flop'; break;
case 1: nextPhase = 'turn'; break;
case 2: nextPhase = 'river'; break;
case 3: nextPhase = 'showdown'; break;
default: nextPhase = null; // Should not happen
}
return { endRound: true, winner: null, nextPhase: nextPhase };
}
// --- Default: The round continues ---
return { endRound: false, winner: null, nextPhase: null };
}
/**
* Determines the winner(s) of the hand at showdown.
* @param {object} room - The poker room object.
* @returns {Array<string>} An array of winner IDs. Can contain multiple IDs in case of a split pot.
*/
export function checkRoomWinners(room) {
const communityCards = room.tapis;
const activePlayers = Object.values(room.players).filter(p => !p.folded);
// Solve each player's hand to find the best possible 5-card combination
const playerSolutions = activePlayers.map(player => ({
id: player.id,
solution: Hand.solve([...communityCards, ...player.hand]),
}));
if (playerSolutions.length === 0) return [];
// Use pokersolver's `Hand.winners()` to find the best hand(s)
const winningSolutions = Hand.winners(playerSolutions.map(ps => ps.solution));
// Find the player IDs that correspond to the winning hand solutions
const winnerIds = [];
for (const winningHand of winningSolutions) {
for (const playerSol of playerSolutions) {
// Compare description and card pool to uniquely identify the hand
if (playerSol.solution.descr === winningHand.descr && playerSol.solution.cardPool.toString() === winningHand.cardPool.toString()) {
if (!winnerIds.includes(playerSol.id)) {
winnerIds.push(playerSol.id);
}
}
}
}
return winnerIds;
}

246
src/game/solitaire.js Normal file
View File

@@ -0,0 +1,246 @@
// --- Constants for Deck Creation ---
const SUITS = ['h', 'd', 's', 'c']; // Hearts, Diamonds, Spades, Clubs
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K'];
// --- Helper Functions for Card Logic ---
/**
* Gets the numerical value of a card's rank for comparison.
* @param {string} rank - e.g., 'A', 'K', '7'
* @returns {number} The numeric value (Ace=1, King=13).
*/
function getRankValue(rank) {
if (rank === 'A') return 1;
if (rank === 'T') return 10;
if (rank === 'J') return 11;
if (rank === 'Q') return 12;
if (rank === 'K') return 13;
return parseInt(rank, 10);
}
/**
* Gets the color ('red' or 'black') of a card's suit.
* @param {string} suit - e.g., 'h', 's'
* @returns {string} 'red' or 'black'.
*/
function getCardColor(suit) {
return (suit === 'h' || suit === 'd') ? 'red' : 'black';
}
// --- Core Game Logic Functions ---
/**
* Creates a standard 52-card deck. Each card is an object.
* @returns {Array<Object>} The unshuffled deck of cards.
*/
export function createDeck() {
const deck = [];
for (const suit of SUITS) {
for (const rank of RANKS) {
deck.push({ suit, rank, faceUp: false });
}
}
return deck;
}
/**
* Shuffles an array in place using the Fisher-Yates algorithm.
* @param {Array} array - The array to shuffle.
* @returns {Array} The shuffled array (mutated in place).
*/
export function shuffle(array) {
let currentIndex = array.length;
// While there remain elements to shuffle.
while (currentIndex !== 0) {
// Pick a remaining element.
const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
}
/**
* Creates a seedable pseudorandom number generator (PRNG) using Mulberry32.
* @param {number} seed - An initial number to seed the generator.
* @returns {function} A function that returns a pseudorandom number between 0 and 1.
*/
export function createSeededRNG(seed) {
return function() {
let t = seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/**
* Shuffles an array using a seedable PRNG via the Fisher-Yates algorithm.
* @param {Array} array - The array to shuffle.
* @param {function} rng - A seedable random number generator function.
* @returns {Array} The shuffled array (mutated in place).
*/
export function seededShuffle(array, rng) {
let currentIndex = array.length;
// While there remain elements to shuffle.
while (currentIndex !== 0) {
// Pick a remaining element using the seeded RNG.
const randomIndex = Math.floor(rng() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
}
/**
* Deals a shuffled deck into the initial Solitaire game state.
* @param {Array<Object>} deck - A shuffled deck of cards.
* @returns {Object} The initial gameState object for Klondike Solitaire.
*/
export function deal(deck) {
const gameState = {
tableauPiles: [[], [], [], [], [], [], []],
foundationPiles: [[], [], [], []],
stockPile: [],
wastePile: [],
};
// Deal cards to the 7 tableau piles
for (let i = 0; i < 7; i++) {
for (let j = i; j < 7; j++) {
gameState.tableauPiles[j].push(deck.shift());
}
}
// Flip the top card of each tableau pile
gameState.tableauPiles.forEach(pile => {
if (pile.length > 0) {
pile[pile.length - 1].faceUp = true;
}
});
// The rest of the deck becomes the stock
gameState.stockPile = deck;
return gameState;
}
/**
* Checks if a proposed move is valid according to the rules of Klondike Solitaire.
* @param {Object} gameState - The current state of the game.
* @param {Object} moveData - The details of the move to be validated.
* @returns {boolean} True if the move is valid, false otherwise.
*/
export function isValidMove(gameState, moveData) {
const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData;
// --- Get Source Pile and Card ---
let sourcePile;
if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex];
else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile;
else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex];
else return false; // Invalid source type
const sourceCard = sourcePile?.[sourceCardIndex];
if (!sourceCard || !sourceCard.faceUp) {
return false; // Cannot move a card that doesn't exist or is face-down
}
// --- Validate Move TO a Tableau Pile ---
if (destPileType === 'tableauPiles') {
const destinationPile = gameState.tableauPiles[destPileIndex];
const topCard = destinationPile[destinationPile.length - 1];
if (!topCard) {
// If the destination tableau is empty, only a King can be moved there.
return sourceCard.rank === 'K';
}
// Card must be opposite color and one rank lower than the destination top card.
const sourceColor = getCardColor(sourceCard.suit);
const destColor = getCardColor(topCard.suit);
const sourceValue = getRankValue(sourceCard.rank);
const destValue = getRankValue(topCard.rank);
return sourceColor !== destColor && destValue - sourceValue === 1;
}
// --- Validate Move TO a Foundation Pile ---
if (destPileType === 'foundationPiles') {
// You can only move one card at a time to a foundation pile.
const stackBeingMoved = sourcePile.slice(sourceCardIndex);
if (stackBeingMoved.length > 1) return false;
const destinationPile = gameState.foundationPiles[destPileIndex];
const topCard = destinationPile[destinationPile.length - 1];
if (!topCard) {
// If the foundation is empty, only an Ace of any suit can be moved there.
return sourceCard.rank === 'A';
}
// Card must be the same suit and one rank higher.
const sourceValue = getRankValue(sourceCard.rank);
const destValue = getRankValue(topCard.rank);
return sourceCard.suit === topCard.suit && sourceValue - destValue === 1;
}
return false; // Invalid destination type
}
/**
* Mutates the game state by performing a valid card move.
* @param {Object} gameState - The current state of the game.
* @param {Object} moveData - The details of the move.
*/
export function moveCard(gameState, moveData) {
const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData;
let sourcePile;
if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex];
else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile;
else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex];
let destPile;
if (destPileType === 'tableauPiles') destPile = gameState.tableauPiles[destPileIndex];
else if (destPileType === 'foundationPiles') destPile = gameState.foundationPiles[destPileIndex];
// Cut the entire stack of cards to be moved from the source pile.
const cardsToMove = sourcePile.splice(sourceCardIndex);
// Add the stack to the destination pile.
destPile.push(...cardsToMove);
// If the source was a tableau pile and there are cards left, flip the new top card.
if (sourcePileType === 'tableauPiles' && sourcePile.length > 0) {
sourcePile[sourcePile.length - 1].faceUp = true;
}
}
/**
* Moves a card from the stock to the waste. If stock is empty, resets it from the waste.
* @param {Object} gameState - The current state of the game.
*/
export function drawCard(gameState) {
if (gameState.stockPile.length > 0) {
const card = gameState.stockPile.pop();
card.faceUp = true;
gameState.wastePile.push(card);
} else if (gameState.wastePile.length > 0) {
// When stock is empty, move the entire waste pile back to stock, face down.
gameState.stockPile = gameState.wastePile.reverse();
gameState.stockPile.forEach(card => (card.faceUp = false));
gameState.wastePile = [];
}
}
/**
* Checks if the game has been won (all 52 cards are in the foundation piles).
* @param {Object} gameState - The current state of the game.
* @returns {boolean} True if the game is won.
*/
export function checkWinCondition(gameState) {
const foundationCardCount = gameState.foundationPiles.reduce((acc, pile) => acc + pile.length, 0);
return foundationCardCount === 52;
}

68
src/game/state.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* This file acts as a simple in-memory store for the application's live state.
* By centralizing state here, we avoid global variables and make data flow more predictable.
*/
// --- Game and Interaction State ---
// Stores active Connect 4 games, keyed by a unique game ID.
export let activeConnect4Games = {};
// Stores active Tic-Tac-Toe games, keyed by a unique game ID.
export let activeTicTacToeGames = {};
// Stores active Solitaire games, keyed by user ID.
export let activeSolitaireGames = {};
// Stores active Poker rooms, keyed by a unique room ID (uuidv4).
export let pokerRooms = {};
// --- User and Session State ---
// Stores active user inventories for paginated embeds, keyed by the interaction ID.
// Format: { [interactionId]: { userId, page, amount, endpoint, timestamp, inventorySkins } }
export let activeInventories = {};
// Stores active user skin searches for paginated embeds, keyed by the interaction ID.
// Format: { [interactionId]: { userId, page, amount, endpoint, timestamp, resultSkins, searchValue } }
export let activeSearchs = {};
// --- Feature-Specific State ---
// Stores active timeout polls, keyed by the interaction ID.
// Format: { [interactionId]: { toUserId, time, for, against, voters, endTime, ... } }
export let activePolls = {};
// Stores active predictions, keyed by a unique prediction ID.
// Format: { [prediId]: { creatorId, label, options, endTime, closed, ... } }
export let activePredis = {};
// Stores users who are currently under a slowmode effect, keyed by user ID.
// Format: { [userId]: { endAt, lastMessage } }
export let activeSlowmodes = {};
// --- Queues for Matchmaking ---
// Stores user IDs waiting to play Tic-Tac-Toe.
export let tictactoeQueue = [];
// Stores user IDs waiting to play Connect 4.
export let connect4Queue = [];
// --- Rate Limiting and Caching ---
// Tracks message timestamps for the channel points system, keyed by user ID.
// Used to limit points earned over a 15-minute window.
// Format: Map<userId, [timestamp1, timestamp2, ...]>
export let messagesTimestamps = new Map();
// Tracks recent AI mention requests for rate limiting, keyed by user ID.
// Used to prevent spamming the AI.
// Format: Map<userId, [timestamp1, timestamp2, ...]>
export let requestTimestamps = new Map();
// In-memory cache for Valorant skin data fetched from the API.
// This prevents re-fetching the same data on every command use.
export let skins = [];

109
src/game/various.js Normal file
View File

@@ -0,0 +1,109 @@
// --- Constants for Games ---
export const C4_ROWS = 6;
export const C4_COLS = 7;
// A predefined list of choices for the /timeout command's duration option.
const TimesChoices = [
{ name: '1 minute', value: 60 },
{ name: '5 minutes', value: 300 },
{ name: '10 minutes', value: 600 },
{ name: '15 minutes', value: 900 },
{ name: '30 minutes', value: 1800 },
{ name: '1 heure', value: 3600 },
{ name: '2 heures', value: 7200 },
{ name: '3 heures', value: 10800 },
{ name: '6 heures', value: 21600 },
{ name: '9 heures', value: 32400 },
{ name: '12 heures', value: 43200 },
{ name: '16 heures', value: 57600 },
{ name: '1 jour', value: 86400 },
];
/**
* Returns the array of time choices for use in command definitions.
* @returns {Array<object>} The array of time choices.
*/
export function getTimesChoices() {
return TimesChoices;
}
// --- Connect 4 Logic ---
/**
* Creates a new, empty Connect 4 game board.
* @returns {Array<Array<null>>} A 2D array representing the board.
*/
export function createConnect4Board() {
return Array(C4_ROWS).fill(null).map(() => Array(C4_COLS).fill(null));
}
/**
* Checks if a player has won the Connect 4 game.
* @param {Array<Array<string>>} board - The game board.
* @param {string} player - The player's symbol ('R' or 'Y').
* @returns {object} An object with `win` (boolean) and `pieces` (array of winning piece coordinates).
*/
export function checkConnect4Win(board, player) {
// Check horizontal
for (let r = 0; r < C4_ROWS; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) {
if (board[r][c] === player && board[r][c+1] === player && board[r][c+2] === player && board[r][c+3] === player) {
return { win: true, pieces: [{row:r, col:c}, {row:r, col:c+1}, {row:r, col:c+2}, {row:r, col:c+3}] };
}
}
}
// Check vertical
for (let r = 0; r <= C4_ROWS - 4; r++) {
for (let c = 0; c < C4_COLS; c++) {
if (board[r][c] === player && board[r+1][c] === player && board[r+2][c] === player && board[r+3][c] === player) {
return { win: true, pieces: [{row:r, col:c}, {row:r+1, col:c}, {row:r+2, col:c}, {row:r+3, col:c}] };
}
}
}
// Check diagonal (down-right)
for (let r = 0; r <= C4_ROWS - 4; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) {
if (board[r][c] === player && board[r+1][c+1] === player && board[r+2][c+2] === player && board[r+3][c+3] === player) {
return { win: true, pieces: [{row:r, col:c}, {row:r+1, col:c+1}, {row:r+2, col:c+2}, {row:r+3, col:c+3}] };
}
}
}
// Check diagonal (up-right)
for (let r = 3; r < C4_ROWS; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) {
if (board[r][c] === player && board[r-1][c+1] === player && board[r-2][c+2] === player && board[r-3][c+3] === player) {
return { win: true, pieces: [{row:r, col:c}, {row:r-1, col:c+1}, {row:r-2, col:c+2}, {row:r-3, col:c+3}] };
}
}
}
return { win: false, pieces: [] };
}
/**
* Checks if the Connect 4 game is a draw (the board is full).
* @param {Array<Array<string>>} board - The game board.
* @returns {boolean} True if the game is a draw.
*/
export function checkConnect4Draw(board) {
// A draw occurs if the top row is completely full.
return board[0].every(cell => cell !== null);
}
/**
* Formats a Connect 4 board into a string with emojis for Discord display.
* @param {Array<Array<string>>} board - The game board.
* @returns {string} The formatted string representation of the board.
*/
export function formatConnect4BoardForDiscord(board) {
const symbols = {
'R': '🔴',
'Y': '🟡',
null: '⚪' // Using a white circle for empty slots
};
return board.map(row => row.map(cell => symbols[cell]).join('')).join('\n');
}

51
src/server/app.js Normal file
View File

@@ -0,0 +1,51 @@
import 'dotenv/config';
import express from 'express';
import { verifyKeyMiddleware } from 'discord-interactions';
import { handleInteraction } from '../bot/handlers/interactionCreate.js';
import { client } from '../bot/client.js';
// Import route handlers
import { apiRoutes } from './routes/api.js';
import { pokerRoutes } from './routes/poker.js';
import { solitaireRoutes } from './routes/solitaire.js';
// --- EXPRESS APP INITIALIZATION ---
const app = express();
const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL;
// --- GLOBAL MIDDLEWARE ---
// CORS Middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', FLAPI_URL);
res.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, ngrok-skip-browser-warning');
next();
});
// --- PRIMARY DISCORD INTERACTION ENDPOINT ---
// This endpoint handles all interactions sent from Discord (slash commands, buttons, etc.)
app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async (req, res) => {
// The actual logic is delegated to a dedicated handler for better organization
await handleInteraction(req, res, client);
});
// JSON Body Parser Middleware
app.use(express.json());
// --- STATIC ASSETS ---
app.use('/public', express.static('public'));
// --- API ROUTES ---
// General API routes (users, polls, etc.)
app.use('/', apiRoutes(client));
// Poker-specific routes
app.use('/poker-room', pokerRoutes(client));
// Solitaire-specific routes
app.use('/solitaire', solitaireRoutes(client));
export { app };

250
src/server/routes/api.js Normal file
View File

@@ -0,0 +1,250 @@
import express from 'express';
import { sleep } from 'openai/core';
// --- Database Imports ---
import {
getAllUsers, getUsersByElo, pruneOldLogs, getLogs, getUser,
getUserLogs, getUserElo, getUserGames, getUserInventory,
queryDailyReward, updateUserCoins, insertLog,
} from '../../database/index.js';
// --- Game State Imports ---
import { activePolls, activeSlowmodes, activePredis } from '../../game/state.js';
// --- Utility and API Imports ---
import { getOnlineUsersWithRole } from '../../utils/index.js';
import { DiscordRequest } from '../../api/discord.js';
// --- Discord.js Builder Imports ---
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
// Create a new router instance
const router = express.Router();
/**
* Factory function to create and configure the main API routes.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
* @returns {object} The configured Express router.
*/
export function apiRoutes(client, io) {
// --- Server Health & Basic Data ---
router.get('/check', (req, res) => {
res.status(200).json({ status: 'OK', message: 'FlopoBot API is running.' });
});
router.get('/users', (req, res) => {
try {
const users = getAllUsers.all();
res.json(users);
} catch (error) {
console.error("Error fetching users:", error);
res.status(500).json({ error: 'Failed to fetch users.' });
}
});
router.get('/users/by-elo', (req, res) => {
try {
const users = getUsersByElo.all();
res.json(users);
} catch (error) {
console.error("Error fetching users by Elo:", error);
res.status(500).json({ error: 'Failed to fetch users by Elo.' });
}
});
router.get('/logs', async (req, res) => {
try {
await pruneOldLogs();
const logs = getLogs.all();
res.status(200).json(logs);
} catch (error) {
console.error("Error fetching logs:", error);
res.status(500).json({ error: 'Failed to fetch logs.' });
}
});
// --- User-Specific Routes ---
router.get('/user/:id/avatar', async (req, res) => {
try {
const user = await client.users.fetch(req.params.id);
const avatarUrl = user.displayAvatarURL({ format: 'png', size: 256 });
res.json({ avatarUrl });
} catch (error) {
res.status(404).json({ error: 'User not found or failed to fetch avatar.' });
}
});
router.get('/user/:id/username', async (req, res) => {
try {
const user = await client.users.fetch(req.params.id);
res.json({ user });
} catch (error) {
res.status(404).json({ error: 'User not found.' });
}
});
router.get('/user/:id/sparkline', (req, res) => {
try {
const logs = getUserLogs.all({ user_id: req.params.id });
res.json({ sparkline: logs });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch logs for sparkline.' });
}
});
router.get('/user/:id/elo', (req, res) => {
try {
const eloData = getUserElo.get({ id: req.params.id });
res.json({ elo: eloData?.elo || null });
} catch(e) {
res.status(500).json({ error: 'Failed to fetch Elo data.' });
}
});
router.get('/user/:id/elo-graph', (req, res) => {
try {
const games = getUserGames.all({ user_id: req.params.id });
const eloHistory = games.map(game => game.p1 === req.params.id ? game.p1_new_elo : game.p2_new_elo);
res.json({ elo_graph: eloHistory });
} catch (e) {
res.status(500).json({ error: 'Failed to generate Elo graph.' });
}
});
router.get('/user/:id/inventory', (req, res) => {
try {
const inventory = getUserInventory.all({ user_id: req.params.id });
res.json({ inventory });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch inventory.' });
}
});
router.post('/user/:id/daily', (req, res) => {
const { id } = req.params;
try {
const akhy = getUser.get(id);
if (!akhy) return res.status(404).json({ message: 'Utilisateur introuvable' });
if (akhy.dailyQueried) return res.status(403).json({ message: 'Récompense journalière déjà récupérée.' });
const amount = 200;
const newCoins = akhy.coins + amount;
queryDailyReward.run(id);
updateUserCoins.run({ id, coins: newCoins });
insertLog.run({
id: `${id}-daily-${Date.now()}`, user_id: id, action: 'DAILY_REWARD',
coins_amount: amount, user_new_amount: newCoins,
});
io.emit('data-updated', { table: 'users' });
res.status(200).json({ message: `+${amount} FlopoCoins! Récompense récupérée !` });
} catch (error) {
res.status(500).json({ error: "Failed to process daily reward." });
}
});
// --- Poll & Timeout Routes ---
router.get('/polls', (req, res) => {
res.json({ activePolls });
});
router.post('/timedout', async (req, res) => {
try {
const { userId } = req.body;
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const member = await guild.members.fetch(userId);
res.status(200).json({ isTimedOut: member?.isCommunicationDisabled() || false });
} catch (e) {
res.status(404).send({ message: 'Member not found or guild unavailable.' });
}
});
// --- Shop & Interaction Routes ---
router.post('/change-nickname', async (req, res) => {
const { userId, nickname, commandUserId } = req.body;
const commandUser = getUser.get(commandUserId);
if (!commandUser) return res.status(404).json({ message: 'Command user not found.' });
if (commandUser.coins < 1000) return res.status(403).json({ message: 'Pas assez de FlopoCoins (1000 requis).' });
try {
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const member = await guild.members.fetch(userId);
await member.setNickname(nickname);
const newCoins = commandUser.coins - 1000;
updateUserCoins.run({ id: commandUserId, coins: newCoins });
insertLog.run({
id: `${commandUserId}-changenick-${Date.now()}`, user_id: commandUserId, action: 'CHANGE_NICKNAME',
target_user_id: userId, coins_amount: -1000, user_new_amount: newCoins,
});
io.emit('data-updated', { table: 'users' });
res.status(200).json({ message: `Le pseudo de ${member.user.username} a été changé.` });
} catch (error) {
res.status(500).json({ message: `Erreur: Impossible de changer le pseudo.` });
}
});
router.post('/spam-ping', async (req, res) => {
// Implement spam-ping logic here...
res.status(501).json({ message: "Not Implemented" });
});
// --- Slowmode Routes ---
router.get('/slowmodes', (req, res) => {
res.status(200).json({ slowmodes: activeSlowmodes });
});
router.post('/slowmode', (req, res) => {
// Implement slowmode logic here...
res.status(501).json({ message: "Not Implemented" });
});
// --- Prediction Routes ---
router.get('/predis', (req, res) => {
const reversedPredis = Object.fromEntries(Object.entries(activePredis).reverse());
res.status(200).json({ predis: reversedPredis });
});
router.post('/start-predi', async (req, res) => {
// Implement prediction start logic here...
res.status(501).json({ message: "Not Implemented" });
});
router.post('/vote-predi', (req, res) => {
// Implement prediction vote logic here...
res.status(501).json({ message: "Not Implemented" });
});
router.post('/end-predi', (req, res) => {
// Implement prediction end logic here...
res.status(501).json({ message: "Not Implemented" });
});
// --- Admin Routes ---
router.post('/buy-coins', (req, res) => {
const { commandUserId, coins } = req.body;
const user = getUser.get(commandUserId);
if (!user) return res.status(404).json({ error: 'User not found' });
const newCoins = user.coins + coins;
updateUserCoins.run({ id: commandUserId, coins: newCoins });
insertLog.run({
id: `${commandUserId}-buycoins-${Date.now()}`, user_id: commandUserId, action: 'BUY_COINS_ADMIN',
coins_amount: coins, user_new_amount: newCoins
});
io.emit('data-updated', { table: 'users' });
res.status(200).json({ message: `Added ${coins} coins.` });
});
return router;
}

316
src/server/routes/poker.js Normal file
View File

@@ -0,0 +1,316 @@
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import { uniqueNamesGenerator, adjectives } from 'unique-names-generator';
import { pokerRooms } from '../../game/state.js';
import { initialShuffledCards, getFirstActivePlayerAfterDealer, getNextActivePlayer, checkEndOfBettingRound, checkRoomWinners } from '../../game/poker.js';
import { pokerEloHandler } from '../../game/elo.js';
import { getUser, updateUserCoins, insertLog } from '../../database/index.js';
import {sleep} from "openai/core";
// Create a new router instance
const router = express.Router();
/**
* Factory function to create and configure the poker API routes.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
* @returns {object} The configured Express router.
*/
export function pokerRoutes(client, io) {
// --- Room Management Endpoints ---
router.get('/', (req, res) => {
res.status(200).json({ rooms: pokerRooms });
});
router.get('/:id', (req, res) => {
const room = pokerRooms[req.params.id];
if (room) {
res.status(200).json({ room });
} else {
res.status(404).json({ message: 'Poker room not found.' });
}
});
router.post('/create', async (req, res) => {
const { creatorId } = req.body;
if (!creatorId) return res.status(400).json({ message: 'Creator ID is required.' });
if (Object.values(pokerRooms).some(room => room.host_id === creatorId || room.players[creatorId])) {
return res.status(403).json({ message: 'You are already in a poker room.' });
}
const creator = await client.users.fetch(creatorId);
const id = uuidv4();
const name = uniqueNamesGenerator({ dictionaries: [adjectives, ['Poker']], separator: ' ', style: 'capital' });
pokerRooms[id] = {
id,
host_id: creatorId,
host_name: creator.globalName || creator.username,
name,
created_at: Date.now(),
last_move_at: Date.now(),
players: {},
queue: {},
pioche: initialShuffledCards(),
tapis: [],
dealer: null,
sb: null,
bb: null,
highest_bet: 0,
current_player: null,
current_turn: null, // 0: pre-flop, 1: flop, 2: turn, 3: river, 4: showdown
playing: false,
winners: [],
waiting_for_restart: false,
fakeMoney: false,
};
// Auto-join the creator to their own room
await joinRoom(id, creatorId, io);
io.emit('poker-update', { type: 'room-created', roomId: id });
res.status(201).json({ roomId: id });
});
router.post('/join', async (req, res) => {
const { userId, roomId } = req.body;
if (!userId || !roomId) return res.status(400).json({ message: 'User ID and Room ID are required.' });
if (!pokerRooms[roomId]) return res.status(404).json({ message: 'Room not found.' });
if (Object.values(pokerRooms).some(r => r.players[userId])) {
return res.status(403).json({ message: 'You are already in a room.' });
}
await joinRoom(roomId, userId, io);
res.status(200).json({ message: 'Successfully joined room.' });
});
router.post('/leave', (req, res) => {
// Implement leave logic...
res.status(501).json({ message: "Not Implemented" });
});
// --- Game Action Endpoints ---
router.post('/:roomId/start', async (req, res) => {
const { roomId } = req.params;
const room = pokerRooms[roomId];
if (!room) return res.status(404).json({ message: 'Room not found.' });
if (Object.keys(room.players).length < 2) return res.status(400).json({ message: 'Not enough players to start.' });
await startNewHand(room, io);
res.status(200).json({ message: 'Game started.' });
});
router.post('/:roomId/action', async (req, res) => {
const { roomId } = req.params;
const { playerId, action, amount } = req.body;
const room = pokerRooms[roomId];
if (!room || !room.players[playerId] || room.current_player !== playerId) {
return res.status(403).json({ message: "It's not your turn or you are not in this game." });
}
const player = room.players[playerId];
switch(action) {
case 'fold':
player.folded = true;
io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName });
break;
case 'check':
if (player.bet < room.highest_bet) return res.status(400).json({ message: 'Cannot check, you must call or raise.' });
io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName });
break;
case 'call':
const callAmount = room.highest_bet - player.bet;
if (callAmount > player.bank) { // All-in call
player.bet += player.bank;
player.bank = 0;
player.allin = true;
} else {
player.bet += callAmount;
player.bank -= callAmount;
}
updatePlayerCoins(player, -callAmount, room.fakeMoney);
io.emit('poker-update', { type: 'player-action', roomId, playerId, action, globalName: player.globalName });
break;
case 'raise':
const totalBet = player.bet + amount;
if (amount > player.bank || totalBet <= room.highest_bet) return res.status(400).json({ message: 'Invalid raise amount.' });
player.bet = totalBet;
player.bank -= amount;
if(player.bank === 0) player.allin = true;
room.highest_bet = totalBet;
updatePlayerCoins(player, -amount, room.fakeMoney);
io.emit('poker-update', { type: 'player-action', roomId, playerId, action, amount, globalName: player.globalName });
break;
default:
return res.status(400).json({ message: 'Invalid action.' });
}
player.last_played_turn = room.current_turn;
await checkRoundCompletion(room, io);
res.status(200).json({ message: `Action '${action}' successful.` });
});
return router;
}
// --- Helper Functions ---
async function joinRoom(roomId, userId, io) {
const user = await client.users.fetch(userId);
const userDB = getUser.get(userId);
const bank = userDB?.coins >= 1000 ? userDB.coins : 1000;
const isFake = userDB?.coins < 1000;
pokerRooms[roomId].players[userId] = {
id: userId,
globalName: user.globalName || user.username,
hand: [],
bank: bank,
bet: 0,
folded: false,
allin: false,
last_played_turn: null,
};
if(isFake) pokerRooms[roomId].fakeMoney = true;
io.emit('poker-update', { type: 'player-join', roomId, player: pokerRooms[roomId].players[userId] });
}
async function startNewHand(room, io) {
room.playing = true;
room.current_turn = 0; // Pre-flop
room.pioche = initialShuffledCards();
room.tapis = [];
room.winners = [];
room.waiting_for_restart = false;
room.highest_bet = 20;
// Reset players for the new hand
Object.values(room.players).forEach(p => {
p.hand = [room.pioche.pop(), room.pioche.pop()];
p.bet = 0;
p.folded = false;
p.allin = false;
p.last_played_turn = null;
});
// Handle blinds
const playerIds = Object.keys(room.players);
const sbPlayer = room.players[playerIds[0]];
const bbPlayer = room.players[playerIds[1]];
sbPlayer.bet = 10;
sbPlayer.bank -= 10;
updatePlayerCoins(sbPlayer, -10, room.fakeMoney);
bbPlayer.bet = 20;
bbPlayer.bank -= 20;
updatePlayerCoins(bbPlayer, -20, room.fakeMoney);
bbPlayer.last_played_turn = 0;
room.current_player = playerIds[2 % playerIds.length];
io.emit('poker-update', { type: 'new-hand', room });
}
async function checkRoundCompletion(room, io) {
room.last_move_at = Date.now();
const roundResult = checkEndOfBettingRound(room);
if (roundResult.endRound) {
if (roundResult.winner) {
// Handle single winner case (everyone else folded)
await handleShowdown(room, io, [roundResult.winner]);
} else {
// Proceed to the next phase
await advanceToNextPhase(room, io, roundResult.nextPhase);
}
} else {
// Continue the round
room.current_player = getNextActivePlayer(room);
io.emit('poker-update', { type: 'next-player', room });
}
}
async function advanceToNextPhase(room, io, phase) {
// Reset player turn markers for the new betting round
Object.values(room.players).forEach(p => p.last_played_turn = null);
switch(phase) {
case 'flop':
room.current_turn = 1;
room.tapis = [room.pioche.pop(), room.pioche.pop(), room.pioche.pop()];
break;
case 'turn':
room.current_turn = 2;
room.tapis.push(room.pioche.pop());
break;
case 'river':
room.current_turn = 3;
room.tapis.push(room.pioche.pop());
break;
case 'showdown':
const winners = checkRoomWinners(room);
await handleShowdown(room, io, winners);
return;
case 'progressive-showdown':
// Show cards and deal remaining community cards one by one
io.emit('poker-update', { type: 'show-cards', room });
while(room.tapis.length < 5) {
await sleep(1500);
room.tapis.push(room.pioche.pop());
io.emit('poker-update', { type: 'community-card-deal', room });
}
const finalWinners = checkRoomWinners(room);
await handleShowdown(room, io, finalWinners);
return;
}
room.current_player = getFirstActivePlayerAfterDealer(room);
io.emit('poker-update', { type: 'phase-change', room });
}
async function handleShowdown(room, io, winners) {
room.current_turn = 4;
room.playing = false;
room.waiting_for_restart = true;
room.winners = winners;
const totalPot = Object.values(room.players).reduce((sum, p) => sum + p.bet, 0);
const winAmount = Math.floor(totalPot / winners.length);
winners.forEach(winnerId => {
const winnerPlayer = room.players[winnerId];
winnerPlayer.bank += winAmount;
updatePlayerCoins(winnerPlayer, winAmount, room.fakeMoney);
});
await pokerEloHandler(room);
io.emit('poker-update', { type: 'showdown', room, winners, winAmount });
}
function updatePlayerCoins(player, amount, isFake) {
if (isFake) return;
const user = getUser.get(player.id);
if (!user) return;
const newCoins = user.coins + amount;
updateUserCoins.run({ id: player.id, coins: newCoins });
insertLog.run({
id: `${player.id}-poker-${Date.now()}`,
user_id: player.id,
action: `POKER_${amount > 0 ? 'WIN' : 'BET'}`,
coins_amount: amount,
user_new_amount: newCoins,
});
}

View File

@@ -0,0 +1,221 @@
import express from 'express';
// --- Game Logic Imports ---
import {
createDeck, shuffle, deal, isValidMove, moveCard, drawCard,
checkWinCondition, createSeededRNG, seededShuffle
} from '../../game/solitaire.js';
// --- Game State & Database Imports ---
import { activeSolitaireGames } from '../../game/state.js';
import {
getSOTD, getUser, insertSOTDStats, deleteUserSOTDStats,
getUserSOTDStats, updateUserCoins, insertLog, getAllSOTDStats
} from '../../database/index.js';
// Create a new router instance
const router = express.Router();
/**
* Factory function to create and configure the solitaire API routes.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
* @returns {object} The configured Express router.
*/
export function solitaireRoutes(client, io) {
// --- Game Initialization Endpoints ---
router.post('/start', (req, res) => {
const { userId, userSeed } = req.body;
if (!userId) return res.status(400).json({ error: 'User ID is required.' });
// If a game already exists for the user, return it instead of creating a new one.
if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) {
return res.json({ success: true, gameState: activeSolitaireGames[userId] });
}
let deck, seed;
if (userSeed) {
// Use the provided seed to create a deterministic game
seed = userSeed;
let numericSeed = 0;
for (let i = 0; i < seed.length; i++) {
numericSeed = (numericSeed + seed.charCodeAt(i)) & 0xFFFFFFFF;
}
const rng = createSeededRNG(numericSeed);
deck = seededShuffle(createDeck(), rng);
} else {
// Create a standard random game
seed = Date.now().toString(36) + Math.random().toString(36).substr(2);
deck = shuffle(createDeck());
}
const gameState = deal(deck);
gameState.seed = seed;
gameState.isSOTD = false;
activeSolitaireGames[userId] = gameState;
res.json({ success: true, gameState });
});
router.post('/start/sotd', (req, res) => {
const { userId } = req.body;
if (!userId || !getUser.get(userId)) {
return res.status(404).json({ error: 'User not found.' });
}
if (activeSolitaireGames[userId]?.isSOTD) {
return res.json({ success: true, gameState: activeSolitaireGames[userId] });
}
const sotd = getSOTD.get();
if (!sotd) {
return res.status(500).json({ error: 'Solitaire of the Day is not configured.'});
}
const gameState = {
tableauPiles: JSON.parse(sotd.tableauPiles),
foundationPiles: JSON.parse(sotd.foundationPiles),
stockPile: JSON.parse(sotd.stockPile),
wastePile: JSON.parse(sotd.wastePile),
isDone: false,
isSOTD: true,
startTime: Date.now(),
endTime: null,
moves: 0,
score: 0,
seed: sotd.seed,
};
activeSolitaireGames[userId] = gameState;
res.json({ success: true, gameState });
});
// --- Game State & Action Endpoints ---
router.get('/sotd/rankings', (req, res) => {
try {
const rankings = getAllSOTDStats.all();
res.json({ rankings });
} catch(e) {
res.status(500).json({ error: "Failed to fetch SOTD rankings."});
}
});
router.get('/state/:userId', (req, res) => {
const { userId } = req.params;
const gameState = activeSolitaireGames[userId];
if (gameState) {
res.json({ success: true, gameState });
} else {
res.status(404).json({ error: 'No active game found for this user.' });
}
});
router.post('/reset', (req, res) => {
const { userId } = req.body;
if (activeSolitaireGames[userId]) {
delete activeSolitaireGames[userId];
}
res.json({ success: true, message: "Game reset."});
});
router.post('/move', (req, res) => {
const { userId, ...moveData } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: 'Game not found.' });
if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'});
if (isValidMove(gameState, moveData)) {
moveCard(gameState, moveData);
updateGameStats(gameState, 'move', moveData);
const win = checkWinCondition(gameState);
if (win) {
gameState.isDone = true;
handleWin(userId, gameState, io);
}
res.json({ success: true, gameState, win });
} else {
res.status(400).json({ error: 'Invalid move' });
}
});
router.post('/draw', (req, res) => {
const { userId } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: 'Game not found.' });
if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'});
drawCard(gameState);
updateGameStats(gameState, 'draw');
res.json({ success: true, gameState });
});
return router;
}
// --- Helper Functions ---
/** Updates game stats like moves and score after an action. */
function updateGameStats(gameState, actionType, moveData = {}) {
if (!gameState.isSOTD) return; // Only track stats for SOTD
gameState.moves++;
if (actionType === 'move') {
if (moveData.destPileType === 'foundationPiles') {
gameState.score += 10; // Move card to foundation
}
if (moveData.sourcePileType === 'foundationPiles') {
gameState.score -= 15; // Move card from foundation (penalty)
}
}
if(actionType === 'draw' && gameState.wastePile.length === 0) {
// Penalty for cycling through an empty stock pile
gameState.score -= 100;
}
}
/** Handles the logic when a game is won. */
function handleWin(userId, gameState, io) {
if (!gameState.isSOTD) return;
gameState.endTime = Date.now();
const timeTaken = gameState.endTime - gameState.startTime;
const currentUser = getUser.get(userId);
const existingStats = getUserSOTDStats.get(userId);
if (!existingStats) {
// First time completing the SOTD, grant bonus coins
const bonus = 1000;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
id: `${userId}-sotd-complete-${Date.now()}`, user_id: userId, action: 'SOTD_WIN',
coins_amount: bonus, user_new_amount: newCoins,
});
io.emit('data-updated', { table: 'users' });
}
// Save the score if it's better than the previous one
const isNewBest = !existingStats ||
gameState.score > existingStats.score ||
(gameState.score === existingStats.score && gameState.moves < existingStats.moves) ||
(gameState.score === existingStats.score && gameState.moves === existingStats.moves && timeTaken < existingStats.time);
if (isNewBest) {
insertSOTDStats.run({
id: userId, user_id: userId,
time: timeTaken,
moves: gameState.moves,
score: gameState.score,
});
io.emit('sotd-update'); // Notify frontend of new rankings
console.log(`New SOTD high score for ${currentUser.globalName}: ${gameState.score} points.`);
}
}

258
src/server/socket.js Normal file
View File

@@ -0,0 +1,258 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
import { activeTicTacToeGames, tictactoeQueue, activeConnect4Games, connect4Queue } from '../game/state.js';
import { createConnect4Board, formatConnect4BoardForDiscord, checkConnect4Win, checkConnect4Draw, C4_ROWS } from '../game/various.js';
import { eloHandler } from '../game/elo.js';
import { getUser } from "../database/index.js";
// --- Module-level State ---
let io;
// --- Main Initialization Function ---
export function initializeSocket(server, client) {
io = server;
io.on('connection', (socket) => {
console.log(`[Socket.IO] User connected: ${socket.id}`);
socket.on('user-connected', async (userId) => {
if (!userId) return;
await refreshQueuesForUser(userId, client);
});
registerTicTacToeEvents(socket, client);
registerConnect4Events(socket, client);
socket.on('disconnect', () => {
console.log(`[Socket.IO] User disconnected: ${socket.id}`);
});
});
setInterval(cleanupStaleGames, 5 * 60 * 1000);
}
export function getSocketIo() {
return io;
}
// --- Event Registration ---
function registerTicTacToeEvents(socket, client) {
socket.on('tictactoeconnection', (e) => refreshQueuesForUser(e.id, client));
socket.on('tictactoequeue', (e) => onQueueJoin(client, 'tictactoe', e.playerId));
socket.on('tictactoeplaying', (e) => onTicTacToeMove(client, e));
socket.on('tictactoegameOver', (e) => onGameOver(client, 'tictactoe', e.playerId, e.winner));
}
function registerConnect4Events(socket, client) {
socket.on('connect4connection', (e) => refreshQueuesForUser(e.id, client));
socket.on('connect4queue', (e) => onQueueJoin(client, 'connect4', e.playerId));
socket.on('connect4playing', (e) => onConnect4Move(client, e));
socket.on('connect4NoTime', (e) => onGameOver(client, 'connect4', e.playerId, e.winner, '(temps écoulé)'));
}
// --- Core Handlers (Preserving Original Logic) ---
async function onQueueJoin(client, gameType, playerId) {
if (!playerId) return;
const { queue, activeGames, title, url } = getGameAssets(gameType);
if (queue.includes(playerId) || Object.values(activeGames).some(g => g.p1.id === playerId || g.p2.id === playerId)) {
return;
}
queue.push(playerId);
console.log(`[${title}] Player ${playerId} joined the queue.`);
if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url);
if (queue.length >= 2) await createGame(client, gameType);
await emitQueueUpdate(client, gameType);
}
async function onTicTacToeMove(client, eventData) {
const { playerId, value, boxId } = eventData;
const lobby = Object.values(activeTicTacToeGames).find(g => (g.p1.id === playerId || g.p2.id === playerId) && !g.gameOver);
if (!lobby) return;
const isP1Turn = lobby.sum % 2 === 1 && value === 'X' && lobby.p1.id === playerId;
const isP2Turn = lobby.sum % 2 === 0 && value === 'O' && lobby.p2.id === playerId;
if (isP1Turn || isP2Turn) {
(isP1Turn) ? lobby.xs.push(boxId) : lobby.os.push(boxId);
lobby.sum++;
lobby.lastmove = Date.now();
await updateDiscordMessage(client, lobby, 'Tic Tac Toe');
io.emit('tictactoeplaying', { allPlayers: Object.values(activeTicTacToeGames) });
}
await emitQueueUpdate(client, 'tictactoe');
}
async function onConnect4Move(client, eventData) {
const { playerId, col } = eventData;
const lobby = Object.values(activeConnect4Games).find(l => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver);
if (!lobby || lobby.turn !== playerId) return;
const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2;
let row;
for (row = C4_ROWS - 1; row >= 0; row--) {
if (lobby.board[row][col] === null) {
lobby.board[row][col] = player.val;
break;
}
}
if (row < 0) return;
lobby.lastmove = Date.now();
const winCheck = checkConnect4Win(lobby.board, player.val);
let winnerId = null;
if (winCheck.win) {
lobby.winningPieces = winCheck.pieces;
winnerId = player.id;
} else if (checkConnect4Draw(lobby.board)) {
winnerId = null; // Represents a draw
} else {
lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id;
io.emit('connect4playing', { allPlayers: Object.values(activeConnect4Games) });
await emitQueueUpdate(client, 'connact4');
await updateDiscordMessage(client, lobby, 'Puissance 4');
return;
}
await onGameOver(client, 'connect4', playerId, winnerId);
}
async function onGameOver(client, gameType, playerId, winnerId, reason = '') {
const { activeGames, title } = getGameAssets(gameType);
const gameKey = Object.keys(activeGames).find(key => key.includes(playerId));
const game = gameKey ? activeGames[gameKey] : undefined;
if (!game || game.gameOver) return;
game.gameOver = true;
let resultText;
if (winnerId === null) {
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase());
resultText = 'Égalité';
} else {
await eloHandler(game.p1.id, game.p2.id, game.p1.id === winnerId ? 1 : 0, game.p2.id === winnerId ? 1 : 0, title.toUpperCase());
const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name;
resultText = `Victoire de ${winnerName}`;
}
await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
if(gameType === 'tictactoe') io.emit('tictactoegameOver', { game, winner: winnerId });
if(gameType === 'connect4') io.emit('connect4gameOver', { game, winner: winnerId });
if (gameKey) {
setTimeout(() => delete activeGames[gameKey], 10000);
}
await emitQueueUpdate(client, gameType);
}
// --- Game Lifecycle & Discord Helpers ---
async function createGame(client, gameType) {
const { queue, activeGames, title } = getGameAssets(gameType);
const p1Id = queue.shift();
const p2Id = queue.shift();
const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]);
let lobby;
if (gameType === 'tictactoe') {
lobby = { p1: { id: p1Id, name: p1.globalName, val: 'X' }, p2: { id: p2Id, name: p2.globalName, val: 'O' }, sum: 1, xs: [], os: [], gameOver: false, lastmove: Date.now() };
} else { // connect4
lobby = { p1: { id: p1Id, name: p1.globalName, val: 'R' }, p2: { id: p2Id, name: p2.globalName, val: 'Y' }, turn: p1Id, board: createConnect4Board(), gameOver: false, lastmove: Date.now(), winningPieces: [] };
}
const msgId = await updateDiscordMessage(client, lobby, title);
lobby.msgId = msgId;
const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby;
io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) });
await emitQueueUpdate(client, gameType);
}
// --- Utility Functions ---
async function refreshQueuesForUser(userId, client) {
// FIX: Mutate the array instead of reassigning it.
let index = tictactoeQueue.indexOf(userId);
if (index > -1) tictactoeQueue.splice(index, 1);
index = connect4Queue.indexOf(userId);
if (index > -1) connect4Queue.splice(index, 1);
await emitQueueUpdate(client, 'tictactoe');
await emitQueueUpdate(client, 'connect4');
}
async function emitQueueUpdate(client, gameType) {
const { queue, activeGames } = getGameAssets(gameType);
const names = await Promise.all(queue.map(async (id) => {
const user = await client.users.fetch(id).catch(() => null);
return user?.globalName || user?.username;
}));
io.emit(`${gameType}queue`, { allPlayers: Object.values(activeGames), queue: names.filter(Boolean) });
}
function getGameAssets(gameType) {
if (gameType === 'tictactoe') return { queue: tictactoeQueue, activeGames: activeTicTacToeGames, title: 'Tic Tac Toe', url: '/tic-tac-toe' };
if (gameType === 'connect4') return { queue: connect4Queue, activeGames: activeConnect4Games, title: 'Puissance 4', url: '/connect-4' };
return { queue: [], activeGames: {} };
}
async function postQueueToDiscord(client, playerId, title, url) {
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(playerId);
const embed = new EmbedBuilder().setTitle(title).setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`).setColor('#5865F2');
const row = new ActionRowBuilder().addComponents(new ButtonBuilder().setLabel(`Jouer contre ${user.username}`).setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}${url}`).setStyle(ButtonStyle.Link));
await generalChannel.send({ embeds: [embed], components: [row] });
} catch (e) { console.error(`Failed to post queue message for ${title}:`, e); }
}
async function updateDiscordMessage(client, game, title, resultText = '') {
const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null);
if (!channel) return null;
let description;
if (title === 'Tic Tac Toe') {
let gridText = '';
for (let i = 1; i <= 9; i++) { gridText += game.xs.includes(i) ? '❌' : game.os.includes(i) ? '⭕' : '🟦'; if (i % 3 === 0) gridText += '\n'; }
description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`;
} else {
description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`;
}
if (resultText) description += `\n### ${resultText}`;
const embed = new EmbedBuilder().setTitle(title).setDescription(description).setColor(game.gameOver ? '#2ade2a' : '#5865f2');
try {
if (game.msgId) {
const message = await channel.messages.fetch(game.msgId);
await message.edit({ embeds: [embed] });
return game.msgId;
} else {
const message = await channel.send({ embeds: [embed] });
return message.id;
}
} catch (e) { return null; }
}
function cleanupStaleGames() {
const now = Date.now();
const STALE_TIMEOUT = 30 * 60 * 1000;
const cleanup = (games, name) => {
Object.keys(games).forEach(key => {
if (now - games[key].lastmove > STALE_TIMEOUT) {
console.log(`[Cleanup] Removing stale ${name} game: ${key}`);
delete games[key];
}
});
};
cleanup(activeTicTacToeGames, 'TicTacToe');
cleanup(activeConnect4Games, 'Connect4');
}

82
src/utils/ai.js Normal file
View File

@@ -0,0 +1,82 @@
import 'dotenv/config';
import OpenAI from "openai";
import {GoogleGenAI} from "@google/genai";
import {Mistral} from '@mistralai/mistralai';
// --- AI Client Initialization ---
// Initialize clients for each AI service. This is done once when the module is loaded.
let openai;
if (process.env.OPENAI_API_KEY) {
openai = new OpenAI();
}
let gemini;
if (process.env.GEMINI_KEY) {
gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY})
}
let mistral;
if (process.env.MISTRAL_KEY) {
mistral = new Mistral({apiKey: process.env.MISTRAL_KEY});
}
/**
* Gets a response from the configured AI model.
* It dynamically chooses the provider based on the MODEL environment variable.
* @param {Array<object>} messageHistory - The conversation history in a standardized format.
* @returns {Promise<string>} The content of the AI's response message.
*/
export async function gork(messageHistory) {
const modelProvider = process.env.MODEL;
console.log(`[AI] Requesting completion from ${modelProvider}...`);
try {
// --- OpenAI Provider ---
if (modelProvider === 'OpenAI' && openai) {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", // Using a modern, cost-effective model
messages: messageHistory,
});
return completion.choices[0].message.content;
}
// --- Google Gemini Provider ---
else if (modelProvider === 'Gemini' && gemini) {
// Gemini requires a slightly different history format.
const contents = messageHistory.map(msg => ({
role: msg.role === 'assistant' ? 'model' : msg.role, // Gemini uses 'model' for assistant role
parts: [{ text: msg.content }],
}));
// The last message should not be from the model
if (contents[contents.length - 1].role === 'model') {
contents.pop();
}
const result = await gemini.generateContent({ contents });
const response = await result.response;
return response.text();
}
// --- Mistral Provider ---
else if (modelProvider === 'Mistral' && mistral) {
const chatResponse = await mistral.chat({
model: 'mistral-large-latest',
messages: messageHistory,
});
return chatResponse.choices[0].message.content;
}
// --- Fallback Case ---
else {
console.warn(`[AI] No valid AI provider configured or API key missing for MODEL=${modelProvider}.`);
return "Le service d'IA n'est pas correctement configuré. Veuillez contacter l'administrateur.";
}
} catch(error) {
console.error(`[AI] Error with ${modelProvider} API:`, error);
return "Oups, une erreur est survenue en contactant le service d'IA.";
}
}

254
src/utils/index.js Normal file
View File

@@ -0,0 +1,254 @@
import 'dotenv/config';
import cron from 'node-cron';
import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator';
// --- Local Imports ---
import { getValorantSkins, getSkinTiers } from '../api/valorant.js';
import { DiscordRequest } from '../api/discord.js';
import { initTodaysSOTD } from '../game/points.js';
import {
insertManyUsers, insertManySkins, resetDailyReward,
pruneOldLogs, getAllUsers as dbGetAllUsers, getSOTD,
} from '../database/index.js';
import { activeInventories, activeSearchs, activePredis, pokerRooms, skins } from '../game/state.js';
export async function InstallGlobalCommands(appId, commands) {
// API endpoint to overwrite global commands
const endpoint = `applications/${appId}/commands`;
try {
// This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands
await DiscordRequest(endpoint, { method: 'PUT', body: commands });
} catch (err) {
console.error(err);
}
}
// --- Data Fetching & Initialization ---
/**
* Fetches all members with the 'Akhy' role and all Valorant skins,
* then syncs them with the database.
* @param {object} client - The Discord.js client instance.
*/
export async function getAkhys(client) {
try {
// 1. Fetch Discord Members
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const members = await guild.members.fetch();
const akhys = members.filter(m => !m.user.bot && m.roles.cache.has(process.env.AKHY_ROLE_ID));
const usersToInsert = akhys.map(akhy => ({
id: akhy.user.id,
username: akhy.user.username,
globalName: akhy.user.globalName,
}));
if (usersToInsert.length > 0) {
insertManyUsers(usersToInsert);
}
console.log(`[Sync] Found and synced ${akhys.size} users with the 'Akhy' role.`);
// 2. Fetch Valorant Skins
const [fetchedSkins, fetchedTiers] = await Promise.all([getValorantSkins(), getSkinTiers()]);
// Clear and rebuild the in-memory skin cache
skins.length = 0;
fetchedSkins.forEach(skin => skins.push(skin));
const skinsToInsert = fetchedSkins
.filter(skin => skin.contentTierUuid)
.map(skin => {
const tier = fetchedTiers.find(t => t.uuid === skin.contentTierUuid) || {};
const basePrice = calculateBasePrice(skin, tier.rank);
return {
uuid: skin.uuid,
displayName: skin.displayName,
contentTierUuid: skin.contentTierUuid,
displayIcon: skin.displayIcon,
user_id: null,
tierRank: tier.rank,
tierColor: tier.highlightColor?.slice(0, 6) || 'F2F3F3',
tierText: formatTierText(tier.rank, skin.displayName),
basePrice: basePrice.toFixed(2),
maxPrice: calculateMaxPrice(basePrice, skin).toFixed(2),
};
});
if (skinsToInsert.length > 0) {
insertManySkins(skinsToInsert);
}
console.log(`[Sync] Fetched and synced ${skinsToInsert.length} Valorant skins.`);
} catch (err) {
console.error('Error during initial data sync (getAkhys):', err);
}
}
// --- Cron Jobs / Scheduled Tasks ---
/**
* Sets up all recurring tasks for the application.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
*/
export function setupCronJobs(client, io) {
// Every 10 minutes: Clean up expired interactive sessions
cron.schedule('*/10 * * * *', () => {
const now = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000;
const cleanup = (sessions, name) => {
let cleanedCount = 0;
for (const id in sessions) {
if (now >= (sessions[id].timestamp || 0) + FIVE_MINUTES) {
delete sessions[id];
cleanedCount++;
}
}
if (cleanedCount > 0) console.log(`[Cron] Cleaned up ${cleanedCount} expired ${name} sessions.`);
};
cleanup(activeInventories, 'inventory');
cleanup(activeSearchs, 'search');
// Cleanup for predis and poker rooms...
// ...
});
// Daily at midnight: Reset daily rewards and init SOTD
cron.schedule('0 0 * * *', async () => {
console.log('[Cron] Running daily midnight tasks...');
try {
resetDailyReward.run();
console.log('[Cron] Daily rewards have been reset for all users.');
if (!getSOTD.get()) {
initTodaysSOTD();
}
} catch (e) {
console.error('[Cron] Error during daily reset:', e);
}
});
// Daily at 7 AM: Re-sync users and skins
cron.schedule('0 7 * * *', () => {
console.log('[Cron] Running daily 7 AM data sync...');
getAkhys(client);
});
}
// --- Formatting Helpers ---
export function capitalize(str) {
if (typeof str !== 'string' || str.length === 0) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function formatTime(seconds) {
const d = Math.floor(seconds / (3600*24));
const h = Math.floor(seconds % (3600*24) / 3600);
const m = Math.floor(seconds % 3600 / 60);
const s = Math.floor(seconds % 60);
const parts = [];
if (d > 0) parts.push(`**${d}** jour${d > 1 ? 's' : ''}`);
if (h > 0) parts.push(`**${h}** heure${h > 1 ? 's' : ''}`);
if (m > 0) parts.push(`**${m}** minute${m > 1 ? 's' : ''}`);
if (s > 0 || parts.length === 0) parts.push(`**${s}** seconde${s > 1 ? 's' : ''}`);
return parts.join(', ').replace(/,([^,]*)$/, ' et$1');
}
// --- External API Helpers ---
/**
* Fetches user data from the "APO" service.
*/
export async function getAPOUsers() {
const fetchUrl = `${process.env.APO_BASE_URL}/users?serverId=${process.env.GUILD_ID}`;
try {
const response = await fetch(fetchUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json();
} catch (error) {
console.error('Error fetching APO users:', error);
return null;
}
}
/**
* Sends a "buy" request to the "APO" service.
* @param {string} userId - The Discord user ID.
* @param {number} amount - The amount to "buy".
*/
export async function postAPOBuy(userId, amount) {
const fetchUrl = `${process.env.APO_BASE_URL}/buy?serverId=${process.env.GUILD_ID}&userId=${userId}&amount=${amount}`;
return fetch(fetchUrl, { method: 'POST' });
}
// --- Miscellaneous Helpers ---
export async function getOnlineUsersWithRole(guild, roleId) {
if (!guild || !roleId) return new Map();
try {
const members = await guild.members.fetch();
return members.filter(m => !m.user.bot && m.presence?.status !== 'offline' && m.roles.cache.has(roleId));
} catch (err) {
console.error('Error fetching online members with role:', err);
return new Map();
}
}
export function getRandomEmoji(list = 0) {
const emojiLists = [
['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨'],
['<:CAUGHT:1323810730155446322>', '<:hinhinhin:1072510144933531758>', '<:o7:1290773422451986533>', '<:zhok:1115221772623683686>', '<:nice:1154049521110765759>', '<:nerd:1087658195603951666>', '<:peepSelfie:1072508131839594597>'],
];
const selectedList = emojiLists[list] || [''];
return selectedList[Math.floor(Math.random() * selectedList.length)];
}
// --- Private Helpers ---
function calculateBasePrice(skin, tierRank) {
const name = skin.displayName.toLowerCase();
let price = 6000; // Default for melee
if (name.includes('classic')) price = 150;
else if (name.includes('shorty')) price = 300;
else if (name.includes('frenzy')) price = 450;
else if (name.includes('ghost')) price = 500;
// ... add all other weapon prices ...
else if (name.includes('vandal') || name.includes('phantom')) price = 2900;
price *= (1 + (tierRank || 0));
if (name.includes('vct')) price *= 1.25;
if (name.includes('champions')) price *= 2;
return price / 1111;
}
function calculateMaxPrice(basePrice, skin) {
let res = basePrice;
res *= (1 + (skin.levels.length / Math.max(skin.levels.length, 2)));
res *= (1 + (skin.chromas.length / 4));
return res;
}
function formatTierText(rank, displayName) {
const tiers = {
0: '**<:select:1362964319498670222> Select**',
1: '**<:deluxe:1362964308094488797> Deluxe**',
2: '**<:premium:1362964330349330703> Premium**',
3: '**<:exclusive:1362964427556651098> Exclusive**',
4: '**<:ultra:1362964339685986314> Ultra**',
};
let res = tiers[rank] || 'Pas de tier';
if (displayName.includes('VCT')) res += ' | Esports';
if (displayName.toLowerCase().includes('champions')) res += ' | Champions';
return res;
}