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">
<profile version="1.0">
<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>
</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
## 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
- FlopoBot has its own website to use it a different way
[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();
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 http from 'http';
import { Server } from 'socket.io';
import "dotenv/config";
import http from "http";
import { Server } from "socket.io";
import { app } from './src/server/app.js';
import { client } from './src/bot/client.js';
import { initializeEvents } from './src/bot/events.js';
import { initializeSocket } from './src/server/socket.js';
import { getAkhys, setupCronJobs } from './src/utils/index.js';
import { app } from "./src/server/app.js";
import { client } from "./src/bot/client.js";
import { initializeEvents } from "./src/bot/events.js";
import { initializeSocket } from "./src/server/socket.js";
import { setupCronJobs } from "./src/utils/index.js";
// --- SERVER INITIALIZATION ---
const PORT = process.env.PORT || 25578;
const server = http.createServer(app);
// --- 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, {
cors: {
origin: FLAPI_URL,
methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
},
pingInterval: 5000,
pingTimeout: 5000,
cors: {
origin: FLAPI_URL,
methods: ["GET", "POST", "PUT", "OPTIONS"],
},
pingInterval: 5000,
pingTimeout: 5000,
});
initializeSocket(io, client);
// --- BOT INITIALIZATION ---
initializeEvents(client, io);
client.login(process.env.BOT_TOKEN).then(() => {
console.log(`Logged in as ${client.user.tag}`);
console.log('[Discord Bot Events Initialized]');
console.log(`Logged in as ${client.user.tag}`);
console.log("[Discord Bot Events Initialized]");
});
// --- APP STARTUP ---
server.listen(PORT, async () => {
console.log(`Express+Socket.IO server listening on port ${PORT}`);
console.log(`[Connected with ${FLAPI_URL}]`);
console.log(`Express+Socket.IO server listening on port ${PORT}`);
console.log(`[Connected with ${FLAPI_URL}]`);
// Initial data fetch and setup
try {
// Initial data fetch and setup
/*try {
await getAkhys(client);
} catch (error) {
console.log('Initial Fetch Error');
}
}*/
// Setup scheduled tasks
//setupCronJobs(client, io);
console.log('[Cron Jobs Initialized]');
// Setup scheduled tasks
setupCronJobs(client, io);
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",
"private": true,
"version": "1.0.0",
"description": "Flopobot le 2 donc en mieux",
"main": "index.js",
"type": "module",
"engines": {
"node": ">=18.x"
},
"scripts": {
"start": "node index.js",
"register": "node commands.js",
"dev": "nodemon index.js"
},
"author": "Milo Gourvest",
"license": "MIT",
"dependencies": {
"@google/genai": "^0.8.0",
"@mistralai/mistralai": "^1.6.0",
"axios": "^1.9.0",
"better-sqlite3": "^11.9.1",
"discord-interactions": "^4.0.0",
"discord.js": "^14.18.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"node-cron": "^3.0.3",
"openai": "^4.104.0",
"pokersolver": "^2.1.4",
"socket.io": "^4.8.1",
"unique-names-generator": "^4.7.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
"name": "t12_flopobot",
"private": true,
"version": "1.0.0",
"description": "Flopobot le 2 donc en mieux",
"main": "index.js",
"type": "module",
"engines": {
"node": ">=18.x"
},
"scripts": {
"start": "node index.js",
"register": "node commands.js",
"dev": "nodemon index.js"
},
"author": "Milo Gourvest",
"license": "MIT",
"dependencies": {
"@google/genai": "^0.8.0",
"@mistralai/mistralai": "^1.6.0",
"axios": "^1.9.0",
"better-sqlite3": "^11.9.1",
"discord-interactions": "^4.0.0",
"discord.js": "^14.18.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"node-cron": "^3.0.3",
"openai": "^4.104.0",
"pokersolver": "^2.1.4",
"socket.io": "^4.8.1",
"unique-names-generator": "^4.7.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@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",
"extends": [
"config:recommended",
":disableDependencyDashboard",
":preserveSemverRanges"
],
"ignorePaths": [
"**/node_modules/**"
]
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":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.
@@ -10,38 +10,38 @@ import 'dotenv/config';
* @throws Will throw an error if the API request is not successful.
*/
export async function DiscordRequest(endpoint, options) {
// Construct the full API URL
const url = 'https://discord.com/api/v10/' + endpoint;
// Construct the full API URL
const url = "https://discord.com/api/v10/" + endpoint;
// Stringify the payload if it exists
if (options && options.body) {
options.body = JSON.stringify(options.body);
}
// Stringify the payload if it exists
if (options && options.body) {
options.body = JSON.stringify(options.body);
}
// Use fetch to make the request, automatically including required headers
const res = await fetch(url, {
headers: {
'Authorization': `Bot ${process.env.DISCORD_TOKEN}`,
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': 'DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)',
},
...options, // Spread the given options (e.g., method, body)
});
// Use fetch to make the request, automatically including required headers
const res = await fetch(url, {
headers: {
Authorization: `Bot ${process.env.DISCORD_TOKEN}`,
"Content-Type": "application/json; charset=UTF-8",
"User-Agent": "DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)",
},
...options, // Spread the given options (e.g., method, body)
});
// If the request was not successful, throw a detailed error
if (!res.ok) {
let data
try {
data = await res.json();
} catch (err) {
data = res;
}
console.error(`Discord API Error on endpoint ${endpoint}:`, res.status, data);
throw new Error(JSON.stringify(data));
}
// If the request was not successful, throw a detailed error
if (!res.ok) {
let data;
try {
data = await res.json();
} catch (err) {
data = res;
}
console.error(`Discord API Error on endpoint ${endpoint}:`, res.status, data);
throw new Error(JSON.stringify(data));
}
// Return the original response object for further processing
return res;
// Return the original response object for further processing
return res;
}
/**
@@ -51,15 +51,15 @@ export async function DiscordRequest(endpoint, options) {
* @param {Array<object>} commands - An array of command objects to install.
*/
export async function InstallGlobalCommands(appId, commands) {
// API endpoint for bulk overwriting global commands
const endpoint = `applications/${appId}/commands`;
// API endpoint for bulk overwriting global commands
const endpoint = `applications/${appId}/commands`;
console.log('Installing global commands...');
try {
// This uses the generic DiscordRequest function to make the API call
await DiscordRequest(endpoint, { method: 'PUT', body: commands });
console.log('Successfully installed global commands.');
} catch (err) {
console.error('Error installing global commands:', err);
}
console.log("Installing global commands...");
try {
// This uses the generic DiscordRequest function to make the API call
await DiscordRequest(endpoint, { method: "PUT", body: commands });
console.log("Successfully installed global commands.");
} catch (err) {
console.error("Error installing global commands:", err);
}
}

View File

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

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import {
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from 'discord-interactions';
import { activeInventories, skins } from '../../game/state.js';
import { getUserInventory } from '../../database/index.js';
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from "discord-interactions";
import { activeInventories, skins } from "../../game/state.js";
import { getUserInventory } from "../../database/index.js";
/**
* Handles the /inventory slash command.
@@ -17,122 +17,149 @@ import { getUserInventory } from '../../database/index.js';
* @param {string} interactionId - The unique ID of the interaction.
*/
export async function handleInventoryCommand(req, res, client, interactionId) {
const { member, guild_id, token, data } = req.body;
const commandUserId = member.user.id;
// User can specify another member, otherwise it defaults to themself
const targetUserId = data.options && data.options.length > 0 ? data.options[0].value : commandUserId;
const { member, guild_id, token, data } = req.body;
const commandUserId = member.user.id;
// User can specify another member, otherwise it defaults to themself
const targetUserId = data.options && data.options.length > 0 ? data.options[0].value : commandUserId;
try {
// --- 1. Fetch Data ---
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(targetUserId);
const inventorySkins = getUserInventory.all({ user_id: targetUserId });
try {
// --- 1. Fetch Data ---
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(targetUserId);
const inventorySkins = getUserInventory.all({ user_id: targetUserId });
// --- 2. Handle Empty Inventory ---
if (inventorySkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
description: "Cet inventaire est vide.",
color: 0x4F545C, // Discord Gray
}],
},
});
}
// --- 2. Handle Empty Inventory ---
if (inventorySkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
description: "Cet inventaire est vide.",
color: 0x4f545c, // Discord Gray
},
],
},
});
}
// --- 3. Store Interactive Session State ---
// This state is crucial for the component handlers to know which inventory to update.
activeInventories[interactionId] = {
akhyId: targetUserId, // The inventory owner
userId: commandUserId, // The user who ran the command
page: 0,
amount: inventorySkins.length,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
inventorySkins: inventorySkins, // Cache the skins to avoid re-querying the DB on each page turn
};
// --- 3. Store Interactive Session State ---
// This state is crucial for the component handlers to know which inventory to update.
activeInventories[interactionId] = {
akhyId: targetUserId, // The inventory owner
userId: commandUserId, // The user who ran the command
page: 0,
amount: inventorySkins.length,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
inventorySkins: inventorySkins, // Cache the skins to avoid re-querying the DB on each page turn
};
// --- 4. Prepare Embed Content ---
const currentSkin = inventorySkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0);
// --- 4. Prepare Embed Content ---
const currentSkin = inventorySkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0);
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? '💠 ' : '';
}
return result || 'N/A';
};
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? "💠 " : "";
}
return result || "N/A";
};
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return 'Base';
};
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName
.replace(/[\r\n]+/g, " ")
.replace(skinInfo.displayName, "")
.trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return "Base";
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
// --- 5. Build Initial Components (Buttons) ---
const components = [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
];
// --- 5. Build Initial Components (Buttons) ---
const components = [
{
type: MessageComponentTypes.BUTTON,
custom_id: `prev_page_${interactionId}`,
label: "⏮️ Préc.",
style: ButtonStyleTypes.SECONDARY,
},
{
type: MessageComponentTypes.BUTTON,
custom_id: `next_page_${interactionId}`,
label: "Suiv. ⏭️",
style: ButtonStyleTypes.SECONDARY,
},
];
const isUpgradable = currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length;
// Only show upgrade button if the skin is upgradable AND the command user owns the inventory
if (isUpgradable && targetUserId === commandUserId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)} Flopos)`,
style: ButtonStyleTypes.PRIMARY,
});
}
const isUpgradable =
currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length;
// Only show upgrade button if the skin is upgradable AND the command user owns the inventory
if (isUpgradable && targetUserId === commandUserId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice / 10).toFixed(0)} Flopos)`,
style: ButtonStyleTypes.PRIMARY,
});
}
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
footer: { text: `Page 1/${inventorySkins.length} | Valeur Totale : ${totalPrice.toFixed(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,}]
}],
},
});
} catch (error) {
console.error('Error handling /inventory command:', error);
return res.status(500).json({ error: 'Failed to generate inventory.' });
}
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [
{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xf2f3f3,
footer: {
text: `Page 1/${inventorySkins.length} | Valeur Totale : ${totalPrice.toFixed(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,
},
],
},
],
},
});
} 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 {
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from 'discord-interactions';
import { activeSearchs, skins } from '../../game/state.js';
import { getAllSkins } from '../../database/index.js';
InteractionResponseType,
InteractionResponseFlags,
MessageComponentTypes,
ButtonStyleTypes,
} from "discord-interactions";
import { activeSearchs, skins } from "../../game/state.js";
import { getAllSkins } from "../../database/index.js";
/**
* Handles the /search slash command.
@@ -17,106 +17,117 @@ import { getAllSkins } from '../../database/index.js';
* @param {string} interactionId - The unique ID of the interaction.
*/
export async function handleSearchCommand(req, res, client, interactionId) {
const { member, guild_id, token, data } = req.body;
const userId = member.user.id;
const searchValue = data.options[0].value.toLowerCase();
const { member, guild_id, token, data } = req.body;
const userId = member.user.id;
const searchValue = data.options[0].value.toLowerCase();
try {
// --- 1. Fetch and Filter Data ---
const allDbSkins = getAllSkins.all();
const resultSkins = allDbSkins.filter((skin) =>
skin.displayName.toLowerCase().includes(searchValue) ||
skin.tierText.toLowerCase().includes(searchValue)
);
try {
// --- 1. Fetch and Filter Data ---
const allDbSkins = getAllSkins.all();
const resultSkins = allDbSkins.filter(
(skin) =>
skin.displayName.toLowerCase().includes(searchValue) || skin.tierText.toLowerCase().includes(searchValue),
);
// --- 2. Handle No Results ---
if (resultSkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Aucun skin ne correspond à votre recherche.',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 2. Handle No Results ---
if (resultSkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Aucun skin ne correspond à votre recherche.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Store Interactive Session State ---
activeSearchs[interactionId] = {
userId: userId,
page: 0,
amount: resultSkins.length,
resultSkins: resultSkins,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
searchValue: searchValue,
};
// --- 3. Store Interactive Session State ---
activeSearchs[interactionId] = {
userId: userId,
page: 0,
amount: resultSkins.length,
resultSkins: resultSkins,
endpoint: `webhooks/${process.env.APP_ID}/${token}/messages/@original`,
timestamp: Date.now(),
searchValue: searchValue,
};
// --- 4. Prepare Initial Embed Content ---
const guild = await client.guilds.fetch(guild_id);
const currentSkin = resultSkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
// --- 4. Prepare Initial Embed Content ---
const guild = await client.guilds.fetch(guild_id);
const currentSkin = resultSkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
// Fetch owner details if the skin is owned
let ownerText = '';
if (currentSkin.user_id) {
try {
const owner = await guild.members.fetch(currentSkin.user_id);
ownerText = `| **@${owner.user.globalName || owner.user.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
ownerText = '| Appartenant à un utilisateur inconnu';
}
}
// Fetch owner details if the skin is owned
let ownerText = "";
if (currentSkin.user_id) {
try {
const owner = await guild.members.fetch(currentSkin.user_id);
ownerText = `| **@${owner.user.globalName || owner.user.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
ownerText = "| Appartenant à un utilisateur inconnu";
}
}
// Helper to get the best possible image for the skin
const getImageUrl = (skinInfo) => {
const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1];
if (lastChroma?.fullRender) return lastChroma.fullRender;
if (lastChroma?.displayIcon) return lastChroma.displayIcon;
// Helper to get the best possible image for the skin
const getImageUrl = (skinInfo) => {
const lastChroma = skinInfo.chromas[skinInfo.chromas.length - 1];
if (lastChroma?.fullRender) return lastChroma.fullRender;
if (lastChroma?.displayIcon) return lastChroma.displayIcon;
const lastLevel = skinInfo.levels[skinInfo.levels.length - 1];
if (lastLevel?.displayIcon) return lastLevel.displayIcon;
const lastLevel = skinInfo.levels[skinInfo.levels.length - 1];
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 ---
const components = [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_search_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_search_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
],
},
];
// --- 5. Build Initial Components & Embed ---
const components = [
{
type: MessageComponentTypes.ACTION_ROW,
components: [
{
type: MessageComponentTypes.BUTTON,
custom_id: `prev_search_page_${interactionId}`,
label: "⏮️ Préc.",
style: ButtonStyleTypes.SECONDARY,
},
{
type: MessageComponentTypes.BUTTON,
custom_id: `next_search_page_${interactionId}`,
label: "Suiv. ⏭️",
style: ButtonStyleTypes.SECONDARY,
},
],
},
];
const embed = {
title: 'Résultats de la recherche',
description: `🔎 _"${searchValue}"_`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
fields: [{
name: `**${currentSkin.displayName}**`,
value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice} Flopos** ${ownerText}`,
}],
image: { url: getImageUrl(skinData) },
footer: { text: `Résultat 1/${resultSkins.length}` },
};
const embed = {
title: "Résultats de la recherche",
description: `🔎 _"${searchValue}"_`,
color: parseInt(currentSkin.tierColor, 16) || 0xf2f3f3,
fields: [
{
name: `**${currentSkin.displayName}**`,
value: `${currentSkin.tierText}\nValeur Max: **${currentSkin.maxPrice} Flopos** ${ownerText}`,
},
],
image: { url: getImageUrl(skinData) },
footer: { text: `Résultat 1/${resultSkins.length}` },
};
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [embed],
components: components,
},
});
} catch (error) {
console.error('Error handling /search command:', error);
return res.status(500).json({ error: 'Failed to execute search.' });
}
// --- 6. Send Final Response ---
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
embeds: [embed],
components: components,
},
});
} catch (error) {
console.error("Error handling /search command:", error);
return res.status(500).json({ error: "Failed to execute search." });
}
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import {
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from 'discord-interactions';
InteractionResponseType,
MessageComponentTypes,
ButtonStyleTypes,
InteractionResponseFlags,
} from "discord-interactions";
import { DiscordRequest } from '../../api/discord.js';
import { activeInventories, skins } from '../../game/state.js';
import { DiscordRequest } from "../../api/discord.js";
import { activeInventories, skins } from "../../game/state.js";
/**
* 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.
*/
export async function handleInventoryNav(req, res, client) {
const { member, data, guild_id } = req.body;
const { custom_id } = data;
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// Extract direction ('prev' or 'next') and the original interaction ID from the custom_id
const [direction, page, interactionId] = custom_id.split('_');
// Extract direction ('prev' or 'next') and the original interaction ID from the custom_id
const [direction, page, interactionId] = custom_id.split("_");
// --- 1. Retrieve the interactive session ---
const inventorySession = activeInventories[interactionId];
// --- 1. Retrieve the interactive session ---
const inventorySession = activeInventories[interactionId];
// --- 2. Validation Checks ---
if (!inventorySession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Oups, cet affichage d'inventaire a expiré. Veuillez relancer la commande `/inventory`.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 2. Validation Checks ---
if (!inventorySession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Oups, cet affichage d'inventaire a expiré. Veuillez relancer la commande `/inventory`.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Ensure the user clicking the button is the one who initiated the command
if (inventorySession.userId !== member.user.id) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas naviguer dans l'inventaire de quelqu'un d'autre.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Ensure the user clicking the button is the one who initiated the command
if (inventorySession.userId !== member.user.id) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas naviguer dans l'inventaire de quelqu'un d'autre.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Update Page Number ---
const { amount } = inventorySession;
if (direction === "next") {
inventorySession.page = (inventorySession.page + 1) % amount;
} else if (direction === "prev") {
inventorySession.page = (inventorySession.page - 1 + amount) % amount;
}
// --- 3. Update Page Number ---
const { amount } = inventorySession;
if (direction === 'next') {
inventorySession.page = (inventorySession.page + 1) % amount;
} else if (direction === 'prev') {
inventorySession.page = (inventorySession.page - 1 + amount) % amount;
}
try {
// --- 4. Rebuild Embed with New Page Content ---
const { page, inventorySkins } = inventorySession;
const currentSkin = inventorySkins[page];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(inventorySession.akhyId);
const totalPrice = inventorySkins.reduce((sum, skin) => sum + (skin.currentPrice || 0), 0);
try {
// --- 4. Rebuild Embed with New Page Content ---
const { page, inventorySkins } = inventorySession;
const currentSkin = inventorySkins[page];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
throw new Error(`Skin data not found for UUID: ${currentSkin.uuid}`);
}
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? "💠 " : "◾ ";
}
return result || "N/A";
};
const 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);
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName
.replace(/[\r\n]+/g, " ")
.replace(skinInfo.displayName, "")
.trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return "Base";
};
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? '💠 ' : '◾ ';
}
return result || 'N/A';
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName.replace(/[\r\n]+/g, ' ').replace(skinInfo.displayName, '').trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return 'Base';
};
// --- 5. Rebuild Components (Buttons) ---
let components = [
{
type: MessageComponentTypes.BUTTON,
custom_id: `prev_page_${interactionId}`,
label: "⏮️ Préc.",
style: ButtonStyleTypes.SECONDARY,
},
{
type: MessageComponentTypes.BUTTON,
custom_id: `next_page_${interactionId}`,
label: "Suiv. ⏭️",
style: ButtonStyleTypes.SECONDARY,
},
];
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
const isUpgradable =
currentSkin.currentLvl < skinData.levels.length || currentSkin.currentChroma < skinData.chromas.length;
// Conditionally add the upgrade button
if (isUpgradable && inventorySession.akhyId === inventorySession.userId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice / 10).toFixed(0)} Flopos)`,
style: ButtonStyleTypes.PRIMARY,
});
}
// --- 5. Rebuild Components (Buttons) ---
let components = [
{ type: MessageComponentTypes.BUTTON, custom_id: `prev_page_${interactionId}`, label: '⏮️ Préc.', style: ButtonStyleTypes.SECONDARY },
{ type: MessageComponentTypes.BUTTON, custom_id: `next_page_${interactionId}`, label: 'Suiv. ⏭️', style: ButtonStyleTypes.SECONDARY },
];
// --- 6. Send PATCH Request to Update the Message ---
await DiscordRequest(inventorySession.endpoint, {
method: "PATCH",
body: {
embeds: [
{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xf2f3f3,
footer: {
text: `Page ${page + 1}/${amount} | Valeur Totale : ${totalPrice.toFixed(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;
// Conditionally add the upgrade button
if (isUpgradable && inventorySession.akhyId === inventorySession.userId) {
components.push({
type: MessageComponentTypes.BUTTON,
custom_id: `upgrade_${interactionId}`,
label: `Upgrade ⏫ (${process.env.VALO_UPGRADE_PRICE || (currentSkin.maxPrice/10).toFixed(0)} Flopos)`,
style: ButtonStyleTypes.PRIMARY,
});
}
// --- 6. Send PATCH Request to Update the Message ---
await DiscordRequest(inventorySession.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(currentSkin.tierColor, 16) || 0xF2F3F3,
footer: { text: `Page ${page + 1}/${amount} | Valeur Totale : ${totalPrice.toFixed(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,
}
});
}
// --- 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 {
InteractionResponseType,
InteractionResponseFlags,
} from 'discord-interactions';
import { DiscordRequest } from '../../api/discord.js';
import { activePolls } from '../../game/state.js';
import { getSocketIo } from '../../server/socket.js';
import { getUser } from '../../database/index.js';
import { InteractionResponseType, InteractionResponseFlags } from "discord-interactions";
import { DiscordRequest } from "../../api/discord.js";
import { activePolls } from "../../game/state.js";
import { getSocketIo } from "../../server/socket.js";
import { getUser } from "../../database/index.js";
/**
* Handles clicks on the 'Yes' or 'No' buttons of a timeout poll.
@@ -13,164 +10,175 @@ import { getUser } from '../../database/index.js';
* @param {object} res - The Express response object.
*/
export async function handlePollVote(req, res) {
const io = getSocketIo();
const { member, data, guild_id } = req.body;
const { custom_id } = data;
const io = getSocketIo();
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// --- 1. Parse Component ID ---
const [_, voteType, pollId] = custom_id.split('_'); // e.g., ['vote', 'for', '12345...']
const isVotingFor = voteType === 'for';
// --- 1. Parse Component ID ---
const [_, voteType, pollId] = custom_id.split("_"); // e.g., ['vote', 'for', '12345...']
const isVotingFor = voteType === "for";
// --- 2. Retrieve Poll and Validate ---
const poll = activePolls[pollId];
const voterId = member.user.id;
// --- 2. Retrieve Poll and Validate ---
const poll = activePolls[pollId];
const voterId = member.user.id;
if (!poll) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Ce sondage de timeout n'est plus actif.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
if (!poll) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Ce sondage de timeout n'est plus actif.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Check if the voter has the required role
if (!member.roles.includes(process.env.VOTING_ROLE_ID)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous n'avez pas le rôle requis pour participer à ce vote.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Check if the voter has the required role
if (!member.roles.includes(process.env.VOTING_ROLE_ID)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous n'avez pas le rôle requis pour participer à ce vote.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent user from voting on themselves
if (poll.toUserId === voterId) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas voter pour vous-même.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent user from voting on themselves
if (poll.toUserId === voterId) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous ne pouvez pas voter pour vous-même.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent double voting
if (poll.voters.includes(voterId)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Vous avez déjà voté pour ce sondage.',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// Prevent double voting
if (poll.voters.includes(voterId)) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Vous avez déjà voté pour ce sondage.",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
}
// --- 3. Record the Vote ---
poll.voters.push(voterId);
if (isVotingFor) {
poll.for++;
} else {
poll.against++;
}
// --- 3. Record the Vote ---
poll.voters.push(voterId);
if (isVotingFor) {
poll.for++;
} else {
poll.against++;
}
io.emit('poll-update'); // Notify frontend clients of the change
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 ---
if (isVotingFor && poll.for >= poll.requiredMajority) {
// --- SUCCESS CASE: MAJORITY REACHED ---
// a. Update the poll message to show success
try {
await DiscordRequest(poll.endpoint, {
method: "PATCH",
body: {
embeds: [
{
title: "Vote Terminé - Timeout Appliqué !",
description: `La majorité a été atteinte. **${poll.toUsername}** a été timeout pendant ${poll.time_display}.`,
fields: [
{
name: "Votes Pour",
value: `${poll.for}\n${votersList}`,
inline: true,
},
],
color: 0x22a55b, // Green for success
},
],
components: [], // Remove buttons
},
});
} catch (err) {
console.error("Error updating final poll message:", err);
}
// a. Update the poll message to show success
try {
await DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Vote Terminé - Timeout Appliqué !',
description: `La majorité a été atteinte. **${poll.toUsername}** a été timeout pendant ${poll.time_display}.`,
fields: [{ name: 'Votes Pour', value: `${poll.for}\n${votersList}`, inline: true }],
color: 0x22A55B, // Green for success
}],
components: [], // Remove buttons
},
});
} catch (err) {
console.error('Error updating final poll message:', err);
}
// b. Execute the timeout via Discord API
try {
const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString();
const endpointTimeout = `guilds/${guild_id}/members/${poll.toUserId}`;
await DiscordRequest(endpointTimeout, {
method: "PATCH",
body: { communication_disabled_until: timeoutUntil },
});
// b. Execute the timeout via Discord API
try {
const timeoutUntil = new Date(Date.now() + poll.time * 1000).toISOString();
const endpointTimeout = `guilds/${guild_id}/members/${poll.toUserId}`;
await DiscordRequest(endpointTimeout, {
method: 'PATCH',
body: { communication_disabled_until: timeoutUntil },
});
// c. Send a public confirmation message and clean up
delete activePolls[pollId];
io.emit("poll-update");
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `💥 <@${poll.toUserId}> a été timeout pendant **${poll.time_display}** par décision démocratique !`,
},
});
} catch (err) {
console.error("Error timing out user:", err);
delete activePolls[pollId];
io.emit("poll-update");
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `La majorité a été atteinte, mais une erreur est survenue lors de l'application du timeout sur <@${poll.toUserId}>.`,
},
});
}
} else {
// --- PENDING CASE: NO MAJORITY YET ---
// c. Send a public confirmation message and clean up
delete activePolls[pollId];
io.emit('poll-update');
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `💥 <@${poll.toUserId}> a été timeout pendant **${poll.time_display}** par décision démocratique !`,
},
});
// a. Send an ephemeral acknowledgment to the voter
res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "Votre vote a été enregistré ! ✅",
flags: InteractionResponseFlags.EPHEMERAL,
},
});
} 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 ---
// b. Update the original poll message asynchronously (no need to await)
// The main countdown interval will also handle this, but this provides a faster update.
const votesNeeded = Math.max(0, poll.requiredMajority - poll.for);
const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000));
const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`;
// a. Send an ephemeral acknowledgment to the voter
res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: 'Votre vote a été enregistré ! ✅',
flags: InteractionResponseFlags.EPHEMERAL,
},
});
// b. Update the original poll message asynchronously (no need to await)
// The main countdown interval will also handle this, but this provides a faster update.
const votesNeeded = Math.max(0, poll.requiredMajority - poll.for);
const remaining = Math.max(0, Math.floor((poll.endTime - Date.now()) / 1000));
const countdownText = `**${Math.floor(remaining / 60)}m ${remaining % 60}s** restantes`;
DiscordRequest(poll.endpoint, {
method: 'PATCH',
body: {
embeds: [{
title: 'Vote de Timeout',
description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`,
fields: [{
name: 'Pour',
value: `${poll.for}\n${votersList}`,
inline: true,
}, {
name: 'Temps restant',
value: `${countdownText}`,
inline: false,
}],
color: 0x5865F2,
}],
// Keep the original components so people can still vote
components: req.body.message.components,
},
}).catch(err => console.error("Error updating poll after vote:", err));
}
DiscordRequest(poll.endpoint, {
method: "PATCH",
body: {
embeds: [
{
title: "Vote de Timeout",
description: `**${poll.username}** propose de timeout **${poll.toUsername}** pendant ${poll.time_display}.\nIl manque **${votesNeeded}** vote(s).`,
fields: [
{
name: "Pour",
value: `${poll.for}\n${votersList}`,
inline: true,
},
{
name: "Temps restant",
value: `${countdownText}`,
inline: false,
},
],
color: 0x5865f2,
},
],
// Keep the original components so people can still vote
components: req.body.message.components,
},
}).catch((err) => console.error("Error updating poll after vote:", err));
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,6 @@ export let activePredis = {};
// Format: { [userId]: { endAt, lastMessage } }
export let activeSlowmodes = {};
// --- Queues for Matchmaking ---
// Stores user IDs waiting to play Tic-Tac-Toe.
@@ -55,7 +54,6 @@ export let connect4Queue = [];
export let queueMessagesEndpoints = [];
// --- Rate Limiting and Caching ---
// Tracks message timestamps for the channel points system, keyed by user ID.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,18 +1,35 @@
import express from 'express';
import express from "express";
// --- Game Logic Imports ---
import {
createDeck, shuffle, deal, isValidMove, moveCard, drawCard,
checkWinCondition, createSeededRNG, seededShuffle, undoMove, draw3Cards, checkAutoSolve, autoSolveMoves
} from '../../game/solitaire.js';
createDeck,
shuffle,
deal,
isValidMove,
moveCard,
drawCard,
checkWinCondition,
createSeededRNG,
seededShuffle,
undoMove,
draw3Cards,
checkAutoSolve,
autoSolveMoves,
} from "../../game/solitaire.js";
// --- Game State & Database Imports ---
import { activeSolitaireGames } from '../../game/state.js';
import { activeSolitaireGames } from "../../game/state.js";
import {
getSOTD, getUser, insertSOTDStats, deleteUserSOTDStats,
getUserSOTDStats, updateUserCoins, insertLog, getAllSOTDStats
} from '../../database/index.js';
import {socketEmit} from "../socket.js";
getSOTD,
getUser,
insertSOTDStats,
deleteUserSOTDStats,
getUserSOTDStats,
updateUserCoins,
insertLog,
getAllSOTDStats,
} from "../../database/index.js";
import { socketEmit } from "../socket.js";
// Create a new router instance
const router = express.Router();
@@ -24,248 +41,262 @@ const router = express.Router();
* @returns {object} The configured Express router.
*/
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) => {
const { userId, userSeed, hardMode } = req.body;
if (!userId) return res.status(400).json({ error: 'User ID is required.' });
// If a game already exists for the user, return it instead of creating a new one.
if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) {
return res.json({
success: true,
gameState: activeSolitaireGames[userId],
});
}
// If a game already exists for the user, return it instead of creating a new one.
if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) {
return res.json({ success: true, gameState: activeSolitaireGames[userId] });
}
let deck, seed;
if (userSeed) {
// Use the provided seed to create a deterministic game
seed = userSeed;
} else {
// Create a random seed if none is provided
seed = Date.now().toString(36) + Math.random().toString(36).substr(2);
}
let deck, seed;
if (userSeed) {
// 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 numericSeed = 0;
for (let i = 0; i < seed.length; i++) {
numericSeed = (numericSeed + seed.charCodeAt(i)) & 0xffffffff;
}
let numericSeed = 0;
for (let i = 0; i < seed.length; i++) {
numericSeed = (numericSeed + seed.charCodeAt(i)) & 0xFFFFFFFF;
}
const rng = createSeededRNG(numericSeed);
deck = seededShuffle(createDeck(), rng);
const rng = createSeededRNG(numericSeed);
deck = seededShuffle(createDeck(), rng);
const gameState = deal(deck);
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);
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 });
});
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.' });
}*/
if (activeSolitaireGames[userId]?.isSOTD) {
return res.json({ success: true, gameState: activeSolitaireGames[userId] });
}
if (activeSolitaireGames[userId]?.isSOTD) {
return res.json({
success: true,
gameState: activeSolitaireGames[userId],
});
}
const sotd = getSOTD.get();
if (!sotd) {
return res.status(500).json({ error: 'Solitaire of the Day is not configured.'});
}
const sotd = getSOTD.get();
if (!sotd) {
return res.status(500).json({ error: "Solitaire of the Day is not configured." });
}
const gameState = {
tableauPiles: JSON.parse(sotd.tableauPiles),
foundationPiles: JSON.parse(sotd.foundationPiles),
stockPile: JSON.parse(sotd.stockPile),
wastePile: JSON.parse(sotd.wastePile),
isDone: false,
isSOTD: true,
startTime: Date.now(),
endTime: null,
moves: 0,
score: 0,
seed: sotd.seed,
hist: [],
hardMode: false,
autocompleting: false,
};
const gameState = {
tableauPiles: JSON.parse(sotd.tableauPiles),
foundationPiles: JSON.parse(sotd.foundationPiles),
stockPile: JSON.parse(sotd.stockPile),
wastePile: JSON.parse(sotd.wastePile),
isDone: false,
isSOTD: true,
startTime: Date.now(),
endTime: null,
moves: 0,
score: 0,
seed: sotd.seed,
hist: [],
hardMode: false,
autocompleting: false,
};
activeSolitaireGames[userId] = gameState;
res.json({ success: true, gameState });
});
activeSolitaireGames[userId] = gameState;
res.json({ success: true, gameState });
});
// --- Game State & Action Endpoints ---
// --- Game State & Action Endpoints ---
router.get('/sotd/rankings', (req, res) => {
try {
const rankings = getAllSOTDStats.all();
res.json({ rankings });
} catch(e) {
res.status(500).json({ error: "Failed to fetch SOTD rankings."});
}
});
router.get("/sotd/rankings", (req, res) => {
try {
const rankings = getAllSOTDStats.all();
res.json({ rankings });
} catch (e) {
res.status(500).json({ error: "Failed to fetch SOTD rankings." });
}
});
router.get('/state/:userId', (req, res) => {
const { userId } = req.params;
const gameState = activeSolitaireGames[userId];
if (gameState) {
res.json({ success: true, gameState });
} else {
res.status(404).json({ error: 'No active game found for this user.' });
}
});
router.get("/state/:userId", (req, res) => {
const { userId } = req.params;
const gameState = activeSolitaireGames[userId];
if (gameState) {
res.json({ success: true, gameState });
} else {
res.status(404).json({ error: "No active game found for this user." });
}
});
router.post('/reset', (req, res) => {
const { userId } = req.body;
if (activeSolitaireGames[userId]) {
delete activeSolitaireGames[userId];
}
res.json({ success: true, message: "Game reset."});
});
router.post("/reset", (req, res) => {
const { userId } = req.body;
if (activeSolitaireGames[userId]) {
delete activeSolitaireGames[userId];
}
res.json({ success: true, message: "Game reset." });
});
router.post('/move', async (req, res) => {
const { userId, ...moveData } = req.body;
const gameState = activeSolitaireGames[userId];
router.post("/move", async (req, res) => {
const { userId, ...moveData } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: 'Game not found.' });
if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'});
if (!gameState) return res.status(404).json({ error: "Game not found." });
if (gameState.isDone) return res.status(400).json({ error: "This game is already completed." });
if (isValidMove(gameState, moveData)) {
moveCard(gameState, moveData);
updateGameStats(gameState, 'move', moveData);
if (isValidMove(gameState, moveData)) {
moveCard(gameState, moveData);
updateGameStats(gameState, "move", moveData);
if (!gameState.autocompleting) {
const canAutoSolve = checkAutoSolve(gameState);
if (canAutoSolve) {
gameState.autocompleting = true;
autoSolveMoves(userId, gameState)
}
}
if (!gameState.autocompleting) {
const canAutoSolve = checkAutoSolve(gameState);
if (canAutoSolve) {
gameState.autocompleting = true;
autoSolveMoves(userId, gameState);
}
}
const win = checkWinCondition(gameState);
if (win) {
gameState.isDone = true;
await handleWin(userId, gameState, io);
}
res.json({ success: true, gameState, win });
} else {
res.status(400).json({ error: 'Invalid move' });
}
});
const win = checkWinCondition(gameState);
if (win) {
gameState.isDone = true;
await handleWin(userId, gameState, io);
}
res.json({ success: true, gameState, win });
} else {
res.status(400).json({ error: "Invalid move" });
}
});
router.post('/draw', (req, res) => {
const { userId } = req.body;
const gameState = activeSolitaireGames[userId];
router.post("/draw", (req, res) => {
const { userId } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: 'Game not found.' });
if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'});
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.hardMode) {
draw3Cards(gameState);
} else {
drawCard(gameState);
}
updateGameStats(gameState, 'draw');
res.json({ success: true, gameState });
});
if (gameState.hardMode) {
draw3Cards(gameState);
} else {
drawCard(gameState);
}
updateGameStats(gameState, "draw");
res.json({ success: true, gameState });
});
router.post('/undo', (req, res) => {
const { userId } = req.body;
const gameState = activeSolitaireGames[userId];
router.post("/undo", (req, res) => {
const { userId } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: 'Game not found.' });
if (gameState.isDone) return res.status(400).json({ error: 'This game is already completed.'});
if (gameState.hist.length === 0) return res.status(400).json({ error: 'No moves to undo.'});
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.hist.length === 0) return res.status(400).json({ error: "No moves to undo." });
undoMove(gameState);
res.json({ success: true, gameState });
})
undoMove(gameState);
res.json({ success: true, gameState });
});
return router;
return router;
}
// --- Helper Functions ---
/** Updates game stats like moves and score after an action. */
function updateGameStats(gameState, actionType, moveData = {}) {
// if (!gameState.isSOTD) return; // Only track stats for SOTD
// if (!gameState.isSOTD) return; // Only track stats for SOTD
gameState.moves++;
if (actionType === 'move') {
if (moveData.destPileType === 'foundationPiles') {
gameState.score += 10; // Move card to foundation
}
if (moveData.sourcePileType === 'foundationPiles') {
gameState.score -= 15; // Move card from foundation (penalty)
}
}
if(actionType === 'draw' && gameState.wastePile.length === 0) {
// Penalty for cycling through an empty stock pile
gameState.score -= 5;
}
gameState.moves++;
if (actionType === "move") {
if (moveData.destPileType === "foundationPiles") {
gameState.score += 10; // Move card to foundation
}
if (moveData.sourcePileType === "foundationPiles") {
gameState.score -= 15; // Move card from foundation (penalty)
}
}
if (actionType === "draw" && gameState.wastePile.length === 0) {
// Penalty for cycling through an empty stock pile
gameState.score -= 5;
}
}
/** Handles the logic when a game is won. */
async function handleWin(userId, gameState, io) {
const currentUser = getUser.get(userId);
if (!currentUser) return;
const currentUser = getUser.get(userId);
if (!currentUser) return;
if (gameState.hardMode) {
const bonus = 100;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
id: `${userId}-hardmode-solitaire-${Date.now()}`, user_id: userId,
action: 'HARDMODE_SOLITAIRE_WIN', target_user_id: null,
coins_amount: bonus, user_new_amount: newCoins,
});
await socketEmit('data-updated', { table: 'users' });
}
if (gameState.hardMode) {
const bonus = 100;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
id: `${userId}-hardmode-solitaire-${Date.now()}`,
user_id: userId,
action: "HARDMODE_SOLITAIRE_WIN",
target_user_id: null,
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();
const timeTaken = gameState.endTime - gameState.startTime;
gameState.endTime = Date.now();
const timeTaken = gameState.endTime - gameState.startTime;
const existingStats = getUserSOTDStats.get(userId);
const existingStats = getUserSOTDStats.get(userId);
if (!existingStats) {
// First time completing the SOTD, grant bonus coins
const bonus = 1000;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
id: `${userId}-sotd-complete-${Date.now()}`, user_id: userId,
action: 'SOTD_WIN', target_user_id: null,
coins_amount: bonus, user_new_amount: newCoins,
});
await socketEmit('data-updated', { table: 'users' });
}
if (!existingStats) {
// First time completing the SOTD, grant bonus coins
const bonus = 1000;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
id: `${userId}-sotd-complete-${Date.now()}`,
user_id: userId,
action: "SOTD_WIN",
target_user_id: null,
coins_amount: bonus,
user_new_amount: newCoins,
});
await socketEmit("data-updated", { table: "users" });
}
// Save the score if it's better than the previous one
const isNewBest = !existingStats ||
gameState.score > existingStats.score ||
(gameState.score === existingStats.score && gameState.moves < existingStats.moves) ||
(gameState.score === existingStats.score && gameState.moves === existingStats.moves && timeTaken < existingStats.time);
// Save the score if it's better than the previous one
const isNewBest =
!existingStats ||
gameState.score > existingStats.score ||
(gameState.score === existingStats.score && gameState.moves < existingStats.moves) ||
(gameState.score === existingStats.score &&
gameState.moves === existingStats.moves &&
timeTaken < existingStats.time);
if (isNewBest) {
deleteUserSOTDStats.run(userId)
insertSOTDStats.run({
id: userId, user_id: userId,
time: timeTaken,
moves: gameState.moves,
score: gameState.score,
});
await socketEmit('sotd-update')
console.log(`New SOTD high score for ${currentUser.globalName}: ${gameState.score} points.`);
}
if (isNewBest) {
deleteUserSOTDStats.run(userId);
insertSOTDStats.run({
id: userId,
user_id: userId,
time: timeTaken,
moves: gameState.moves,
score: gameState.score,
});
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 {
activeTicTacToeGames,
tictactoeQueue,
activeConnect4Games,
connect4Queue,
queueMessagesEndpoints, activePredis
} from '../game/state.js';
import { createConnect4Board, formatConnect4BoardForDiscord, checkConnect4Win, checkConnect4Draw, C4_ROWS } from '../game/various.js';
import { eloHandler } from '../game/elo.js';
activeTicTacToeGames,
tictactoeQueue,
activeConnect4Games,
connect4Queue,
queueMessagesEndpoints,
activePredis,
} from "../game/state.js";
import {
createConnect4Board,
formatConnect4BoardForDiscord,
checkConnect4Win,
checkConnect4Draw,
C4_ROWS,
} from "../game/various.js";
import { eloHandler } from "../game/elo.js";
import { getUser } from "../database/index.js";
// --- Module-level State ---
@@ -16,70 +23,73 @@ let io;
// --- Main Initialization Function ---
export function initializeSocket(server, client) {
io = server;
io = server;
io.on('connection', (socket) => {
socket.on('user-connected', async (userId) => {
if (!userId) return;
await refreshQueuesForUser(userId, client);
});
io.on("connection", (socket) => {
socket.on("user-connected", async (userId) => {
if (!userId) return;
await refreshQueuesForUser(userId, client);
});
registerTicTacToeEvents(socket, client);
registerConnect4Events(socket, client);
registerTicTacToeEvents(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
socket.on('disconnecting', async () => {
const discordId = socket.handshake.auth?.discordId; // or your mapping
await refreshQueuesForUser(discordId, client);
});
// catch tab kills / network drops
socket.on("disconnecting", async () => {
const discordId = socket.handshake.auth?.discordId; // or your mapping
await refreshQueuesForUser(discordId, client);
});
socket.on('disconnect', () => {
//
});
});
socket.on("disconnect", () => {
//
});
});
setInterval(cleanupStaleGames, 5 * 60 * 1000);
setInterval(cleanupStaleGames, 5 * 60 * 1000);
}
export function getSocketIo() {
return io;
return io;
}
// --- Event Registration ---
function registerTicTacToeEvents(socket, client) {
socket.on('tictactoeconnection', (e) => refreshQueuesForUser(e.id, client));
socket.on('tictactoequeue', (e) => onQueueJoin(client, 'tictactoe', e.playerId));
socket.on('tictactoeplaying', (e) => onTicTacToeMove(client, e));
socket.on('tictactoegameOver', (e) => onGameOver(client, 'tictactoe', e.playerId, e.winner));
socket.on("tictactoeconnection", (e) => refreshQueuesForUser(e.id, client));
socket.on("tictactoequeue", (e) => onQueueJoin(client, "tictactoe", e.playerId));
socket.on("tictactoeplaying", (e) => onTicTacToeMove(client, e));
socket.on("tictactoegameOver", (e) => onGameOver(client, "tictactoe", e.playerId, e.winner));
}
function registerConnect4Events(socket, client) {
socket.on('connect4connection', (e) => refreshQueuesForUser(e.id, client));
socket.on('connect4queue', (e) => onQueueJoin(client, 'connect4', e.playerId));
socket.on('connect4playing', (e) => onConnect4Move(client, e));
socket.on('connect4NoTime', (e) => onGameOver(client, 'connect4', e.playerId, e.winner, '(temps écoulé)'));
socket.on("connect4connection", (e) => refreshQueuesForUser(e.id, client));
socket.on("connect4queue", (e) => onQueueJoin(client, "connect4", e.playerId));
socket.on("connect4playing", (e) => onConnect4Move(client, e));
socket.on("connect4NoTime", (e) => onGameOver(client, "connect4", e.playerId, e.winner, "(temps écoulé)"));
}
// --- Core Handlers (Preserving Original Logic) ---
async function onQueueJoin(client, gameType, playerId) {
if (!playerId) return;
const { queue, activeGames, title, url } = getGameAssets(gameType);
if (!playerId) return;
const { queue, activeGames, title, url } = getGameAssets(gameType);
if (queue.includes(playerId) || Object.values(activeGames).some(g => g.p1.id === playerId || g.p2.id === playerId)) {
return;
}
if (
queue.includes(playerId) ||
Object.values(activeGames).some((g) => g.p1.id === playerId || g.p2.id === playerId)
) {
return;
}
queue.push(playerId);
console.log(`[${title}] Player ${playerId} joined the queue.`);
queue.push(playerId);
console.log(`[${title}] Player ${playerId} joined the queue.`);
if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url);
if (queue.length >= 2) await createGame(client, gameType);
if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url);
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.
*/
function checkTicTacToeWin(moves) {
const winningCombinations = [
[1, 2, 3], [4, 5, 6], [7, 8, 9], // Rows
[1, 4, 7], [2, 5, 8], [3, 6, 9], // Columns
[1, 5, 9], [3, 5, 7] // Diagonals
];
for (const combination of winningCombinations) {
if (combination.every(num => moves.includes(num))) {
return true;
}
}
return false;
const winningCombinations = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9], // Rows
[1, 4, 7],
[2, 5, 8],
[3, 6, 9], // Columns
[1, 5, 9],
[3, 5, 7], // Diagonals
];
for (const combination of winningCombinations) {
if (combination.every((num) => moves.includes(num))) {
return true;
}
}
return false;
}
async function onTicTacToeMove(client, eventData) {
const { playerId, value, boxId } = eventData;
const lobby = Object.values(activeTicTacToeGames).find(g => (g.p1.id === playerId || g.p2.id === playerId) && !g.gameOver);
if (!lobby) return;
const { playerId, value, boxId } = eventData;
const lobby = Object.values(activeTicTacToeGames).find(
(g) => (g.p1.id === playerId || g.p2.id === playerId) && !g.gameOver,
);
if (!lobby) return;
const isP1Turn = lobby.sum % 2 === 1 && value === 'X' && lobby.p1.id === playerId;
const isP2Turn = lobby.sum % 2 === 0 && value === 'O' && lobby.p2.id === playerId;
const isP1Turn = lobby.sum % 2 === 1 && value === "X" && lobby.p1.id === playerId;
const isP2Turn = lobby.sum % 2 === 0 && value === "O" && lobby.p2.id === playerId;
if (isP1Turn || isP2Turn) {
const playerMoves = isP1Turn ? lobby.xs : lobby.os;
playerMoves.push(boxId);
lobby.sum++;
lobby.lastmove = Date.now();
if (isP1Turn || isP2Turn) {
const playerMoves = isP1Turn ? lobby.xs : lobby.os;
playerMoves.push(boxId);
lobby.sum++;
lobby.lastmove = Date.now();
if (isP1Turn) lobby.p1.move = boxId
if (isP2Turn) lobby.p2.move = boxId
if (isP1Turn) lobby.p1.move = boxId;
if (isP2Turn) lobby.p2.move = boxId;
io.emit('tictactoeplaying', { allPlayers: Object.values(activeTicTacToeGames) });
const hasWon = checkTicTacToeWin(playerMoves);
if (hasWon) {
// The current player has won. End the game.
await onGameOver(client, 'tictactoe', playerId, playerId);
} else if (lobby.sum > 9) {
// It's a draw (9 moves made, sum is now 10). End the game.
await onGameOver(client, 'tictactoe', playerId, null); // null winner for a draw
} else {
// The game continues. Update the state and notify clients.
await updateDiscordMessage(client, lobby, 'Tic Tac Toe');
}
}
await emitQueueUpdate(client, 'tictactoe');
io.emit("tictactoeplaying", {
allPlayers: Object.values(activeTicTacToeGames),
});
const hasWon = checkTicTacToeWin(playerMoves);
if (hasWon) {
// The current player has won. End the game.
await onGameOver(client, "tictactoe", playerId, playerId);
} else if (lobby.sum > 9) {
// It's a draw (9 moves made, sum is now 10). End the game.
await onGameOver(client, "tictactoe", playerId, null); // null winner for a draw
} else {
// The game continues. Update the state and notify clients.
await updateDiscordMessage(client, lobby, "Tic Tac Toe");
}
}
await emitQueueUpdate(client, "tictactoe");
}
async function onConnect4Move(client, eventData) {
const { playerId, col } = eventData;
const lobby = Object.values(activeConnect4Games).find(l => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver);
if (!lobby || lobby.turn !== playerId) return;
const { playerId, col } = eventData;
const lobby = Object.values(activeConnect4Games).find(
(l) => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver,
);
if (!lobby || lobby.turn !== playerId) return;
const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2;
let row;
for (row = C4_ROWS - 1; row >= 0; row--) {
if (lobby.board[row][col] === null) {
lobby.board[row][col] = player.val;
break;
}
}
if (row < 0) return;
const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2;
let row;
for (row = C4_ROWS - 1; row >= 0; row--) {
if (lobby.board[row][col] === null) {
lobby.board[row][col] = player.val;
break;
}
}
if (row < 0) return;
lobby.lastmove = Date.now();
const winCheck = checkConnect4Win(lobby.board, player.val);
lobby.lastmove = Date.now();
const winCheck = checkConnect4Win(lobby.board, player.val);
let winnerId = null;
if (winCheck.win) {
lobby.winningPieces = winCheck.pieces;
winnerId = player.id;
} else if (checkConnect4Draw(lobby.board)) {
winnerId = null; // Represents a draw
} else {
lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id;
io.emit('connect4playing', { allPlayers: Object.values(activeConnect4Games) });
await emitQueueUpdate(client, 'connact4');
await updateDiscordMessage(client, lobby, 'Puissance 4');
return;
}
await onGameOver(client, 'connect4', playerId, winnerId);
let winnerId = null;
if (winCheck.win) {
lobby.winningPieces = winCheck.pieces;
winnerId = player.id;
} else if (checkConnect4Draw(lobby.board)) {
winnerId = null; // Represents a draw
} else {
lobby.turn = lobby.p1.id === playerId ? lobby.p2.id : lobby.p1.id;
io.emit("connect4playing", {
allPlayers: Object.values(activeConnect4Games),
});
await emitQueueUpdate(client, "connact4");
await updateDiscordMessage(client, lobby, "Puissance 4");
return;
}
await onGameOver(client, "connect4", playerId, winnerId);
}
async function onGameOver(client, gameType, playerId, winnerId, reason = '') {
const { activeGames, title } = getGameAssets(gameType);
const gameKey = Object.keys(activeGames).find(key => key.includes(playerId));
const game = gameKey ? activeGames[gameKey] : undefined;
if (!game || game.gameOver) return;
async function onGameOver(client, gameType, playerId, winnerId, reason = "") {
const { activeGames, title } = getGameAssets(gameType);
const gameKey = Object.keys(activeGames).find((key) => key.includes(playerId));
const game = gameKey ? activeGames[gameKey] : undefined;
if (!game || game.gameOver) return;
game.gameOver = true;
let resultText;
if (winnerId === null) {
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase());
resultText = 'Égalité';
} else {
await eloHandler(game.p1.id, game.p2.id, game.p1.id === winnerId ? 1 : 0, game.p2.id === winnerId ? 1 : 0, title.toUpperCase());
const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name;
resultText = `Victoire de ${winnerName}`;
}
game.gameOver = true;
let resultText;
if (winnerId === null) {
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase());
resultText = "Égalité";
} else {
await eloHandler(
game.p1.id,
game.p2.id,
game.p1.id === winnerId ? 1 : 0,
game.p2.id === winnerId ? 1 : 0,
title.toUpperCase(),
);
const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name;
resultText = `Victoire de ${winnerName}`;
}
await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
if(gameType === 'tictactoe') io.emit('tictactoegameOver', { game, winner: winnerId });
if(gameType === 'connect4') io.emit('connect4gameOver', { game, winner: winnerId });
if (gameType === "tictactoe") io.emit("tictactoegameOver", { game, winner: winnerId });
if (gameType === "connect4") io.emit("connect4gameOver", { game, winner: winnerId });
if (gameKey) {
setTimeout(() => delete activeGames[gameKey], 1000)
}
if (gameKey) {
setTimeout(() => delete activeGames[gameKey], 1000);
}
}
// --- Game Lifecycle & Discord Helpers ---
async function createGame(client, gameType) {
const { queue, activeGames, title } = getGameAssets(gameType);
const p1Id = queue.shift();
const p2Id = queue.shift();
const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]);
const { queue, activeGames, title } = getGameAssets(gameType);
const p1Id = queue.shift();
const p2Id = queue.shift();
const [p1, p2] = await Promise.all([client.users.fetch(p1Id), client.users.fetch(p2Id)]);
let lobby;
if (gameType === 'tictactoe') {
lobby = { p1: { id: p1Id, name: p1.globalName, val: 'X', 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: [] };
}
let lobby;
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(),
};
} 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);
lobby.msgId = msgId;
const msgId = await updateDiscordMessage(client, lobby, title);
lobby.msgId = msgId;
const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby;
const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby;
io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) });
await emitQueueUpdate(client, gameType);
io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) });
await emitQueueUpdate(client, gameType);
}
// --- Utility Functions ---
async function refreshQueuesForUser(userId, client) {
// FIX: Mutate the array instead of reassigning it.
let index = tictactoeQueue.indexOf(userId);
if (index > -1) {
tictactoeQueue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(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());
await queueMsg.edit({ embeds: [updatedEmbed], components: [] });
delete queueMessagesEndpoints[userId];
} catch (e) {
console.error('Error updating queue message : ', e);
}
}
// FIX: Mutate the array instead of reassigning it.
let index = tictactoeQueue.indexOf(userId);
if (index > -1) {
tictactoeQueue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(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());
await queueMsg.edit({ embeds: [updatedEmbed], components: [] });
delete queueMessagesEndpoints[userId];
} catch (e) {
console.error("Error updating queue message : ", e);
}
}
index = connect4Queue.indexOf(userId);
if (index > -1) {
connect4Queue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(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());
await queueMsg.edit({ embeds: [updatedEmbed], components: [] });
delete queueMessagesEndpoints[userId];
} catch (e) {
console.error('Error updating queue message : ', e);
}
}
index = connect4Queue.indexOf(userId);
if (index > -1) {
connect4Queue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(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());
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, 'connect4');
await emitQueueUpdate(client, "tictactoe");
await emitQueueUpdate(client, "connect4");
}
async function emitQueueUpdate(client, gameType) {
const { queue, activeGames } = getGameAssets(gameType);
const names = await Promise.all(queue.map(async (id) => {
const user = await client.users.fetch(id).catch(() => null);
return user?.globalName || user?.username;
}));
io.emit(`${gameType}queue`, { allPlayers: Object.values(activeGames), queue: names.filter(Boolean) });
const { queue, activeGames } = getGameAssets(gameType);
const names = await Promise.all(
queue.map(async (id) => {
const user = await client.users.fetch(id).catch(() => null);
return user?.globalName || user?.username;
}),
);
io.emit(`${gameType}queue`, {
allPlayers: Object.values(activeGames),
queue: names.filter(Boolean),
});
}
function getGameAssets(gameType) {
if (gameType === 'tictactoe') return { queue: tictactoeQueue, activeGames: activeTicTacToeGames, title: 'Tic Tac Toe', url: '/tic-tac-toe' };
if (gameType === 'connect4') return { queue: connect4Queue, activeGames: activeConnect4Games, title: 'Puissance 4', url: '/connect-4' };
return { queue: [], activeGames: {} };
if (gameType === "tictactoe")
return {
queue: tictactoeQueue,
activeGames: activeTicTacToeGames,
title: "Tic Tac Toe",
url: "/tic-tac-toe",
};
if (gameType === "connect4")
return {
queue: connect4Queue,
activeGames: activeConnect4Games,
title: "Puissance 4",
url: "/connect-4",
};
return { queue: [], activeGames: {} };
}
async function postQueueToDiscord(client, playerId, title, url) {
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(playerId);
const embed = new EmbedBuilder().setTitle(title).setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`).setColor('#5865F2').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); }
try {
const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID);
const user = await client.users.fetch(playerId);
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`)
.setColor("#5865F2")
.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 = '') {
const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null);
if (!channel) return null;
async function updateDiscordMessage(client, game, title, resultText = "") {
const channel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID).catch(() => null);
if (!channel) return null;
let description;
if (title === 'Tic Tac Toe') {
let gridText = '';
for (let i = 1; i <= 9; i++) { gridText += game.xs.includes(i) ? '❌' : game.os.includes(i) ? '⭕' : '🟦'; if (i % 3 === 0) gridText += '\n'; }
description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`;
} else {
description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`;
}
if (resultText) description += `\n### ${resultText}`;
let description;
if (title === "Tic Tac Toe") {
let gridText = "";
for (let i = 1; i <= 9; i++) {
gridText += game.xs.includes(i) ? "❌" : game.os.includes(i) ? "⭕" : "🟦";
if (i % 3 === 0) gridText += "\n";
}
description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`;
} else {
description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`;
}
if (resultText) description += `\n### ${resultText}`;
const embed = new EmbedBuilder().setTitle(title).setDescription(description).setColor(game.gameOver ? '#2ade2a' : '#5865f2');
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(game.gameOver ? "#2ade2a" : "#5865f2");
try {
if (game.msgId) {
const message = await channel.messages.fetch(game.msgId);
await message.edit({ embeds: [embed] });
return game.msgId;
} else {
const message = await channel.send({ embeds: [embed] });
return message.id;
}
} catch (e) { return null; }
try {
if (game.msgId) {
const message = await channel.messages.fetch(game.msgId);
await message.edit({ embeds: [embed] });
return game.msgId;
} else {
const message = await channel.send({ embeds: [embed] });
return message.id;
}
} catch (e) {
return null;
}
}
function cleanupStaleGames() {
const now = Date.now();
const STALE_TIMEOUT = 30 * 60 * 1000;
const cleanup = (games, name) => {
Object.keys(games).forEach(key => {
if (now - games[key].lastmove > STALE_TIMEOUT) {
console.log(`[Cleanup] Removing stale ${name} game: ${key}`);
delete games[key];
}
});
};
cleanup(activeTicTacToeGames, 'TicTacToe');
cleanup(activeConnect4Games, 'Connect4');
const now = Date.now();
const STALE_TIMEOUT = 30 * 60 * 1000;
const cleanup = (games, name) => {
Object.keys(games).forEach((key) => {
if (now - games[key].lastmove > STALE_TIMEOUT) {
console.log(`[Cleanup] Removing stale ${name} game: ${key}`);
delete games[key];
}
});
};
cleanup(activeTicTacToeGames, "TicTacToe");
cleanup(activeConnect4Games, "Connect4");
}
/* EMITS */
export async function socketEmit(event, data) {
io.emit(event, data);
io.emit(event, data);
}
export async function emitDataUpdated(data) {
io.emit('data-updated', data);
io.emit("data-updated", data);
}
export async function emitPokerUpdate(data) {
io.emit('poker-update', data);
io.emit("poker-update", 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 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 {GoogleGenAI} from "@google/genai";
import {Mistral} from '@mistralai/mistralai';
import { GoogleGenAI } from "@google/genai";
import { Mistral } from "@mistralai/mistralai";
// --- AI Client Initialization ---
// Initialize clients for each AI service. This is done once when the module is loaded.
let openai;
if (process.env.OPENAI_API_KEY) {
openai = new OpenAI();
openai = new OpenAI();
}
let gemini;
if (process.env.GEMINI_KEY) {
gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY})
gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_KEY });
}
let mistral;
if (process.env.MISTRAL_KEY) {
mistral = new Mistral({apiKey: process.env.MISTRAL_KEY});
mistral = new Mistral({ apiKey: process.env.MISTRAL_KEY });
}
/**
* Gets a response from the configured AI model.
* It dynamically chooses the provider based on the MODEL environment variable.
@@ -29,175 +28,180 @@ if (process.env.MISTRAL_KEY) {
* @returns {Promise<string>} The content of the AI's response message.
*/
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 {
// --- OpenAI Provider ---
if (modelProvider === 'OpenAI' && openai) {
const completion = await openai.chat.completions.create({
model: "gpt-5", // Using a modern, cost-effective model
reasoning_effort: "low",
messages: messageHistory,
});
return completion.choices[0].message.content;
}
try {
// --- OpenAI Provider ---
if (modelProvider === "OpenAI" && openai) {
const completion = await openai.chat.completions.create({
model: "gpt-5", // Using a modern, cost-effective model
reasoning_effort: "low",
messages: messageHistory,
});
return completion.choices[0].message.content;
}
// --- Google Gemini Provider ---
else if (modelProvider === 'Gemini' && gemini) {
// Gemini requires a slightly different history format.
const contents = messageHistory.map(msg => ({
role: msg.role === 'assistant' ? 'model' : msg.role, // Gemini uses 'model' for assistant role
parts: [{ text: msg.content }],
}));
// --- Google Gemini Provider ---
else if (modelProvider === "Gemini" && gemini) {
// Gemini requires a slightly different history format.
const contents = messageHistory.map((msg) => ({
role: msg.role === "assistant" ? "model" : msg.role, // Gemini uses 'model' for assistant role
parts: [{ text: msg.content }],
}));
// The last message should not be from the model
if (contents[contents.length - 1].role === 'model') {
contents.pop();
}
// The last message should not be from the model
if (contents[contents.length - 1].role === "model") {
contents.pop();
}
const result = await gemini.generateContent({ contents });
const response = await result.response;
return response.text();
}
const result = await gemini.generateContent({ contents });
const response = await result.response;
return response.text();
}
// --- Mistral Provider ---
else if (modelProvider === 'Mistral' && mistral) {
const chatResponse = await mistral.chat({
model: 'mistral-large-latest',
messages: messageHistory,
});
return chatResponse.choices[0].message.content;
}
// --- Mistral Provider ---
else if (modelProvider === "Mistral" && mistral) {
const chatResponse = await mistral.chat({
model: "mistral-large-latest",
messages: messageHistory,
});
return chatResponse.choices[0].message.content;
}
// --- Fallback Case ---
else {
console.warn(`[AI] No valid AI provider configured or API key missing for MODEL=${modelProvider}.`);
return "Le service d'IA n'est pas correctement configuré. Veuillez contacter l'administrateur.";
}
} catch(error) {
console.error(`[AI] Error with ${modelProvider} API:`, error);
return "Oups, une erreur est survenue en contactant le service d'IA.";
}
// --- Fallback Case ---
else {
console.warn(`[AI] No valid AI provider configured or API key missing for MODEL=${modelProvider}.`);
return "Le service d'IA n'est pas correctement configuré. Veuillez contacter l'administrateur.";
}
} catch (error) {
console.error(`[AI] Error with ${modelProvider} API:`, error);
return "Oups, une erreur est survenue en contactant le service d'IA.";
}
}
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 INCLUDE_ATTACHMENT_URLS = (process.env.AI_INCLUDE_ATTACHMENT_URLS || 'true') === 'true';
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 INCLUDE_ATTACHMENT_URLS = (process.env.AI_INCLUDE_ATTACHMENT_URLS || "true") === "true";
export const stripMentionsOfBot = (text, botId) =>
text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
export const stripMentionsOfBot = (text, botId) => text.replace(new RegExp(`<@!?${botId}>`, "g"), "").trim();
export const sanitize = (s) =>
(s || '')
.replace(/\s+/g, ' ')
.replace(/```/g, 'ʼʼʼ') // éviter de casser des fences éventuels
.trim();
(s || "")
.replace(/\s+/g, " ")
.replace(/```/g, "ʼʼʼ") // éviter de casser des fences éventuels
.trim();
export const shortTs = (d) => new Date(d).toISOString(); // compact et triable
export function buildParticipantsMap(messages) {
const map = {};
for (const m of messages) {
const id = m.author.id;
if (!map[id]) {
map[id] = {
id,
username: m.author.username,
globalName: m.author.globalName || null,
isBot: !!m.author.bot,
};
}
}
return map;
const map = {};
for (const m of messages) {
const id = m.author.id;
if (!map[id]) {
map[id] = {
id,
username: m.author.username,
globalName: m.author.globalName || null,
isBot: !!m.author.bot,
};
}
}
return map;
}
export function buildTranscript(messages, botId) {
// Oldest -> newest, JSONL compact, une ligne par message pertinent
const lines = [];
for (const m of messages) {
const content = sanitize(m.content);
const atts = Array.from(m.attachments?.values?.() || []);
if (!content && atts.length === 0) continue;
// Oldest -> newest, JSONL compact, une ligne par message pertinent
const lines = [];
for (const m of messages) {
const content = sanitize(m.content);
const atts = Array.from(m.attachments?.values?.() || []);
if (!content && atts.length === 0) continue;
const attMeta = atts.length
? atts.slice(0, MAX_ATTS_PER_MESSAGE).map(a => ({
id: a.id,
name: a.name,
type: a.contentType || 'application/octet-stream',
size: a.size,
isImage: !!(a.contentType && a.contentType.startsWith('image/')),
width: a.width || undefined,
height: a.height || undefined,
spoiler: typeof a.spoiler === 'boolean' ? a.spoiler : false,
url: INCLUDE_ATTACHMENT_URLS ? a.url : undefined, // désactive par défaut
}))
: undefined;
const attMeta = atts.length
? atts.slice(0, MAX_ATTS_PER_MESSAGE).map((a) => ({
id: a.id,
name: a.name,
type: a.contentType || "application/octet-stream",
size: a.size,
isImage: !!(a.contentType && a.contentType.startsWith("image/")),
width: a.width || undefined,
height: a.height || undefined,
spoiler: typeof a.spoiler === "boolean" ? a.spoiler : false,
url: INCLUDE_ATTACHMENT_URLS ? a.url : undefined, // désactive par défaut
}))
: undefined;
const line = {
t: shortTs(m.createdTimestamp || Date.now()),
id: m.author.id,
nick: m.member?.nickname || m.author.globalName || m.author.username,
isBot: !!m.author.bot,
mentionsBot: new RegExp(`<@!?${botId}>`).test(m.content || ''),
replyTo: m.reference?.messageId || null,
content,
attachments: attMeta,
};
lines.push(line);
}
return lines.map(l => JSON.stringify(l)).join('\n');
const line = {
t: shortTs(m.createdTimestamp || Date.now()),
id: m.author.id,
nick: m.member?.nickname || m.author.globalName || m.author.username,
isBot: !!m.author.bot,
mentionsBot: new RegExp(`<@!?${botId}>`).test(m.content || ""),
replyTo: m.reference?.messageId || null,
content,
attachments: attMeta,
};
lines.push(line);
}
return lines.map((l) => JSON.stringify(l)).join("\n");
}
export function buildAiMessages({
botId,
botName = 'FlopoBot',
invokerId,
invokerName,
requestText,
transcript,
participants,
repliedUserId,
invokerAttachments = [],
botId,
botName = "FlopoBot",
invokerId,
invokerName,
requestText,
transcript,
participants,
repliedUserId,
invokerAttachments = [],
}) {
const system = {
role: 'system',
content:
`Tu es ${botName} (ID: ${botId}) sur un serveur Discord. Style: bref, naturel, détendu, comme un pote.
const system = {
role: "system",
content: `Tu es ${botName} (ID: ${botId}) sur un serveur Discord. Style: bref, naturel, détendu, comme un pote.
Règles de sortie:
- Réponds en français, en 13 phrases.
- Réponds PRINCIPALEMENT au message de <@${invokerId}>. Le transcript est un contexte facultatif.
- Pas de "Untel a dit…", pas de longs préambules.
- 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").`,
};
};
const attLines = invokerAttachments.length
? invokerAttachments.map(a => `- ${a.name} (${a.type || 'type inconnu'}, ${a.size ?? '?'} o${a.isImage ? ', image' : ''})`).join('\n')
: '';
const attLines = invokerAttachments.length
? invokerAttachments
.map((a) => `- ${a.name} (${a.type || "type inconnu"}, ${a.size ?? "?"} o${a.isImage ? ", image" : ""})`)
.join("\n")
: "";
const user = {
role: 'user',
content:
`Tâche: répondre brièvement à <@${invokerId}>.
const user = {
role: "user",
content: `Tâche: répondre brièvement à <@${invokerId}>.
Message de <@${invokerId}> (${invokerName || 'inconnu'}):
Message de <@${invokerId}> (${invokerName || "inconnu"}):
"""
${requestText}
"""
${invokerAttachments.length ? `Pièces jointes du message:
${
invokerAttachments.length
? `Pièces jointes du message:
${attLines}
` : ''}${repliedUserId ? `Ce message répond à <@${repliedUserId}>.` : ''}
`
: ""
}${repliedUserId ? `Ce message répond à <@${repliedUserId}>.` : ""}
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):
\`\`\`jsonl
${transcript}
\`\`\``,
};
};
return [system, user];
return [system, user];
}

View File

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

View File

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