mirror of
https://github.com/cassoule/flopobot_v2.git
synced 2026-01-18 16:37:40 +01:00
refactoring first steps
This commit is contained in:
123
commands.js
123
commands.js
@@ -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
BIN
flopobot.db-shm
Normal file
Binary file not shown.
BIN
flopobot.db-wal
Normal file
BIN
flopobot.db-wal
Normal file
Binary file not shown.
5167
old_index.js
Normal file
5167
old_index.js
Normal file
File diff suppressed because it is too large
Load Diff
60
src/api/discord.js
Normal file
60
src/api/discord.js
Normal 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
11
src/api/valorant.js
Normal 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
28
src/bot/client.js
Normal 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,
|
||||
],
|
||||
});
|
||||
55
src/bot/commands/floposite.js
Normal file
55
src/bot/commands/floposite.js
Normal 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
71
src/bot/commands/info.js
Normal 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.' });
|
||||
}
|
||||
}
|
||||
131
src/bot/commands/inventory.js
Normal file
131
src/bot/commands/inventory.js
Normal 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
122
src/bot/commands/search.js
Normal 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
68
src/bot/commands/skins.js
Normal 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
212
src/bot/commands/timeout.js
Normal 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 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
188
src/bot/commands/valorant.js
Normal file
188
src/bot/commands/valorant.js
Normal 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
|
||||
}
|
||||
151
src/bot/components/inventoryNav.js
Normal file
151
src/bot/components/inventoryNav.js
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
176
src/bot/components/pollVote.js
Normal file
176
src/bot/components/pollVote.js
Normal 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));
|
||||
}
|
||||
}
|
||||
121
src/bot/components/searchNav.js
Normal file
121
src/bot/components/searchNav.js
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
196
src/bot/components/upgradeSkin.js
Normal file
196
src/bot/components/upgradeSkin.js
Normal 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
56
src/bot/events.js
Normal 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!`);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
89
src/bot/handlers/interactionCreate.js
Normal file
89
src/bot/handlers/interactionCreate.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
191
src/bot/handlers/messageCreate.js
Normal file
191
src/bot/handlers/messageCreate.js
Normal 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
113
src/config/commands.js
Normal 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
185
src/database/index.js
Normal 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
144
src/game/elo.js
Normal 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
191
src/game/points.js
Normal 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
139
src/game/poker.js
Normal 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
246
src/game/solitaire.js
Normal 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
68
src/game/state.js
Normal 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
109
src/game/various.js
Normal 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
51
src/server/app.js
Normal 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
250
src/server/routes/api.js
Normal 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
316
src/server/routes/poker.js
Normal 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,
|
||||
});
|
||||
}
|
||||
221
src/server/routes/solitaire.js
Normal file
221
src/server/routes/solitaire.js
Normal 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
258
src/server/socket.js
Normal 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
82
src/utils/ai.js
Normal 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
254
src/utils/index.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user