Merge pull request #57 from cassoule/market-place

Market place
This commit is contained in:
Milo Gourvest
2025-12-19 17:50:16 +01:00
committed by GitHub
10 changed files with 1233 additions and 169 deletions

2
.gitignore vendored
View File

@@ -4,4 +4,4 @@ flopobot.db
flopobot.db-shm
flopobot.db-wal
.idea
flopobot_bc.db
*.db

Binary file not shown.

View File

@@ -1,7 +1,5 @@
import { InteractionResponseType, InteractionResponseFlags } from "discord-interactions";
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import { postAPOBuy } from "../../utils/index.js";
import { InteractionResponseFlags, InteractionResponseType } from "discord-interactions";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { DiscordRequest } from "../../api/discord.js";
import { getAllAvailableSkins, getUser, insertLog, updateSkin, updateUserCoins } from "../../database/index.js";
import { skins } from "../../game/state.js";
@@ -14,6 +12,14 @@ import { skins } from "../../game/state.js";
* @param {object} client - The Discord.js client instance.
*/
export async function handleValorantCommand(req, res, client) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `L'ouverture de caisses Valorant en commande discord est désactivée. Tu peux aller en ouvrir sur FlopoSite.`,
flags: InteractionResponseFlags.EPHEMERAL,
},
});
const { member, token } = req.body;
const userId = member.user.id;
const valoPrice = parseInt(process.env.VALO_PRICE, 10) || 500;
@@ -125,8 +131,7 @@ export async function handleValorantCommand(req, res, client) {
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é.",
content: "Oups, il y a eu un petit problème lors de l'ouverture de la caisse.",
embeds: [],
},
});

View File

@@ -1,5 +1,5 @@
import { handleMessageCreate } from "./handlers/messageCreate.js";
import { getAkhys, setupCronJobs } from "../utils/index.js";
import { getAkhys } from "../utils/index.js";
/**
* Initializes and attaches all necessary event listeners to the Discord client.
@@ -17,7 +17,7 @@ export function initializeEvents(client, io) {
console.log("[Startup] Bot is ready, performing initial data sync...");
await getAkhys(client);
console.log("[Startup] Setting up scheduled tasks...");
setupCronJobs(client, io);
//setupCronJobs(client, io);
console.log("--- FlopoBOT is fully operational ---");
});

View File

@@ -8,110 +8,266 @@ export const flopoDB = new Database("flopobot.db");
flopoDB.exec(`
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
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 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
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 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
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
);
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
id
PRIMARY
KEY,
bidder_id
TEXT
REFERENCES
users,
market_offer_id
REFERENCES
market_offers,
offer_amount
INTEGER,
offered_at
TIMESTAMP
DEFAULT
CURRENT_TIMESTAMP
);
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,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
id
PRIMARY
KEY,
user_id
TEXT
REFERENCES
users,
action
TEXT,
target_user_id
TEXT
REFERENCES
users,
coins_amount
INTEGER,
user_new_amount
INTEGER,
created_at
TIMESTAMP
DEFAULT
CURRENT_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
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 elos
(
id PRIMARY KEY REFERENCES users,
elo INTEGER
id
PRIMARY
KEY
REFERENCES
users,
elo
INTEGER
);
CREATE TABLE IF NOT EXISTS sotd
(
id INT PRIMARY KEY,
tableauPiles TEXT,
foundationPiles TEXT,
stockPile TEXT,
wastePile TEXT,
isDone BOOLEAN DEFAULT false,
seed TEXT
id
INT
PRIMARY
KEY,
tableauPiles
TEXT,
foundationPiles
TEXT,
stockPile
TEXT,
wastePile
TEXT,
isDone
BOOLEAN
DEFAULT
false,
seed
TEXT
);
CREATE TABLE IF NOT EXISTS sotd_stats
(
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users,
time INTEGER,
moves INTEGER,
score INTEGER
id
TEXT
PRIMARY
KEY,
user_id
TEXT
REFERENCES
users,
time
INTEGER,
moves
INTEGER,
score
INTEGER
);
`);
@@ -122,17 +278,48 @@ flopoDB.exec(`
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
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();
@@ -140,19 +327,44 @@ 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
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();
@@ -160,17 +372,51 @@ stmtSkins.run();
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
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();
@@ -178,11 +424,22 @@ 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
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();
@@ -190,13 +447,27 @@ stmtBids.run();
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,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
id
PRIMARY
KEY,
user_id
TEXT
REFERENCES
users,
action
TEXT,
target_user_id
TEXT
REFERENCES
users,
coins_amount
INTEGER,
user_new_amount
INTEGER,
created_at
TIMESTAMP
DEFAULT
CURRENT_TIMESTAMP
)
`);
stmtLogs.run();
@@ -204,17 +475,33 @@ stmtLogs.run();
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
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();
@@ -222,8 +509,13 @@ stmtGames.run();
export const stmtElos = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS elos
(
id PRIMARY KEY REFERENCES users,
elo INTEGER
id
PRIMARY
KEY
REFERENCES
users,
elo
INTEGER
)
`);
stmtElos.run();
@@ -231,13 +523,24 @@ stmtElos.run();
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
id
INT
PRIMARY
KEY,
tableauPiles
TEXT,
foundationPiles
TEXT,
stockPile
TEXT,
wastePile
TEXT,
isDone
BOOLEAN
DEFAULT
false,
seed
TEXT
)
`);
stmtSOTD.run();
@@ -245,11 +548,20 @@ stmtSOTD.run();
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
id
TEXT
PRIMARY
KEY,
user_id
TEXT
REFERENCES
users,
time
INTEGER,
moves
INTEGER,
score
INTEGER
)
`);
stmtSOTDStats.run();
@@ -291,9 +603,9 @@ export const getAllAkhys = flopoDB.prepare(
----------------------------*/
export const insertSkin = flopoDB.prepare(
`INSERT INTO skins (uuid, displayName, contentTierUuid, displayIcon, user_id, tierRank, tierColor, tierText,
basePrice, currentLvl, currentChroma, currentPrice, maxPrice)
basePrice, maxPrice)
VALUES (@uuid, @displayName, @contentTierUuid, @displayIcon, @user_id, @tierRank, @tierColor, @tierText,
@basePrice, @currentLvl, @currentChroma, @currentPrice, @maxPrice)`,
@basePrice, @maxPrice)`,
);
export const updateSkin = flopoDB.prepare(
`UPDATE skins
@@ -371,6 +683,20 @@ export const insertMarketOffer = flopoDB.prepare(`
VALUES (@id, @skin_uuid, @seller_id, @starting_price, @buyout_price, @status, @opening_at, @closing_at)
`);
export const updateMarketOffer = flopoDB.prepare(`
UPDATE market_offers
SET final_price = @final_price,
status = @status,
buyer_id = @buyer_id
WHERE id = @id
`);
export const deleteMarketOffer = flopoDB.prepare(`
DELETE
FROM market_offers
WHERE id = ?
`);
/* -------------------------
BIDS
----------------------------*/
@@ -401,6 +727,12 @@ export const insertBid = flopoDB.prepare(`
VALUES (@id, @bidder_id, @market_offer_id, @offer_amount)
`);
export const deleteBid = flopoDB.prepare(`
DELETE
FROM bids
WHERE id = ?
`);
/* -------------------------
BULK TRANSACTIONS (synchronous)
----------------------------*/

View File

@@ -4,6 +4,7 @@ import { sleep } from "openai/core";
// --- Database Imports ---
import {
getAllAkhys,
getAllAvailableSkins,
getAllUsers,
getLogs,
getMarketOffersBySkin,
@@ -19,6 +20,7 @@ import {
insertUser,
pruneOldLogs,
queryDailyReward,
updateSkin,
updateUserCoins,
} from "../../database/index.js";
@@ -32,6 +34,7 @@ import { DiscordRequest } from "../../api/discord.js";
// --- Discord.js Builder Imports ---
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js";
import { emitDataUpdated, socketEmit } from "../socket.js";
import { handleCaseOpening } from "../../utils/marketNotifs.js";
// Create a new router instance
const router = express.Router();
@@ -114,6 +117,151 @@ export function apiRoutes(client, io) {
}
});
router.post("/open-case", async (req, res) => {
const { userId, caseType } = req.body;
let caseTypeVal, tierWeights;
switch (caseType) {
case "standard":
caseTypeVal = 1;
tierWeights = {
"12683d76-48d7-84a3-4e09-6985794f0445": 50, // Select
"0cebb8be-46d7-c12a-d306-e9907bfc5a25": 30, // Deluxe
"60bca009-4182-7998-dee7-b8a2558dc369": 15, // Premium
"e046854e-406c-37f4-6607-19a9ba8426fc": 4, // Exclusive
"411e4a55-4e59-7757-41f0-86a53f101bb5": 1, // Ultra
};
break;
case "premium":
caseTypeVal = 2;
tierWeights = {
"12683d76-48d7-84a3-4e09-6985794f0445": 35, // Select
"0cebb8be-46d7-c12a-d306-e9907bfc5a25": 30, // Deluxe
"60bca009-4182-7998-dee7-b8a2558dc369": 30, // Premium
"e046854e-406c-37f4-6607-19a9ba8426fc": 4, // Exclusive
"411e4a55-4e59-7757-41f0-86a53f101bb5": 1, // Ultra
};
break;
case "ultra":
caseTypeVal = 4;
tierWeights = {
"12683d76-48d7-84a3-4e09-6985794f0445": 33, // Select
"0cebb8be-46d7-c12a-d306-e9907bfc5a25": 28, // Deluxe
"60bca009-4182-7998-dee7-b8a2558dc369": 28, // Premium
"e046854e-406c-37f4-6607-19a9ba8426fc": 8, // Exclusive
"411e4a55-4e59-7757-41f0-86a53f101bb5": 3, // Ultra
};
break;
default:
return res.status(400).json({ error: "Invalid case type." });
}
const commandUser = getUser.get(userId);
if (!commandUser) return res.status(404).json({ error: "User not found." });
const valoPrice = (parseInt(process.env.VALO_PRICE, 10) || 500) * caseTypeVal;
if (commandUser.coins < valoPrice) return res.status(403).json({ error: "Not enough FlopoCoins." });
try {
const dbSkins = getAllAvailableSkins.all();
const filteredSkins = skins.filter((s) => dbSkins.find((dbSkin) => dbSkin.uuid === s.uuid));
filteredSkins.forEach((s) => {
let dbSkin = getSkin.get(s.uuid);
s.tierColor = dbSkin?.tierColor;
});
filteredSkins.forEach((s) => {
s.weight = tierWeights[s.tierUuid] ?? 1; // fallback if missing
});
function weightedSample(arr, count) {
let totalWeight = arr.reduce((sum, x) => sum + x.weight, 0);
const list = [...arr];
const result = [];
for (let i = 0; i < count && list.length > 0; i++) {
let r = Math.random() * totalWeight;
let running = 0;
let pickIndex = -1;
for (let j = 0; j < list.length; j++) {
running += list[j].weight;
if (r <= running) {
pickIndex = j;
break;
}
}
if (pickIndex < 0) break;
const picked = list.splice(pickIndex, 1)[0];
result.push(picked);
// Subtract removed weight
totalWeight -= picked.weight;
}
return result;
}
const selectedSkins = weightedSample(filteredSkins, 100);
const randomSelectedSkinIndex = Math.floor(Math.random() * (selectedSkins.length - 1));
const randomSelectedSkinUuid = selectedSkins[randomSelectedSkinIndex].uuid;
const dbSkin = getSkin.get(randomSelectedSkinUuid);
const randomSkinData = skins.find((skin) => skin.uuid === dbSkin.uuid);
if (!randomSkinData) {
throw new Error(`Could not find skin data for UUID: ${dbSkin.uuid}`);
}
// --- Randomize Level and Chroma ---
const randomLevel = Math.floor(Math.random() * randomSkinData.levels.length) + 1;
let randomChroma = 1;
if (randomLevel === randomSkinData.levels.length && randomSkinData.chromas.length > 1) {
// Ensure chroma is at least 1 and not greater than the number of chromas
randomChroma = Math.floor(Math.random() * randomSkinData.chromas.length) + 1;
}
// --- Calculate Price ---
const calculatePrice = () => {
let result = parseFloat(dbSkin.basePrice);
result *= 1 + randomLevel / Math.max(randomSkinData.levels.length, 2);
result *= 1 + randomChroma / 4;
return parseFloat(result.toFixed(0));
};
const finalPrice = calculatePrice();
// --- Update Database ---
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,
});
updateSkin.run({
uuid: randomSkinData.uuid,
user_id: userId,
currentLvl: randomLevel,
currentChroma: randomChroma,
currentPrice: finalPrice,
});
console.log(
`[${Date.now()}] ${userId} opened a ${caseType} Valorant case and received skin ${randomSelectedSkinUuid}`,
);
const updatedSkin = getSkin.get(randomSkinData.uuid);
await handleCaseOpening(caseType, userId, randomSelectedSkinUuid, client);
res.json({ selectedSkins, randomSelectedSkinUuid, randomSelectedSkinIndex, updatedSkin });
} catch (error) {
console.error("Error fetching skins:", error);
res.status(500).json({ error: "Failed to fetch skins." });
}
});
router.get("/skin/:id", (req, res) => {
try {
const skinData = skins.find((s) => s.uuid === req.params.id);
@@ -168,6 +316,14 @@ export function apiRoutes(client, io) {
});
// --- User-Specific Routes ---
router.get("/user/:id", async (req, res) => {
try {
const user = getUser.get(req.params.id);
res.json({ user });
} catch (error) {
res.status(404).json({ error: "User not found." });
}
});
router.get("/user/:id/avatar", async (req, res) => {
try {
@@ -188,6 +344,15 @@ export function apiRoutes(client, io) {
}
});
router.get("/user/:id/coins", async (req, res) => {
try {
const user = getUser.get(req.params.id);
res.json({ coins: user.coins });
} catch (error) {
res.status(404).json({ error: "User not found." });
}
});
router.get("/user/:id/sparkline", (req, res) => {
try {
const logs = getUserLogs.all({ user_id: req.params.id });

View File

@@ -8,13 +8,17 @@ import { ButtonStyle } from "discord.js";
import {
getMarketOfferById,
getMarketOffers,
getMarketOffersBySkin,
getOfferBids,
getSkin,
getUser,
insertBid,
insertLog,
insertMarketOffer,
updateUserCoins,
} from "../../database/index.js";
import { emitMarketUpdate } from "../socket.js";
import { handleNewMarketOffer, handleNewMarketOfferBid } from "../../utils/marketNotifs.js";
// Create a new router instance
const router = express.Router();
@@ -67,12 +71,43 @@ export function marketRoutes(client, io) {
});
router.post("/place-offer", async (req, res) => {
const { seller_id, skin_uuid, starting_price, delay, duration, timestamp } = req.body;
const now = Date.now();
try {
// Placeholder for placing an offer logic
// Extract data from req.body and process accordingly
res.status(200).send({ message: "Offer placed successfully" });
const skin = getSkin.get(skin_uuid);
if (!skin) return res.status(404).send({ error: "Skin not found" });
const seller = getUser.get(seller_id);
if (!seller) return res.status(404).send({ error: "Seller not found" });
if (skin.user_id !== seller.id) return res.status(403).send({ error: "You do not own this skin" });
const existingOffers = getMarketOffersBySkin.all(skin.uuid);
if (
existingOffers.length > 0 &&
existingOffers.some((offer) => offer.status === "open" || offer.status === "pending")
) {
return res.status(403).send({ error: "This skin already has an open or pending offer." });
}
const opening_at = now + delay;
const closing_at = opening_at + duration;
const offerId = Date.now() + "-" + seller.id + "-" + skin.uuid;
insertMarketOffer.run({
id: offerId,
skin_uuid: skin.uuid,
seller_id: seller.id,
starting_price: starting_price,
buyout_price: null,
status: delay > 0 ? "pending" : "open",
opening_at: opening_at,
closing_at: closing_at,
});
await emitMarketUpdate();
await handleNewMarketOffer(offerId, client);
res.status(200).send({ message: "Offre créée avec succès" });
} catch (e) {
res.status(500).send({ error: e });
console.log(e);
return res.status(500).send({ error: e });
}
});
@@ -92,11 +127,11 @@ export function marketRoutes(client, io) {
if (lastBid?.bidder_id === buyer_id)
return res.status(403).send({ error: "You are already the highest bidder" });
if (bid_amount < lastBid?.offer_amount + 10) {
return res.status(403).send({ message: "Bid amount is below minimum" });
return res.status(403).send({ error: "Bid amount is below minimum" });
}
} else {
if (bid_amount < offer.starting_price + 10) {
return res.status(403).send({ message: "Bid amount is below minimum" });
return res.status(403).send({ error: "Bid amount is below minimum" });
}
}
@@ -105,16 +140,15 @@ export function marketRoutes(client, io) {
if (bidder.coins < bid_amount)
return res.status(403).send({ error: "You do not have enough coins to place this bid" });
// TODO:
// buyer must refunded on outbid
const bidId = Date.now() + "-" + buyer_id + "-" + offer.id;
insertBid.run({
id: bidId,
bidder_id: buyer_id,
market_offer_id: offer.id,
offer_amount: bid_amount,
});
const newCoinsAmount = bidder.coins - bid_amount;
updateUserCoins.run({ buyer_id, coins: newCoinsAmount });
updateUserCoins.run({ id: buyer_id, coins: newCoinsAmount });
insertLog.run({
id: `${buyer_id}-bid-${offer.id}-${Date.now()}`,
user_id: buyer_id,
@@ -124,7 +158,24 @@ export function marketRoutes(client, io) {
user_new_amount: newCoinsAmount,
});
res.status(200).send({ message: "Bid placed successfully" });
// Refund the previous highest bidder
if (lastBid) {
const previousBidder = getUser.get(lastBid.bidder_id);
const refundedCoinsAmount = previousBidder.coins + lastBid.offer_amount;
updateUserCoins.run({ id: previousBidder.id, coins: refundedCoinsAmount });
insertLog.run({
id: `${previousBidder.id}-bid-refund-${offer.id}-${Date.now()}`,
user_id: previousBidder.id,
action: "BID_REFUNDED",
target_user_id: null,
coins_amount: lastBid.offer_amount,
user_new_amount: refundedCoinsAmount,
});
}
await handleNewMarketOfferBid(offer.id, bidId, client);
await emitMarketUpdate();
res.status(200).send({ error: "Bid placed successfully" });
} catch (e) {
console.log(`[${Date.now()}]`, e);
res.status(500).send({ error: e });

View File

@@ -459,3 +459,5 @@ export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, ro
export const emitToast = (payload) => io.emit("blackjack:toast", payload);
export const emitSolitaireUpdate = (userId, moves) => io.emit("solitaire:update", { userId, moves });
export const emitMarketUpdate = () => io.emit("market:update");

View File

@@ -6,14 +6,26 @@ import { getSkinTiers, getValorantSkins } from "../api/valorant.js";
import { DiscordRequest } from "../api/discord.js";
import { initTodaysSOTD } from "../game/points.js";
import {
deleteBid,
deleteMarketOffer,
getAllAkhys,
getAllUsers,
getMarketOffers,
getOfferBids,
getSkin,
getUser,
insertManySkins,
insertUser,
resetDailyReward,
updateMarketOffer,
updateSkin,
updateUserAvatar,
updateUserCoins,
} from "../database/index.js";
import { activeInventories, activeSearchs, skins } from "../game/state.js";
import { activeInventories, activePredis, activeSearchs, pokerRooms, skins } from "../game/state.js";
import { emitMarketUpdate } from "../server/socket.js";
import { handleMarketOfferClosing, handleMarketOfferOpening } from "./marketNotifs.js";
import { client } from "../bot/client.js";
export async function InstallGlobalCommands(appId, commands) {
// API endpoint to overwrite global commands
@@ -111,6 +123,11 @@ export async function getAkhys(client) {
* @param {object} io - The Socket.IO server instance.
*/
export function setupCronJobs(client, io) {
// Every 5 minutes: Update market offers
cron.schedule("* * * * *", () => {
handleMarketOffersUpdate();
});
// Every 10 minutes: Clean up expired interactive sessions
cron.schedule("*/10 * * * *", () => {
const now = Date.now();
@@ -130,9 +147,28 @@ export function setupCronJobs(client, io) {
cleanup(activeInventories, "inventory");
cleanup(activeSearchs, "search");
for (const id in pokerRooms) {
if (pokerRooms[id].last_move_at !== null) {
if (now >= pokerRooms[id].last_move_at + FIVE_MINUTES * 3) {
delete pokerRooms[id];
console.log(`[Cron] Cleaned up expired poker room ID: ${id}`);
}
} else {
if (now >= pokerRooms[id].created_at + FIVE_MINUTES * 6) {
delete pokerRooms[id];
console.log(`[Cron] Cleaned up expired poker room ID: ${id}`);
}
}
}
// TODO: Cleanup for predis and poker rooms...
// ...
let cleanedCount = 0;
for (const id in activePredis) {
if (now >= (activePredis[id].endTime || 0)) {
delete activePredis[id];
cleanedCount++;
}
}
if (cleanedCount > 0) console.log(`[Cron] Cleaned up ${cleanedCount} expired predictions.`);
});
// Daily at midnight: Reset daily rewards and init SOTD
@@ -147,6 +183,23 @@ export function setupCronJobs(client, io) {
} catch (e) {
console.error("[Cron] Error during daily reset:", e);
}
try {
const offers = getMarketOffers.all();
const now = Date.now();
const TWO_DAYS = 2 * 24 * 60 * 60 * 1000;
for (const offer of offers) {
if (now >= offer.closing_at + TWO_DAYS) {
const offerBids = getOfferBids.all(offer.id);
for (const bid of offerBids) {
deleteBid.run(bid.id);
}
deleteMarketOffer.run(offer.id);
console.log(`[Cron] Deleted expired market offer ID: ${offer.id}`);
}
}
} catch (e) {
console.error("[Cron] Error during Market Offers clean up:", e);
}
});
// Daily at 7 AM: Re-sync users and skins
@@ -223,6 +276,61 @@ export async function postAPOBuy(userId, amount) {
// --- Miscellaneous Helpers ---
function handleMarketOffersUpdate() {
const now = Date.now();
const offers = getMarketOffers.all();
offers.forEach(async (offer) => {
if (now >= offer.opening_at && offer.status === "pending") {
updateMarketOffer.run({ id: offer.id, final_price: null, buyer_id: null, status: "open" });
await handleMarketOfferOpening(offer.id, client);
await emitMarketUpdate();
}
if (now >= offer.closing_at && offer.status !== "closed") {
const bids = getOfferBids.all(offer.id);
if (bids.length === 0) {
// No bids placed, mark as closed without a sale
updateMarketOffer.run({
id: offer.id,
buyer_id: null,
final_price: null,
status: "closed",
});
await emitMarketUpdate();
} else {
const lastBid = bids[0];
const seller = getUser.get(offer.seller_id);
const buyer = getUser.get(lastBid.bidder_id);
try {
// Change skin ownership
const skin = getSkin.get(offer.skin_uuid);
if (!skin) throw new Error(`Skin not found for offer ID: ${offer.id}`);
updateSkin.run({
user_id: buyer.id,
currentLvl: skin.currentLvl,
currentChroma: skin.currentChroma,
currentPrice: skin.currentPrice,
uuid: skin.uuid,
});
updateMarketOffer.run({
id: offer.id,
buyer_id: buyer.id,
final_price: lastBid.offer_amount,
status: "closed",
});
const newUserCoins = seller.coins + lastBid.offer_amount;
updateUserCoins.run({ id: seller.id, coins: newUserCoins });
await emitMarketUpdate();
} catch (e) {
console.error(`[Market Cron] Error processing offer ID: ${offer.id}`, e);
}
}
await handleMarketOfferClosing(offer.id, client);
}
});
}
export async function getOnlineUsersWithRole(guild, roleId) {
if (!guild || !roleId) return new Map();
try {

401
src/utils/marketNotifs.js Normal file
View File

@@ -0,0 +1,401 @@
import { getMarketOfferById, getOfferBids, getSkin, getUser } from "../database/index.js";
import { EmbedBuilder } from "discord.js";
export async function handleNewMarketOffer(offerId, client) {
const offer = getMarketOfferById.get(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
const discordUserSeller = await client.users.fetch(offer.seller_id);
try {
const userSeller = getUser.get(offer.seller_id);
if (discordUserSeller && userSeller?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Offre créée")
.setDescription(`Ton offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** a bien été créée !`)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "📌 Statut",
value: `\`${offer.status}\``,
inline: true,
},
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
inline: true,
},
{
name: "⏰ Ouverture",
value: `<t:${Math.floor(offer.opening_at / 1000)}:F>`,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
discordUserSeller.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(e);
}
// Send notification in guild channel
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setTitle("🔔 Nouvelle offre")
.setDescription(`Une offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** a été créée !`)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
inline: true,
},
{
name: "⏰ Ouverture",
value: `<t:${Math.floor(offer.opening_at / 1000)}:F>`,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
},
{
name: "Créée par",
value: `<@${offer.seller_id}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""}`,
},
)
.setTimestamp();
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
}
}
export async function handleMarketOfferOpening(offerId, client) {
const offer = getMarketOfferById.get(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
try {
const discordUserSeller = await client.users.fetch(offer.seller_id);
const userSeller = getUser.get(offer.seller_id);
if (discordUserSeller && userSeller?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Début des enchères")
.setDescription(
`Les enchères sur ton offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** viennent de commencer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "📌 Statut",
value: `\`${offer.status}\``,
inline: true,
},
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
discordUserSeller.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(e);
}
// Send notification in guild channel
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setTitle("🔔 Début des enchères")
.setDescription(
`Les enchères sur l'offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** viennent de commencer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
},
)
.setTimestamp();
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
}
}
export async function handleMarketOfferClosing(offerId, client) {
const offer = getMarketOfferById.get(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
const bids = getOfferBids.all(offer.id);
const discordUserSeller = await client.users.fetch(offer.seller_id);
try {
const userSeller = getUser.get(offer.seller_id);
if (discordUserSeller && userSeller?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Fin des enchères")
.setDescription(
`Les enchères sur ton offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setTimestamp();
if (bids.length === 0) {
embed.addFields(
{
name: "❌ Aucune enchère n'a été placée sur cette offre.",
value: "Tu conserves ce skin dans ton inventaire.",
},
{
name: "🆔 ID de loffre",
value: `\`${offer.id}\``,
inline: false,
},
);
} else {
const highestBid = bids[0];
const highestBidderUser = await client.users.fetch(highestBid.bidder_id);
embed.addFields(
{
name: "✅ Enchères terminées avec succès !",
value: `Ton skin a été vendu pour \`${highestBid.offer_amount} coins\` à <@${highestBid.bidder_id}> ${highestBidderUser ? "(" + highestBidderUser.username + ")" : ""}.`,
},
{
name: "🆔 ID de loffre",
value: `\`${offer.id}\``,
inline: false,
},
);
}
discordUserSeller.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(e);
}
// Send notification in guild channel
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setTitle("🔔 Fin des enchères")
.setDescription(
`Les enchères sur l'offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setTimestamp();
if (bids.length === 0) {
embed.addFields({
name: "❌ Aucune enchère n'a été placée sur cette offre.",
value: "",
});
} else {
const highestBid = bids[0];
const highestBidderUser = await client.users.fetch(highestBid.bidder_id);
embed.addFields({
name: "✅ Enchères terminées avec succès !",
value: `Le skin de <@${offer.seller_id}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""} a été vendu pour \`${highestBid.offer_amount} coins\` à <@${highestBid.bidder_id}> ${highestBidderUser ? "(" + highestBidderUser.username + ")" : ""}.`,
});
const discordUserBidder = await client.users.fetch(highestBid.bidder_id);
const userBidder = getUser.get(highestBid.bidder_id);
if (discordUserBidder && userBidder?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Fin des enchères")
.setDescription(
`Les enchères sur l'offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setTimestamp();
const highestBid = bids[0];
embed.addFields({
name: "✅ Enchères terminées avec succès !",
value: `Tu as acheté ce skin pour \`${highestBid.offer_amount} coins\` à <@${offer.seller_id}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""}. Il a été ajouté à ton inventaire.`,
});
discordUserBidder.send({ embeds: [embed] }).catch(console.error);
}
}
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
}
}
export async function handleNewMarketOfferBid(offerId, bidId, client) {
// Notify Seller and Bidder
const offer = getMarketOfferById.get(offerId);
if (!offer) return;
const bid = getOfferBids.get(offerId);
if (!bid) return;
const skin = getSkin.get(offer.skin_uuid);
const bidderUser = client.users.fetch(bid.bidder_id);
try {
const discordUserSeller = await client.users.fetch(offer.seller_id);
const userSeller = getUser.get(offer.seller_id);
if (discordUserSeller && userSeller?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Nouvelle enchère")
.setDescription(
`Il y a eu une nouvelle enchère sur ton offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}**.`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "👤 Enchérisseur",
value: `<@${bid.bidder_id}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
inline: true,
},
{
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
discordUserSeller.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(`Erreur lors de la notification du vendeur : ${e}`);
}
try {
const discordUserNewBidder = await client.users.fetch(bid.bidder_id);
const userNewBidder = getUser.get(bid.bidder_id);
if (discordUserNewBidder && userNewBidder?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Nouvelle enchère")
.setDescription(
`Ton enchère sur l'offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}** a bien été placée!`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields({
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
inline: true,
})
.setTimestamp();
discordUserNewBidder.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(`Erreur lors de la notification de l'enchérriseur : ${e}`);
}
try {
const offerBids = getOfferBids.all(offer.id);
if (offerBids.length < 2) return; // No previous bidder to notify
const discordUserPreviousBidder = await client.users.fetch(offerBids[1].bidder_id);
const userPreviousBidder = getUser.get(offerBids[1].bidder_id);
if (discordUserPreviousBidder && userPreviousBidder?.isAkhy) {
const embed = new EmbedBuilder()
.setTitle("🔔 Nouvelle enchère")
.setDescription(
`Quelqu'un a surenchéri sur l'offre pour le skin **${skin ? skin.displayName : offer.skin_uuid}**, tu n'es plus le meilleur enchérisseur !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.addFields(
{
name: "👤 Enchérisseur",
value: `<@${bid.bidder_id}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
inline: true,
},
{
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
inline: true,
},
)
.setTimestamp();
discordUserPreviousBidder.send({ embeds: [embed] }).catch(console.error);
}
} catch (e) {
console.error(e);
}
// Notify previous highest bidder
}
export async function handleCaseOpening(caseType, userId, skinUuid, client) {
const discordUser = await client.users.fetch(userId);
const skin = getSkin.get(skinUuid);
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setTitle("🔔 Ouverture de caisse")
.setDescription(
`${discordUser ? discordUser.username : "Un utilisateur"} vient d'ouvrir une caisse **${caseType}** et a obtenu le skin **${skin.displayName}** !`,
)
.setThumbnail(skin.displayIcon)
.setColor(skin.tierColor) // Discord blurple
.addFields(
{
name: "💰 Valeur estimée",
value: `\`${skin.currentPrice} coins\``,
inline: true,
},
{
name: "Level",
value: `${skin.currentLvl}`,
},
)
.setTimestamp();
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
}
}