fix: big fix + prettier

This commit is contained in:
Milo
2025-11-06 02:48:36 +01:00
parent 4025b98a86
commit b60db69157
50 changed files with 11102 additions and 8367 deletions

61
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,61 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -1,6 +1,6 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="EditorConfigDeprecatedDescriptor" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>
</component> </component>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

7
.idea/prettier.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
</component>
</project>

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 120,
"useTabs": true
}

View File

@@ -1,6 +1,7 @@
# FLOPOBOT DEUXIEME DU NOM # FLOPOBOT DEUXIEME DU NOM
## Project structure ## Project structure
Below is a basic overview of the project structure: Below is a basic overview of the project structure:
``` ```
@@ -15,5 +16,6 @@ Below is a basic overview of the project structure:
``` ```
## FlopoSite ## FlopoSite
- FlopoBot has its own website to use it a different way - FlopoBot has its own website to use it a different way
[FlopoSite's repo](https://github.com/cassoule/floposite) [FlopoSite's repo](https://github.com/cassoule/floposite)

View File

@@ -1,5 +1,5 @@
import { registerCommands } from './src/config/commands.js'; import { registerCommands } from "./src/config/commands.js";
console.log('Registering global commands...'); console.log("Registering global commands...");
registerCommands(); registerCommands();
console.log('Commands registered.'); console.log("Commands registered.");

9
eslint.config.js Normal file
View File

@@ -0,0 +1,9 @@
import globals from "globals";
import json from "@eslint/json";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs}"], languageOptions: { globals: globals.node } },
{ files: ["**/*.json"], plugins: { json }, language: "json/json" },
{ files: ["**/*.jsonc"], plugins: { json }, language: "json/jsonc" },
]);

View File

@@ -1,53 +1,51 @@
import 'dotenv/config'; import "dotenv/config";
import http from 'http'; import http from "http";
import { Server } from 'socket.io'; import { Server } from "socket.io";
import { app } from './src/server/app.js'; import { app } from "./src/server/app.js";
import { client } from './src/bot/client.js'; import { client } from "./src/bot/client.js";
import { initializeEvents } from './src/bot/events.js'; import { initializeEvents } from "./src/bot/events.js";
import { initializeSocket } from './src/server/socket.js'; import { initializeSocket } from "./src/server/socket.js";
import { getAkhys, setupCronJobs } from './src/utils/index.js'; import { setupCronJobs } from "./src/utils/index.js";
// --- SERVER INITIALIZATION --- // --- SERVER INITIALIZATION ---
const PORT = process.env.PORT || 25578; const PORT = process.env.PORT || 25578;
const server = http.createServer(app); const server = http.createServer(app);
// --- SOCKET.IO INITIALIZATION --- // --- SOCKET.IO INITIALIZATION ---
const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL; const FLAPI_URL = process.env.DEV_SITE === "true" ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL;
export const io = new Server(server, { export const io = new Server(server, {
cors: { cors: {
origin: FLAPI_URL, origin: FLAPI_URL,
methods: ['GET', 'POST', 'PUT', 'OPTIONS'], methods: ["GET", "POST", "PUT", "OPTIONS"],
}, },
pingInterval: 5000, pingInterval: 5000,
pingTimeout: 5000, pingTimeout: 5000,
}); });
initializeSocket(io, client); initializeSocket(io, client);
// --- BOT INITIALIZATION --- // --- BOT INITIALIZATION ---
initializeEvents(client, io); initializeEvents(client, io);
client.login(process.env.BOT_TOKEN).then(() => { client.login(process.env.BOT_TOKEN).then(() => {
console.log(`Logged in as ${client.user.tag}`); console.log(`Logged in as ${client.user.tag}`);
console.log('[Discord Bot Events Initialized]'); console.log("[Discord Bot Events Initialized]");
}); });
// --- APP STARTUP --- // --- APP STARTUP ---
server.listen(PORT, async () => { server.listen(PORT, async () => {
console.log(`Express+Socket.IO server listening on port ${PORT}`); console.log(`Express+Socket.IO server listening on port ${PORT}`);
console.log(`[Connected with ${FLAPI_URL}]`); console.log(`[Connected with ${FLAPI_URL}]`);
// Initial data fetch and setup // Initial data fetch and setup
try { /*try {
await getAkhys(client); await getAkhys(client);
} catch (error) { } catch (error) {
console.log('Initial Fetch Error'); console.log('Initial Fetch Error');
} }*/
// Setup scheduled tasks // Setup scheduled tasks
//setupCronJobs(client, io); setupCronJobs(client, io);
console.log('[Cron Jobs Initialized]'); console.log("[Cron Jobs Initialized]");
console.log('--- FlopoBOT is ready ---'); console.log("--- FlopoBOT is ready ---");
}); });

6696
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,41 @@
{ {
"name": "t12_flopobot", "name": "t12_flopobot",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"description": "Flopobot le 2 donc en mieux", "description": "Flopobot le 2 donc en mieux",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=18.x" "node": ">=18.x"
}, },
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"register": "node commands.js", "register": "node commands.js",
"dev": "nodemon index.js" "dev": "nodemon index.js"
}, },
"author": "Milo Gourvest", "author": "Milo Gourvest",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@google/genai": "^0.8.0", "@google/genai": "^0.8.0",
"@mistralai/mistralai": "^1.6.0", "@mistralai/mistralai": "^1.6.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"better-sqlite3": "^11.9.1", "better-sqlite3": "^11.9.1",
"discord-interactions": "^4.0.0", "discord-interactions": "^4.0.0",
"discord.js": "^14.18.0", "discord.js": "^14.18.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"openai": "^4.104.0", "openai": "^4.104.0",
"pokersolver": "^2.1.4", "pokersolver": "^2.1.4",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.0" "@eslint/json": "^0.14.0",
} "eslint": "^9.39.1",
"globals": "^16.5.0",
"nodemon": "^3.0.0",
"prettier": "3.6.2"
}
} }

View File

@@ -1,11 +1,5 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": ["config:recommended", ":disableDependencyDashboard", ":preserveSemverRanges"],
"config:recommended", "ignorePaths": ["**/node_modules/**"]
":disableDependencyDashboard",
":preserveSemverRanges"
],
"ignorePaths": [
"**/node_modules/**"
]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,190 +1,381 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
export const flopoDB = new Database("flopobot.db");
export const flopoDB = new Database('flopobot.db');
export const stmtUsers = flopoDB.prepare(` export const stmtUsers = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users
id TEXT PRIMARY KEY, (
username TEXT NOT NULL, id TEXT PRIMARY KEY,
globalName TEXT, username TEXT NOT NULL,
warned BOOLEAN DEFAULT 0, globalName TEXT,
warns INTEGER DEFAULT 0, warned BOOLEAN DEFAULT 0,
allTimeWarns INTEGER DEFAULT 0, warns INTEGER DEFAULT 0,
totalRequests INTEGER DEFAULT 0, allTimeWarns INTEGER DEFAULT 0,
coins INTEGER DEFAULT 0, totalRequests INTEGER DEFAULT 0,
dailyQueried BOOLEAN DEFAULT 0, coins INTEGER DEFAULT 0,
avatarUrl TEXT DEFAULT NULL, dailyQueried BOOLEAN DEFAULT 0,
isAkhy BOOLEAN DEFAULT 0 avatarUrl TEXT DEFAULT NULL,
) isAkhy BOOLEAN DEFAULT 0
)
`); `);
stmtUsers.run(); stmtUsers.run();
export const stmtSkins = flopoDB.prepare(` export const stmtSkins = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS skins ( CREATE TABLE IF NOT EXISTS skins
uuid TEXT PRIMARY KEY, (
displayName TEXT, uuid TEXT PRIMARY KEY,
contentTierUuid TEXT, displayName TEXT,
displayIcon TEXT, contentTierUuid TEXT,
user_id TEXT REFERENCES users, displayIcon TEXT,
tierRank TEXT, user_id TEXT REFERENCES users,
tierColor TEXT, tierRank TEXT,
tierText TEXT, tierColor TEXT,
basePrice TEXT, tierText TEXT,
currentLvl INTEGER DEFAULT NULL, basePrice TEXT,
currentChroma INTEGER DEFAULT NULL, currentLvl INTEGER DEFAULT NULL,
currentPrice INTEGER DEFAULT NULL, currentChroma INTEGER DEFAULT NULL,
maxPrice INTEGER DEFAULT NULL currentPrice INTEGER DEFAULT NULL,
) maxPrice INTEGER DEFAULT NULL
)
`); `);
stmtSkins.run() stmtSkins.run();
export const insertUser = flopoDB.prepare('INSERT INTO users (id, username, globalName, warned, warns, allTimeWarns, totalRequests, avatarUrl, isAkhy) VALUES (@id, @username, @globalName, @warned, @warns, @allTimeWarns, @totalRequests, @avatarUrl, @isAkhy)'); export const insertUser = flopoDB.prepare(
export const updateUser = flopoDB.prepare('UPDATE users SET warned = @warned, warns = @warns, allTimeWarns = @allTimeWarns, totalRequests = @totalRequests WHERE id = @id'); `INSERT INTO users (id, username, globalName, warned, warns, allTimeWarns, totalRequests, avatarUrl, isAkhy)
export const updateUserAvatar = flopoDB.prepare('UPDATE users SET avatarUrl = @avatarUrl WHERE id = @id'); VALUES (@id, @username, @globalName, @warned, @warns, @allTimeWarns, @totalRequests, @avatarUrl, @isAkhy)`,
export const queryDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 1 WHERE id = ?`); );
export const resetDailyReward = flopoDB.prepare(`UPDATE users SET dailyQueried = 0`); export const updateUser = flopoDB.prepare(
export const updateUserCoins = flopoDB.prepare('UPDATE users SET coins = @coins WHERE id = @id'); `UPDATE users
export const getUser = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id WHERE users.id = ?'); SET warned = @warned,
export const getAllUsers = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id ORDER BY coins DESC'); warns = @warns,
export const getAllAkhys = flopoDB.prepare('SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id WHERE isAkhy = 1 ORDER BY coins DESC'); allTimeWarns = @allTimeWarns,
totalRequests = @totalRequests
WHERE id = @id`,
);
export const updateUserAvatar = flopoDB.prepare("UPDATE users SET avatarUrl = @avatarUrl 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`);
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 getAllAkhys = flopoDB.prepare(
"SELECT users.*,elos.elo FROM users LEFT JOIN elos ON elos.id = users.id WHERE isAkhy = 1 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 insertSkin = flopoDB.prepare(
export const updateSkin = flopoDB.prepare('UPDATE skins SET user_id = @user_id, currentLvl = @currentLvl, currentChroma = @currentChroma, currentPrice = @currentPrice WHERE uuid = @uuid'); `INSERT INTO skins (uuid, displayName, contentTierUuid, displayIcon, user_id, tierRank, tierColor, tierText,
export const hardUpdateSkin = flopoDB.prepare('UPDATE skins SET displayName = @displayName, contentTierUuid = @contentTierUuid, displayIcon = @displayIcon, tierRank = @tierRank, tierColor = @tierColor, tierText = @tierText, basePrice = @basePrice, user_id = @user_id, currentLvl = @currentLvl, currentChroma = @currentChroma, currentPrice = @currentPrice, maxPrice = @maxPrice WHERE uuid = @uuid'); basePrice, currentLvl, currentChroma, currentPrice, maxPrice)
export const getSkin = flopoDB.prepare('SELECT * FROM skins WHERE uuid = ?'); VALUES (@uuid, @displayName, @contentTierUuid, @displayIcon, @user_id, @tierRank, @tierColor, @tierText,
export const getAllSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC'); @basePrice, @currentLvl, @currentChroma, @currentPrice, @maxPrice)`,
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 updateSkin = flopoDB.prepare(
export const getTopSkins = flopoDB.prepare('SELECT * FROM skins ORDER BY maxPrice DESC LIMIT 10'); `UPDATE skins
SET user_id = @user_id,
currentLvl = @currentLvl,
currentChroma = @currentChroma,
currentPrice = @currentPrice
WHERE uuid = @uuid`,
);
export const hardUpdateSkin = flopoDB.prepare(
`UPDATE skins
SET displayName = @displayName,
contentTierUuid = @contentTierUuid,
displayIcon = @displayIcon,
tierRank = @tierRank,
tierColor = @tierColor,
tierText = @tierText,
basePrice = @basePrice,
user_id = @user_id,
currentLvl = @currentLvl,
currentChroma = @currentChroma,
currentPrice = @currentPrice,
maxPrice = @maxPrice
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 stmtMarketOffers = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS market_offers
(
id PRIMARY KEY,
skin_uuid TEXT REFERENCES skins,
seller_id TEXT REFERENCES users,
starting_price INTEGER NOT NULL,
buyout_price INTEGER DEFAULT NULL,
final_price INTEGER DEFAULT NULL,
status TEXT NOT NULL,
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
opening_at TIMESTAMP NOT NULL,
closing_at TIMESTAMP NOT NULL,
buyer_id TEXT REFERENCES users DEFAULT NULL
)
`);
stmtMarketOffers.run();
export const stmtBids = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS bids
(
id PRIMARY KEY,
bidder_id TEXT REFERENCES users,
market_offer_id REFERENCES market_offers,
offer_amount INTEGER,
offered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
stmtBids.run();
export const getMarketOffers = flopoDB.prepare(`
SELECT market_offers.*,
skins.displayName AS skinName,
skins.displayIcon AS skinIcon,
seller.username AS sellerName,
seller.globalName AS sellerGlobalName,
buyer.username AS buyerName,
buyer.globalName AS buyerGlobalName
FROM market_offers
JOIN skins ON skins.uuid = market_offers.skin_uuid
JOIN users AS seller ON seller.id = market_offers.seller_id
LEFT JOIN users AS buyer ON buyer.id = market_offers.buyer_id
ORDER BY market_offers.posted_at DESC
`);
export const getMarketOfferById = flopoDB.prepare(`
SELECT market_offers.*,
skins.displayName AS skinName,
skins.displayIcon AS skinIcon,
seller.username AS sellerName,
seller.globalName AS sellerGlobalName,
buyer.username AS buyerName,
buyer.globalName AS buyerGlobalName
FROM market_offers
JOIN skins ON skins.uuid = market_offers.skin_uuid
JOIN users AS seller ON seller.id = market_offers.seller_id
LEFT JOIN users AS buyer ON buyer.id = market_offers.buyer_id
WHERE market_offers.id = ?
`);
export const insertMarketOffer = flopoDB.prepare(`
INSERT INTO market_offers (id, skin_uuid, seller_id, starting_price, buyout_price, status, opening_at, closing_at)
VALUES (@id, @skin_uuid, @seller_id, @starting_price, @buyout_price, @status, @opening_at, @closing_at)
`);
export const getBids = flopoDB.prepare(`
SELECT bids.*,
bidder.username AS bidderName,
bidder.globalName AS bidderGlobalName
FROM bids
JOIN users AS bidder ON bidder.id = bids.bidder_id
ORDER BY bids.offer_amount DESC, bids.offered_at ASC
`);
export const getBidById = flopoDB.prepare(`
SELECT bids.*
FROM bids
WHERE bids.id = ?
`);
export const getOfferBids = flopoDB.prepare(`
SELECT bids.*
FROM bids
WHERE bids.market_offer_id = ?
ORDER BY bids.offer_amount DESC, bids.offered_at ASC
`);
export const insertBid = flopoDB.prepare(`
INSERT INTO bids (id, bidder_id, market_offer_id, offer_amount)
VALUES (@id, @bidder_id, @market_offer_id, @offer_amount)
`);
export const insertManyUsers = flopoDB.transaction(async (users) => { export const insertManyUsers = flopoDB.transaction(async (users) => {
for (const user of users) try { await insertUser.run(user) } catch (e) { /**/ } for (const user of users)
try {
await insertUser.run(user);
} catch (e) {}
}); });
export const updateManyUsers = flopoDB.transaction(async (users) => { export const updateManyUsers = flopoDB.transaction(async (users) => {
for (const user of users) try { await updateUser.run(user) } catch (e) { console.log('user update failed') } for (const user of users)
try {
await updateUser.run(user);
} catch (e) {
console.log("user update failed");
}
}); });
export const insertManySkins = flopoDB.transaction(async (skins) => { export const insertManySkins = flopoDB.transaction(async (skins) => {
for (const skin of skins) try { await insertSkin.run(skin) } catch (e) {} for (const skin of skins)
try {
await insertSkin.run(skin);
} catch (e) {}
}); });
export const updateManySkins = flopoDB.transaction(async (skins) => { export const updateManySkins = flopoDB.transaction(async (skins) => {
for (const skin of skins) try { await updateSkin.run(skin) } catch (e) {} for (const skin of skins)
try {
await updateSkin.run(skin);
} catch (e) {}
}); });
export const stmtLogs = flopoDB.prepare(` export const stmtLogs = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS logs ( CREATE TABLE IF NOT EXISTS logs
id PRIMARY KEY, (
user_id TEXT REFERENCES users, id PRIMARY KEY,
action TEXT, user_id TEXT REFERENCES users,
target_user_id TEXT REFERENCES users, action TEXT,
coins_amount INTEGER, target_user_id TEXT REFERENCES users,
user_new_amount INTEGER coins_amount INTEGER,
) user_new_amount INTEGER
)
`); `);
stmtLogs.run() 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 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(` export const stmtGames = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS games ( CREATE TABLE IF NOT EXISTS games
id PRIMARY KEY, (
p1 TEXT REFERENCES users, id PRIMARY KEY,
p2 TEXT REFERENCES users, p1 TEXT REFERENCES users,
p1_score INTEGER, p2 TEXT REFERENCES users,
p2_score INTEGER, p1_score INTEGER,
p1_elo INTEGER, p2_score INTEGER,
p2_elo INTEGER, p1_elo INTEGER,
p1_new_elo INTEGER, p2_elo INTEGER,
p2_new_elo INTEGER, p1_new_elo INTEGER,
type TEXT, p2_new_elo INTEGER,
timestamp TIMESTAMP type TEXT,
) timestamp TIMESTAMP
)
`); `);
stmtGames.run() 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 ORDER BY timestamp');
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 ORDER BY timestamp",
);
export const stmtElos = flopoDB.prepare(` export const stmtElos = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS elos ( CREATE TABLE IF NOT EXISTS elos
id PRIMARY KEY REFERENCES users, (
elo INTEGER id PRIMARY KEY REFERENCES users,
) elo INTEGER
)
`); `);
stmtElos.run() stmtElos.run();
export const insertElos = flopoDB.prepare(`INSERT INTO elos (id, elo) VALUES (@id, @elo)`); export const insertElos = flopoDB.prepare(`INSERT INTO elos (id, elo)
export const getElos = flopoDB.prepare(`SELECT * FROM elos`); VALUES (@id, @elo)`);
export const getUserElo = flopoDB.prepare(`SELECT * FROM elos WHERE id = @id`); export const getElos = flopoDB.prepare(`SELECT *
export const updateElo = flopoDB.prepare('UPDATE elos SET elo = @elo WHERE id = @id'); 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(
export const getUsersByElo = flopoDB.prepare('SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC') "SELECT * FROM users JOIN elos ON elos.id = users.id ORDER BY elos.elo DESC",
);
export const stmtSOTD = flopoDB.prepare(` export const stmtSOTD = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS sotd ( CREATE TABLE IF NOT EXISTS sotd
id INT PRIMARY KEY, (
tableauPiles TEXT, id INT PRIMARY KEY,
foundationPiles TEXT, tableauPiles TEXT,
stockPile TEXT, foundationPiles TEXT,
wastePile TEXT, stockPile TEXT,
isDone BOOLEAN DEFAULT false, wastePile TEXT,
seed TEXT isDone BOOLEAN DEFAULT false,
) seed TEXT
)
`); `);
stmtSOTD.run() stmtSOTD.run();
export const getSOTD = flopoDB.prepare(`SELECT * FROM sotd WHERE id = '0'`) export const getSOTD = flopoDB.prepare(`SELECT *
export const insertSOTD = flopoDB.prepare(`INSERT INTO sotd (id, tableauPiles, foundationPiles, stockPile, wastePile, seed) VALUES (0, @tableauPiles, @foundationPiles, @stockPile, @wastePile, @seed)`) FROM sotd
export const deleteSOTD = flopoDB.prepare(`DELETE FROM sotd WHERE id = '0'`) WHERE id = '0'`);
export const insertSOTD =
flopoDB.prepare(`INSERT INTO sotd (id, tableauPiles, foundationPiles, stockPile, wastePile, seed)
VALUES (0, @tableauPiles, @foundationPiles, @stockPile, @wastePile, @seed)`);
export const deleteSOTD = flopoDB.prepare(`DELETE
FROM sotd
WHERE id = '0'`);
export const stmtSOTDStats = flopoDB.prepare(` export const stmtSOTDStats = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS sotd_stats ( CREATE TABLE IF NOT EXISTS sotd_stats
id TEXT PRIMARY KEY, (
user_id TEXT REFERENCES users, id TEXT PRIMARY KEY,
time INTEGER, user_id TEXT REFERENCES users,
moves INTEGER, time INTEGER,
score INTEGER moves INTEGER,
) score INTEGER
)
`); `);
stmtSOTDStats.run() 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 getAllSOTDStats = flopoDB.prepare(`SELECT sotd_stats.*, users.globalName
export const getUserSOTDStats = flopoDB.prepare(`SELECT * FROM sotd_stats WHERE user_id = ?`); FROM sotd_stats
export const insertSOTDStats = flopoDB.prepare(`INSERT INTO sotd_stats (id, user_id, time, moves, score) VALUES (@id, @user_id, @time, @moves, @score)`); JOIN users ON users.id = sotd_stats.user_id
export const clearSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats`); ORDER BY score DESC, moves ASC, time ASC`);
export const deleteUserSOTDStats = flopoDB.prepare(`DELETE FROM sotd_stats WHERE user_id = ?`); 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() { export async function pruneOldLogs() {
const users = flopoDB.prepare(` const users = flopoDB
SELECT user_id .prepare(
FROM logs `
GROUP BY user_id SELECT user_id
HAVING COUNT(*) > ${process.env.LOGS_BY_USER} FROM logs
`).all(); GROUP BY user_id
HAVING COUNT(*) > ${process.env.LOGS_BY_USER}
`,
)
.all();
const transaction = flopoDB.transaction(() => { const transaction = flopoDB.transaction(() => {
for (const { user_id } of users) { for (const { user_id } of users) {
flopoDB.prepare(` flopoDB
DELETE FROM logs .prepare(
WHERE id IN ( `
SELECT id FROM ( DELETE
SELECT id, FROM logs
ROW_NUMBER() OVER (ORDER BY id DESC) AS rn WHERE id IN (SELECT id
FROM logs FROM (SELECT id,
WHERE user_id = ? ROW_NUMBER() OVER (ORDER BY id DESC) AS rn
) FROM logs
WHERE rn > ${process.env.LOGS_BY_USER} WHERE user_id = ?)
) WHERE rn > ${process.env.LOGS_BY_USER})
`).run(user_id); `,
} )
}); .run(user_id);
}
});
transaction() transaction();
} }

View File

@@ -2,384 +2,425 @@
// Core blackjack helpers for a single continuous room. // Core blackjack helpers for a single continuous room.
// Inspired by your poker helpers API style. // Inspired by your poker helpers API style.
import {emitToast} from "../server/socket.js"; import { emitToast } from "../server/socket.js";
import {getUser, insertLog, updateUserCoins} from "../database/index.js"; import { getUser, insertLog, updateUserCoins } from "../database/index.js";
import {client} from "../bot/client.js"; import { client } from "../bot/client.js";
import {EmbedBuilder} from "discord.js"; import { EmbedBuilder } from "discord.js";
export const RANKS = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; export const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"];
export const SUITS = ["d","s","c","h"]; export const SUITS = ["d", "s", "c", "h"];
// Build a single 52-card deck like "Ad","Ts", etc. // Build a single 52-card deck like "Ad","Ts", etc.
export const singleDeck = RANKS.flatMap(r => SUITS.map(s => `${r}${s}`)); export const singleDeck = RANKS.flatMap((r) => SUITS.map((s) => `${r}${s}`));
export function buildShoe(decks = 6) { export function buildShoe(decks = 6) {
const shoe = []; const shoe = [];
for (let i = 0; i < decks; i++) shoe.push(...singleDeck); for (let i = 0; i < decks; i++) shoe.push(...singleDeck);
return shuffle(shoe); return shuffle(shoe);
} }
export function shuffle(arr) { export function shuffle(arr) {
// FisherYates // FisherYates
const a = [...arr]; const a = [...arr];
for (let i = a.length - 1; i > 0; i--) { for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]]; [a[i], a[j]] = [a[j], a[i]];
} }
return a; return a;
} }
// Draw one card from the shoe; if empty, caller should reshuffle at end of round. // Draw one card from the shoe; if empty, caller should reshuffle at end of round.
export function draw(shoe) { export function draw(shoe) {
return shoe.pop(); return shoe.pop();
} }
// Return an object describing the best value of a hand with flexible Aces. // Return an object describing the best value of a hand with flexible Aces.
export function handValue(cards) { export function handValue(cards) {
// Count with all aces as 11, then reduce as needed // Count with all aces as 11, then reduce as needed
let total = 0; let total = 0;
let aces = 0; let aces = 0;
for (const c of cards) { for (const c of cards) {
const r = c[0]; const r = c[0];
if (r === "A") { total += 11; aces += 1; } if (r === "A") {
else if (r === "T" || r === "J" || r === "Q" || r === "K") total += 10; total += 11;
else total += Number(r); aces += 1;
} } else if (r === "T" || r === "J" || r === "Q" || r === "K") total += 10;
while (total > 21 && aces > 0) { else total += Number(r);
total -= 10; // convert an Ace from 11 to 1 }
aces -= 1; while (total > 21 && aces > 0) {
} total -= 10; // convert an Ace from 11 to 1
const soft = (aces > 0); // if any Ace still counted as 11, it's a soft hand aces -= 1;
return { total, soft }; }
const soft = aces > 0; // if any Ace still counted as 11, it's a soft hand
return { total, soft };
} }
export function isBlackjack(cards) { export function isBlackjack(cards) {
return cards.length === 2 && handValue(cards).total === 21; return cards.length === 2 && handValue(cards).total === 21;
} }
export function isBust(cards) { export function isBust(cards) {
return handValue(cards).total > 21; return handValue(cards).total > 21;
} }
// Dealer draw rule. By default, dealer stands on soft 17 (S17). // Dealer draw rule. By default, dealer stands on soft 17 (S17).
export function dealerShouldHit(dealerCards, hitSoft17 = false) { export function dealerShouldHit(dealerCards, hitSoft17 = false) {
const v = handValue(dealerCards); const v = handValue(dealerCards);
if (v.total < 17) return true; if (v.total < 17) return true;
if (v.total === 17 && v.soft && hitSoft17) return true; if (v.total === 17 && v.soft && hitSoft17) return true;
return false; return false;
} }
// Compare a player hand to dealer and return outcome. // Compare a player hand to dealer and return outcome.
export function compareHands(playerCards, dealerCards) { export function compareHands(playerCards, dealerCards) {
const pv = handValue(playerCards).total; const pv = handValue(playerCards).total;
const dv = handValue(dealerCards).total; const dv = handValue(dealerCards).total;
if (pv > 21) return "lose"; if (pv > 21) return "lose";
if (dv > 21) return "win"; if (dv > 21) return "win";
if (pv > dv) return "win"; if (pv > dv) return "win";
if (pv < dv) return "lose"; if (pv < dv) return "lose";
return "push"; return "push";
} }
// Compute payout for a single finished hand (no splits here). // Compute payout for a single finished hand (no splits here).
// options: { blackjackPayout: 1.5, allowSurrender: false } // options: { blackjackPayout: 1.5, allowSurrender: false }
export function settleHand({ bet, playerCards, dealerCards, doubled = false, surrendered = false, blackjackPayout = 1.5 }) { export function settleHand({
if (surrendered) return { delta: -bet / 2, result: "surrender" }; bet,
playerCards,
dealerCards,
doubled = false,
surrendered = false,
blackjackPayout = 1.5,
}) {
if (surrendered) return { delta: -bet / 2, result: "surrender" };
const pBJ = isBlackjack(playerCards); const pBJ = isBlackjack(playerCards);
const dBJ = isBlackjack(dealerCards); const dBJ = isBlackjack(dealerCards);
if (pBJ && !dBJ) return { delta: bet * blackjackPayout, result: "blackjack" }; if (pBJ && !dBJ) return { delta: bet * blackjackPayout, result: "blackjack" };
if (!pBJ && dBJ) return { delta: -bet, result: "lose" }; if (!pBJ && dBJ) return { delta: -bet, result: "lose" };
if (pBJ && dBJ) return { delta: 0, result: "push" }; if (pBJ && dBJ) return { delta: 0, result: "push" };
const outcome = compareHands(playerCards, dealerCards); const outcome = compareHands(playerCards, dealerCards);
let unit = bet; let unit = bet;
if (outcome === "win") return { delta: unit, result: "win" }; if (outcome === "win") return { delta: unit, result: "win" };
if (outcome === "lose") return { delta: -unit, result: "lose" }; if (outcome === "lose") return { delta: -unit, result: "lose" };
return { delta: 0, result: "push" }; return { delta: 0, result: "push" };
} }
// Helper to decide if doubling is still allowed (first decision, 2 cards, not hit yet). // Helper to decide if doubling is still allowed (first decision, 2 cards, not hit yet).
export function canDouble(hand) { export function canDouble(hand) {
return hand.cards.length === 2 && !hand.hasActed; return hand.cards.length === 2 && !hand.hasActed;
} }
// Very small utility to format a public-safe snapshot of room state // Very small utility to format a public-safe snapshot of room state
export function publicPlayerView(player) { export function publicPlayerView(player) {
// Hide hole cards until dealer reveal is fine for dealer only; player cards are visible. // Hide hole cards until dealer reveal is fine for dealer only; player cards are visible.
return { return {
id: player.id, id: player.id,
globalName: player.globalName, globalName: player.globalName,
avatar: player.avatar, avatar: player.avatar,
bank: player.bank, bank: player.bank,
currentBet: player.currentBet, currentBet: player.currentBet,
inRound: player.inRound, inRound: player.inRound,
hands: player.hands.map(h => ({ hands: player.hands.map((h) => ({
cards: h.cards, cards: h.cards,
stood: h.stood, stood: h.stood,
busted: h.busted, busted: h.busted,
doubled: h.doubled, doubled: h.doubled,
surrendered: h.surrendered, surrendered: h.surrendered,
result: h.result ?? null, result: h.result ?? null,
total: handValue(h.cards).total, total: handValue(h.cards).total,
soft: handValue(h.cards).soft, soft: handValue(h.cards).soft,
bet: h.bet, bet: h.bet,
})), })),
}; };
} }
// Build initial room object // Build initial room object
export function createBlackjackRoom({ export function createBlackjackRoom({
minBet = 10, minBet = 10,
maxBet = 10000, maxBet = 10000,
fakeMoney = false, fakeMoney = false,
decks = 6, decks = 6,
hitSoft17 = false, hitSoft17 = false,
blackjackPayout = 1.5, blackjackPayout = 1.5,
cutCardRatio = 0.25, // reshuffle when 25% of shoe remains cutCardRatio = 0.25, // reshuffle when 25% of shoe remains
phaseDurations = { phaseDurations = {
bettingMs: 15000, bettingMs: 15000,
dealMs: 1000, dealMs: 1000,
playMsPerPlayer: 20000, playMsPerPlayer: 20000,
revealMs: 1000, revealMs: 1000,
payoutMs: 10000, payoutMs: 10000,
}, },
animation = { animation = {
dealerDrawMs: 500, dealerDrawMs: 500,
} },
} = {}) { } = {}) {
return { return {
id: "blackjack-room", id: "blackjack-room",
name: "Blackjack", name: "Blackjack",
created_at: Date.now(), created_at: Date.now(),
status: "betting", // betting | dealing | playing | dealer | payout | shuffle status: "betting", // betting | dealing | playing | dealer | payout | shuffle
phase_ends_at: Date.now() + phaseDurations.bettingMs, phase_ends_at: Date.now() + phaseDurations.bettingMs,
minBet, maxBet, fakeMoney, minBet,
settings: { decks, hitSoft17, blackjackPayout, cutCardRatio, phaseDurations, animation }, maxBet,
shoe: buildShoe(decks), fakeMoney,
discard: [], settings: {
dealer: { cards: [], holeHidden: true }, decks,
players: {}, // userId -> { id, globalName, avatar, bank, currentBet, inRound, hands: [{cards, stood, busted, doubled, surrendered, hasActed}], activeHand: 0 } hitSoft17,
leavingAfterRound: {}, blackjackPayout,
}; cutCardRatio,
phaseDurations,
animation,
},
shoe: buildShoe(decks),
discard: [],
dealer: { cards: [], holeHidden: true },
players: {}, // userId -> { id, globalName, avatar, bank, currentBet, inRound, hands: [{cards, stood, busted, doubled, surrendered, hasActed}], activeHand: 0 }
leavingAfterRound: {},
};
} }
// Reshuffle at start of the next round if the shoe is low // Reshuffle at start of the next round if the shoe is low
export function needsReshuffle(room) { export function needsReshuffle(room) {
return room.shoe.length < singleDeck.length * room.settings.decks * room.settings.cutCardRatio; return room.shoe.length < singleDeck.length * room.settings.decks * room.settings.cutCardRatio;
} }
// --- Round Lifecycle helpers --- // --- Round Lifecycle helpers ---
export function resetForNewRound(room) { export function resetForNewRound(room) {
room.status = "betting"; room.status = "betting";
room.dealer = { cards: [], holeHidden: true }; room.dealer = { cards: [], holeHidden: true };
room.leavingAfterRound = {}; room.leavingAfterRound = {};
// Clear per-round attributes on players, but keep bank and presence // Clear per-round attributes on players, but keep bank and presence
for (const p of Object.values(room.players)) { for (const p of Object.values(room.players)) {
p.inRound = false; p.inRound = false;
p.currentBet = 0; p.currentBet = 0;
p.hands = [ { cards: [], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false, bet: 0 } ]; p.hands = [
p.activeHand = 0; {
} cards: [],
stood: false,
busted: false,
doubled: false,
surrendered: false,
hasActed: false,
bet: 0,
},
];
p.activeHand = 0;
}
} }
export function startBetting(room, now) { export function startBetting(room, now) {
resetForNewRound(room); resetForNewRound(room);
if (needsReshuffle(room)) { if (needsReshuffle(room)) {
room.status = "shuffle"; room.status = "shuffle";
// quick shuffle animation phase // quick shuffle animation phase
room.shoe = buildShoe(room.settings.decks); room.shoe = buildShoe(room.settings.decks);
} }
room.status = "betting"; room.status = "betting";
room.phase_ends_at = now + room.settings.phaseDurations.bettingMs; room.phase_ends_at = now + room.settings.phaseDurations.bettingMs;
} }
export function dealInitial(room) { export function dealInitial(room) {
room.status = "dealing"; room.status = "dealing";
// Deal one to each player who placed a bet, then again, then dealer up + hole // Deal one to each player who placed a bet, then again, then dealer up + hole
const actives = Object.values(room.players).filter(p => p.currentBet >= room.minBet); const actives = Object.values(room.players).filter((p) => p.currentBet >= room.minBet);
for (const p of actives) { for (const p of actives) {
p.inRound = true; p.inRound = true;
p.hands = [ { cards: [draw(room.shoe)], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false, bet: p.currentBet } ]; p.hands = [
} {
room.dealer.cards = [draw(room.shoe), draw(room.shoe)]; cards: [draw(room.shoe)],
room.dealer.holeHidden = true; stood: false,
for (const p of actives) { busted: false,
p.hands[0].cards.push(draw(room.shoe)); doubled: false,
} surrendered: false,
room.status = "playing"; hasActed: false,
bet: p.currentBet,
},
];
}
room.dealer.cards = [draw(room.shoe), draw(room.shoe)];
room.dealer.holeHidden = true;
for (const p of actives) {
p.hands[0].cards.push(draw(room.shoe));
}
room.status = "playing";
} }
export function autoActions(room) { export function autoActions(room) {
// Auto-stand if player already blackjack // Auto-stand if player already blackjack
for (const p of Object.values(room.players)) { for (const p of Object.values(room.players)) {
if (!p.inRound) continue; if (!p.inRound) continue;
const h = p.hands[p.activeHand]; const h = p.hands[p.activeHand];
if (isBlackjack(h.cards)) { if (isBlackjack(h.cards)) {
h.stood = true; h.stood = true;
h.hasActed = true; h.hasActed = true;
} }
} }
} }
export function everyoneDone(room) { export function everyoneDone(room) {
return Object.values(room.players).every(p => { return Object.values(room.players).every((p) => {
if (!p.inRound) return true; if (!p.inRound) return true;
return p.hands.filter(h => !h.stood && !h.busted && !h.surrendered)?.length === 0; return p.hands.filter((h) => !h.stood && !h.busted && !h.surrendered)?.length === 0;
}); });
} }
export function dealerPlay(room) { export function dealerPlay(room) {
room.status = "dealer"; room.status = "dealer";
room.dealer.holeHidden = false; room.dealer.holeHidden = false;
while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) { while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) {
room.dealer.cards.push(draw(room.shoe)); room.dealer.cards.push(draw(room.shoe));
} }
} }
export async function settleAll(room) { export async function settleAll(room) {
room.status = "payout"; room.status = "payout";
const allRes = {} const allRes = {};
for (const p of Object.values(room.players)) { for (const p of Object.values(room.players)) {
if (!p.inRound) continue; if (!p.inRound) continue;
for (const hand of p.hands) { for (const hand of p.hands) {
const res = settleHand({ const res = settleHand({
bet: hand.bet, bet: hand.bet,
playerCards: hand.cards, playerCards: hand.cards,
dealerCards: room.dealer.cards, dealerCards: room.dealer.cards,
doubled: hand.doubled, doubled: hand.doubled,
surrendered: hand.surrendered, surrendered: hand.surrendered,
blackjackPayout: room.settings.blackjackPayout, blackjackPayout: room.settings.blackjackPayout,
}); });
if (allRes[p.id]) { if (allRes[p.id]) {
allRes[p.id].push(res); allRes[p.id].push(res);
} else { } else {
allRes[p.id] = [res]; allRes[p.id] = [res];
} }
p.totalDelta += res.delta p.totalDelta += res.delta;
p.totalBets++ p.totalBets++;
if (res.result === 'win' || res.result === 'push' || res.result === 'blackjack') { if (res.result === "win" || res.result === "push" || res.result === "blackjack") {
const userDB = getUser.get(p.id); const userDB = getUser.get(p.id);
if (userDB) { if (userDB) {
const coins = userDB.coins; const coins = userDB.coins;
try { try {
updateUserCoins.run({ id: p.id, coins: coins + hand.bet + res.delta }); updateUserCoins.run({
insertLog.run({ id: p.id,
id: `${p.id}-blackjack-${Date.now()}`, coins: coins + hand.bet + res.delta,
user_id: p.id, target_user_id: null, });
action: 'BLACKJACK_PAYOUT', insertLog.run({
coins_amount: res.delta + hand.bet, user_new_amount: coins + hand.bet + res.delta, id: `${p.id}-blackjack-${Date.now()}`,
}); user_id: p.id,
p.bank = coins + hand.bet + res.delta target_user_id: null,
} catch (e) { action: "BLACKJACK_PAYOUT",
console.log(e) coins_amount: res.delta + hand.bet,
} user_new_amount: coins + hand.bet + res.delta,
} });
} p.bank = coins + hand.bet + res.delta;
emitToast({ type: `payout-res`, allRes }); } catch (e) {
hand.result = res.result; console.log(e);
hand.delta = res.delta; }
try { }
const guild = await client.guilds.fetch(process.env.GUILD_ID); }
const generalChannel = guild.channels.cache.find( emitToast({ type: `payout-res`, allRes });
ch => ch.name === 'général' || ch.name === 'general' hand.result = res.result;
); hand.delta = res.delta;
const msg = await generalChannel.messages.fetch(p.msgId); try {
const updatedEmbed = new EmbedBuilder() const guild = await client.guilds.fetch(process.env.GUILD_ID);
.setDescription(`<@${p.id}> joue au Blackjack.`) const generalChannel = guild.channels.cache.find((ch) => ch.name === "général" || ch.name === "general");
.addFields( const msg = await generalChannel.messages.fetch(p.msgId);
{ const updatedEmbed = new EmbedBuilder()
name: `Gains`, .setDescription(`<@${p.id}> joue au Blackjack.`)
value: `**${p.totalDelta >= 0 ? '+' + p.totalDelta : p.totalDelta}** Flopos`, .addFields(
inline: true {
}, name: `Gains`,
{ value: `**${p.totalDelta >= 0 ? "+" + p.totalDelta : p.totalDelta}** Flopos`,
name: `Mises jouées`, inline: true,
value: `**${p.totalBets}**`, },
inline: true {
} name: `Mises jouées`,
) value: `**${p.totalBets}**`,
.setColor(p.totalDelta >= 0 ? 0x22A55B : 0xED4245) inline: true,
.setTimestamp(new Date()); },
await msg.edit({ embeds: [updatedEmbed], components: [] }); )
} catch (e) { .setColor(p.totalDelta >= 0 ? 0x22a55b : 0xed4245)
console.log(e); .setTimestamp(new Date());
} await msg.edit({ embeds: [updatedEmbed], components: [] });
} } catch (e) {
} console.log(e);
}
}
}
} }
// Apply a player decision; returns a string event or throws on invalid. // Apply a player decision; returns a string event or throws on invalid.
export function applyAction(room, playerId, action) { export function applyAction(room, playerId, action) {
const p = room.players[playerId]; const p = room.players[playerId];
if (!p || !p.inRound || room.status !== "playing") throw new Error("Not allowed"); if (!p || !p.inRound || room.status !== "playing") throw new Error("Not allowed");
const hand = p.hands[p.activeHand]; const hand = p.hands[p.activeHand];
switch (action) { switch (action) {
case "hit": { case "hit": {
if (hand.stood || hand.busted) throw new Error("Already ended"); if (hand.stood || hand.busted) throw new Error("Already ended");
hand.hasActed = true; hand.hasActed = true;
hand.cards.push(draw(room.shoe)); hand.cards.push(draw(room.shoe));
if (isBust(hand.cards)) hand.busted = true; if (isBust(hand.cards)) hand.busted = true;
return "hit"; return "hit";
} }
case "stand": { case "stand": {
hand.stood = true; hand.stood = true;
hand.hasActed = true; hand.hasActed = true;
p.activeHand++; p.activeHand++;
return "stand"; return "stand";
} }
case "double": { case "double": {
if (!canDouble(hand)) throw new Error("Cannot double now"); if (!canDouble(hand)) throw new Error("Cannot double now");
hand.doubled = true; hand.doubled = true;
hand.bet*=2 hand.bet *= 2;
p.currentBet+=hand.bet/2 p.currentBet += hand.bet / 2;
hand.hasActed = true; hand.hasActed = true;
// The caller (routes) must also handle additional balance lock on the bet if using real coins // The caller (routes) must also handle additional balance lock on the bet if using real coins
hand.cards.push(draw(room.shoe)); hand.cards.push(draw(room.shoe));
if (isBust(hand.cards)) hand.busted = true; if (isBust(hand.cards)) hand.busted = true;
else hand.stood = true; else hand.stood = true;
p.activeHand++; p.activeHand++;
return "double"; return "double";
} }
case "split": { case "split": {
if (hand.cards.length !== 2) throw new Error("Cannot split: not exactly 2 cards"); if (hand.cards.length !== 2) throw new Error("Cannot split: not exactly 2 cards");
const r0 = hand.cards[0][0]; const r0 = hand.cards[0][0];
const r1 = hand.cards[1][0]; const r1 = hand.cards[1][0];
if (r0 !== r1) throw new Error("Cannot split: cards not same rank"); if (r0 !== r1) throw new Error("Cannot split: cards not same rank");
const cardA = hand.cards[0]; const cardA = hand.cards[0];
const cardB = hand.cards[1]; const cardB = hand.cards[1];
hand.cards = [cardA]; hand.cards = [cardA];
hand.stood = false; hand.stood = false;
hand.busted = false; hand.busted = false;
hand.doubled = false; hand.doubled = false;
hand.surrendered = false; hand.surrendered = false;
hand.hasActed = false; hand.hasActed = false;
const newHand = { const newHand = {
cards: [cardB], cards: [cardB],
stood: false, stood: false,
busted: false, busted: false,
doubled: false, doubled: false,
surrendered: false, surrendered: false,
hasActed: false, hasActed: false,
bet: hand.bet, bet: hand.bet,
} };
p.currentBet *= 2 p.currentBet *= 2;
p.hands.splice(p.activeHand + 1, 0, newHand); p.hands.splice(p.activeHand + 1, 0, newHand);
hand.cards.push(draw(room.shoe)); hand.cards.push(draw(room.shoe));
newHand.cards.push(draw(room.shoe)); newHand.cards.push(draw(room.shoe));
return "split"; return "split";
} }
default: default:
throw new Error("Invalid action"); throw new Error("Invalid action");
} }
} }

View File

@@ -1,12 +1,6 @@
import { import { getUser, getUserElo, insertElos, updateElo, insertGame } from "../database/index.js";
getUser, import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
getUserElo, import { client } from "../bot/client.js";
insertElos,
updateElo,
insertGame,
} from '../database/index.js';
import {ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder} from "discord.js";
import {client} from "../bot/client.js";
/** /**
* Handles Elo calculation for a standard 1v1 game. * Handles Elo calculation for a standard 1v1 game.
@@ -17,81 +11,85 @@ import {client} from "../bot/client.js";
* @param {string} type - The type of game being played (e.g., 'TICTACTOE', 'CONNECT4'). * @param {string} type - The type of game being played (e.g., 'TICTACTOE', 'CONNECT4').
*/ */
export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) { export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
// --- 1. Fetch Player Data --- // --- 1. Fetch Player Data ---
const p1DB = getUser.get(p1Id); const p1DB = getUser.get(p1Id);
const p2DB = getUser.get(p2Id); const p2DB = getUser.get(p2Id);
if (!p1DB || !p2DB) { if (!p1DB || !p2DB) {
console.error(`Elo Handler: Could not find user data for ${p1Id} or ${p2Id}.`); console.error(`Elo Handler: Could not find user data for ${p1Id} or ${p2Id}.`);
return; return;
} }
let p1EloData = getUserElo.get({ id: p1Id }); let p1EloData = getUserElo.get({ id: p1Id });
let p2EloData = getUserElo.get({ id: p2Id }); let p2EloData = getUserElo.get({ id: p2Id });
// --- 2. Initialize Elo if it doesn't exist --- // --- 2. Initialize Elo if it doesn't exist ---
if (!p1EloData) { if (!p1EloData) {
await insertElos.run({ id: p1Id, elo: 1000 }); await insertElos.run({ id: p1Id, elo: 1000 });
p1EloData = { id: p1Id, elo: 1000 }; p1EloData = { id: p1Id, elo: 1000 };
} }
if (!p2EloData) { if (!p2EloData) {
await insertElos.run({ id: p2Id, elo: 1000 }); await insertElos.run({ id: p2Id, elo: 1000 });
p2EloData = { id: p2Id, elo: 1000 }; p2EloData = { id: p2Id, elo: 1000 };
} }
const p1CurrentElo = p1EloData.elo; const p1CurrentElo = p1EloData.elo;
const p2CurrentElo = p2EloData.elo; const p2CurrentElo = p2EloData.elo;
// --- 3. Calculate Elo Change --- // --- 3. Calculate Elo Change ---
// The K-factor determines how much the Elo rating changes after a game. // The K-factor determines how much the Elo rating changes after a game.
const K_FACTOR = 32; const K_FACTOR = 32;
// Calculate expected scores // Calculate expected scores
const expectedP1 = 1 / (1 + Math.pow(10, (p2CurrentElo - p1CurrentElo) / 400)); const expectedP1 = 1 / (1 + Math.pow(10, (p2CurrentElo - p1CurrentElo) / 400));
const expectedP2 = 1 / (1 + Math.pow(10, (p1CurrentElo - p2CurrentElo) / 400)); const expectedP2 = 1 / (1 + Math.pow(10, (p1CurrentElo - p2CurrentElo) / 400));
// Calculate new Elo ratings // Calculate new Elo ratings
const p1NewElo = Math.round(p1CurrentElo + K_FACTOR * (p1Score - expectedP1)); const p1NewElo = Math.round(p1CurrentElo + K_FACTOR * (p1Score - expectedP1));
const p2NewElo = Math.round(p2CurrentElo + K_FACTOR * (p2Score - expectedP2)); const p2NewElo = Math.round(p2CurrentElo + K_FACTOR * (p2Score - expectedP2));
// Ensure Elo doesn't drop below a certain threshold (e.g., 100) // Ensure Elo doesn't drop below a certain threshold (e.g., 100)
const finalP1Elo = Math.max(0, p1NewElo); const finalP1Elo = Math.max(0, p1NewElo);
const finalP2Elo = Math.max(0, p2NewElo); const finalP2Elo = Math.max(0, p2NewElo);
console.log(`Elo Update (${type}) for ${p1DB.globalName}: ${p1CurrentElo} -> ${finalP1Elo}`); console.log(`Elo Update (${type}) for ${p1DB.globalName}: ${p1CurrentElo} -> ${finalP1Elo}`);
console.log(`Elo Update (${type}) for ${p2DB.globalName}: ${p2CurrentElo} -> ${finalP2Elo}`); console.log(`Elo Update (${type}) for ${p2DB.globalName}: ${p2CurrentElo} -> ${finalP2Elo}`);
try { try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user1 = await client.users.fetch(p1Id); const user1 = await client.users.fetch(p1Id);
const user2 = await client.users.fetch(p2Id); const user2 = await client.users.fetch(p2Id);
const diff1 = finalP1Elo - p1CurrentElo; const diff1 = finalP1Elo - p1CurrentElo;
const diff2 = finalP2Elo - p2CurrentElo; const diff2 = finalP2Elo - p2CurrentElo;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`FlopoRank - ${type}`) .setTitle(`FlopoRank - ${type}`)
.setDescription(` .setDescription(
**${user1.globalName || user1.username}** a ${diff1 > 0 ? 'gagné' : 'perdu'} **${Math.abs(diff1)}** elo 🏆 ${p1CurrentElo} ${diff1 > 0 ? '↗️' : '↘️'} **${finalP1Elo}**\n `
**${user2.globalName || user2.username}** a ${diff2 > 0 ? 'gagné' : 'perdu'} **${Math.abs(diff2)}** elo 🏆 ${p2CurrentElo} ${diff2 > 0 ? '↗️' : '↘️'} **${finalP2Elo}**\n **${user1.globalName || user1.username}** a ${diff1 > 0 ? "gagné" : "perdu"} **${Math.abs(diff1)}** elo 🏆 ${p1CurrentElo} ${diff1 > 0 ? "↗️" : "↘️"} **${finalP1Elo}**\n
`) **${user2.globalName || user2.username}** a ${diff2 > 0 ? "gagné" : "perdu"} **${Math.abs(diff2)}** elo 🏆 ${p2CurrentElo} ${diff2 > 0 ? "↗️" : "↘️"} **${finalP2Elo}**\n
.setColor('#5865f2'); `,
await generalChannel.send({ embeds: [embed] }); )
} catch (e) { console.error(`Failed to post elo update message`, e); } .setColor("#5865f2");
await generalChannel.send({ embeds: [embed] });
} catch (e) {
console.error(`Failed to post elo update message`, e);
}
// --- 4. Update Database --- // --- 4. Update Database ---
updateElo.run({ id: p1Id, elo: finalP1Elo }); updateElo.run({ id: p1Id, elo: finalP1Elo });
updateElo.run({ id: p2Id, elo: finalP2Elo }); updateElo.run({ id: p2Id, elo: finalP2Elo });
insertGame.run({ insertGame.run({
id: `${p1Id}-${p2Id}-${Date.now()}`, id: `${p1Id}-${p2Id}-${Date.now()}`,
p1: p1Id, p1: p1Id,
p2: p2Id, p2: p2Id,
p1_score: p1Score, p1_score: p1Score,
p2_score: p2Score, p2_score: p2Score,
p1_elo: p1CurrentElo, p1_elo: p1CurrentElo,
p2_elo: p2CurrentElo, p2_elo: p2CurrentElo,
p1_new_elo: finalP1Elo, p1_new_elo: finalP1Elo,
p2_new_elo: finalP2Elo, p2_new_elo: finalP2Elo,
type: type, type: type,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
/** /**
@@ -99,64 +97,66 @@ export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
* @param {object} room - The poker room object containing player and winner info. * @param {object} room - The poker room object containing player and winner info.
*/ */
export async function pokerEloHandler(room) { export async function pokerEloHandler(room) {
if (room.fakeMoney) { if (room.fakeMoney) {
console.log("Skipping Elo update for fake money poker game."); console.log("Skipping Elo update for fake money poker game.");
return; return;
} }
const playerIds = Object.keys(room.players); const playerIds = Object.keys(room.players);
if (playerIds.length < 2) return; // Not enough players to calculate Elo if (playerIds.length < 2) return; // Not enough players to calculate Elo
// Fetch all players' Elo data at once // Fetch all players' Elo data at once
const dbPlayers = playerIds.map(id => { const dbPlayers = playerIds.map((id) => {
const user = getUser.get(id); const user = getUser.get(id);
const elo = getUserElo.get({ id })?.elo || 1000; const elo = getUserElo.get({ id })?.elo || 1000;
return { ...user, elo }; return { ...user, elo };
}); });
const winnerIds = new Set(room.winners); const winnerIds = new Set(room.winners);
const playerCount = dbPlayers.length; const playerCount = dbPlayers.length;
const K_BASE = 16; // A lower K-factor is often used for multi-player games 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; const averageElo = dbPlayers.reduce((sum, p) => sum + p.elo, 0) / playerCount;
dbPlayers.forEach(player => { dbPlayers.forEach((player) => {
// Expected score is the chance of winning against an "average" player from the field // 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)); const expectedScore = 1 / (1 + Math.pow(10, (averageElo - player.elo) / 400));
// Determine actual score // Determine actual score
let actualScore; let actualScore;
if (winnerIds.has(player.id)) { if (winnerIds.has(player.id)) {
// Winners share the "win" points // Winners share the "win" points
actualScore = 1 / winnerIds.size; actualScore = 1 / winnerIds.size;
} else { } else {
actualScore = 0; actualScore = 0;
} }
// Dynamic K-factor: higher impact for more significant results // Dynamic K-factor: higher impact for more significant results
const kFactor = K_BASE * playerCount; const kFactor = K_BASE * playerCount;
const eloChange = kFactor * (actualScore - expectedScore); const eloChange = kFactor * (actualScore - expectedScore);
const newElo = Math.max(100, Math.round(player.elo + eloChange)); const newElo = Math.max(100, Math.round(player.elo + eloChange));
if (!isNaN(newElo)) { if (!isNaN(newElo)) {
console.log(`Elo Update (POKER) for ${player.globalName}: ${player.elo} -> ${newElo} (Δ: ${eloChange.toFixed(2)})`); console.log(
updateElo.run({ id: player.id, elo: newElo }); `Elo Update (POKER) for ${player.globalName}: ${player.elo} -> ${newElo} (Δ: ${eloChange.toFixed(2)})`,
);
updateElo.run({ id: player.id, elo: newElo });
insertGame.run({ insertGame.run({
id: `${player.id}-poker-${Date.now()}`, id: `${player.id}-poker-${Date.now()}`,
p1: player.id, p1: player.id,
p2: null, // No single opponent p2: null, // No single opponent
p1_score: actualScore, p1_score: actualScore,
p2_score: null, p2_score: null,
p1_elo: player.elo, p1_elo: player.elo,
p2_elo: Math.round(averageElo), // Log the average opponent Elo for context p2_elo: Math.round(averageElo), // Log the average opponent Elo for context
p1_new_elo: newElo, p1_new_elo: newElo,
p2_new_elo: null, p2_new_elo: null,
type: 'POKER_ROUND', type: "POKER_ROUND",
timestamp: Date.now(), timestamp: Date.now(),
}); });
} else { } else {
console.error(`Error calculating new Elo for ${player.globalName}.`); console.error(`Error calculating new Elo for ${player.globalName}.`);
} }
}); });
} }

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
// --- Constants for Deck Creation --- // --- Constants for Deck Creation ---
import {sleep} from "openai/core"; import { sleep } from "openai/core";
import {emitSolitaireUpdate, emitUpdate} from "../server/socket.js"; import { emitSolitaireUpdate, emitUpdate } from "../server/socket.js";
const SUITS = ['h', 'd', 's', 'c']; // Hearts, Diamonds, Spades, Clubs 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']; const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"];
// --- Helper Functions for Card Logic --- // --- Helper Functions for Card Logic ---
@@ -13,12 +13,12 @@ const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K'];
* @returns {number} The numeric value (Ace=1, King=13). * @returns {number} The numeric value (Ace=1, King=13).
*/ */
function getRankValue(rank) { function getRankValue(rank) {
if (rank === 'A') return 1; if (rank === "A") return 1;
if (rank === 'T') return 10; if (rank === "T") return 10;
if (rank === 'J') return 11; if (rank === "J") return 11;
if (rank === 'Q') return 12; if (rank === "Q") return 12;
if (rank === 'K') return 13; if (rank === "K") return 13;
return parseInt(rank, 10); return parseInt(rank, 10);
} }
/** /**
@@ -27,10 +27,9 @@ function getRankValue(rank) {
* @returns {string} 'red' or 'black'. * @returns {string} 'red' or 'black'.
*/ */
function getCardColor(suit) { function getCardColor(suit) {
return (suit === 'h' || suit === 'd') ? 'red' : 'black'; return suit === "h" || suit === "d" ? "red" : "black";
} }
// --- Core Game Logic Functions --- // --- Core Game Logic Functions ---
/** /**
@@ -38,13 +37,13 @@ function getCardColor(suit) {
* @returns {Array<Object>} The unshuffled deck of cards. * @returns {Array<Object>} The unshuffled deck of cards.
*/ */
export function createDeck() { export function createDeck() {
const deck = []; const deck = [];
for (const suit of SUITS) { for (const suit of SUITS) {
for (const rank of RANKS) { for (const rank of RANKS) {
deck.push({ suit, rank, faceUp: false }); deck.push({ suit, rank, faceUp: false });
} }
} }
return deck; return deck;
} }
/** /**
@@ -53,16 +52,16 @@ export function createDeck() {
* @returns {Array} The shuffled array (mutated in place). * @returns {Array} The shuffled array (mutated in place).
*/ */
export function shuffle(array) { export function shuffle(array) {
let currentIndex = array.length; let currentIndex = array.length;
// While there remain elements to shuffle. // While there remain elements to shuffle.
while (currentIndex !== 0) { while (currentIndex !== 0) {
// Pick a remaining element. // Pick a remaining element.
const randomIndex = Math.floor(Math.random() * currentIndex); const randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--; currentIndex--;
// And swap it with the current element. // And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]; [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
} }
return array; return array;
} }
/** /**
@@ -71,12 +70,12 @@ export function shuffle(array) {
* @returns {function} A function that returns a pseudorandom number between 0 and 1. * @returns {function} A function that returns a pseudorandom number between 0 and 1.
*/ */
export function createSeededRNG(seed) { export function createSeededRNG(seed) {
return function() { return function () {
let t = seed += 0x6D2B79F5; let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ t >>> 15, t | 1); t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61); t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}; };
} }
/** /**
@@ -86,16 +85,16 @@ export function createSeededRNG(seed) {
* @returns {Array} The shuffled array (mutated in place). * @returns {Array} The shuffled array (mutated in place).
*/ */
export function seededShuffle(array, rng) { export function seededShuffle(array, rng) {
let currentIndex = array.length; let currentIndex = array.length;
// While there remain elements to shuffle. // While there remain elements to shuffle.
while (currentIndex !== 0) { while (currentIndex !== 0) {
// Pick a remaining element using the seeded RNG. // Pick a remaining element using the seeded RNG.
const randomIndex = Math.floor(rng() * currentIndex); const randomIndex = Math.floor(rng() * currentIndex);
currentIndex--; currentIndex--;
// And swap it with the current element. // And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]]; [array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
} }
return array; return array;
} }
/** /**
@@ -104,31 +103,31 @@ export function seededShuffle(array, rng) {
* @returns {Object} The initial gameState object for Klondike Solitaire. * @returns {Object} The initial gameState object for Klondike Solitaire.
*/ */
export function deal(deck) { export function deal(deck) {
const gameState = { const gameState = {
tableauPiles: [[], [], [], [], [], [], []], tableauPiles: [[], [], [], [], [], [], []],
foundationPiles: [[], [], [], []], foundationPiles: [[], [], [], []],
stockPile: [], stockPile: [],
wastePile: [], wastePile: [],
}; };
// Deal cards to the 7 tableau piles // Deal cards to the 7 tableau piles
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
for (let j = i; j < 7; j++) { for (let j = i; j < 7; j++) {
gameState.tableauPiles[j].push(deck.shift()); gameState.tableauPiles[j].push(deck.shift());
} }
} }
// Flip the top card of each tableau pile // Flip the top card of each tableau pile
gameState.tableauPiles.forEach(pile => { gameState.tableauPiles.forEach((pile) => {
if (pile.length > 0) { if (pile.length > 0) {
pile[pile.length - 1].faceUp = true; pile[pile.length - 1].faceUp = true;
} }
}); });
// The rest of the deck becomes the stock // The rest of the deck becomes the stock
gameState.stockPile = deck; gameState.stockPile = deck;
return gameState; return gameState;
} }
/** /**
@@ -138,59 +137,59 @@ export function deal(deck) {
* @returns {boolean} True if the move is valid, false otherwise. * @returns {boolean} True if the move is valid, false otherwise.
*/ */
export function isValidMove(gameState, moveData) { export function isValidMove(gameState, moveData) {
const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData; const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData;
// --- Get Source Pile and Card --- // --- Get Source Pile and Card ---
let sourcePile; let sourcePile;
if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex]; if (sourcePileType === "tableauPiles") sourcePile = gameState.tableauPiles[sourcePileIndex];
else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile; else if (sourcePileType === "wastePile") sourcePile = gameState.wastePile;
else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex]; else if (sourcePileType === "foundationPiles") sourcePile = gameState.foundationPiles[sourcePileIndex];
else return false; // Invalid source type else return false; // Invalid source type
const sourceCard = sourcePile?.[sourceCardIndex]; const sourceCard = sourcePile?.[sourceCardIndex];
if (!sourceCard || !sourceCard.faceUp) { if (!sourceCard || !sourceCard.faceUp) {
return false; // Cannot move a card that doesn't exist or is face-down return false; // Cannot move a card that doesn't exist or is face-down
} }
// --- Validate Move TO a Tableau Pile --- // --- Validate Move TO a Tableau Pile ---
if (destPileType === 'tableauPiles') { if (destPileType === "tableauPiles") {
const destinationPile = gameState.tableauPiles[destPileIndex]; const destinationPile = gameState.tableauPiles[destPileIndex];
const topCard = destinationPile[destinationPile.length - 1]; const topCard = destinationPile[destinationPile.length - 1];
if (!topCard) { if (!topCard) {
// If the destination tableau is empty, only a King can be moved there. // If the destination tableau is empty, only a King can be moved there.
return sourceCard.rank === 'K'; return sourceCard.rank === "K";
} }
// Card must be opposite color and one rank lower than the destination top card. // Card must be opposite color and one rank lower than the destination top card.
const sourceColor = getCardColor(sourceCard.suit); const sourceColor = getCardColor(sourceCard.suit);
const destColor = getCardColor(topCard.suit); const destColor = getCardColor(topCard.suit);
const sourceValue = getRankValue(sourceCard.rank); const sourceValue = getRankValue(sourceCard.rank);
const destValue = getRankValue(topCard.rank); const destValue = getRankValue(topCard.rank);
return sourceColor !== destColor && destValue - sourceValue === 1; return sourceColor !== destColor && destValue - sourceValue === 1;
} }
// --- Validate Move TO a Foundation Pile --- // --- Validate Move TO a Foundation Pile ---
if (destPileType === 'foundationPiles') { if (destPileType === "foundationPiles") {
// You can only move one card at a time to a foundation pile. // You can only move one card at a time to a foundation pile.
const stackBeingMoved = sourcePile.slice(sourceCardIndex); const stackBeingMoved = sourcePile.slice(sourceCardIndex);
if (stackBeingMoved.length > 1) return false; if (stackBeingMoved.length > 1) return false;
const destinationPile = gameState.foundationPiles[destPileIndex]; const destinationPile = gameState.foundationPiles[destPileIndex];
const topCard = destinationPile[destinationPile.length - 1]; const topCard = destinationPile[destinationPile.length - 1];
if (!topCard) { if (!topCard) {
// If the foundation is empty, only an Ace of any suit can be moved there. // If the foundation is empty, only an Ace of any suit can be moved there.
return sourceCard.rank === 'A'; return sourceCard.rank === "A";
} }
// Card must be the same suit and one rank higher. // Card must be the same suit and one rank higher.
const sourceValue = getRankValue(sourceCard.rank); const sourceValue = getRankValue(sourceCard.rank);
const destValue = getRankValue(topCard.rank); const destValue = getRankValue(topCard.rank);
return sourceCard.suit === topCard.suit && sourceValue - destValue === 1; return sourceCard.suit === topCard.suit && sourceValue - destValue === 1;
} }
return false; // Invalid destination type return false; // Invalid destination type
} }
/** /**
@@ -199,41 +198,41 @@ export function isValidMove(gameState, moveData) {
* @param {Object} moveData - The details of the move. * @param {Object} moveData - The details of the move.
*/ */
export function moveCard(gameState, moveData) { export function moveCard(gameState, moveData) {
const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData; const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex } = moveData;
let sourcePile; let sourcePile;
if (sourcePileType === 'tableauPiles') sourcePile = gameState.tableauPiles[sourcePileIndex]; if (sourcePileType === "tableauPiles") sourcePile = gameState.tableauPiles[sourcePileIndex];
else if (sourcePileType === 'wastePile') sourcePile = gameState.wastePile; else if (sourcePileType === "wastePile") sourcePile = gameState.wastePile;
else if (sourcePileType === 'foundationPiles') sourcePile = gameState.foundationPiles[sourcePileIndex]; else if (sourcePileType === "foundationPiles") sourcePile = gameState.foundationPiles[sourcePileIndex];
let destPile; let destPile;
if (destPileType === 'tableauPiles') destPile = gameState.tableauPiles[destPileIndex]; if (destPileType === "tableauPiles") destPile = gameState.tableauPiles[destPileIndex];
else if (destPileType === 'foundationPiles') destPile = gameState.foundationPiles[destPileIndex]; else if (destPileType === "foundationPiles") destPile = gameState.foundationPiles[destPileIndex];
// Cut the entire stack of cards to be moved from the source pile. // Cut the entire stack of cards to be moved from the source pile.
const cardsToMove = sourcePile.splice(sourceCardIndex); const cardsToMove = sourcePile.splice(sourceCardIndex);
// Add the stack to the destination pile. // Add the stack to the destination pile.
destPile.push(...cardsToMove); destPile.push(...cardsToMove);
const histMove = { const histMove = {
move: 'move', move: "move",
sourcePileType: sourcePileType, sourcePileType: sourcePileType,
sourcePileIndex: sourcePileIndex, sourcePileIndex: sourcePileIndex,
sourceCardIndex: sourceCardIndex, sourceCardIndex: sourceCardIndex,
destPileType: destPileType, destPileType: destPileType,
destPileIndex: destPileIndex, destPileIndex: destPileIndex,
cardsMoved: cardsToMove, cardsMoved: cardsToMove,
cardWasFlipped: false, cardWasFlipped: false,
points: destPileType === 'foundationPiles' ? 11 : 1 // Points for moving to foundation points: destPileType === "foundationPiles" ? 11 : 1, // Points for moving to foundation
} };
// If the source was a tableau pile and there are cards left, flip the new top card. // If the source was a tableau pile and there are cards left, flip the new top card.
if (sourcePileType === 'tableauPiles' && sourcePile.length > 0) { if (sourcePileType === "tableauPiles" && sourcePile.length > 0) {
sourcePile[sourcePile.length - 1].faceUp = true; sourcePile[sourcePile.length - 1].faceUp = true;
histMove.cardWasFlipped = true; histMove.cardWasFlipped = true;
} }
gameState.hist.push(histMove) gameState.hist.push(histMove);
} }
/** /**
@@ -241,52 +240,51 @@ export function moveCard(gameState, moveData) {
* @param {Object} gameState - The current state of the game. * @param {Object} gameState - The current state of the game.
*/ */
export function drawCard(gameState) { export function drawCard(gameState) {
if (gameState.stockPile.length > 0) { if (gameState.stockPile.length > 0) {
const card = gameState.stockPile.pop(); const card = gameState.stockPile.pop();
card.faceUp = true; card.faceUp = true;
gameState.wastePile.push(card); gameState.wastePile.push(card);
gameState.hist.push({ gameState.hist.push({
move: 'draw', move: "draw",
card: card card: card,
}) });
} else if (gameState.wastePile.length > 0) { } else if (gameState.wastePile.length > 0) {
// When stock is empty, move the entire waste pile back to stock, face down. // When stock is empty, move the entire waste pile back to stock, face down.
gameState.stockPile = gameState.wastePile.reverse(); gameState.stockPile = gameState.wastePile.reverse();
gameState.stockPile.forEach(card => (card.faceUp = false)); gameState.stockPile.forEach((card) => (card.faceUp = false));
gameState.wastePile = []; gameState.wastePile = [];
gameState.hist.push({ gameState.hist.push({
move: 'draw-reset', move: "draw-reset",
}) });
} }
} }
export function draw3Cards(gameState) { export function draw3Cards(gameState) {
if (gameState.stockPile.length > 0) { if (gameState.stockPile.length > 0) {
let cards = [] let cards = [];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (gameState.stockPile.length > 0) { if (gameState.stockPile.length > 0) {
const card = gameState.stockPile.pop(); const card = gameState.stockPile.pop();
card.faceUp = true; card.faceUp = true;
gameState.wastePile.push(card); gameState.wastePile.push(card);
cards.push(card); cards.push(card);
} else { } else {
break; // Stop if stock runs out break; // Stop if stock runs out
} }
} }
gameState.hist.push({ gameState.hist.push({
move: 'draw-3', move: "draw-3",
cards: cards, cards: cards,
}) });
} else if (gameState.wastePile.length > 0) { } else if (gameState.wastePile.length > 0) {
// When stock is empty, move the entire waste pile back to stock, face down. // When stock is empty, move the entire waste pile back to stock, face down.
gameState.stockPile = gameState.wastePile.reverse(); gameState.stockPile = gameState.wastePile.reverse();
gameState.stockPile.forEach(card => (card.faceUp = false)); gameState.stockPile.forEach((card) => (card.faceUp = false));
gameState.wastePile = []; gameState.wastePile = [];
gameState.hist.push({ gameState.hist.push({
move: 'draw-reset', move: "draw-reset",
}) });
} }
} }
/** /**
@@ -295,8 +293,8 @@ export function draw3Cards(gameState) {
* @returns {boolean} True if the game is won. * @returns {boolean} True if the game is won.
*/ */
export function checkWinCondition(gameState) { export function checkWinCondition(gameState) {
const foundationCardCount = gameState.foundationPiles.reduce((acc, pile) => acc + pile.length, 0); const foundationCardCount = gameState.foundationPiles.reduce((acc, pile) => acc + pile.length, 0);
return foundationCardCount === 52; return foundationCardCount === 52;
} }
/** /**
@@ -305,64 +303,64 @@ export function checkWinCondition(gameState) {
* @returns {boolean} True if the game can be auto-solved. * @returns {boolean} True if the game can be auto-solved.
*/ */
export function checkAutoSolve(gameState) { export function checkAutoSolve(gameState) {
if (gameState.stockPile.length > 0 || gameState.wastePile.length > 0) return false; if (gameState.stockPile.length > 0 || gameState.wastePile.length > 0) return false;
for (const pile of gameState.tableauPiles) { for (const pile of gameState.tableauPiles) {
for (const card of pile) { for (const card of pile) {
if (!card.faceUp) return false; if (!card.faceUp) return false;
} }
} }
return true; return true;
} }
export function autoSolveMoves(userId, gameState) { export function autoSolveMoves(userId, gameState) {
const moves = []; const moves = [];
const foundations = JSON.parse(JSON.stringify(gameState.foundationPiles)); const foundations = JSON.parse(JSON.stringify(gameState.foundationPiles));
const tableau = JSON.parse(JSON.stringify(gameState.tableauPiles)); const tableau = JSON.parse(JSON.stringify(gameState.tableauPiles));
function canMoveToFoundation(card) { function canMoveToFoundation(card) {
let foundationPile = foundations.find(pile => pile[pile.length - 1]?.suit === card.suit); let foundationPile = foundations.find((pile) => pile[pile.length - 1]?.suit === card.suit);
if (!foundationPile) { if (!foundationPile) {
foundationPile = foundations.find(pile => pile.length === 0); foundationPile = foundations.find((pile) => pile.length === 0);
} }
if (foundationPile.length === 0) { if (foundationPile.length === 0) {
return card.rank === 'A'; // Only Ace can be placed on empty foundation return card.rank === "A"; // Only Ace can be placed on empty foundation
} else { } else {
const topCard = foundationPile[foundationPile.length - 1]; const topCard = foundationPile[foundationPile.length - 1];
return card.suit === topCard.suit && getRankValue(card.rank) === getRankValue(topCard.rank) + 1; return card.suit === topCard.suit && getRankValue(card.rank) === getRankValue(topCard.rank) + 1;
} }
} }
let moved; let moved;
do { do {
moved = false; moved = false;
for (let i = 0; i < tableau.length; i++) { for (let i = 0; i < tableau.length; i++) {
const column = tableau[i]; const column = tableau[i];
if (column.length === 0) continue; if (column.length === 0) continue;
const card = column[column.length - 1]; // Top card of the tableau column const card = column[column.length - 1]; // Top card of the tableau column
let foundationIndex = foundations.findIndex(pile => pile[pile.length - 1]?.suit === card.suit); let foundationIndex = foundations.findIndex((pile) => pile[pile.length - 1]?.suit === card.suit);
if (foundationIndex === -1) { if (foundationIndex === -1) {
foundationIndex = foundations.findIndex(pile => pile.length === 0); foundationIndex = foundations.findIndex((pile) => pile.length === 0);
} }
if(canMoveToFoundation(card)) { if (canMoveToFoundation(card)) {
let moveData = { let moveData = {
destPileIndex: foundationIndex, destPileIndex: foundationIndex,
destPileType: 'foundationPiles', destPileType: "foundationPiles",
sourceCardIndex: column.length - 1, sourceCardIndex: column.length - 1,
sourcePileIndex: i, sourcePileIndex: i,
sourcePileType: 'tableauPiles', sourcePileType: "tableauPiles",
userId: userId, userId: userId,
} };
tableau[i].pop() tableau[i].pop();
foundations[foundationIndex].push(card) foundations[foundationIndex].push(card);
//moveCard(gameState, moveData) //moveCard(gameState, moveData)
moves.push(moveData); moves.push(moveData);
moved = true; moved = true;
} }
} }
} while (moved)//(foundations.reduce((acc, pile) => acc + pile.length, 0)); } while (moved); //(foundations.reduce((acc, pile) => acc + pile.length, 0));
emitSolitaireUpdate(userId, moves) emitSolitaireUpdate(userId, moves);
} }
/** /**
@@ -371,98 +369,99 @@ export function autoSolveMoves(userId, gameState) {
* @param {Object} gameState - The current game state, which includes a `hist` array. * @param {Object} gameState - The current game state, which includes a `hist` array.
*/ */
export function undoMove(gameState) { export function undoMove(gameState) {
if (!gameState.hist || gameState.hist.length === 0) { if (!gameState.hist || gameState.hist.length === 0) {
console.log("No moves to undo."); console.log("No moves to undo.");
return; // Nothing to undo return; // Nothing to undo
} }
const lastMove = gameState.hist.pop(); // Get and remove the last move from history const lastMove = gameState.hist.pop(); // Get and remove the last move from history
gameState.moves++; // Undoing a move counts as a new move gameState.moves++; // Undoing a move counts as a new move
gameState.score -= lastMove.points || 1; // Revert score based on points from the last move gameState.score -= lastMove.points || 1; // Revert score based on points from the last move
switch (lastMove.move) { switch (lastMove.move) {
case 'move': case "move":
undoCardMove(gameState, lastMove); undoCardMove(gameState, lastMove);
break; break;
case 'draw': case "draw":
undoDraw(gameState, lastMove); undoDraw(gameState, lastMove);
break; break;
case 'draw-3': case "draw-3":
undoDraw3(gameState, lastMove); undoDraw3(gameState, lastMove);
break; break;
case 'draw-reset': case "draw-reset":
undoDrawReset(gameState, lastMove); undoDrawReset(gameState, lastMove);
break; break;
default: default:
// If an unknown move type is found, push it back to avoid corrupting the history // If an unknown move type is found, push it back to avoid corrupting the history
gameState.hist.push(lastMove); gameState.hist.push(lastMove);
gameState.moves--; // Revert the move count increment gameState.moves--; // Revert the move count increment
gameState.score += lastMove.points || 1; // Revert the score decrement gameState.score += lastMove.points || 1; // Revert the score decrement
console.error("Unknown move type in history:", lastMove); console.error("Unknown move type in history:", lastMove);
break; break;
} }
} }
// --- Helper functions for undoing specific moves --- // --- Helper functions for undoing specific moves ---
function undoCardMove(gameState, moveData) { function undoCardMove(gameState, moveData) {
const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex, cardsMoved, cardWasFlipped } = moveData; const { sourcePileType, sourcePileIndex, sourceCardIndex, destPileType, destPileIndex, cardsMoved, cardWasFlipped } =
moveData;
// 1. Find the destination pile (where the cards are NOW) // 1. Find the destination pile (where the cards are NOW)
let currentPile; let currentPile;
if (destPileType === 'tableauPiles') currentPile = gameState.tableauPiles[destPileIndex]; if (destPileType === "tableauPiles") currentPile = gameState.tableauPiles[destPileIndex];
else if (destPileType === 'foundationPiles') currentPile = gameState.foundationPiles[destPileIndex]; else if (destPileType === "foundationPiles") currentPile = gameState.foundationPiles[destPileIndex];
// 2. Remove the moved cards from their current pile // 2. Remove the moved cards from their current pile
// Using splice with a negative index removes from the end of the array // Using splice with a negative index removes from the end of the array
currentPile.splice(-cardsMoved.length); currentPile.splice(-cardsMoved.length);
// 3. Find the original source pile // 3. Find the original source pile
let originalPile; let originalPile;
if (sourcePileType === 'tableauPiles') originalPile = gameState.tableauPiles[sourcePileIndex]; if (sourcePileType === "tableauPiles") originalPile = gameState.tableauPiles[sourcePileIndex];
else if (sourcePileType === 'wastePile') originalPile = gameState.wastePile; else if (sourcePileType === "wastePile") originalPile = gameState.wastePile;
else if (sourcePileType === 'foundationPiles') originalPile = gameState.foundationPiles[sourcePileIndex]; else if (sourcePileType === "foundationPiles") originalPile = gameState.foundationPiles[sourcePileIndex];
// 4. Put the cards back where they came from // 4. Put the cards back where they came from
// Using splice to insert the cards back at their original index // Using splice to insert the cards back at their original index
originalPile.splice(sourceCardIndex, 0, ...cardsMoved); originalPile.splice(sourceCardIndex, 0, ...cardsMoved);
// 5. If a card was flipped during the move, flip it back to face-down // 5. If a card was flipped during the move, flip it back to face-down
if (cardWasFlipped) { if (cardWasFlipped) {
const cardToUnflip = originalPile[sourceCardIndex - 1]; const cardToUnflip = originalPile[sourceCardIndex - 1];
if (cardToUnflip) { if (cardToUnflip) {
cardToUnflip.faceUp = false; cardToUnflip.faceUp = false;
} }
} }
} }
function undoDraw(gameState, moveData) { function undoDraw(gameState, moveData) {
// A 'draw' move means a card went from stock to waste. // A 'draw' move means a card went from stock to waste.
// To undo, move it from waste back to stock and flip it face-down. // To undo, move it from waste back to stock and flip it face-down.
const cardToReturn = gameState.wastePile.pop(); const cardToReturn = gameState.wastePile.pop();
if (cardToReturn) { if (cardToReturn) {
cardToReturn.faceUp = false; cardToReturn.faceUp = false;
gameState.stockPile.push(cardToReturn); gameState.stockPile.push(cardToReturn);
} }
} }
function undoDraw3(gameState, moveData) { function undoDraw3(gameState, moveData) {
// A 'draw-3' move means up to 3 cards went from stock to // A 'draw-3' move means up to 3 cards went from stock to
// waste. To undo, move them back to stock and flip them face-down. // waste. To undo, move them back to stock and flip them face-down.
const cardsToReturn = moveData.cards || []; const cardsToReturn = moveData.cards || [];
for (let i = 0; i < cardsToReturn.length; i++) { for (let i = 0; i < cardsToReturn.length; i++) {
const card = gameState.wastePile.pop(); const card = gameState.wastePile.pop();
if (card) { if (card) {
card.faceUp = false; card.faceUp = false;
gameState.stockPile.push(card); gameState.stockPile.push(card);
} }
} }
} }
function undoDrawReset(gameState, moveData) { function undoDrawReset(gameState, moveData) {
// A 'draw-reset' means the waste pile was moved to the stock pile. // A 'draw-reset' means the waste pile was moved to the stock pile.
// To undo, move the stock pile back to the waste pile and flip cards face-up. // To undo, move the stock pile back to the waste pile and flip cards face-up.
gameState.wastePile = gameState.stockPile.reverse(); gameState.wastePile = gameState.stockPile.reverse();
gameState.wastePile.forEach(card => (card.faceUp = true)); gameState.wastePile.forEach((card) => (card.faceUp = true));
gameState.stockPile = []; gameState.stockPile = [];
} }

View File

@@ -44,7 +44,6 @@ export let activePredis = {};
// Format: { [userId]: { endAt, lastMessage } } // Format: { [userId]: { endAt, lastMessage } }
export let activeSlowmodes = {}; export let activeSlowmodes = {};
// --- Queues for Matchmaking --- // --- Queues for Matchmaking ---
// Stores user IDs waiting to play Tic-Tac-Toe. // Stores user IDs waiting to play Tic-Tac-Toe.
@@ -55,7 +54,6 @@ export let connect4Queue = [];
export let queueMessagesEndpoints = []; export let queueMessagesEndpoints = [];
// --- Rate Limiting and Caching --- // --- Rate Limiting and Caching ---
// Tracks message timestamps for the channel points system, keyed by user ID. // Tracks message timestamps for the channel points system, keyed by user ID.
@@ -70,4 +68,4 @@ export let requestTimestamps = new Map();
// In-memory cache for Valorant skin data fetched from the API. // In-memory cache for Valorant skin data fetched from the API.
// This prevents re-fetching the same data on every command use. // This prevents re-fetching the same data on every command use.
export let skins = []; export let skins = [];

View File

@@ -4,19 +4,19 @@ export const C4_COLS = 7;
// A predefined list of choices for the /timeout command's duration option. // A predefined list of choices for the /timeout command's duration option.
const TimesChoices = [ const TimesChoices = [
{ name: '1 minute', value: 60 }, { name: "1 minute", value: 60 },
{ name: '5 minutes', value: 300 }, { name: "5 minutes", value: 300 },
{ name: '10 minutes', value: 600 }, { name: "10 minutes", value: 600 },
{ name: '15 minutes', value: 900 }, { name: "15 minutes", value: 900 },
{ name: '30 minutes', value: 1800 }, { name: "30 minutes", value: 1800 },
{ name: '1 heure', value: 3600 }, { name: "1 heure", value: 3600 },
{ name: '2 heures', value: 7200 }, { name: "2 heures", value: 7200 },
{ name: '3 heures', value: 10800 }, { name: "3 heures", value: 10800 },
{ name: '6 heures', value: 21600 }, { name: "6 heures", value: 21600 },
{ name: '9 heures', value: 32400 }, { name: "9 heures", value: 32400 },
{ name: '12 heures', value: 43200 }, { name: "12 heures", value: 43200 },
{ name: '16 heures', value: 57600 }, { name: "16 heures", value: 57600 },
{ name: '1 jour', value: 86400 }, { name: "1 jour", value: 86400 },
]; ];
/** /**
@@ -24,10 +24,9 @@ const TimesChoices = [
* @returns {Array<object>} The array of time choices. * @returns {Array<object>} The array of time choices.
*/ */
export function getTimesChoices() { export function getTimesChoices() {
return TimesChoices; return TimesChoices;
} }
// --- Connect 4 Logic --- // --- Connect 4 Logic ---
/** /**
@@ -35,7 +34,9 @@ export function getTimesChoices() {
* @returns {Array<Array<null>>} A 2D array representing the board. * @returns {Array<Array<null>>} A 2D array representing the board.
*/ */
export function createConnect4Board() { export function createConnect4Board() {
return Array(C4_ROWS).fill(null).map(() => Array(C4_COLS).fill(null)); return Array(C4_ROWS)
.fill(null)
.map(() => Array(C4_COLS).fill(null));
} }
/** /**
@@ -45,43 +46,95 @@ export function createConnect4Board() {
* @returns {object} An object with `win` (boolean) and `pieces` (array of winning piece coordinates). * @returns {object} An object with `win` (boolean) and `pieces` (array of winning piece coordinates).
*/ */
export function checkConnect4Win(board, player) { export function checkConnect4Win(board, player) {
// Check horizontal // Check horizontal
for (let r = 0; r < C4_ROWS; r++) { for (let r = 0; r < C4_ROWS; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) { 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) { if (
return { win: true, pieces: [{row:r, col:c}, {row:r, col:c+1}, {row:r, col:c+2}, {row:r, col:c+3}] }; 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 // Check vertical
for (let r = 0; r <= C4_ROWS - 4; r++) { for (let r = 0; r <= C4_ROWS - 4; r++) {
for (let c = 0; c < C4_COLS; c++) { 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) { if (
return { win: true, pieces: [{row:r, col:c}, {row:r+1, col:c}, {row:r+2, col:c}, {row:r+3, col:c}] }; 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) // Check diagonal (down-right)
for (let r = 0; r <= C4_ROWS - 4; r++) { for (let r = 0; r <= C4_ROWS - 4; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) { 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) { if (
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}] }; 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) // Check diagonal (up-right)
for (let r = 3; r < C4_ROWS; r++) { for (let r = 3; r < C4_ROWS; r++) {
for (let c = 0; c <= C4_COLS - 4; c++) { 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) { if (
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}] }; 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: [] }; return { win: false, pieces: [] };
} }
/** /**
@@ -90,8 +143,8 @@ export function checkConnect4Win(board, player) {
* @returns {boolean} True if the game is a draw. * @returns {boolean} True if the game is a draw.
*/ */
export function checkConnect4Draw(board) { export function checkConnect4Draw(board) {
// A draw occurs if the top row is completely full. // A draw occurs if the top row is completely full.
return board[0].every(cell => cell !== null); return board[0].every((cell) => cell !== null);
} }
/** /**
@@ -100,10 +153,10 @@ export function checkConnect4Draw(board) {
* @returns {string} The formatted string representation of the board. * @returns {string} The formatted string representation of the board.
*/ */
export function formatConnect4BoardForDiscord(board) { export function formatConnect4BoardForDiscord(board) {
const symbols = { const symbols = {
'R': '🔴', R: "🔴",
'Y': '🟡', Y: "🟡",
null: '⚪' // Using a white circle for empty slots null: "⚪", // Using a white circle for empty slots
}; };
return board.map(row => row.map(cell => symbols[cell]).join('')).join('\n'); return board.map((row) => row.map((cell) => symbols[cell]).join("")).join("\n");
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +1,364 @@
// /routes/blackjack.js // /routes/blackjack.js
import express from "express"; import express from "express";
import { import {
createBlackjackRoom, createBlackjackRoom,
startBetting, startBetting,
dealInitial, dealInitial,
autoActions, autoActions,
everyoneDone, everyoneDone,
dealerPlay, dealerPlay,
settleAll, settleAll,
applyAction, applyAction,
publicPlayerView, publicPlayerView,
handValue, handValue,
dealerShouldHit, draw dealerShouldHit,
draw,
} from "../../game/blackjack.js"; } from "../../game/blackjack.js";
// Optional: hook into your DB & Discord systems if available // Optional: hook into your DB & Discord systems if available
import { getUser, updateUserCoins, insertLog } from "../../database/index.js"; import { getUser, updateUserCoins, insertLog } from "../../database/index.js";
import { client } from "../../bot/client.js"; import { client } from "../../bot/client.js";
import {emitToast, emitUpdate} from "../socket.js"; import { emitToast, emitUpdate } from "../socket.js";
import {EmbedBuilder} from "discord.js"; import { EmbedBuilder } from "discord.js";
export function blackjackRoutes(io) { export function blackjackRoutes(io) {
const router = express.Router(); const router = express.Router();
// --- Singleton continuous room --- // --- Singleton continuous room ---
const room = createBlackjackRoom({ const room = createBlackjackRoom({
minBet: 10, minBet: 10,
maxBet: 10000, maxBet: 10000,
fakeMoney: false, fakeMoney: false,
decks: 6, decks: 6,
hitSoft17: false, // S17 (dealer stands on soft 17) if false hitSoft17: false, // S17 (dealer stands on soft 17) if false
blackjackPayout: 1.5, // 3:2 blackjackPayout: 1.5, // 3:2
cutCardRatio: 0.25, cutCardRatio: 0.25,
phaseDurations: { bettingMs: 10000, dealMs: 2000, playMsPerPlayer: 20000, revealMs: 1000, payoutMs: 7000 }, phaseDurations: {
animation: { dealerDrawMs: 1000 } bettingMs: 10000,
}); dealMs: 2000,
playMsPerPlayer: 20000,
revealMs: 1000,
payoutMs: 7000,
},
animation: { dealerDrawMs: 1000 },
});
const sleep = (ms) => new Promise(res => setTimeout(res, ms)); const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
let animatingDealer = false; let animatingDealer = false;
async function runDealerAnimation() { async function runDealerAnimation() {
if (animatingDealer) return; if (animatingDealer) return;
animatingDealer = true; animatingDealer = true;
room.status = "dealer"; room.status = "dealer";
room.dealer.holeHidden = false; room.dealer.holeHidden = false;
await sleep(room.settings.phaseDurations.revealMs ?? 1000); await sleep(room.settings.phaseDurations.revealMs ?? 1000);
room.phase_ends_at = Date.now() + (room.settings.phaseDurations.revealMs ?? 1000); room.phase_ends_at = Date.now() + (room.settings.phaseDurations.revealMs ?? 1000);
emitUpdate("dealer-reveal", snapshot(room)); emitUpdate("dealer-reveal", snapshot(room));
await sleep(room.settings.phaseDurations.revealMs ?? 1000); await sleep(room.settings.phaseDurations.revealMs ?? 1000);
while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) { while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) {
room.dealer.cards.push(draw(room.shoe)); room.dealer.cards.push(draw(room.shoe));
room.phase_ends_at = Date.now() + (room.settings.animation?.dealerDrawMs ?? 500); room.phase_ends_at = Date.now() + (room.settings.animation?.dealerDrawMs ?? 500);
emitUpdate("dealer-hit", snapshot(room)); emitUpdate("dealer-hit", snapshot(room));
await sleep(room.settings.animation?.dealerDrawMs ?? 500); await sleep(room.settings.animation?.dealerDrawMs ?? 500);
} }
settleAll(room); settleAll(room);
room.status = "payout"; room.status = "payout";
room.phase_ends_at = Date.now() + (room.settings.phaseDurations.payoutMs ?? 10000); room.phase_ends_at = Date.now() + (room.settings.phaseDurations.payoutMs ?? 10000);
emitUpdate("payout", snapshot(room)) emitUpdate("payout", snapshot(room));
animatingDealer = false; animatingDealer = false;
} }
function autoTimeoutAFK(now) { function autoTimeoutAFK(now) {
if (room.status !== "playing") return false; if (room.status !== "playing") return false;
if (!room.phase_ends_at || now < room.phase_ends_at) return false; if (!room.phase_ends_at || now < room.phase_ends_at) return false;
let changed = false; let changed = false;
for (const p of Object.values(room.players)) { for (const p of Object.values(room.players)) {
if (!p.inRound) continue; if (!p.inRound) continue;
const h = p.hands[p.activeHand]; const h = p.hands[p.activeHand];
if (!h.hasActed && !h.busted && !h.stood && !h.surrendered) { if (!h.hasActed && !h.busted && !h.stood && !h.surrendered) {
h.surrendered = true; h.surrendered = true;
h.stood = true; h.stood = true;
h.hasActed = true; h.hasActed = true;
room.leavingAfterRound[p.id] = true; // kick at end of round room.leavingAfterRound[p.id] = true; // kick at end of round
emitToast({ type: "player-timeout", userId: p.id }); emitToast({ type: "player-timeout", userId: p.id });
changed = true; changed = true;
} else if (h.hasActed && !h.stood) { } else if (h.hasActed && !h.stood) {
h.stood = true; h.stood = true;
room.leavingAfterRound[p.id] = true; // kick at end of round room.leavingAfterRound[p.id] = true; // kick at end of round
emitToast({ type: "player-auto-stand", userId: p.id }); emitToast({ type: "player-auto-stand", userId: p.id });
changed = true; changed = true;
} }
} }
if (changed) emitUpdate("auto-surrender", snapshot(room)); if (changed) emitUpdate("auto-surrender", snapshot(room));
return changed; return changed;
} }
function snapshot(r) { function snapshot(r) {
return { return {
id: r.id, id: r.id,
name: r.name, name: r.name,
status: r.status, status: r.status,
phase_ends_at: r.phase_ends_at, phase_ends_at: r.phase_ends_at,
minBet: r.minBet, minBet: r.minBet,
maxBet: r.maxBet, maxBet: r.maxBet,
settings: r.settings, settings: r.settings,
dealer: { cards: r.dealer.holeHidden ? [r.dealer.cards[0], "XX"] : r.dealer.cards, total: r.dealer.holeHidden ? null : handValue(r.dealer.cards).total }, dealer: {
players: Object.values(r.players).map(publicPlayerView), cards: r.dealer.holeHidden ? [r.dealer.cards[0], "XX"] : r.dealer.cards,
shoeCount: r.shoe.length, total: r.dealer.holeHidden ? null : handValue(r.dealer.cards).total,
}; },
} players: Object.values(r.players).map(publicPlayerView),
shoeCount: r.shoe.length,
};
}
// --- Public endpoints --- // --- Public endpoints ---
router.get("/", (req, res) => res.status(200).json({ room: snapshot(room) })); router.get("/", (req, res) => res.status(200).json({ room: snapshot(room) }));
router.post("/join", async (req, res) => { router.post("/join", async (req, res) => {
const { userId } = req.body; const { userId } = req.body;
if (!userId) return res.status(400).json({ message: "userId required" }); if (!userId) return res.status(400).json({ message: "userId required" });
if (room.players[userId]) return res.status(200).json({ message: "Already here" }); if (room.players[userId]) return res.status(200).json({ message: "Already here" });
const user = await client.users.fetch(userId); const user = await client.users.fetch(userId);
const bank = getUser.get(userId)?.coins ?? 0; const bank = getUser.get(userId)?.coins ?? 0;
room.players[userId] = { room.players[userId] = {
id: userId, id: userId,
globalName: user.globalName || user.username, globalName: user.globalName || user.username,
avatar: user.displayAvatarURL({ dynamic: true, size: 256 }), avatar: user.displayAvatarURL({ dynamic: true, size: 256 }),
bank, bank,
currentBet: 0, currentBet: 0,
inRound: false, inRound: false,
hands: [{ cards: [], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false, bet: 0 }], hands: [
activeHand: 0, {
joined_at: Date.now(), cards: [],
msgId: null, stood: false,
totalDelta: 0, busted: false,
totalBets: 0, doubled: false,
}; surrendered: false,
hasActed: false,
bet: 0,
},
],
activeHand: 0,
joined_at: Date.now(),
msgId: null,
totalDelta: 0,
totalBets: 0,
};
try { try {
const guild = await client.guilds.fetch(process.env.GUILD_ID); const guild = await client.guilds.fetch(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.find( const generalChannel = guild.channels.cache.find((ch) => ch.name === "général" || ch.name === "general");
ch => ch.name === 'général' || ch.name === 'general' const embed = new EmbedBuilder()
); .setDescription(`<@${userId}> joue au Blackjack`)
const embed = new EmbedBuilder() .addFields(
.setDescription(`<@${userId}> joue au Blackjack`) {
.addFields( name: `Gains`,
{ value: `**${room.players[userId].totalDelta >= 0 ? "+" + room.players[userId].totalDelta : room.players[userId].totalDelta}** Flopos`,
name: `Gains`, inline: true,
value: `**${room.players[userId].totalDelta >= 0 ? '+' + room.players[userId].totalDelta : room.players[userId].totalDelta}** Flopos`, },
inline: true {
}, name: `Mises jouées`,
{ value: `**${room.players[userId].totalBets}**`,
name: `Mises jouées`, inline: true,
value: `**${room.players[userId].totalBets}**`, },
inline: true )
} .setColor("#5865f2")
) .setTimestamp(new Date());
.setColor('#5865f2')
.setTimestamp(new Date());
const msg = await generalChannel.send({ embeds: [embed] }); const msg = await generalChannel.send({ embeds: [embed] });
room.players[userId].msgId = msg.id; room.players[userId].msgId = msg.id;
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
emitUpdate("player-joined", snapshot(room)); emitUpdate("player-joined", snapshot(room));
return res.status(200).json({ message: "joined" }); return res.status(200).json({ message: "joined" });
}); });
router.post("/leave", async (req, res) => { router.post("/leave", async (req, res) => {
const { userId } = req.body; const { userId } = req.body;
if (!userId || !room.players[userId]) return res.status(404).json({ message: "not in room" }); if (!userId || !room.players[userId]) return res.status(404).json({ message: "not in room" });
try { try {
const guild = await client.guilds.fetch(process.env.GUILD_ID); const guild = await client.guilds.fetch(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.find( const generalChannel = guild.channels.cache.find((ch) => ch.name === "général" || ch.name === "general");
ch => ch.name === 'général' || ch.name === 'general' const msg = await generalChannel.messages.fetch(room.players[userId].msgId);
); const updatedEmbed = new EmbedBuilder()
const msg = await generalChannel.messages.fetch(room.players[userId].msgId); .setDescription(`<@${userId}> a quitté la table de Blackjack.`)
const updatedEmbed = new EmbedBuilder() .addFields(
.setDescription(`<@${userId}> a quitté la table de Blackjack.`) {
.addFields( name: `Gains`,
{ value: `**${room.players[userId].totalDelta >= 0 ? "+" + room.players[userId].totalDelta : room.players[userId].totalDelta}** Flopos`,
name: `Gains`, inline: true,
value: `**${room.players[userId].totalDelta >= 0 ? '+' + room.players[userId].totalDelta : room.players[userId].totalDelta}** Flopos`, },
inline: true {
}, name: `Mises jouées`,
{ value: `**${room.players[userId].totalBets}**`,
name: `Mises jouées`, inline: true,
value: `**${room.players[userId].totalBets}**`, },
inline: true )
} .setColor(room.players[userId].totalDelta >= 0 ? 0x22a55b : 0xed4245)
) .setTimestamp(new Date());
.setColor(room.players[userId].totalDelta >= 0 ? 0x22A55B : 0xED4245) await msg.edit({ embeds: [updatedEmbed], components: [] });
.setTimestamp(new Date()); } catch (e) {
await msg.edit({ embeds: [updatedEmbed], components: [] }); console.log(e);
} catch (e) { }
console.log(e);
}
const p = room.players[userId]; const p = room.players[userId];
if (p.inRound) { if (p.inRound) {
// leave after round to avoid abandoning an active bet // leave after round to avoid abandoning an active bet
room.leavingAfterRound[userId] = true; room.leavingAfterRound[userId] = true;
return res.status(200).json({ message: "will-leave-after-round" }); return res.status(200).json({ message: "will-leave-after-round" });
} else { } else {
delete room.players[userId]; delete room.players[userId];
emitUpdate("player-left", snapshot(room)); emitUpdate("player-left", snapshot(room));
return res.status(200).json({ message: "left" }); return res.status(200).json({ message: "left" });
} }
}); });
router.post("/bet", (req, res) => { router.post("/bet", (req, res) => {
const { userId, amount } = req.body; const { userId, amount } = req.body;
const p = room.players[userId]; const p = room.players[userId];
if (!p) return res.status(404).json({ message: "not in room" }); if (!p) return res.status(404).json({ message: "not in room" });
if (room.status !== "betting") return res.status(403).json({ message: "betting-closed" }); if (room.status !== "betting") return res.status(403).json({ message: "betting-closed" });
const bet = Math.floor(Number(amount) || 0); const bet = Math.floor(Number(amount) || 0);
if (bet < room.minBet || bet > room.maxBet) return res.status(400).json({ message: "invalid-bet" }); if (bet < room.minBet || bet > room.maxBet) return res.status(400).json({ message: "invalid-bet" });
if (!room.settings.fakeMoney) { if (!room.settings.fakeMoney) {
const userDB = getUser.get(userId); const userDB = getUser.get(userId);
const coins = userDB?.coins ?? 0; const coins = userDB?.coins ?? 0;
if (coins < bet) return res.status(403).json({ message: "insufficient-funds" }); if (coins < bet) return res.status(403).json({ message: "insufficient-funds" });
updateUserCoins.run({ id: userId, coins: coins - bet }); updateUserCoins.run({ id: userId, coins: coins - bet });
insertLog.run({ insertLog.run({
id: `${userId}-blackjack-${Date.now()}`, id: `${userId}-blackjack-${Date.now()}`,
user_id: userId, target_user_id: null, user_id: userId,
action: 'BLACKJACK_BET', target_user_id: null,
coins_amount: -bet, user_new_amount: coins - bet, action: "BLACKJACK_BET",
}); coins_amount: -bet,
p.bank = coins - bet; user_new_amount: coins - bet,
} });
p.bank = coins - bet;
}
p.currentBet = bet; p.currentBet = bet;
p.hands[p.activeHand].bet = bet; p.hands[p.activeHand].bet = bet;
emitToast({ type: "player-bet", userId, amount: bet }); emitToast({ type: "player-bet", userId, amount: bet });
emitUpdate("bet-placed", snapshot(room)); emitUpdate("bet-placed", snapshot(room));
return res.status(200).json({ message: "bet-accepted" }); return res.status(200).json({ message: "bet-accepted" });
}); });
router.post("/action/:action", (req, res) => { router.post("/action/:action", (req, res) => {
const { userId } = req.body; const { userId } = req.body;
const action = req.params.action; const action = req.params.action;
const p = room.players[userId]; const p = room.players[userId];
if (!p) return res.status(404).json({ message: "not in room" }); if (!p) return res.status(404).json({ message: "not in room" });
if (!p.inRound || room.status !== "playing") return res.status(403).json({ message: "not-your-turn" }); if (!p.inRound || room.status !== "playing") return res.status(403).json({ message: "not-your-turn" });
// Handle extra coin lock for double // Handle extra coin lock for double
if (action === "double" && !room.settings.fakeMoney) { if (action === "double" && !room.settings.fakeMoney) {
const userDB = getUser.get(userId); const userDB = getUser.get(userId);
const coins = userDB?.coins ?? 0; const coins = userDB?.coins ?? 0;
const hand = p.hands[p.activeHand]; const hand = p.hands[p.activeHand];
if (coins < hand.bet) return res.status(403).json({ message: "insufficient-funds-for-double" }); if (coins < hand.bet) return res.status(403).json({ message: "insufficient-funds-for-double" });
updateUserCoins.run({ id: userId, coins: coins - hand.bet }); updateUserCoins.run({ id: userId, coins: coins - hand.bet });
insertLog.run({ insertLog.run({
id: `${userId}-blackjack-${Date.now()}`, id: `${userId}-blackjack-${Date.now()}`,
user_id: userId, target_user_id: null, user_id: userId,
action: 'BLACKJACK_DOUBLE', target_user_id: null,
coins_amount: -hand.bet, user_new_amount: coins - hand.bet, action: "BLACKJACK_DOUBLE",
}); coins_amount: -hand.bet,
p.bank = coins - hand.bet; user_new_amount: coins - hand.bet,
// effective bet size is handled in settlement via hand.doubled flag });
} p.bank = coins - hand.bet;
// effective bet size is handled in settlement via hand.doubled flag
}
if (action === "split" && !room.settings.fakeMoney) { if (action === "split" && !room.settings.fakeMoney) {
const userDB = getUser.get(userId); const userDB = getUser.get(userId);
const coins = userDB?.coins ?? 0; const coins = userDB?.coins ?? 0;
const hand = p.hands[p.activeHand]; const hand = p.hands[p.activeHand];
if (coins < hand.bet) return res.status(403).json({ message: "insufficient-funds-for-split" }); if (coins < hand.bet) return res.status(403).json({ message: "insufficient-funds-for-split" });
updateUserCoins.run({ id: userId, coins: coins - hand.bet }); updateUserCoins.run({ id: userId, coins: coins - hand.bet });
insertLog.run({ insertLog.run({
id: `${userId}-blackjack-${Date.now()}`, id: `${userId}-blackjack-${Date.now()}`,
user_id: userId, target_user_id: null, user_id: userId,
action: 'BLACKJACK_SPLIT', target_user_id: null,
coins_amount: -hand.bet, user_new_amount: coins - hand.bet, action: "BLACKJACK_SPLIT",
}); coins_amount: -hand.bet,
p.bank = coins - hand.bet; user_new_amount: coins - hand.bet,
// effective bet size is handled in settlement via hand.doubled flag });
} p.bank = coins - hand.bet;
// effective bet size is handled in settlement via hand.doubled flag
}
try { try {
const evt = applyAction(room, userId, action); const evt = applyAction(room, userId, action);
emitToast({ type: `player-${evt}`, userId }); emitToast({ type: `player-${evt}`, userId });
emitUpdate("player-action", snapshot(room)); emitUpdate("player-action", snapshot(room));
return res.status(200).json({ message: "ok" }); return res.status(200).json({ message: "ok" });
} catch (e) { } catch (e) {
return res.status(400).json({ message: e.message }); return res.status(400).json({ message: e.message });
} }
}); });
// --- Game loop --- // --- Game loop ---
// Simple phase machine that runs regardless of player count. // Simple phase machine that runs regardless of player count.
setInterval(async () => { setInterval(async () => {
const now = Date.now(); const now = Date.now();
if (room.status === "betting" && now >= room.phase_ends_at) { if (room.status === "betting" && now >= room.phase_ends_at) {
const hasBets = Object.values(room.players).some(p => p.currentBet >= room.minBet); const hasBets = Object.values(room.players).some((p) => p.currentBet >= room.minBet);
if (!hasBets) { if (!hasBets) {
// Extend betting window if no one bet // Extend betting window if no one bet
room.phase_ends_at = now + room.settings.phaseDurations.bettingMs; room.phase_ends_at = now + room.settings.phaseDurations.bettingMs;
emitUpdate("betting-extend", snapshot(room)); emitUpdate("betting-extend", snapshot(room));
return; return;
} }
dealInitial(room); dealInitial(room);
autoActions(room); autoActions(room);
emitUpdate("initial-deal", snapshot(room)); emitUpdate("initial-deal", snapshot(room));
room.phase_ends_at = Date.now() + room.settings.phaseDurations.playMsPerPlayer; room.phase_ends_at = Date.now() + room.settings.phaseDurations.playMsPerPlayer;
emitUpdate("playing-start", snapshot(room)); emitUpdate("playing-start", snapshot(room));
return; return;
} }
if (room.status === "playing") { if (room.status === "playing") {
// If the per-round playing timer expired, auto-surrender AFKs (you already added this) // If the per-round playing timer expired, auto-surrender AFKs (you already added this)
if (room.phase_ends_at && now >= room.phase_ends_at) { if (room.phase_ends_at && now >= room.phase_ends_at) {
autoTimeoutAFK(now); autoTimeoutAFK(now);
} }
// Everyone acted before the timer? Cut short and go straight to dealer. // Everyone acted before the timer? Cut short and go straight to dealer.
if (everyoneDone(room) && !animatingDealer) { if (everyoneDone(room) && !animatingDealer) {
// Set a new server-driven deadline for the reveal pause, // Set a new server-driven deadline for the reveal pause,
// so the client's countdown immediately reflects the phase change. // so the client's countdown immediately reflects the phase change.
room.phase_ends_at = Date.now(); room.phase_ends_at = Date.now();
emitUpdate("playing-cut-short", snapshot(room)); emitUpdate("playing-cut-short", snapshot(room));
// Now run the animated dealer with per-step updates // Now run the animated dealer with per-step updates
runDealerAnimation(); runDealerAnimation();
} }
} }
if (room.status === "payout" && now >= room.phase_ends_at) { if (room.status === "payout" && now >= room.phase_ends_at) {
// Remove leavers // Remove leavers
for (const userId of Object.keys(room.leavingAfterRound)) { for (const userId of Object.keys(room.leavingAfterRound)) {
delete room.players[userId]; delete room.players[userId];
} }
// Prepare next round // Prepare next round
startBetting(room, now); startBetting(room, now);
emitUpdate("new-round", snapshot(room)); emitUpdate("new-round", snapshot(room));
} }
}, 100); }, 100);
return router; return router;
} }

View File

@@ -1,7 +1,7 @@
import express from "express"; import express from "express";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
import {erinyesRooms} from "../../game/state.js"; import { erinyesRooms } from "../../game/state.js";
import {socketEmit} from "../socket.js"; import { socketEmit } from "../socket.js";
const router = express.Router(); const router = express.Router();
@@ -12,89 +12,91 @@ const router = express.Router();
* @returns {object} The configured Express router. * @returns {object} The configured Express router.
*/ */
export function erinyesRoutes(client, io) { export function erinyesRoutes(client, io) {
// --- Router Management Endpoints
// --- Router Management Endpoints router.get("/", (req, res) => {
res.status(200).json({ rooms: erinyesRooms });
});
router.get('/', (req, res) => { router.get("/:id", (req, res) => {
res.status(200).json({ rooms: erinyesRooms }) const room = erinyesRooms[req.params.id];
}) if (room) {
res.status(200).json({ room });
} else {
res.status(404).json({ message: "Room not found." });
}
});
router.get('/:id', (req, res) => { router.post("/create", async (req, res) => {
const room = erinyesRooms[req.params.id]; const { creatorId } = req.body;
if (room) { if (!creatorId) return res.status(404).json({ message: "Creator ID is required." });
res.status(200).json({ room });
} else {
res.status(404).json({ message: 'Room not found.' });
}
})
router.post('/create', async (req, res) => { if (Object.values(erinyesRooms).some((room) => creatorId === room.host_id || room.players[creatorId])) {
const { creatorId } = req.body; res.status(404).json({ message: "You are already in a room." });
if (!creatorId) return res.status(404).json({ message: 'Creator ID is required.' }); }
if (Object.values(erinyesRooms).some(room => creatorId === room.host_id || room.players[creatorId])) { const creator = await client.users.fetch(creatorId);
res.status(404).json({ message: 'You are already in a room.' }); const id = uuidv4();
}
const creator = await client.users.fetch(creatorId); createRoom({
const id = uuidv4() host_id: creatorId,
host_name: creator.globalName,
game_rules: {}, // Specific game rules
roles: [], // Every role in the game
});
createRoom({ await socketEmit("erinyes-update", {
host_id: creatorId, room: erinyesRooms[id],
host_name: creator.globalName, type: "room-created",
game_rules: {}, // Specific game rules });
roles: [], // Every role in the game res.status(200).json({ room: id });
}) });
await socketEmit('erinyes-update', { room: erinyesRooms[id], type: 'room-created' }); return router;
res.status(200).json({ room: id });
})
return router;
} }
function createRoom(config) { function createRoom(config) {
erinyesRooms[config.id] = { erinyesRooms[config.id] = {
host_id: config.host_id, host_id: config.host_id,
host_name: config.host_name, host_name: config.host_name,
created_at: Date.now(), created_at: Date.now(),
last_move_at: null, last_move_at: null,
players: {}, players: {},
current_player: null, current_player: null,
current_turn: null, current_turn: null,
playing: false, playing: false,
game_rules: createGameRules(config.game_rules), game_rules: createGameRules(config.game_rules),
roles: config.roles, roles: config.roles,
roles_rules: createRolesRules(config.roles), roles_rules: createRolesRules(config.roles),
bonuses: {} bonuses: {},
} };
} }
function createGameRules(config) { function createGameRules(config) {
return { return {
day_vote_time: config.day_vote_time ?? 60000, day_vote_time: config.day_vote_time ?? 60000,
// ... // ...
}; };
} }
function createRolesRules(roles) { function createRolesRules(roles) {
const roles_rules = {} const roles_rules = {};
roles.forEach(role => { roles.forEach((role) => {
switch (role) { switch (role) {
case 'erynie': case "erynie":
roles_rules[role] = { roles_rules[role] = {
//... //...
}; };
break; break;
//... //...
default: default:
roles_rules[role] = { roles_rules[role] = {
//... //...
}; };
break; break;
} }
}) });
return roles_rules; return roles_rules;
} }

View File

@@ -0,0 +1,73 @@
import express from "express";
// --- Database Imports ---
// --- Game State Imports ---
// --- Utility and API Imports ---
// --- Discord.js Builder Imports ---
import { ButtonStyle } from "discord.js";
import { getMarketOfferById, getMarketOffers, getOfferBids } from "../../database/index.js";
// Create a new router instance
const router = express.Router();
/**
* Factory function to create and configure the market 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 marketRoutes(client, io) {
router.get("/offers", async (req, res) => {
try {
const offers = getMarketOffers.all();
res.status(200).send({ offers });
} catch (e) {
res.status(500).send({ error: e });
}
});
router.get("/offers/:id", async (req, res) => {
try {
const offer = getMarketOfferById.get(req.params.id);
if (offer) {
res.status(200).send({ offer });
} else {
res.status(404).send({ error: "Offer not found" });
}
} catch (e) {
res.status(500).send({ error: e });
}
});
router.get("/offers/:id/bids", async (req, res) => {
try {
// Placeholder for fetching bids logic
const bids = getOfferBids.get(req.params.id);
res.status(200).send({ bids });
} catch (e) {
res.status(500).send({ error: e });
}
});
router.post("/place-offer", async (req, res) => {
try {
// Placeholder for placing an offer logic
// Extract data from req.body and process accordingly
res.status(200).send({ message: "Offer placed successfully" });
} catch (e) {
res.status(500).send({ error: e });
}
});
router.post("/offers/:id/place-bid", async (req, res) => {
try {
// Placeholder for placing a bid logic
// Extract data from req.body and process accordingly
res.status(200).send({ message: "Bid placed successfully" });
} catch (e) {
res.status(500).send({ error: e });
}
});
return router;
}

View File

@@ -1,18 +1,24 @@
import express from 'express'; import express from "express";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from "uuid";
import { uniqueNamesGenerator, adjectives } from 'unique-names-generator'; import { uniqueNamesGenerator, adjectives } from "unique-names-generator";
import pkg from 'pokersolver'; import pkg from "pokersolver";
const { Hand } = pkg; const { Hand } = pkg;
import { pokerRooms } from '../../game/state.js'; import { pokerRooms } from "../../game/state.js";
import { initialShuffledCards, getFirstActivePlayerAfterDealer, getNextActivePlayer, checkEndOfBettingRound, checkRoomWinners } from '../../game/poker.js'; import {
import { pokerEloHandler } from '../../game/elo.js'; initialShuffledCards,
import { getUser, updateUserCoins, insertLog } from '../../database/index.js'; 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"; import { sleep } from "openai/core";
import {client} from "../../bot/client.js"; import { client } from "../../bot/client.js";
import {emitPokerToast, emitPokerUpdate} from "../socket.js"; import { emitPokerToast, emitPokerUpdate } from "../socket.js";
import {ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder} from "discord.js"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import {formatAmount} from "../../utils/index.js"; import { formatAmount } from "../../utils/index.js";
const router = express.Router(); const router = express.Router();
@@ -23,474 +29,540 @@ const router = express.Router();
* @returns {object} The configured Express router. * @returns {object} The configured Express router.
*/ */
export function pokerRoutes(client, io) { export function pokerRoutes(client, io) {
// --- Room Management Endpoints ---
// --- Room Management Endpoints --- router.get("/", (req, res) => {
res.status(200).json({ rooms: pokerRooms });
});
router.get('/', (req, res) => { router.get("/:id", (req, res) => {
res.status(200).json({ rooms: pokerRooms }); const room = pokerRooms[req.params.id];
}); if (room) {
res.status(200).json({ room });
} else {
res.status(404).json({ message: "Poker room not found." });
}
});
router.get('/:id', (req, res) => { router.post("/create", async (req, res) => {
const room = pokerRooms[req.params.id]; const { creatorId, minBet, fakeMoney } = req.body;
if (room) { if (!creatorId) return res.status(400).json({ message: "Creator ID is required." });
res.status(200).json({ room });
} else {
res.status(404).json({ message: 'Poker room not found.' });
}
});
router.post('/create', async (req, res) => { if (Object.values(pokerRooms).some((room) => room.host_id === creatorId || room.players[creatorId])) {
const { creatorId, minBet, fakeMoney } = req.body; return res.status(403).json({ message: "You are already in a poker room." });
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])) { const guild = await client.guilds.fetch(process.env.GUILD_ID);
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",
});
const guild = await client.guilds.fetch(process.env.GUILD_ID); pokerRooms[id] = {
const creator = await client.users.fetch(creatorId); id,
const id = uuidv4(); host_id: creatorId,
const name = uniqueNamesGenerator({ dictionaries: [adjectives, ['Poker']], separator: ' ', style: 'capital' }); host_name: creator.globalName || creator.username,
name,
created_at: Date.now(),
last_move_at: null,
players: {},
queue: {},
afk: {},
pioche: initialShuffledCards(),
tapis: [],
dealer: null,
sb: null,
bb: null,
highest_bet: 0,
current_player: null,
current_turn: null,
playing: false,
winners: [],
waiting_for_restart: false,
fakeMoney: fakeMoney,
minBet: minBet,
};
pokerRooms[id] = { await joinRoom(id, creatorId, io); // Auto-join the creator
id, host_id: creatorId, host_name: creator.globalName || creator.username, await emitPokerUpdate({ room: pokerRooms[id], type: "room-created" });
name, created_at: Date.now(), last_move_at: null,
players: {}, queue: {}, afk: {}, pioche: initialShuffledCards(), tapis: [],
dealer: null, sb: null, bb: null, highest_bet: 0, current_player: null,
current_turn: null, playing: false, winners: [], waiting_for_restart: false, fakeMoney: fakeMoney,
minBet: minBet,
};
await joinRoom(id, creatorId, io); // Auto-join the creator try {
await emitPokerUpdate({ room: pokerRooms[id], type: 'room-created' }); const generalChannel = guild.channels.cache.find((ch) => ch.name === "général" || ch.name === "general");
const embed = new EmbedBuilder()
.setTitle("Flopoker 🃏")
.setDescription(`<@${creatorId}> a créé une table de poker`)
.addFields(
{ name: `Nom`, value: `**${name}**`, inline: true },
{
name: `${fakeMoney ? "Mise initiale" : "Prix d'entrée"}`,
value: `**${formatAmount(minBet)}** 🪙`,
inline: true,
},
{
name: `Fake Money`,
value: `${fakeMoney ? "**Oui** ✅" : "**Non** ❌"}`,
inline: true,
},
)
.setColor("#5865f2")
.setTimestamp(new Date());
try { const row = new ActionRowBuilder().addComponents(
const generalChannel = guild.channels.cache.find( new ButtonBuilder()
ch => ch.name === 'général' || ch.name === 'general' .setLabel(`Rejoindre la table ${name}`)
); .setURL(`${process.env.DEV_SITE === "true" ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/poker/${id}`)
const embed = new EmbedBuilder() .setStyle(ButtonStyle.Link),
.setTitle('Flopoker 🃏') );
.setDescription(`<@${creatorId}> a créé une table de poker`)
.addFields(
{ name: `Nom`, value: `**${name}**`, inline: true },
{ name: `${fakeMoney ? 'Mise initiale' : 'Prix d\'entrée'}`, value: `**${formatAmount(minBet)}** 🪙`, inline: true },
{ name: `Fake Money`, value: `${fakeMoney ? '**Oui** ✅' : '**Non** ❌'}`, inline: true },
)
.setColor('#5865f2')
.setTimestamp(new Date());
const row = new ActionRowBuilder().addComponents( await generalChannel.send({ embeds: [embed], components: [row] });
new ButtonBuilder() } catch (e) {
.setLabel(`Rejoindre la table ${name}`) console.log(e);
.setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}/poker/${id}`) }
.setStyle(ButtonStyle.Link)
);
await generalChannel.send({ embeds: [embed], components: [row] }); res.status(201).json({ roomId: id });
} catch (e) { });
console.log(e)
}
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] || r.queue[userId])) {
return res.status(403).json({ message: "You are already in a room or queue." });
}
if (!pokerRooms[roomId].fakeMoney && pokerRooms[roomId].minBet > (getUser.get(userId)?.coins ?? 0)) {
return res.status(403).json({ message: "You do not have enough coins to join this room." });
}
router.post('/join', async (req, res) => { await joinRoom(roomId, userId, io);
const { userId, roomId } = req.body; res.status(200).json({ message: "Successfully joined." });
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] || r.queue[userId])) {
return res.status(403).json({ message: 'You are already in a room or queue.' });
}
if (!pokerRooms[roomId].fakeMoney && pokerRooms[roomId].minBet > (getUser.get(userId)?.coins ?? 0)) {
return res.status(403).json({ message: 'You do not have enough coins to join this room.' });
}
await joinRoom(roomId, userId, io); router.post("/accept", async (req, res) => {
res.status(200).json({ message: 'Successfully joined.' }); const { hostId, playerId, roomId } = req.body;
}); const room = pokerRooms[roomId];
if (!room || room.host_id !== hostId || !room.queue[playerId]) {
return res.status(403).json({ message: "Unauthorized or player not in queue." });
}
router.post('/accept', async (req, res) => { if (!room.fakeMoney) {
const { hostId, playerId, roomId } = req.body; const userDB = getUser.get(playerId);
const room = pokerRooms[roomId]; if (userDB) {
if (!room || room.host_id !== hostId || !room.queue[playerId]) { updateUserCoins.run({
return res.status(403).json({ message: 'Unauthorized or player not in queue.' }); id: playerId,
} coins: userDB.coins - room.minBet,
});
insertLog.run({
id: `${playerId}-poker-${Date.now()}`,
user_id: playerId,
target_user_id: null,
action: "POKER_JOIN",
coins_amount: -room.minBet,
user_new_amount: userDB.coins - room.minBet,
});
}
}
if (!room.fakeMoney) { room.players[playerId] = room.queue[playerId];
const userDB = getUser.get(playerId); delete room.queue[playerId];
if (userDB) {
updateUserCoins.run({ id: playerId, coins: userDB.coins - room.minBet });
insertLog.run({
id: `${playerId}-poker-${Date.now()}`,
user_id: playerId, target_user_id: null,
action: 'POKER_JOIN',
coins_amount: -room.minBet, user_new_amount: userDB.coins - room.minBet,
})
}
}
room.players[playerId] = room.queue[playerId]; await emitPokerUpdate({ room: room, type: "player-accepted" });
delete room.queue[playerId]; res.status(200).json({ message: "Player accepted." });
});
await emitPokerUpdate({ room: room, type: 'player-accepted' }); router.post("/leave", async (req, res) => {
res.status(200).json({ message: 'Player accepted.' }); const { userId, roomId } = req.body;
});
router.post('/leave', async (req, res) => { if (!pokerRooms[roomId]) return res.status(404).send({ message: "Table introuvable" });
const { userId, roomId } = req.body if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: "Joueur introuvable" });
if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) if (
if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: 'Joueur introuvable' }) pokerRooms[roomId].playing &&
pokerRooms[roomId].current_turn !== null &&
pokerRooms[roomId].current_turn !== 4
) {
pokerRooms[roomId].afk[userId] = pokerRooms[roomId].players[userId];
if (pokerRooms[roomId].playing && (pokerRooms[roomId].current_turn !== null && pokerRooms[roomId].current_turn !== 4)) { try {
pokerRooms[roomId].afk[userId] = pokerRooms[roomId].players[userId] pokerRooms[roomId].players[userId].folded = true;
pokerRooms[roomId].players[userId].last_played_turn = pokerRooms[roomId].current_turn;
if (pokerRooms[roomId].current_player === userId) {
await checkRoundCompletion(pokerRooms[roomId], io);
}
} catch (e) {
console.log(e);
}
try { await emitPokerUpdate({ type: "player-afk" });
pokerRooms[roomId].players[userId].folded = true return res.status(200);
pokerRooms[roomId].players[userId].last_played_turn = pokerRooms[roomId].current_turn }
if (pokerRooms[roomId].current_player === userId) {
await checkRoundCompletion(pokerRooms[roomId], io);
}
} catch(e) {
console.log(e)
}
await emitPokerUpdate({ type: 'player-afk' }); try {
return res.status(200) updatePlayerCoins(
} pokerRooms[roomId].players[userId],
pokerRooms[roomId].players[userId].bank,
pokerRooms[roomId].fakeMoney,
);
delete pokerRooms[roomId].players[userId];
try { if (userId === pokerRooms[roomId].host_id) {
updatePlayerCoins(pokerRooms[roomId].players[userId], pokerRooms[roomId].players[userId].bank, pokerRooms[roomId].fakeMoney); const newHostId = Object.keys(pokerRooms[roomId].players).find((id) => id !== userId);
delete pokerRooms[roomId].players[userId] if (!newHostId) {
delete pokerRooms[roomId];
} else {
pokerRooms[roomId].host_id = newHostId;
}
}
} catch (e) {
console.log(e);
}
if (userId === pokerRooms[roomId].host_id) { await emitPokerUpdate({ type: "player-left" });
const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== userId) return res.status(200);
if (!newHostId) { });
delete pokerRooms[roomId]
} else {
pokerRooms[roomId].host_id = newHostId
}
}
} catch (e) {
console.log(e)
}
await emitPokerUpdate({ type: 'player-left' }); router.post("/kick", async (req, res) => {
return res.status(200) const { commandUserId, userId, roomId } = req.body;
});
router.post('/kick', async (req, res) => { if (!pokerRooms[roomId]) return res.status(404).send({ message: "Table introuvable" });
const { commandUserId, userId, roomId } = req.body if (!pokerRooms[roomId].players[commandUserId]) return res.status(404).send({ message: "Joueur introuvable" });
if (pokerRooms[roomId].host_id !== commandUserId) return res.status(403).send({ message: "Seul l'host peut kick" });
if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: "Joueur introuvable" });
if (!pokerRooms[roomId]) return res.status(404).send({ message: 'Table introuvable' }) if (
if (!pokerRooms[roomId].players[commandUserId]) return res.status(404).send({ message: 'Joueur introuvable' }) pokerRooms[roomId].playing &&
if (pokerRooms[roomId].host_id !== commandUserId) return res.status(403).send({ message: 'Seul l\'host peut kick' }) pokerRooms[roomId].current_turn !== null &&
if (!pokerRooms[roomId].players[userId]) return res.status(404).send({ message: 'Joueur introuvable' }) pokerRooms[roomId].current_turn !== 4
) {
return res.status(403).send({ message: "Playing" });
}
if (pokerRooms[roomId].playing && (pokerRooms[roomId].current_turn !== null && pokerRooms[roomId].current_turn !== 4)) { try {
return res.status(403).send({ message: 'Playing' }) updatePlayerCoins(
} pokerRooms[roomId].players[userId],
pokerRooms[roomId].players[userId].bank,
pokerRooms[roomId].fakeMoney,
);
delete pokerRooms[roomId].players[userId];
try { if (userId === pokerRooms[roomId].host_id) {
updatePlayerCoins(pokerRooms[roomId].players[userId], pokerRooms[roomId].players[userId].bank, pokerRooms[roomId].fakeMoney); const newHostId = Object.keys(pokerRooms[roomId].players).find((id) => id !== userId);
delete pokerRooms[roomId].players[userId] if (!newHostId) {
delete pokerRooms[roomId];
} else {
pokerRooms[roomId].host_id = newHostId;
}
}
} catch (e) {
console.log(e);
}
if (userId === pokerRooms[roomId].host_id) { await emitPokerUpdate({ type: "player-kicked" });
const newHostId = Object.keys(pokerRooms[roomId].players).find(id => id !== userId) return res.status(200);
if (!newHostId) { });
delete pokerRooms[roomId]
} else {
pokerRooms[roomId].host_id = newHostId
}
}
} catch (e) {
console.log(e)
}
await emitPokerUpdate({ type: 'player-kicked' }); // --- Game Action Endpoints ---
return res.status(200)
});
// --- Game Action Endpoints --- router.post("/start", async (req, res) => {
const { roomId } = req.body;
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." });
router.post('/start', async (req, res) => { await startNewHand(room, io);
const { roomId } = req.body; res.status(200).json({ message: "Game started." });
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); // NEW: Endpoint to start the next hand
res.status(200).json({ message: 'Game started.' }); router.post("/next-hand", async (req, res) => {
}); const { roomId } = req.body;
const room = pokerRooms[roomId];
if (!room || !room.waiting_for_restart) {
return res.status(400).json({ message: "Not ready for the next hand." });
}
await startNewHand(room, io);
res.status(200).json({ message: "Next hand started." });
});
// NEW: Endpoint to start the next hand router.post("/action/:action", async (req, res) => {
router.post('/next-hand', async (req, res) => { const { playerId, amount, roomId } = req.body;
const { roomId } = req.body; const { action } = req.params;
const room = pokerRooms[roomId]; const room = pokerRooms[roomId];
if (!room || !room.waiting_for_restart) {
return res.status(400).json({ message: 'Not ready for the next hand.' });
}
await startNewHand(room, io);
res.status(200).json({ message: 'Next hand started.' });
});
router.post('/action/:action', async (req, res) => { if (!room || !room.players[playerId] || room.current_player !== playerId) {
const { playerId, amount, roomId } = req.body; return res.status(403).json({ message: "It's not your turn or you are not in this game." });
const { action } = req.params; }
const room = pokerRooms[roomId];
if (!room || !room.players[playerId] || room.current_player !== playerId) { const player = room.players[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;
await emitPokerToast({
type: "player-fold",
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
});
break;
case "check":
if (player.bet < room.highest_bet) return res.status(400).json({ message: "Cannot check." });
await emitPokerToast({
type: "player-check",
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
});
break;
case "call":
const callAmount = Math.min(room.highest_bet - player.bet, player.bank);
player.bank -= callAmount;
player.bet += callAmount;
if (player.bank === 0) player.allin = true;
await emitPokerToast({
type: "player-call",
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
});
break;
case "raise":
if (amount <= 0 || amount > player.bank || player.bet + amount <= room.highest_bet) {
return res.status(400).json({ message: "Invalid raise amount." });
}
player.bank -= amount;
player.bet += amount;
if (player.bank === 0) player.allin = true;
room.highest_bet = player.bet;
await emitPokerToast({
type: "player-raise",
amount: amount,
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
});
break;
default:
return res.status(400).json({ message: "Invalid action." });
}
switch(action) { player.last_played_turn = room.current_turn;
case 'fold': await checkRoundCompletion(room, io);
player.folded = true; res.status(200).json({ message: `Action '${action}' successful.` });
await emitPokerToast({ });
type: 'player-fold',
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
})
break;
case 'check':
if (player.bet < room.highest_bet) return res.status(400).json({ message: 'Cannot check.' });
await emitPokerToast({
type: 'player-check',
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
})
break;
case 'call':
const callAmount = Math.min(room.highest_bet - player.bet, player.bank);
player.bank -= callAmount;
player.bet += callAmount;
if (player.bank === 0) player.allin = true;
await emitPokerToast({
type: 'player-call',
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
})
break;
case 'raise':
if (amount <= 0 || amount > player.bank || (player.bet + amount) <= room.highest_bet) {
return res.status(400).json({ message: 'Invalid raise amount.' });
}
player.bank -= amount;
player.bet += amount;
if (player.bank === 0) player.allin = true;
room.highest_bet = player.bet;
await emitPokerToast({
type: 'player-raise',
amount: amount,
playerId: player.id,
playerName: player.globalName,
roomId: room.id,
})
break;
default: return res.status(400).json({ message: 'Invalid action.' });
}
player.last_played_turn = room.current_turn; return router;
await checkRoundCompletion(room, io);
res.status(200).json({ message: `Action '${action}' successful.` });
});
return router;
} }
// --- Helper Functions --- // --- Helper Functions ---
async function joinRoom(roomId, userId, io) { async function joinRoom(roomId, userId, io) {
const user = await client.users.fetch(userId); const user = await client.users.fetch(userId);
const userDB = getUser.get(userId); const userDB = getUser.get(userId);
const room = pokerRooms[roomId]; const room = pokerRooms[roomId];
const playerObject = { const playerObject = {
id: userId, globalName: user.globalName || user.username, avatar: user.displayAvatarURL({ dynamic: true, size: 256 }), id: userId,
hand: [], bank: room.minBet, bet: 0, folded: false, allin: false, globalName: user.globalName || user.username,
last_played_turn: null, solve: null avatar: user.displayAvatarURL({ dynamic: true, size: 256 }),
}; hand: [],
bank: room.minBet,
bet: 0,
folded: false,
allin: false,
last_played_turn: null,
solve: null,
};
if (room.playing) { if (room.playing) {
room.queue[userId] = playerObject; room.queue[userId] = playerObject;
} else { } else {
room.players[userId] = playerObject; room.players[userId] = playerObject;
if (!room.fakeMoney) { if (!room.fakeMoney) {
updateUserCoins.run({ id: userId, coins: userDB.coins - room.minBet }); updateUserCoins.run({ id: userId, coins: userDB.coins - room.minBet });
insertLog.run({ insertLog.run({
id: `${userId}-poker-${Date.now()}`, id: `${userId}-poker-${Date.now()}`,
user_id: userId, target_user_id: null, user_id: userId,
action: 'POKER_JOIN', target_user_id: null,
coins_amount: -room.minBet, user_new_amount: userDB.coins - room.minBet, action: "POKER_JOIN",
}) coins_amount: -room.minBet,
} user_new_amount: userDB.coins - room.minBet,
} });
}
}
await emitPokerUpdate({ room: room, type: 'player-joined' }); await emitPokerUpdate({ room: room, type: "player-joined" });
} }
async function startNewHand(room, io) { async function startNewHand(room, io) {
const playerIds = Object.keys(room.players); const playerIds = Object.keys(room.players);
if (playerIds.length < 2) { if (playerIds.length < 2) {
room.playing = false; // Not enough players to continue room.playing = false; // Not enough players to continue
await emitPokerUpdate({ room: room, type: 'new-hand' }); await emitPokerUpdate({ room: room, type: "new-hand" });
return; return;
} }
room.playing = true; room.playing = true;
room.current_turn = 0; // Pre-flop room.current_turn = 0; // Pre-flop
room.pioche = initialShuffledCards(); room.pioche = initialShuffledCards();
room.tapis = []; room.tapis = [];
room.winners = []; room.winners = [];
room.waiting_for_restart = false; room.waiting_for_restart = false;
room.highest_bet = 20; room.highest_bet = 20;
room.last_move_at = Date.now(); room.last_move_at = Date.now();
// Rotate dealer // Rotate dealer
const oldDealerIndex = playerIds.indexOf(room.dealer); const oldDealerIndex = playerIds.indexOf(room.dealer);
room.dealer = playerIds[(oldDealerIndex + 1) % playerIds.length]; room.dealer = playerIds[(oldDealerIndex + 1) % playerIds.length];
Object.values(room.players).forEach(p => { Object.values(room.players).forEach((p) => {
p.hand = [room.pioche.pop(), room.pioche.pop()]; p.hand = [room.pioche.pop(), room.pioche.pop()];
p.bet = 0; p.folded = false; p.allin = false; p.last_played_turn = null; p.bet = 0;
}); p.folded = false;
updatePlayerHandSolves(room); // NEW: Calculate initial hand strength p.allin = false;
p.last_played_turn = null;
});
updatePlayerHandSolves(room); // NEW: Calculate initial hand strength
// Handle blinds based on new dealer // Handle blinds based on new dealer
const dealerIndex = playerIds.indexOf(room.dealer); const dealerIndex = playerIds.indexOf(room.dealer);
const sbPlayer = room.players[playerIds[(dealerIndex + 1) % playerIds.length]]; const sbPlayer = room.players[playerIds[(dealerIndex + 1) % playerIds.length]];
const bbPlayer = room.players[playerIds[(dealerIndex + 2) % playerIds.length]]; const bbPlayer = room.players[playerIds[(dealerIndex + 2) % playerIds.length]];
room.sb = sbPlayer.id; room.sb = sbPlayer.id;
room.bb = bbPlayer.id; room.bb = bbPlayer.id;
sbPlayer.bank -= 10; sbPlayer.bet = 10; sbPlayer.bank -= 10;
bbPlayer.bank -= 20; bbPlayer.bet = 20; sbPlayer.bet = 10;
bbPlayer.bank -= 20;
bbPlayer.bet = 20;
bbPlayer.last_played_turn = 0; bbPlayer.last_played_turn = 0;
room.current_player = playerIds[(dealerIndex + 3) % playerIds.length]; room.current_player = playerIds[(dealerIndex + 3) % playerIds.length];
await emitPokerUpdate({ room: room, type: 'room-started' }); await emitPokerUpdate({ room: room, type: "room-started" });
} }
async function checkRoundCompletion(room, io) { async function checkRoundCompletion(room, io) {
room.last_move_at = Date.now(); room.last_move_at = Date.now();
const roundResult = checkEndOfBettingRound(room); const roundResult = checkEndOfBettingRound(room);
if (roundResult.endRound) { if (roundResult.endRound) {
if (roundResult.winner) { if (roundResult.winner) {
await handleShowdown(room, io, [roundResult.winner]); await handleShowdown(room, io, [roundResult.winner]);
} else { } else {
await advanceToNextPhase(room, io, roundResult.nextPhase); await advanceToNextPhase(room, io, roundResult.nextPhase);
} }
} else { } else {
room.current_player = getNextActivePlayer(room); room.current_player = getNextActivePlayer(room);
await emitPokerUpdate({ room: room, type: 'round-continue' }); await emitPokerUpdate({ room: room, type: "round-continue" });
} }
} }
async function advanceToNextPhase(room, io, phase) { async function advanceToNextPhase(room, io, phase) {
Object.values(room.players).forEach(p => { if (!p.folded) p.last_played_turn = null; }); Object.values(room.players).forEach((p) => {
if (!p.folded) p.last_played_turn = null;
});
switch(phase) { switch (phase) {
case 'flop': case "flop":
room.current_turn = 1; room.current_turn = 1;
room.tapis.push(room.pioche.pop(), room.pioche.pop(), room.pioche.pop()); room.tapis.push(room.pioche.pop(), room.pioche.pop(), room.pioche.pop());
break; break;
case 'turn': case "turn":
room.current_turn = 2; room.current_turn = 2;
room.tapis.push(room.pioche.pop()); room.tapis.push(room.pioche.pop());
break; break;
case 'river': case "river":
room.current_turn = 3; room.current_turn = 3;
room.tapis.push(room.pioche.pop()); room.tapis.push(room.pioche.pop());
break; break;
case 'showdown': case "showdown":
await handleShowdown(room, io, checkRoomWinners(room)); await handleShowdown(room, io, checkRoomWinners(room));
return; return;
case 'progressive-showdown': case "progressive-showdown":
await emitPokerUpdate({ room: room, type: 'progressive-showdown' }); await emitPokerUpdate({ room: room, type: "progressive-showdown" });
while(room.tapis.length < 5) { while (room.tapis.length < 5) {
await sleep(500); await sleep(500);
room.tapis.push(room.pioche.pop()); room.tapis.push(room.pioche.pop());
updatePlayerHandSolves(room); updatePlayerHandSolves(room);
await emitPokerUpdate({ room: room, type: 'progressive-showdown' }); await emitPokerUpdate({ room: room, type: "progressive-showdown" });
} }
await handleShowdown(room, io, checkRoomWinners(room)); await handleShowdown(room, io, checkRoomWinners(room));
return; return;
} }
updatePlayerHandSolves(room); // NEW: Update hand strength after new cards updatePlayerHandSolves(room); // NEW: Update hand strength after new cards
room.current_player = getFirstActivePlayerAfterDealer(room); room.current_player = getFirstActivePlayerAfterDealer(room);
await emitPokerUpdate({ room: room, type: 'phase-advanced' }); await emitPokerUpdate({ room: room, type: "phase-advanced" });
} }
async function handleShowdown(room, io, winners) { async function handleShowdown(room, io, winners) {
room.current_turn = 4; room.current_turn = 4;
room.playing = false; room.playing = false;
room.waiting_for_restart = true; room.waiting_for_restart = true;
room.winners = winners; room.winners = winners;
room.current_player = null; room.current_player = null;
let totalPot = 0; let totalPot = 0;
Object.values(room.players).forEach(p => { totalPot += p.bet; }); Object.values(room.players).forEach((p) => {
totalPot += p.bet;
});
const winAmount = winners.length > 0 ? Math.floor(totalPot / winners.length) : 0; const winAmount = winners.length > 0 ? Math.floor(totalPot / winners.length) : 0;
winners.forEach(winnerId => { winners.forEach((winnerId) => {
const winnerPlayer = room.players[winnerId]; const winnerPlayer = room.players[winnerId];
if(winnerPlayer) { if (winnerPlayer) {
winnerPlayer.bank += winAmount; winnerPlayer.bank += winAmount;
} }
}); });
await clearAfkPlayers(room); await clearAfkPlayers(room);
//await pokerEloHandler(room); //await pokerEloHandler(room);
await emitPokerUpdate({ room: room, type: 'showdown' }); await emitPokerUpdate({ room: room, type: "showdown" });
await emitPokerToast({ await emitPokerToast({
type: 'player-winner', type: "player-winner",
playerIds: winners, playerIds: winners,
roomId: room.id, roomId: room.id,
amount: winAmount, amount: winAmount,
}) });
} }
// NEW: Function to calculate and update hand strength for all players // NEW: Function to calculate and update hand strength for all players
function updatePlayerHandSolves(room) { function updatePlayerHandSolves(room) {
const communityCards = room.tapis; const communityCards = room.tapis;
for (const player of Object.values(room.players)) { for (const player of Object.values(room.players)) {
if (!player.folded) { if (!player.folded) {
const allCards = [...communityCards, ...player.hand]; const allCards = [...communityCards, ...player.hand];
player.solve = Hand.solve(allCards).descr; player.solve = Hand.solve(allCards).descr;
} }
} }
} }
function updatePlayerCoins(player, amount, isFake) { function updatePlayerCoins(player, amount, isFake) {
if (isFake) return; if (isFake) return;
const user = getUser.get(player.id); const user = getUser.get(player.id);
if (!user) return; if (!user) return;
const userDB = getUser.get(player.id); const userDB = getUser.get(player.id);
updateUserCoins.run({ id: player.id, coins: userDB.coins + amount }); updateUserCoins.run({ id: player.id, coins: userDB.coins + amount });
insertLog.run({ insertLog.run({
id: `${player.id}-poker-${Date.now()}`, id: `${player.id}-poker-${Date.now()}`,
user_id: player.id, target_user_id: null, user_id: player.id,
action: `POKER_${amount > 0 ? 'WIN' : 'LOSE'}`, target_user_id: null,
coins_amount: amount, user_new_amount: userDB.coins + amount, action: `POKER_${amount > 0 ? "WIN" : "LOSE"}`,
}); coins_amount: amount,
user_new_amount: userDB.coins + amount,
});
} }
async function clearAfkPlayers(room) { async function clearAfkPlayers(room) {
Object.keys(room.afk).forEach(playerId => { Object.keys(room.afk).forEach((playerId) => {
if (room.players[playerId]) { if (room.players[playerId]) {
updatePlayerCoins(room.players[playerId], room.players[playerId].bank, room.fakeMoney); updatePlayerCoins(room.players[playerId], room.players[playerId].bank, room.fakeMoney);
delete room.players[playerId]; delete room.players[playerId];
} }
}); });
room.afk = {}; room.afk = {};
} }

View File

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

View File

@@ -1,13 +1,20 @@
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { import {
activeTicTacToeGames, activeTicTacToeGames,
tictactoeQueue, tictactoeQueue,
activeConnect4Games, activeConnect4Games,
connect4Queue, connect4Queue,
queueMessagesEndpoints, activePredis queueMessagesEndpoints,
} from '../game/state.js'; activePredis,
import { createConnect4Board, formatConnect4BoardForDiscord, checkConnect4Win, checkConnect4Draw, C4_ROWS } from '../game/various.js'; } from "../game/state.js";
import { eloHandler } from '../game/elo.js'; import {
createConnect4Board,
formatConnect4BoardForDiscord,
checkConnect4Win,
checkConnect4Draw,
C4_ROWS,
} from "../game/various.js";
import { eloHandler } from "../game/elo.js";
import { getUser } from "../database/index.js"; import { getUser } from "../database/index.js";
// --- Module-level State --- // --- Module-level State ---
@@ -16,70 +23,73 @@ let io;
// --- Main Initialization Function --- // --- Main Initialization Function ---
export function initializeSocket(server, client) { export function initializeSocket(server, client) {
io = server; io = server;
io.on('connection', (socket) => { io.on("connection", (socket) => {
socket.on('user-connected', async (userId) => { socket.on("user-connected", async (userId) => {
if (!userId) return; if (!userId) return;
await refreshQueuesForUser(userId, client); await refreshQueuesForUser(userId, client);
}); });
registerTicTacToeEvents(socket, client); registerTicTacToeEvents(socket, client);
registerConnect4Events(socket, client); registerConnect4Events(socket, client);
socket.on('tictactoe:queue:leave', async ({ discordId }) => await refreshQueuesForUser(discordId, client)); socket.on("tictactoe:queue:leave", async ({ discordId }) => await refreshQueuesForUser(discordId, client));
// catch tab kills / network drops // catch tab kills / network drops
socket.on('disconnecting', async () => { socket.on("disconnecting", async () => {
const discordId = socket.handshake.auth?.discordId; // or your mapping const discordId = socket.handshake.auth?.discordId; // or your mapping
await refreshQueuesForUser(discordId, client); await refreshQueuesForUser(discordId, client);
}); });
socket.on('disconnect', () => { socket.on("disconnect", () => {
// //
}); });
}); });
setInterval(cleanupStaleGames, 5 * 60 * 1000); setInterval(cleanupStaleGames, 5 * 60 * 1000);
} }
export function getSocketIo() { export function getSocketIo() {
return io; return io;
} }
// --- Event Registration --- // --- Event Registration ---
function registerTicTacToeEvents(socket, client) { function registerTicTacToeEvents(socket, client) {
socket.on('tictactoeconnection', (e) => refreshQueuesForUser(e.id, client)); socket.on("tictactoeconnection", (e) => refreshQueuesForUser(e.id, client));
socket.on('tictactoequeue', (e) => onQueueJoin(client, 'tictactoe', e.playerId)); socket.on("tictactoequeue", (e) => onQueueJoin(client, "tictactoe", e.playerId));
socket.on('tictactoeplaying', (e) => onTicTacToeMove(client, e)); socket.on("tictactoeplaying", (e) => onTicTacToeMove(client, e));
socket.on('tictactoegameOver', (e) => onGameOver(client, 'tictactoe', e.playerId, e.winner)); socket.on("tictactoegameOver", (e) => onGameOver(client, "tictactoe", e.playerId, e.winner));
} }
function registerConnect4Events(socket, client) { function registerConnect4Events(socket, client) {
socket.on('connect4connection', (e) => refreshQueuesForUser(e.id, client)); socket.on("connect4connection", (e) => refreshQueuesForUser(e.id, client));
socket.on('connect4queue', (e) => onQueueJoin(client, 'connect4', e.playerId)); socket.on("connect4queue", (e) => onQueueJoin(client, "connect4", e.playerId));
socket.on('connect4playing', (e) => onConnect4Move(client, e)); socket.on("connect4playing", (e) => onConnect4Move(client, e));
socket.on('connect4NoTime', (e) => onGameOver(client, 'connect4', e.playerId, e.winner, '(temps écoulé)')); socket.on("connect4NoTime", (e) => onGameOver(client, "connect4", e.playerId, e.winner, "(temps écoulé)"));
} }
// --- Core Handlers (Preserving Original Logic) --- // --- Core Handlers (Preserving Original Logic) ---
async function onQueueJoin(client, gameType, playerId) { async function onQueueJoin(client, gameType, playerId) {
if (!playerId) return; if (!playerId) return;
const { queue, activeGames, title, url } = getGameAssets(gameType); const { queue, activeGames, title, url } = getGameAssets(gameType);
if (queue.includes(playerId) || Object.values(activeGames).some(g => g.p1.id === playerId || g.p2.id === playerId)) { if (
return; queue.includes(playerId) ||
} Object.values(activeGames).some((g) => g.p1.id === playerId || g.p2.id === playerId)
) {
return;
}
queue.push(playerId); queue.push(playerId);
console.log(`[${title}] Player ${playerId} joined the queue.`); console.log(`[${title}] Player ${playerId} joined the queue.`);
if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url); if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url);
if (queue.length >= 2) await createGame(client, gameType); if (queue.length >= 2) await createGame(client, gameType);
await emitQueueUpdate(client, gameType); await emitQueueUpdate(client, gameType);
} }
/** /**
@@ -88,262 +98,365 @@ async function onQueueJoin(client, gameType, playerId) {
* @returns {boolean} - True if the player has won, false otherwise. * @returns {boolean} - True if the player has won, false otherwise.
*/ */
function checkTicTacToeWin(moves) { function checkTicTacToeWin(moves) {
const winningCombinations = [ const winningCombinations = [
[1, 2, 3], [4, 5, 6], [7, 8, 9], // Rows [1, 2, 3],
[1, 4, 7], [2, 5, 8], [3, 6, 9], // Columns [4, 5, 6],
[1, 5, 9], [3, 5, 7] // Diagonals [7, 8, 9], // Rows
]; [1, 4, 7],
for (const combination of winningCombinations) { [2, 5, 8],
if (combination.every(num => moves.includes(num))) { [3, 6, 9], // Columns
return true; [1, 5, 9],
} [3, 5, 7], // Diagonals
} ];
return false; for (const combination of winningCombinations) {
if (combination.every((num) => moves.includes(num))) {
return true;
}
}
return false;
} }
async function onTicTacToeMove(client, eventData) { async function onTicTacToeMove(client, eventData) {
const { playerId, value, boxId } = eventData; const { playerId, value, boxId } = eventData;
const lobby = Object.values(activeTicTacToeGames).find(g => (g.p1.id === playerId || g.p2.id === playerId) && !g.gameOver); const lobby = Object.values(activeTicTacToeGames).find(
if (!lobby) return; (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 isP1Turn = lobby.sum % 2 === 1 && value === "X" && lobby.p1.id === playerId;
const isP2Turn = lobby.sum % 2 === 0 && value === 'O' && lobby.p2.id === playerId; const isP2Turn = lobby.sum % 2 === 0 && value === "O" && lobby.p2.id === playerId;
if (isP1Turn || isP2Turn) { if (isP1Turn || isP2Turn) {
const playerMoves = isP1Turn ? lobby.xs : lobby.os; const playerMoves = isP1Turn ? lobby.xs : lobby.os;
playerMoves.push(boxId); playerMoves.push(boxId);
lobby.sum++; lobby.sum++;
lobby.lastmove = Date.now(); lobby.lastmove = Date.now();
if (isP1Turn) lobby.p1.move = boxId if (isP1Turn) lobby.p1.move = boxId;
if (isP2Turn) lobby.p2.move = boxId if (isP2Turn) lobby.p2.move = boxId;
io.emit('tictactoeplaying', { allPlayers: Object.values(activeTicTacToeGames) }); io.emit("tictactoeplaying", {
const hasWon = checkTicTacToeWin(playerMoves); allPlayers: Object.values(activeTicTacToeGames),
if (hasWon) { });
// The current player has won. End the game. const hasWon = checkTicTacToeWin(playerMoves);
await onGameOver(client, 'tictactoe', playerId, playerId); if (hasWon) {
} else if (lobby.sum > 9) { // The current player has won. End the game.
// It's a draw (9 moves made, sum is now 10). End the game. await onGameOver(client, "tictactoe", playerId, playerId);
await onGameOver(client, 'tictactoe', playerId, null); // null winner for a draw } else if (lobby.sum > 9) {
} else { // It's a draw (9 moves made, sum is now 10). End the game.
// The game continues. Update the state and notify clients. await onGameOver(client, "tictactoe", playerId, null); // null winner for a draw
await updateDiscordMessage(client, lobby, 'Tic Tac Toe'); } else {
} // The game continues. Update the state and notify clients.
} await updateDiscordMessage(client, lobby, "Tic Tac Toe");
await emitQueueUpdate(client, 'tictactoe'); }
}
await emitQueueUpdate(client, "tictactoe");
} }
async function onConnect4Move(client, eventData) { async function onConnect4Move(client, eventData) {
const { playerId, col } = eventData; const { playerId, col } = eventData;
const lobby = Object.values(activeConnect4Games).find(l => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver); const lobby = Object.values(activeConnect4Games).find(
if (!lobby || lobby.turn !== playerId) return; (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; const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2;
let row; let row;
for (row = C4_ROWS - 1; row >= 0; row--) { for (row = C4_ROWS - 1; row >= 0; row--) {
if (lobby.board[row][col] === null) { if (lobby.board[row][col] === null) {
lobby.board[row][col] = player.val; lobby.board[row][col] = player.val;
break; break;
} }
} }
if (row < 0) return; if (row < 0) return;
lobby.lastmove = Date.now(); lobby.lastmove = Date.now();
const winCheck = checkConnect4Win(lobby.board, player.val); const winCheck = checkConnect4Win(lobby.board, player.val);
let winnerId = null; let winnerId = null;
if (winCheck.win) { if (winCheck.win) {
lobby.winningPieces = winCheck.pieces; lobby.winningPieces = winCheck.pieces;
winnerId = player.id; winnerId = player.id;
} else if (checkConnect4Draw(lobby.board)) { } else if (checkConnect4Draw(lobby.board)) {
winnerId = null; // Represents a draw winnerId = null; // Represents a draw
} else { } else {
lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id; lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id;
io.emit('connect4playing', { allPlayers: Object.values(activeConnect4Games) }); io.emit("connect4playing", {
await emitQueueUpdate(client, 'connact4'); allPlayers: Object.values(activeConnect4Games),
await updateDiscordMessage(client, lobby, 'Puissance 4'); });
return; await emitQueueUpdate(client, "connact4");
} await updateDiscordMessage(client, lobby, "Puissance 4");
await onGameOver(client, 'connect4', playerId, winnerId); return;
}
await onGameOver(client, "connect4", playerId, winnerId);
} }
async function onGameOver(client, gameType, playerId, winnerId, reason = '') { async function onGameOver(client, gameType, playerId, winnerId, reason = "") {
const { activeGames, title } = getGameAssets(gameType); const { activeGames, title } = getGameAssets(gameType);
const gameKey = Object.keys(activeGames).find(key => key.includes(playerId)); const gameKey = Object.keys(activeGames).find((key) => key.includes(playerId));
const game = gameKey ? activeGames[gameKey] : undefined; const game = gameKey ? activeGames[gameKey] : undefined;
if (!game || game.gameOver) return; if (!game || game.gameOver) return;
game.gameOver = true; game.gameOver = true;
let resultText; let resultText;
if (winnerId === null) { if (winnerId === null) {
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase()); await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase());
resultText = 'Égalité'; resultText = "Égalité";
} else { } else {
await eloHandler(game.p1.id, game.p2.id, game.p1.id === winnerId ? 1 : 0, game.p2.id === winnerId ? 1 : 0, title.toUpperCase()); await eloHandler(
const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name; game.p1.id,
resultText = `Victoire de ${winnerName}`; 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}`); await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
if(gameType === 'tictactoe') io.emit('tictactoegameOver', { game, winner: winnerId }); if (gameType === "tictactoe") io.emit("tictactoegameOver", { game, winner: winnerId });
if(gameType === 'connect4') io.emit('connect4gameOver', { game, winner: winnerId }); if (gameType === "connect4") io.emit("connect4gameOver", { game, winner: winnerId });
if (gameKey) { if (gameKey) {
setTimeout(() => delete activeGames[gameKey], 1000) setTimeout(() => delete activeGames[gameKey], 1000);
} }
} }
// --- Game Lifecycle & Discord Helpers --- // --- Game Lifecycle & Discord Helpers ---
async function createGame(client, gameType) { async function createGame(client, gameType) {
const { queue, activeGames, title } = getGameAssets(gameType); const { queue, activeGames, title } = getGameAssets(gameType);
const p1Id = queue.shift(); const p1Id = queue.shift();
const p2Id = queue.shift(); const p2Id = queue.shift();
const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]); const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]);
let lobby; let lobby;
if (gameType === 'tictactoe') { if (gameType === "tictactoe") {
lobby = { p1: { id: p1Id, name: p1.globalName, val: 'X', avatar: p1.displayAvatarURL({ dynamic: true, size: 256 }) }, p2: { id: p2Id, name: p2.globalName, val: 'O', avatar: p2.displayAvatarURL({ dynamic: true, size: 256 }) }, sum: 1, xs: [], os: [], gameOver: false, lastmove: Date.now() }; lobby = {
} else { // connect4 p1: {
lobby = { p1: { id: p1Id, name: p1.globalName, val: 'R', avatar: p1.displayAvatarURL({ dynamic: true, size: 256 }) }, p2: { id: p2Id, name: p2.globalName, val: 'Y', avatar: p2.displayAvatarURL({ dynamic: true, size: 256 }) }, turn: p1Id, board: createConnect4Board(), gameOver: false, lastmove: Date.now(), winningPieces: [] }; id: p1Id,
} name: p1.globalName,
val: "X",
avatar: p1.displayAvatarURL({ dynamic: true, size: 256 }),
},
p2: {
id: p2Id,
name: p2.globalName,
val: "O",
avatar: p2.displayAvatarURL({ dynamic: true, size: 256 }),
},
sum: 1,
xs: [],
os: [],
gameOver: false,
lastmove: Date.now(),
};
} else {
// connect4
lobby = {
p1: {
id: p1Id,
name: p1.globalName,
val: "R",
avatar: p1.displayAvatarURL({ dynamic: true, size: 256 }),
},
p2: {
id: p2Id,
name: p2.globalName,
val: "Y",
avatar: p2.displayAvatarURL({ dynamic: true, size: 256 }),
},
turn: p1Id,
board: createConnect4Board(),
gameOver: false,
lastmove: Date.now(),
winningPieces: [],
};
}
const msgId = await updateDiscordMessage(client, lobby, title); const msgId = await updateDiscordMessage(client, lobby, title);
lobby.msgId = msgId; lobby.msgId = msgId;
const gameKey = `${p1Id}-${p2Id}`; const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby; activeGames[gameKey] = lobby;
io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) }); io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) });
await emitQueueUpdate(client, gameType); await emitQueueUpdate(client, gameType);
} }
// --- Utility Functions --- // --- Utility Functions ---
async function refreshQueuesForUser(userId, client) { async function refreshQueuesForUser(userId, client) {
// FIX: Mutate the array instead of reassigning it. // FIX: Mutate the array instead of reassigning it.
let index = tictactoeQueue.indexOf(userId); let index = tictactoeQueue.indexOf(userId);
if (index > -1) { if (index > -1) {
tictactoeQueue.splice(index, 1); tictactoeQueue.splice(index, 1);
try { try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(userId); const user = await client.users.fetch(userId);
const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]) const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]);
const updatedEmbed = new EmbedBuilder().setTitle('Tic Tac Toe').setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`).setColor(0xED4245).setTimestamp(new Date()); const updatedEmbed = new EmbedBuilder()
await queueMsg.edit({ embeds: [updatedEmbed], components: [] }); .setTitle("Tic Tac Toe")
delete queueMessagesEndpoints[userId]; .setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`)
} catch (e) { .setColor(0xed4245)
console.error('Error updating queue message : ', e); .setTimestamp(new Date());
} await queueMsg.edit({ embeds: [updatedEmbed], components: [] });
} delete queueMessagesEndpoints[userId];
} catch (e) {
console.error("Error updating queue message : ", e);
}
}
index = connect4Queue.indexOf(userId); index = connect4Queue.indexOf(userId);
if (index > -1) { if (index > -1) {
connect4Queue.splice(index, 1); connect4Queue.splice(index, 1);
try { try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(userId); const user = await client.users.fetch(userId);
const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]) const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]);
const updatedEmbed = new EmbedBuilder().setTitle('Puissance 4').setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`).setColor(0xED4245).setTimestamp(new Date()); const updatedEmbed = new EmbedBuilder()
await queueMsg.edit({ embeds: [updatedEmbed], components: [] }); .setTitle("Puissance 4")
delete queueMessagesEndpoints[userId]; .setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`)
} catch (e) { .setColor(0xed4245)
console.error('Error updating queue message : ', e); .setTimestamp(new Date());
} await queueMsg.edit({ embeds: [updatedEmbed], components: [] });
} delete queueMessagesEndpoints[userId];
} catch (e) {
console.error("Error updating queue message : ", e);
}
}
await emitQueueUpdate(client, 'tictactoe'); await emitQueueUpdate(client, "tictactoe");
await emitQueueUpdate(client, 'connect4'); await emitQueueUpdate(client, "connect4");
} }
async function emitQueueUpdate(client, gameType) { async function emitQueueUpdate(client, gameType) {
const { queue, activeGames } = getGameAssets(gameType); const { queue, activeGames } = getGameAssets(gameType);
const names = await Promise.all(queue.map(async (id) => { const names = await Promise.all(
const user = await client.users.fetch(id).catch(() => null); queue.map(async (id) => {
return user?.globalName || user?.username; 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) }); }),
);
io.emit(`${gameType}queue`, {
allPlayers: Object.values(activeGames),
queue: names.filter(Boolean),
});
} }
function getGameAssets(gameType) { function getGameAssets(gameType) {
if (gameType === 'tictactoe') return { queue: tictactoeQueue, activeGames: activeTicTacToeGames, title: 'Tic Tac Toe', url: '/tic-tac-toe' }; if (gameType === "tictactoe")
if (gameType === 'connect4') return { queue: connect4Queue, activeGames: activeConnect4Games, title: 'Puissance 4', url: '/connect-4' }; return {
return { queue: [], activeGames: {} }; 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) { async function postQueueToDiscord(client, playerId, title, url) {
try { try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(playerId); 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').setTimestamp(new Date()); const embed = new EmbedBuilder()
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)); .setTitle(title)
const msg = await generalChannel.send({ embeds: [embed], components: [row] }); .setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`)
queueMessagesEndpoints[playerId] = msg.id .setColor("#5865F2")
} catch (e) { console.error(`Failed to post queue message for ${title}:`, e); } .setTimestamp(new Date());
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),
);
const msg = await generalChannel.send({
embeds: [embed],
components: [row],
});
queueMessagesEndpoints[playerId] = msg.id;
} catch (e) {
console.error(`Failed to post queue message for ${title}:`, e);
}
} }
async function updateDiscordMessage(client, game, title, resultText = '') { async function updateDiscordMessage(client, game, title, resultText = "") {
const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null); const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null);
if (!channel) return null; if (!channel) return null;
let description; let description;
if (title === 'Tic Tac Toe') { if (title === "Tic Tac Toe") {
let gridText = ''; let gridText = "";
for (let i = 1; i <= 9; i++) { gridText += game.xs.includes(i) ? '❌' : game.os.includes(i) ? '⭕' : '🟦'; if (i % 3 === 0) gridText += '\n'; } for (let i = 1; i <= 9; i++) {
description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`; gridText += game.xs.includes(i) ? "❌" : game.os.includes(i) ? "⭕" : "🟦";
} else { if (i % 3 === 0) gridText += "\n";
description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`; }
} description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`;
if (resultText) description += `\n### ${resultText}`; } 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'); const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(game.gameOver ? "#2ade2a" : "#5865f2");
try { try {
if (game.msgId) { if (game.msgId) {
const message = await channel.messages.fetch(game.msgId); const message = await channel.messages.fetch(game.msgId);
await message.edit({ embeds: [embed] }); await message.edit({ embeds: [embed] });
return game.msgId; return game.msgId;
} else { } else {
const message = await channel.send({ embeds: [embed] }); const message = await channel.send({ embeds: [embed] });
return message.id; return message.id;
} }
} catch (e) { return null; } } catch (e) {
return null;
}
} }
function cleanupStaleGames() { function cleanupStaleGames() {
const now = Date.now(); const now = Date.now();
const STALE_TIMEOUT = 30 * 60 * 1000; const STALE_TIMEOUT = 30 * 60 * 1000;
const cleanup = (games, name) => { const cleanup = (games, name) => {
Object.keys(games).forEach(key => { Object.keys(games).forEach((key) => {
if (now - games[key].lastmove > STALE_TIMEOUT) { if (now - games[key].lastmove > STALE_TIMEOUT) {
console.log(`[Cleanup] Removing stale ${name} game: ${key}`); console.log(`[Cleanup] Removing stale ${name} game: ${key}`);
delete games[key]; delete games[key];
} }
}); });
}; };
cleanup(activeTicTacToeGames, 'TicTacToe'); cleanup(activeTicTacToeGames, "TicTacToe");
cleanup(activeConnect4Games, 'Connect4'); cleanup(activeConnect4Games, "Connect4");
} }
/* EMITS */ /* EMITS */
export async function socketEmit(event, data) { export async function socketEmit(event, data) {
io.emit(event, data); io.emit(event, data);
} }
export async function emitDataUpdated(data) { export async function emitDataUpdated(data) {
io.emit('data-updated', data); io.emit("data-updated", data);
} }
export async function emitPokerUpdate(data) { export async function emitPokerUpdate(data) {
io.emit('poker-update', data); io.emit("poker-update", data);
} }
export async function emitPokerToast(data) { export async function emitPokerToast(data) {
io.emit('poker-toast', data); io.emit("poker-toast", data);
} }
export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, room }); export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, room });
export const emitToast = (payload) => io.emit("blackjack:toast", payload); export const emitToast = (payload) => io.emit("blackjack:toast", payload);
export const emitSolitaireUpdate = (userId, moves) => io.emit('solitaire:update', {userId, moves}); export const emitSolitaireUpdate = (userId, moves) => io.emit("solitaire:update", { userId, moves });

View File

@@ -1,27 +1,26 @@
import 'dotenv/config'; import "dotenv/config";
import OpenAI from "openai"; import OpenAI from "openai";
import {GoogleGenAI} from "@google/genai"; import { GoogleGenAI } from "@google/genai";
import {Mistral} from '@mistralai/mistralai'; import { Mistral } from "@mistralai/mistralai";
// --- AI Client Initialization --- // --- AI Client Initialization ---
// Initialize clients for each AI service. This is done once when the module is loaded. // Initialize clients for each AI service. This is done once when the module is loaded.
let openai; let openai;
if (process.env.OPENAI_API_KEY) { if (process.env.OPENAI_API_KEY) {
openai = new OpenAI(); openai = new OpenAI();
} }
let gemini; let gemini;
if (process.env.GEMINI_KEY) { if (process.env.GEMINI_KEY) {
gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY}) gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY });
} }
let mistral; let mistral;
if (process.env.MISTRAL_KEY) { if (process.env.MISTRAL_KEY) {
mistral = new Mistral({apiKey: process.env.MISTRAL_KEY}); mistral = new Mistral({ apiKey: process.env.MISTRAL_KEY });
} }
/** /**
* Gets a response from the configured AI model. * Gets a response from the configured AI model.
* It dynamically chooses the provider based on the MODEL environment variable. * It dynamically chooses the provider based on the MODEL environment variable.
@@ -29,175 +28,180 @@ if (process.env.MISTRAL_KEY) {
* @returns {Promise<string>} The content of the AI's response message. * @returns {Promise<string>} The content of the AI's response message.
*/ */
export async function gork(messageHistory) { export async function gork(messageHistory) {
const modelProvider = process.env.MODEL; const modelProvider = process.env.MODEL;
console.log(`[AI] Requesting completion from ${modelProvider}...`); console.log(`[AI] Requesting completion from ${modelProvider}...`);
try { try {
// --- OpenAI Provider --- // --- OpenAI Provider ---
if (modelProvider === 'OpenAI' && openai) { if (modelProvider === "OpenAI" && openai) {
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: "gpt-5", // Using a modern, cost-effective model model: "gpt-5", // Using a modern, cost-effective model
reasoning_effort: "low", reasoning_effort: "low",
messages: messageHistory, messages: messageHistory,
}); });
return completion.choices[0].message.content; return completion.choices[0].message.content;
} }
// --- Google Gemini Provider --- // --- Google Gemini Provider ---
else if (modelProvider === 'Gemini' && gemini) { else if (modelProvider === "Gemini" && gemini) {
// Gemini requires a slightly different history format. // Gemini requires a slightly different history format.
const contents = messageHistory.map(msg => ({ const contents = messageHistory.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : msg.role, // Gemini uses 'model' for assistant role role: msg.role === "assistant" ? "model" : msg.role, // Gemini uses 'model' for assistant role
parts: [{ text: msg.content }], parts: [{ text: msg.content }],
})); }));
// The last message should not be from the model // The last message should not be from the model
if (contents[contents.length - 1].role === 'model') { if (contents[contents.length - 1].role === "model") {
contents.pop(); contents.pop();
} }
const result = await gemini.generateContent({ contents }); const result = await gemini.generateContent({ contents });
const response = await result.response; const response = await result.response;
return response.text(); return response.text();
} }
// --- Mistral Provider --- // --- Mistral Provider ---
else if (modelProvider === 'Mistral' && mistral) { else if (modelProvider === "Mistral" && mistral) {
const chatResponse = await mistral.chat({ const chatResponse = await mistral.chat({
model: 'mistral-large-latest', model: "mistral-large-latest",
messages: messageHistory, messages: messageHistory,
}); });
return chatResponse.choices[0].message.content; return chatResponse.choices[0].message.content;
} }
// --- Fallback Case --- // --- Fallback Case ---
else { else {
console.warn(`[AI] No valid AI provider configured or API key missing for MODEL=${modelProvider}.`); 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."; return "Le service d'IA n'est pas correctement configuré. Veuillez contacter l'administrateur.";
} }
} catch(error) { } catch (error) {
console.error(`[AI] Error with ${modelProvider} API:`, error); console.error(`[AI] Error with ${modelProvider} API:`, error);
return "Oups, une erreur est survenue en contactant le service d'IA."; return "Oups, une erreur est survenue en contactant le service d'IA.";
} }
} }
export const CONTEXT_LIMIT = parseInt(process.env.AI_CONTEXT_MESSAGES || '100', 10); export const CONTEXT_LIMIT = parseInt(process.env.AI_CONTEXT_MESSAGES || "100", 10);
export const MAX_ATTS_PER_MESSAGE = parseInt(process.env.AI_MAX_ATTS_PER_MSG || '3', 10); export const MAX_ATTS_PER_MESSAGE = parseInt(process.env.AI_MAX_ATTS_PER_MSG || "3", 10);
export const INCLUDE_ATTACHMENT_URLS = (process.env.AI_INCLUDE_ATTACHMENT_URLS || 'true') === 'true'; export const INCLUDE_ATTACHMENT_URLS = (process.env.AI_INCLUDE_ATTACHMENT_URLS || "true") === "true";
export const stripMentionsOfBot = (text, botId) => export const stripMentionsOfBot = (text, botId) => text.replace(new RegExp(`<@!?${botId}>`, "g"), "").trim();
text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
export const sanitize = (s) => export const sanitize = (s) =>
(s || '') (s || "")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.replace(/```/g, 'ʼʼʼ') // éviter de casser des fences éventuels .replace(/```/g, "ʼʼʼ") // éviter de casser des fences éventuels
.trim(); .trim();
export const shortTs = (d) => new Date(d).toISOString(); // compact et triable export const shortTs = (d) => new Date(d).toISOString(); // compact et triable
export function buildParticipantsMap(messages) { export function buildParticipantsMap(messages) {
const map = {}; const map = {};
for (const m of messages) { for (const m of messages) {
const id = m.author.id; const id = m.author.id;
if (!map[id]) { if (!map[id]) {
map[id] = { map[id] = {
id, id,
username: m.author.username, username: m.author.username,
globalName: m.author.globalName || null, globalName: m.author.globalName || null,
isBot: !!m.author.bot, isBot: !!m.author.bot,
}; };
} }
} }
return map; return map;
} }
export function buildTranscript(messages, botId) { export function buildTranscript(messages, botId) {
// Oldest -> newest, JSONL compact, une ligne par message pertinent // Oldest -> newest, JSONL compact, une ligne par message pertinent
const lines = []; const lines = [];
for (const m of messages) { for (const m of messages) {
const content = sanitize(m.content); const content = sanitize(m.content);
const atts = Array.from(m.attachments?.values?.() || []); const atts = Array.from(m.attachments?.values?.() || []);
if (!content && atts.length === 0) continue; if (!content && atts.length === 0) continue;
const attMeta = atts.length const attMeta = atts.length
? atts.slice(0, MAX_ATTS_PER_MESSAGE).map(a => ({ ? atts.slice(0, MAX_ATTS_PER_MESSAGE).map((a) => ({
id: a.id, id: a.id,
name: a.name, name: a.name,
type: a.contentType || 'application/octet-stream', type: a.contentType || "application/octet-stream",
size: a.size, size: a.size,
isImage: !!(a.contentType && a.contentType.startsWith('image/')), isImage: !!(a.contentType && a.contentType.startsWith("image/")),
width: a.width || undefined, width: a.width || undefined,
height: a.height || undefined, height: a.height || undefined,
spoiler: typeof a.spoiler === 'boolean' ? a.spoiler : false, spoiler: typeof a.spoiler === "boolean" ? a.spoiler : false,
url: INCLUDE_ATTACHMENT_URLS ? a.url : undefined, // désactive par défaut url: INCLUDE_ATTACHMENT_URLS ? a.url : undefined, // désactive par défaut
})) }))
: undefined; : undefined;
const line = { const line = {
t: shortTs(m.createdTimestamp || Date.now()), t: shortTs(m.createdTimestamp || Date.now()),
id: m.author.id, id: m.author.id,
nick: m.member?.nickname || m.author.globalName || m.author.username, nick: m.member?.nickname || m.author.globalName || m.author.username,
isBot: !!m.author.bot, isBot: !!m.author.bot,
mentionsBot: new RegExp(`<@!?${botId}>`).test(m.content || ''), mentionsBot: new RegExp(`<@!?${botId}>`).test(m.content || ""),
replyTo: m.reference?.messageId || null, replyTo: m.reference?.messageId || null,
content, content,
attachments: attMeta, attachments: attMeta,
}; };
lines.push(line); lines.push(line);
} }
return lines.map(l => JSON.stringify(l)).join('\n'); return lines.map((l) => JSON.stringify(l)).join("\n");
} }
export function buildAiMessages({ export function buildAiMessages({
botId, botId,
botName = 'FlopoBot', botName = "FlopoBot",
invokerId, invokerId,
invokerName, invokerName,
requestText, requestText,
transcript, transcript,
participants, participants,
repliedUserId, repliedUserId,
invokerAttachments = [], invokerAttachments = [],
}) { }) {
const system = { const system = {
role: 'system', role: "system",
content: content: `Tu es ${botName} (ID: ${botId}) sur un serveur Discord. Style: bref, naturel, détendu, comme un pote.
`Tu es ${botName} (ID: ${botId}) sur un serveur Discord. Style: bref, naturel, détendu, comme un pote.
Règles de sortie: Règles de sortie:
- Réponds en français, en 13 phrases. - Réponds en français, en 13 phrases.
- Réponds PRINCIPALEMENT au message de <@${invokerId}>. Le transcript est un contexte facultatif. - Réponds PRINCIPALEMENT au message de <@${invokerId}>. Le transcript est un contexte facultatif.
- Pas de "Untel a dit…", pas de longs préambules. - Pas de "Untel a dit…", pas de longs préambules.
- Utilise <@ID> pour mentionner quelqu'un. - Utilise <@ID> pour mentionner quelqu'un.
- Tu ne peux PAS ouvrir les liens; si des pièces jointes existent, tu peux simplement les mentionner (ex: "ta photo", "le PDF").`, - Tu ne peux PAS ouvrir les liens; si des pièces jointes existent, tu peux simplement les mentionner (ex: "ta photo", "le PDF").`,
}; };
const attLines = invokerAttachments.length const attLines = invokerAttachments.length
? invokerAttachments.map(a => `- ${a.name} (${a.type || 'type inconnu'}, ${a.size ?? '?'} o${a.isImage ? ', image' : ''})`).join('\n') ? invokerAttachments
: ''; .map((a) => `- ${a.name} (${a.type || "type inconnu"}, ${a.size ?? "?"} o${a.isImage ? ", image" : ""})`)
.join("\n")
: "";
const user = { const user = {
role: 'user', role: "user",
content: content: `Tâche: répondre brièvement à <@${invokerId}>.
`Tâche: répondre brièvement à <@${invokerId}>.
Message de <@${invokerId}> (${invokerName || 'inconnu'}): Message de <@${invokerId}> (${invokerName || "inconnu"}):
""" """
${requestText} ${requestText}
""" """
${invokerAttachments.length ? `Pièces jointes du message: ${
invokerAttachments.length
? `Pièces jointes du message:
${attLines} ${attLines}
` : ''}${repliedUserId ? `Ce message répond à <@${repliedUserId}>.` : ''} `
: ""
}${repliedUserId ? `Ce message répond à <@${repliedUserId}>.` : ""}
Participants (id -> nom): Participants (id -> nom):
${Object.values(participants).map(p => `- ${p.id} -> ${p.globalName || p.username}`).join('\n')} ${Object.values(participants)
.map((p) => `- ${p.id} -> ${p.globalName || p.username}`)
.join("\n")}
Contexte (transcript JSONL; à utiliser seulement si utile): Contexte (transcript JSONL; à utiliser seulement si utile):
\`\`\`jsonl \`\`\`jsonl
${transcript} ${transcript}
\`\`\``, \`\`\``,
}; };
return [system, user]; return [system, user];
} }

View File

@@ -1,75 +1,75 @@
export const roles = { export const roles = {
erynie_1: { erynie_1: {
name: 'Erinye', name: "Erinye",
subtitle: 'Mégère, la haine', subtitle: "Mégère, la haine",
descr: '', descr: "",
powers: { powers: {
double_vote: { double_vote: {
descr: 'Les Erinyes peuvent tuer une deuxième personne (1 seule fois).', descr: "Les Erinyes peuvent tuer une deuxième personne (1 seule fois).",
charges: 1, charges: 1,
disabled: false, disabled: false,
}, },
}, },
passive: {}, passive: {},
team: 'Erinyes', team: "Erinyes",
}, },
erynie_2: { erynie_2: {
name: 'Erinye', name: "Erinye",
subtitle: 'Tisiphone, la vengeance', subtitle: "Tisiphone, la vengeance",
descr: '', descr: "",
powers: { powers: {
one_shot: { one_shot: {
descr: 'Tuer une personne de son choix en plus du vote des Erinyes (1 seule fois).', descr: "Tuer une personne de son choix en plus du vote des Erinyes (1 seule fois).",
charges: 1, charges: 1,
disabled: false, disabled: false,
}, },
}, },
passive: {}, passive: {},
team: 'Erinyes', team: "Erinyes",
}, },
erynie_3: { erynie_3: {
name: 'Erinye', name: "Erinye",
subtitle: 'Alecto, l\'implacable', subtitle: "Alecto, l'implacable",
descr: '', descr: "",
powers: { powers: {
silence: { silence: {
descr: 'Empêche l\'utilisation du pouvoir de quelqu\'un pour le prochain tour.', descr: "Empêche l'utilisation du pouvoir de quelqu'un pour le prochain tour.",
charges: 999, charges: 999,
disabled: false, disabled: false,
} },
}, },
passive: { passive: {
descr: 'Voit quels pouvoirs ont été utilisés.', descr: "Voit quels pouvoirs ont été utilisés.",
disabled: false, disabled: false,
}, },
team: 'Erinyes', team: "Erinyes",
}, },
narcisse: { narcisse: {
name: 'Narcisse', name: "Narcisse",
subtitle: '', subtitle: "",
descr: '', descr: "",
powers: {}, powers: {},
passive: { passive: {
descr: 'S\'il devient maire ...', descr: "S'il devient maire ...",
disabled: false, disabled: false,
}, },
}, },
charon: { charon: {
name: 'Charon', name: "Charon",
subtitle: 'Sorcier', subtitle: "Sorcier",
descr: 'C\'est le passeur, il est appelé chaque nuit après les Erinyes pour décider du sort des mortels.', descr: "C'est le passeur, il est appelé chaque nuit après les Erinyes pour décider du sort des mortels.",
powers: { powers: {
revive: { revive: {
descr: 'Refuser de faire traverser le Styx (sauver quelqu\'un)', descr: "Refuser de faire traverser le Styx (sauver quelqu'un)",
charges: 1, charges: 1,
disabled: false, disabled: false,
}, },
kill: { kill: {
descr: 'Traverser le Styx (tuer quelqu\'un)', descr: "Traverser le Styx (tuer quelqu'un)",
charges: 1, charges: 1,
disabled: false, disabled: false,
} },
}, },
}, },
//... //...
} };

View File

@@ -1,27 +1,30 @@
import 'dotenv/config'; import "dotenv/config";
import cron from 'node-cron'; import cron from "node-cron";
import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator';
// --- Local Imports --- // --- Local Imports ---
import { getValorantSkins, getSkinTiers } from '../api/valorant.js'; import { getSkinTiers, getValorantSkins } from "../api/valorant.js";
import { DiscordRequest } from '../api/discord.js'; import { DiscordRequest } from "../api/discord.js";
import { initTodaysSOTD } from '../game/points.js'; import { initTodaysSOTD } from "../game/points.js";
import { import {
insertManyUsers, insertManySkins, resetDailyReward, getAllAkhys,
pruneOldLogs, getAllUsers as dbGetAllUsers, getSOTD, getUser, getAllUsers, insertUser, stmtUsers, getAllUsers,
} from '../database/index.js'; insertManySkins,
import { activeInventories, activeSearchs, activePredis, pokerRooms, skins } from '../game/state.js'; insertUser,
resetDailyReward,
updateUserAvatar,
} from "../database/index.js";
import { activeInventories, activeSearchs, skins } from "../game/state.js";
export async function InstallGlobalCommands(appId, commands) { export async function InstallGlobalCommands(appId, commands) {
// API endpoint to overwrite global commands // API endpoint to overwrite global commands
const endpoint = `applications/${appId}/commands`; const endpoint = `applications/${appId}/commands`;
try { try {
// This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands // 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 }); await DiscordRequest(endpoint, { method: "PUT", body: commands });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
// --- Data Fetching & Initialization --- // --- Data Fetching & Initialization ---
@@ -32,74 +35,74 @@ export async function InstallGlobalCommands(appId, commands) {
* @param {object} client - The Discord.js client instance. * @param {object} client - The Discord.js client instance.
*/ */
export async function getAkhys(client) { export async function getAkhys(client) {
try { try {
// 1. Fetch Discord Members // 1. Fetch Discord Members
const initial_akhys = getAllUsers.all().length; const initial_akhys = getAllUsers.all().length;
const guild = await client.guilds.fetch(process.env.GUILD_ID); const guild = await client.guilds.fetch(process.env.GUILD_ID);
const members = await guild.members.fetch(); const members = await guild.members.fetch();
const akhys = members.filter(m => !m.user.bot && m.roles.cache.has(process.env.AKHY_ROLE_ID)); 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,
warned: 0,
warns: 0,
allTimeWarns: 0,
totalRequests: 0,
avatarUrl: akhy.user.displayAvatarURL({ dynamic: true, size: 256 }),
isAkhy: 1,
}));
const usersToInsert = akhys.map(akhy => ({ if (usersToInsert.length > 0) {
id: akhy.user.id, usersToInsert.forEach((user) => {
username: akhy.user.username, try {
globalName: akhy.user.globalName, insertUser.run(user);
warned: 0, } catch (err) {}
warns: 0, });
allTimeWarns: 0, }
totalRequests: 0,
avatarUrl: akhy.user.displayAvatarURL({ dynamic: true, size: 256 }),
isAkhy: 1
}));
if (usersToInsert.length > 0) { const new_akhys = getAllUsers.all().length;
usersToInsert.forEach(user => { const diff = new_akhys - initial_akhys;
try { insertUser.run(user) } catch (err) {} console.log(
}) `[Sync] Found and synced ${usersToInsert.length} ${diff !== 0 ? "(" + (diff > 0 ? "+" + diff : diff) + ") " : ""}users with the 'Akhy' role. (ID:${process.env.AKHY_ROLE_ID})`,
} );
const new_akhys = getAllUsers.all().length; // 2. Fetch Valorant Skins
const diff = new_akhys - initial_akhys const [fetchedSkins, fetchedTiers] = await Promise.all([getValorantSkins(), getSkinTiers()]);
console.log(`[Sync] Found and synced ${usersToInsert.length} ${diff !== 0 ? '(' + (diff > 0 ? '+' + diff : diff) + ') ' : ''}users with the 'Akhy' role. (ID:${process.env.AKHY_ROLE_ID})`);
// 2. Fetch Valorant Skins // Clear and rebuild the in-memory skin cache
const [fetchedSkins, fetchedTiers] = await Promise.all([getValorantSkins(), getSkinTiers()]); skins.length = 0;
fetchedSkins.forEach((skin) => skins.push(skin));
// Clear and rebuild the in-memory skin cache const skinsToInsert = fetchedSkins
skins.length = 0; .filter((skin) => skin.contentTierUuid)
fetchedSkins.forEach(skin => skins.push(skin)); .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(0),
maxPrice: calculateMaxPrice(basePrice, skin).toFixed(0),
};
});
const skinsToInsert = fetchedSkins if (skinsToInsert.length > 0) {
.filter(skin => skin.contentTierUuid) insertManySkins(skinsToInsert);
.map(skin => { }
const tier = fetchedTiers.find(t => t.uuid === skin.contentTierUuid) || {}; console.log(`[Sync] Fetched and synced ${skinsToInsert.length} Valorant skins.`);
const basePrice = calculateBasePrice(skin, tier.rank); } catch (err) {
return { console.error("Error during initial data sync (getAkhys):", err);
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(0),
maxPrice: calculateMaxPrice(basePrice, skin).toFixed(0),
};
});
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 --- // --- Cron Jobs / Scheduled Tasks ---
/** /**
@@ -108,72 +111,87 @@ export async function getAkhys(client) {
* @param {object} io - The Socket.IO server instance. * @param {object} io - The Socket.IO server instance.
*/ */
export function setupCronJobs(client, io) { export function setupCronJobs(client, io) {
// Every 10 minutes: Clean up expired interactive sessions // Every 10 minutes: Clean up expired interactive sessions
cron.schedule('*/10 * * * *', () => { cron.schedule("*/10 * * * *", () => {
const now = Date.now(); const now = Date.now();
const FIVE_MINUTES = 5 * 60 * 1000; const FIVE_MINUTES = 5 * 60 * 1000;
const ONE_DAY = 24 * 60 * 60 * 1000; const ONE_DAY = 24 * 60 * 60 * 1000;
const cleanup = (sessions, name) => { const cleanup = (sessions, name) => {
let cleanedCount = 0; let cleanedCount = 0;
for (const id in sessions) { for (const id in sessions) {
if (now >= (sessions[id].timestamp || 0) + FIVE_MINUTES) { if (now >= (sessions[id].timestamp || 0) + FIVE_MINUTES) {
delete sessions[id]; delete sessions[id];
cleanedCount++; cleanedCount++;
} }
} }
if (cleanedCount > 0) console.log(`[Cron] Cleaned up ${cleanedCount} expired ${name} sessions.`); if (cleanedCount > 0) console.log(`[Cron] Cleaned up ${cleanedCount} expired ${name} sessions.`);
}; };
cleanup(activeInventories, 'inventory'); cleanup(activeInventories, "inventory");
cleanup(activeSearchs, 'search'); cleanup(activeSearchs, "search");
// Cleanup for predis and poker rooms... // TODO: Cleanup for predis and poker rooms...
// ... // ...
}); });
// Daily at midnight: Reset daily rewards and init SOTD // Daily at midnight: Reset daily rewards and init SOTD
cron.schedule('0 0 * * *', async () => { cron.schedule("0 0 * * *", async () => {
console.log('[Cron] Running daily midnight tasks...'); console.log("[Cron] Running daily midnight tasks...");
try { try {
resetDailyReward.run(); resetDailyReward.run();
console.log('[Cron] Daily rewards have been reset for all users.'); console.log("[Cron] Daily rewards have been reset for all users.");
//if (!getSOTD.get()) { //if (!getSOTD.get()) {
initTodaysSOTD(); initTodaysSOTD();
//} //}
} catch (e) { } catch (e) {
console.error('[Cron] Error during daily reset:', e); console.error("[Cron] Error during daily reset:", e);
} }
}); });
// Daily at 7 AM: Re-sync users and skins // Daily at 7 AM: Re-sync users and skins
cron.schedule('0 7 * * *', async () => { cron.schedule("0 7 * * *", async () => {
console.log('[Cron] Running daily 7 AM data sync...'); console.log("[Cron] Running daily 7 AM data sync...");
await getAkhys(client); await getAkhys(client);
}); try {
const akhys = getAllAkhys.all();
for (const akhy of akhys) {
const user = await client.users.cache.get(akhy.id);
try {
updateUserAvatar.run({
id: akhy.id,
avatarUrl: user.displayAvatarURL({ dynamic: true, size: 256 }),
});
} catch (err) {
console.error(`[Cron] Error updating avatar for user ID: ${akhy.id}`, err);
}
}
} catch (e) {
console.error("[Cron] Error during daily avatar update:", e);
}
});
} }
// --- Formatting Helpers --- // --- Formatting Helpers ---
export function capitalize(str) { export function capitalize(str) {
if (typeof str !== 'string' || str.length === 0) return ''; if (typeof str !== "string" || str.length === 0) return "";
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
} }
export function formatTime(seconds) { export function formatTime(seconds) {
const d = Math.floor(seconds / (3600*24)); const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor(seconds % (3600*24) / 3600); const h = Math.floor((seconds % (3600 * 24)) / 3600);
const m = Math.floor(seconds % 3600 / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
const parts = []; const parts = [];
if (d > 0) parts.push(`**${d}** jour${d > 1 ? 's' : ''}`); if (d > 0) parts.push(`**${d}** jour${d > 1 ? "s" : ""}`);
if (h > 0) parts.push(`**${h}** heure${h > 1 ? 's' : ''}`); if (h > 0) parts.push(`**${h}** heure${h > 1 ? "s" : ""}`);
if (m > 0) parts.push(`**${m}** minute${m > 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' : ''}`); if (s > 0 || parts.length === 0) parts.push(`**${s}** seconde${s > 1 ? "s" : ""}`);
return parts.join(', ').replace(/,([^,]*)$/, ' et$1'); return parts.join(", ").replace(/,([^,]*)$/, " et$1");
} }
// --- External API Helpers --- // --- External API Helpers ---
@@ -182,15 +200,15 @@ export function formatTime(seconds) {
* Fetches user data from the "APO" service. * Fetches user data from the "APO" service.
*/ */
export async function getAPOUsers() { export async function getAPOUsers() {
const fetchUrl = `${process.env.APO_BASE_URL}/users?serverId=${process.env.GUILD_ID}`; const fetchUrl = `${process.env.APO_BASE_URL}/users?serverId=${process.env.GUILD_ID}`;
try { try {
const response = await fetch(fetchUrl); const response = await fetch(fetchUrl);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error fetching APO users:', error); console.error("Error fetching APO users:", error);
return null; return null;
} }
} }
/** /**
@@ -199,112 +217,124 @@ export async function getAPOUsers() {
* @param {number} amount - The amount to "buy". * @param {number} amount - The amount to "buy".
*/ */
export async function postAPOBuy(userId, amount) { export async function postAPOBuy(userId, amount) {
const fetchUrl = `${process.env.APO_BASE_URL}/buy?serverId=${process.env.GUILD_ID}&userId=${userId}&amount=${amount}`; const fetchUrl = `${process.env.APO_BASE_URL}/buy?serverId=${process.env.GUILD_ID}&userId=${userId}&amount=${amount}`;
return fetch(fetchUrl, { method: 'POST' }); return fetch(fetchUrl, { method: "POST" });
} }
// --- Miscellaneous Helpers --- // --- Miscellaneous Helpers ---
export async function getOnlineUsersWithRole(guild, roleId) { export async function getOnlineUsersWithRole(guild, roleId) {
if (!guild || !roleId) return new Map(); if (!guild || !roleId) return new Map();
try { try {
const members = await guild.members.fetch(); const members = await guild.members.fetch();
return members.filter(m => !m.user.bot && m.presence?.status !== 'offline' && m.presence?.status !== undefined && m.roles.cache.has(roleId)); return members.filter(
} catch (err) { (m) =>
console.error('Error fetching online members with role:', err); !m.user.bot &&
return new Map(); m.presence?.status !== "offline" &&
} m.presence?.status !== undefined &&
m.roles.cache.has(roleId),
);
} catch (err) {
console.error("Error fetching online members with role:", err);
return new Map();
}
} }
export function getRandomEmoji(list = 0) { export function getRandomEmoji(list = 0) {
const emojiLists = [ const emojiLists = [
['😭','😄','😌','🤓','😎','😤','🤖','😶‍🌫️','🌏','📸','💿','👋','🌊','✨'], ["😭", "😄", "😌", "🤓", "😎", "😤", "🤖", "😶‍🌫️", "🌏", "📸", "💿", "👋", "🌊", "✨"],
['<:CAUGHT:1323810730155446322>', '<:hinhinhin:1072510144933531758>', '<:o7:1290773422451986533>', '<:zhok:1115221772623683686>', '<:nice:1154049521110765759>', '<:nerd:1087658195603951666>', '<:peepSelfie:1072508131839594597>'], [
]; "<:CAUGHT:1323810730155446322>",
const selectedList = emojiLists[list] || ['']; "<:hinhinhin:1072510144933531758>",
return selectedList[Math.floor(Math.random() * selectedList.length)]; "<:o7:1290773422451986533>",
"<:zhok:1115221772623683686>",
"<:nice:1154049521110765759>",
"<:nerd:1087658195603951666>",
"<:peepSelfie:1072508131839594597>",
],
];
const selectedList = emojiLists[list] || [""];
return selectedList[Math.floor(Math.random() * selectedList.length)];
} }
export function formatAmount(amount) { export function formatAmount(amount) {
if (amount >= 1000000000) { if (amount >= 1000000000) {
amount /= 1000000000 amount /= 1000000000;
return ( return (
amount amount
.toFixed(2) .toFixed(2)
.toString() .toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + 'Md' .replace(/\B(?=(\d{3})+(?!\d))/g, " ") + "Md"
) );
} }
if (amount >= 1000000) { if (amount >= 1000000) {
amount /= 1000000 amount /= 1000000;
return ( return (
amount amount
.toFixed(2) .toFixed(2)
.toString() .toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + 'M' .replace(/\B(?=(\d{3})+(?!\d))/g, " ") + "M"
) );
} }
if (amount >= 10000) { if (amount >= 10000) {
amount /= 1000 amount /= 1000;
return ( return (
amount amount
.toFixed(2) .toFixed(2)
.toString() .toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + 'K' .replace(/\B(?=(\d{3})+(?!\d))/g, " ") + "K"
) );
} }
return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') return amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
} }
// --- Private Helpers --- // --- Private Helpers ---
export function calculateBasePrice(skin, tierRank) { export function calculateBasePrice(skin, tierRank) {
const name = skin.displayName.toLowerCase(); const name = skin.displayName.toLowerCase();
let price = 6000; // Default for melee let price = 6000; // Default for melee
if (name.includes('classic')) price = 150; if (name.includes("classic")) price = 150;
else if (name.includes('shorty')) price = 300; else if (name.includes("shorty")) price = 300;
else if (name.includes('frenzy')) price = 450; else if (name.includes("frenzy")) price = 450;
else if (name.includes('ghost')) price = 500; else if (name.includes("ghost")) price = 500;
else if (name.includes('sheriff')) price = 800; else if (name.includes("sheriff")) price = 800;
else if (name.includes('stinger')) price = 1000; else if (name.includes("stinger")) price = 1000;
else if (name.includes('spectre')) price = 1600; else if (name.includes("spectre")) price = 1600;
else if (name.includes('bucky')) price = 900; else if (name.includes("bucky")) price = 900;
else if (name.includes('judge')) price = 1500; else if (name.includes("judge")) price = 1500;
else if (name.includes('bulldog')) price = 2100; else if (name.includes("bulldog")) price = 2100;
else if (name.includes('guardian')) price = 2700 else if (name.includes("guardian")) price = 2700;
else if (name.includes('vandal') || name.includes('phantom')) price = 2900; else if (name.includes("vandal") || name.includes("phantom")) price = 2900;
else if (name.includes('marshal')) price = 950; else if (name.includes("marshal")) price = 950;
else if (name.includes('outlaw')) price = 2400; else if (name.includes("outlaw")) price = 2400;
else if (name.includes('operator')) price = 4500; else if (name.includes("operator")) price = 4500;
else if (name.includes('ares')) price = 1700; else if (name.includes("ares")) price = 1700;
else if (name.includes('odin')) price = 3200; else if (name.includes("odin")) price = 3200;
price *= (1 + (tierRank || 0)); price *= 1 + (tierRank || 0);
if (name.includes('vct')) price *= 1.25; if (name.includes("vct")) price *= 1.25;
if (name.includes('champions')) price *= 2; if (name.includes("champions")) price *= 2;
return price / 124; return price / 124;
} }
export function calculateMaxPrice(basePrice, skin) { export function calculateMaxPrice(basePrice, skin) {
let res = basePrice; let res = basePrice;
res *= (1 + (skin.levels.length / Math.max(skin.levels.length, 2))); res *= 1 + skin.levels.length / Math.max(skin.levels.length, 2);
res *= (1 + (skin.chromas.length / 4)); res *= 1 + skin.chromas.length / 4;
return res; return res;
} }
function formatTierText(rank, displayName) { function formatTierText(rank, displayName) {
const tiers = { const tiers = {
0: '**<:select:1362964319498670222> Select**', 0: "**<:select:1362964319498670222> Select**",
1: '**<:deluxe:1362964308094488797> Deluxe**', 1: "**<:deluxe:1362964308094488797> Deluxe**",
2: '**<:premium:1362964330349330703> Premium**', 2: "**<:premium:1362964330349330703> Premium**",
3: '**<:exclusive:1362964427556651098> Exclusive**', 3: "**<:exclusive:1362964427556651098> Exclusive**",
4: '**<:ultra:1362964339685986314> Ultra**', 4: "**<:ultra:1362964339685986314> Ultra**",
}; };
let res = tiers[rank] || 'Pas de tier'; let res = tiers[rank] || "Pas de tier";
if (displayName.includes('VCT')) res += ' | Esports'; if (displayName.includes("VCT")) res += " | Esports";
if (displayName.toLowerCase().includes('champions')) res += ' | Champions'; if (displayName.toLowerCase().includes("champions")) res += " | Champions";
return res; return res;
} }