55 Commits

Author SHA1 Message Date
Milo Gourvest
19f9a11e4b Merge pull request #87 from cassoule/milo-260310
user.username in sotd rankings
2026-03-16 13:47:03 +01:00
Milo
78ecf9698c user.username in sotd rankings 2026-03-16 13:46:41 +01:00
Milo Gourvest
82c9c8ddad Merge pull request #86 from cassoule/milo-260310
skins prices rework
2026-03-15 19:44:59 +01:00
Milo
622522afa7 skins prices rework 2026-03-15 19:43:43 +01:00
Milo Gourvest
bd4375b2cc Merge pull request #85 from cassoule/milo-260310
deploy test
2026-03-11 17:16:00 +01:00
Milo
e4beb7f5be deploy test 2026-03-11 17:14:25 +01:00
Milo Gourvest
ede1489016 Merge pull request #84 from cassoule/milo-260310
deploy workflow
2026-03-11 17:09:29 +01:00
Milo
ef6022ae35 deploy workflow 2026-03-11 17:08:16 +01:00
Milo Gourvest
30233e5239 Merge pull request #83 from cassoule/milo-260310
db route
2026-03-11 15:00:47 +01:00
Milo
ee231b517e db route 2026-03-11 15:00:26 +01:00
Milo Gourvest
1523d0f696 Merge pull request #82 from cassoule/milo-260310
reduce discord requests
2026-03-11 13:05:46 +01:00
Milo
4cc1b17984 reduce discord requests 2026-03-11 13:05:19 +01:00
Milo Gourvest
e18aabadb6 Merge pull request #81 from cassoule/milo-260310
rate limit info on start
2026-03-11 10:26:31 +01:00
Milo
4d1de5d48c rate limit info on start 2026-03-11 10:11:31 +01:00
Milo Gourvest
885f7d9b44 Merge pull request #80 from cassoule/milo-260310
oauth2 debug
2026-03-11 10:00:57 +01:00
Milo
7f043a7c93 oauth2 debug 2026-03-11 10:00:29 +01:00
Milo Gourvest
72e67be565 Merge pull request #79 from cassoule/milo-260310
catch logging error
2026-03-11 09:54:36 +01:00
Milo
2d2e2d71a8 catch logging error 2026-03-11 09:54:05 +01:00
Milo Gourvest
d7eb194db6 Merge pull request #78 from cassoule/milo-260224
Milo 260224
2026-03-05 14:41:09 +01:00
Milo
45f90dc207 refund fix 2026-03-05 14:40:40 +01:00
Milo
6af86b9032 CASES 2026-03-05 14:35:56 +01:00
milo
aeec76e457 cs skins 2026-03-01 17:02:51 +01:00
Milo
c635252758 wip 2026-02-27 17:20:23 +01:00
Milo Gourvest
639e7a9c3c Merge pull request #77 from cassoule/milo-260209
hm2
2026-02-15 12:36:27 +01:00
milo
d53e43f9c4 hm2 2026-02-15 12:34:39 +01:00
Milo Gourvest
6072d19642 Merge pull request #76 from cassoule/milo-260209
hm
2026-02-15 12:30:29 +01:00
milo
a8fac1cb19 hm 2026-02-15 12:29:45 +01:00
Milo Gourvest
6c2f9df2f0 Merge pull request #75 from cassoule/milo-260209
Milo 260209
2026-02-15 11:53:08 +01:00
milo
3115df3cdd blackjack split fix 2026-02-15 11:52:39 +01:00
milo
db7d9aec8f feat: new backend handled login 2026-02-11 18:01:45 +01:00
milo
373a4c6edc prisma migrations fix 2026-02-10 18:27:19 +01:00
milo
1c432e68bd tiny changes 2026-02-10 03:13:09 +01:00
milo
9e12065f0d clean, lint, format 2026-02-10 02:20:24 +01:00
Milo Gourvest
2cabd43769 Merge pull request #74 from cassoule/feat/db-changes
Feat/db changes
2026-02-06 20:22:00 +01:00
Milo
bb7f0047bb prisma refactor 2026-02-06 20:21:15 +01:00
Milo Gourvest
adcd4cac1f Merge pull request #73 from cassoule/milo-260130
elo loss factor adjusted
2026-02-06 17:52:31 +01:00
Milo
c4c8eaf5d6 feat: micro-transactions 2026-02-06 17:47:18 +01:00
milo
1371200041 elo loss factor adjusted
*.8 -> *.7
2026-01-30 15:59:46 +01:00
Milo Gourvest
ff8ffd7503 Merge pull request #72 from cassoule/milo-260129
blackjack chat box
2026-01-29 17:00:43 +01:00
milo
3b63b17124 blackjack chat box 2026-01-29 17:00:16 +01:00
Milo Gourvest
ad7bddd1dd Merge pull request #71 from cassoule/milo-260129
snake v0.2
2026-01-29 10:01:47 +01:00
Milo
29798e40c7 snake v0.2 2026-01-29 09:52:40 +01:00
Milo Gourvest
2a81ab578c Merge pull request #70 from cassoule/milo-260129
message update
2026-01-29 09:07:46 +01:00
Milo
b47def3a9f message update 2026-01-29 09:07:02 +01:00
Milo Gourvest
54059b7133 Merge pull request #69 from cassoule/milo-260128
Milo 260128
2026-01-28 17:29:09 +01:00
milo
4dd5be7e2f hell yeah 2026-01-28 17:17:25 +01:00
Milo
12eac37226 snake 2026-01-28 11:57:15 +01:00
Milo Gourvest
26c03ed62a Merge pull request #68 from cassoule/milo-260127
fixes
2026-01-27 22:53:08 +01:00
milo
beabace9eb fixes 2026-01-27 22:52:38 +01:00
Milo Gourvest
a08e9ed626 Merge pull request #67 from cassoule/milo-260125
some fixes
2026-01-25 17:12:25 +01:00
milo
1514bb08c2 some fixes 2026-01-25 17:06:57 +01:00
Milo Gourvest
41e8f01b25 Merge pull request #66 from cassoule/milo-260122
fix
2026-01-22 11:16:02 +01:00
Milo
aac8aeb348 fix 2026-01-22 11:15:30 +01:00
Milo Gourvest
1412ed7ea2 Merge pull request #65 from cassoule/milo-260121
feat: monke game
2026-01-22 10:38:00 +01:00
Milo
f8e3d994da feat: monke game 2026-01-22 10:30:04 +01:00
56 changed files with 7437 additions and 2570 deletions

18
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: Deploy to Hetzner
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_IP }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: ~/deploy.sh

3
.gitignore vendored
View File

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

View File

@@ -7,9 +7,9 @@
```
├── public/
│ └── images/ # Static assets
├── src/
├── src/
│ ├── api/ # External API integrations
│ ├── bot/
│ ├── bot/
│ │ ├── commands/ # Slash command implementations
│ │ ├── components/ # Discord message components
│ │ ├── handlers/ # Event handlers
@@ -17,10 +17,10 @@
│ │ └── events.js # Event registration
│ ├── config/
│ │ └── commands.js # Slash command definitions
│ ├── database/
│ ├── database/
│ │ └── index.js # Database connection and models
│ ├── game/ # Game logic and data
│ ├── server/
│ ├── server/
│ │ ├── routes/ # Express routes
│ │ ├── app.js # Express app setup
│ │ └── socket.js # Socket.io setup
@@ -30,6 +30,7 @@
```
## Features
- **Moderation Tools** : Includes commands for managing server members.
- **AI Integration** : Utilizes AI APIs for enhanced interactions.
- **Game Mechanics** : Implements game features and logic.
@@ -38,12 +39,14 @@
- **Web Integration** : Designed to work alongside a [FlopoSite](https://floposite.com) (see [FlopoSite's repo)](https://github.com/cassoule/floposite)).
## Additional Information
Note that FlopoBot is a work in progress, and new features and improvements are continually being added. Contributions and feedback are welcome !
FlopoBot was orriginally created to be integrated in a specific Discord server, so adding it to other servers won't provide the full experience (for now).
Note that FlopoBot is a work in progress, and new features and improvements are continually being added. Contributions and feedback are welcome !
FlopoBot was orriginally created to be integrated in a specific Discord server, so adding it to other servers won't provide the full experience (for now).
FlopoSite though is public and can be used by anyone :)
## Related Links
- [FlopoSite Website](https://floposite.com)
- [FlopoSite Repository](https://github.com/cassoule/floposite)
- [FlopoSite Repository](https://github.com/cassoule/floposite)

View File

@@ -26,9 +26,15 @@ initializeSocket(io, client);
// --- BOT INITIALIZATION ---
initializeEvents(client, io);
client.rest.on("rateLimited", (info) => {
console.log("Rate limited:", info);
});
console.log("Logging in...");
client.login(process.env.BOT_TOKEN).then(() => {
console.log(`Logged in as ${client.user.tag}`);
console.log("[Discord Bot Events Initialized]");
}).catch((error) => {
console.error("Error logging in to Discord:", error);
});
// --- APP STARTUP ---

1086
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,23 +11,32 @@
"scripts": {
"start": "node index.js",
"register": "node commands.js",
"dev": "nodemon index.js"
"dev": "nodemon index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"stripe:whook": "stripe listen --forward-to localhost:3000/api/buy-coins"
},
"author": "Milo Gourvest",
"license": "MIT",
"dependencies": {
"@google/genai": "^1.30.0",
"@mistralai/mistralai": "^1.6.0",
"@prisma/client": "^6.19.2",
"axios": "^1.9.0",
"better-sqlite3": "^11.9.1",
"discord-interactions": "^4.0.0",
"discord.js": "^14.18.0",
"discord.js": "^14.25.1",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"node-cron": "^3.0.3",
"openai": "^4.104.0",
"pnpm": "^10.29.2",
"pokersolver": "^2.1.4",
"prisma": "^6.19.2",
"socket.io": "^4.8.1",
"stripe": "^20.3.0",
"unique-names-generator": "^4.7.1",
"uuid": "^11.1.0"
},

3241
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- "@prisma/client"
- "@prisma/engines"
- prisma
- protobufjs

View File

@@ -0,0 +1,137 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"globalName" TEXT,
"warned" INTEGER NOT NULL DEFAULT 0,
"warns" INTEGER NOT NULL DEFAULT 0,
"allTimeWarns" INTEGER NOT NULL DEFAULT 0,
"totalRequests" INTEGER NOT NULL DEFAULT 0,
"coins" INTEGER NOT NULL DEFAULT 0,
"dailyQueried" INTEGER NOT NULL DEFAULT 0,
"avatarUrl" TEXT,
"isAkhy" INTEGER NOT NULL DEFAULT 0
);
-- CreateTable
CREATE TABLE "skins" (
"uuid" TEXT NOT NULL PRIMARY KEY,
"displayName" TEXT,
"contentTierUuid" TEXT,
"displayIcon" TEXT,
"user_id" TEXT,
"tierRank" TEXT,
"tierColor" TEXT,
"tierText" TEXT,
"basePrice" TEXT,
"currentLvl" INTEGER,
"currentChroma" INTEGER,
"currentPrice" INTEGER,
"maxPrice" INTEGER,
CONSTRAINT "skins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "market_offers" (
"id" TEXT NOT NULL PRIMARY KEY,
"skin_uuid" TEXT NOT NULL,
"seller_id" TEXT NOT NULL,
"starting_price" INTEGER NOT NULL,
"buyout_price" INTEGER,
"final_price" INTEGER,
"status" TEXT NOT NULL,
"posted_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
"opening_at" DATETIME NOT NULL,
"closing_at" DATETIME NOT NULL,
"buyer_id" TEXT,
CONSTRAINT "market_offers_skin_uuid_fkey" FOREIGN KEY ("skin_uuid") REFERENCES "skins" ("uuid") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "market_offers_seller_id_fkey" FOREIGN KEY ("seller_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "market_offers_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "bids" (
"id" TEXT NOT NULL PRIMARY KEY,
"bidder_id" TEXT NOT NULL,
"market_offer_id" TEXT NOT NULL,
"offer_amount" INTEGER NOT NULL,
"offered_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bids_bidder_id_fkey" FOREIGN KEY ("bidder_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "bids_market_offer_id_fkey" FOREIGN KEY ("market_offer_id") REFERENCES "market_offers" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"action" TEXT,
"target_user_id" TEXT,
"coins_amount" INTEGER,
"user_new_amount" INTEGER,
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "logs_target_user_id_fkey" FOREIGN KEY ("target_user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "games" (
"id" TEXT NOT NULL PRIMARY KEY,
"p1" TEXT NOT NULL,
"p2" TEXT,
"p1_score" INTEGER,
"p2_score" INTEGER,
"p1_elo" INTEGER,
"p2_elo" INTEGER,
"p1_new_elo" INTEGER,
"p2_new_elo" INTEGER,
"type" TEXT,
"timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "games_p1_fkey" FOREIGN KEY ("p1") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "games_p2_fkey" FOREIGN KEY ("p2") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "elos" (
"id" TEXT NOT NULL PRIMARY KEY,
"elo" INTEGER NOT NULL,
CONSTRAINT "elos_id_fkey" FOREIGN KEY ("id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "sotd" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"tableauPiles" TEXT,
"foundationPiles" TEXT,
"stockPile" TEXT,
"wastePile" TEXT,
"isDone" INTEGER NOT NULL DEFAULT 0,
"seed" TEXT
);
-- CreateTable
CREATE TABLE "sotd_stats" (
"id" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"time" INTEGER,
"moves" INTEGER,
"score" INTEGER,
CONSTRAINT "sotd_stats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "transactions" (
"id" TEXT NOT NULL PRIMARY KEY,
"session_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"coins_amount" INTEGER NOT NULL,
"amount_cents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'eur',
"customer_email" TEXT,
"customer_name" TEXT,
"payment_status" TEXT NOT NULL,
"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "transactions_session_id_key" ON "transactions"("session_id");

View File

@@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "cs_skins" (
"id" TEXT NOT NULL PRIMARY KEY,
"market_hash_name" TEXT NOT NULL,
"displayName" TEXT,
"image_url" TEXT,
"rarity" TEXT,
"rarity_color" TEXT,
"weapon_type" TEXT,
"float" REAL,
"wear_state" TEXT,
"is_stattrak" BOOLEAN NOT NULL DEFAULT false,
"is_souvenir" BOOLEAN NOT NULL DEFAULT false,
"price" INTEGER,
"user_id" TEXT,
CONSTRAINT "cs_skins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_market_offers" (
"id" TEXT NOT NULL PRIMARY KEY,
"skin_uuid" TEXT,
"cs_skin_id" TEXT,
"seller_id" TEXT NOT NULL,
"starting_price" INTEGER NOT NULL,
"buyout_price" INTEGER,
"final_price" INTEGER,
"status" TEXT NOT NULL,
"posted_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
"opening_at" DATETIME NOT NULL,
"closing_at" DATETIME NOT NULL,
"buyer_id" TEXT,
CONSTRAINT "market_offers_skin_uuid_fkey" FOREIGN KEY ("skin_uuid") REFERENCES "skins" ("uuid") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "market_offers_cs_skin_id_fkey" FOREIGN KEY ("cs_skin_id") REFERENCES "cs_skins" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "market_offers_seller_id_fkey" FOREIGN KEY ("seller_id") REFERENCES "users" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "market_offers_buyer_id_fkey" FOREIGN KEY ("buyer_id") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_market_offers" ("buyer_id", "buyout_price", "closing_at", "final_price", "id", "opening_at", "posted_at", "seller_id", "skin_uuid", "starting_price", "status") SELECT "buyer_id", "buyout_price", "closing_at", "final_price", "id", "opening_at", "posted_at", "seller_id", "skin_uuid", "starting_price", "status" FROM "market_offers";
DROP TABLE "market_offers";
ALTER TABLE "new_market_offers" RENAME TO "market_offers";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

199
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,199 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id
username String
globalName String?
warned Int @default(0)
warns Int @default(0)
allTimeWarns Int @default(0)
totalRequests Int @default(0)
coins Int @default(0)
dailyQueried Int @default(0)
avatarUrl String?
isAkhy Int @default(0)
elo Elo?
skins Skin[]
csSkins CsSkin[] @relation("CsSkins")
sellerOffers MarketOffer[] @relation("Seller")
buyerOffers MarketOffer[] @relation("Buyer")
bids Bid[]
logs Log[] @relation("UserLogs")
targetLogs Log[] @relation("TargetUserLogs")
gamesAsP1 Game[] @relation("Player1")
gamesAsP2 Game[] @relation("Player2")
sotdStats SotdStat[]
transactions Transaction[]
@@map("users")
}
model Skin {
uuid String @id
displayName String?
contentTierUuid String?
displayIcon String?
userId String? @map("user_id")
tierRank String?
tierColor String?
tierText String?
basePrice String?
currentLvl Int?
currentChroma Int?
currentPrice Int?
maxPrice Int?
owner User? @relation(fields: [userId], references: [id])
marketOffers MarketOffer[]
@@map("skins")
}
model CsSkin {
id String @id @default(uuid())
marketHashName String @map("market_hash_name")
displayName String?
imageUrl String? @map("image_url")
rarity String?
rarityColor String? @map("rarity_color")
weaponType String? @map("weapon_type")
float Float?
wearState String? @map("wear_state")
isStattrak Boolean @default(false) @map("is_stattrak")
isSouvenir Boolean @default(false) @map("is_souvenir")
price Int?
userId String? @map("user_id")
owner User? @relation("CsSkins", fields: [userId], references: [id])
marketOffers MarketOffer[] @relation("CsSkinOffers")
@@map("cs_skins")
}
model MarketOffer {
id String @id
skinUuid String? @map("skin_uuid")
csSkinId String? @map("cs_skin_id")
sellerId String @map("seller_id")
startingPrice Int @map("starting_price")
buyoutPrice Int? @map("buyout_price")
finalPrice Int? @map("final_price")
status String
postedAt DateTime? @default(now()) @map("posted_at")
openingAt DateTime @map("opening_at")
closingAt DateTime @map("closing_at")
buyerId String? @map("buyer_id")
skin Skin? @relation(fields: [skinUuid], references: [uuid])
csSkin CsSkin? @relation("CsSkinOffers", fields: [csSkinId], references: [id])
seller User @relation("Seller", fields: [sellerId], references: [id])
buyer User? @relation("Buyer", fields: [buyerId], references: [id])
bids Bid[]
@@map("market_offers")
}
model Bid {
id String @id
bidderId String @map("bidder_id")
marketOfferId String @map("market_offer_id")
offerAmount Int @map("offer_amount")
offeredAt DateTime? @default(now()) @map("offered_at")
bidder User @relation(fields: [bidderId], references: [id])
marketOffer MarketOffer @relation(fields: [marketOfferId], references: [id])
@@map("bids")
}
model Log {
id String @id
userId String @map("user_id")
action String?
targetUserId String? @map("target_user_id")
coinsAmount Int? @map("coins_amount")
userNewAmount Int? @map("user_new_amount")
createdAt DateTime? @default(now()) @map("created_at")
user User @relation("UserLogs", fields: [userId], references: [id])
targetUser User? @relation("TargetUserLogs", fields: [targetUserId], references: [id])
@@map("logs")
}
model Game {
id String @id
p1 String
p2 String?
p1Score Int? @map("p1_score")
p2Score Int? @map("p2_score")
p1Elo Int? @map("p1_elo")
p2Elo Int? @map("p2_elo")
p1NewElo Int? @map("p1_new_elo")
p2NewElo Int? @map("p2_new_elo")
type String?
timestamp DateTime? @default(now()) @map("timestamp")
player1 User @relation("Player1", fields: [p1], references: [id])
player2 User? @relation("Player2", fields: [p2], references: [id])
@@map("games")
}
model Elo {
id String @id
elo Int
user User @relation(fields: [id], references: [id])
@@map("elos")
}
model Sotd {
id Int @id
tableauPiles String?
foundationPiles String?
stockPile String?
wastePile String?
isDone Int @default(0)
seed String?
@@map("sotd")
}
model SotdStat {
id String @id
userId String @map("user_id")
time Int?
moves Int?
score Int?
user User @relation(fields: [userId], references: [id])
@@map("sotd_stats")
}
model Transaction {
id String @id
sessionId String @unique @map("session_id")
userId String @map("user_id")
coinsAmount Int @map("coins_amount")
amountCents Int @map("amount_cents")
currency String @default("eur")
customerEmail String? @map("customer_email")
customerName String? @map("customer_name")
paymentStatus String @map("payment_status")
createdAt DateTime? @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@map("transactions")
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

51
src/api/cs.js Normal file
View File

@@ -0,0 +1,51 @@
import { csSkinsData, csSkinsPrices } from "../utils/cs.state.js";
const params = new URLSearchParams({
app_id: 730,
currency: "EUR",
});
export const fetchSuggestedPrices = async () => {
try {
const response = await fetch(`https://api.skinport.com/v1/items?${params}`, {
method: "GET",
headers: { "Accept-Encoding": "br" },
});
const data = await response.json();
data.forEach((skin) => {
if (skin.market_hash_name) {
csSkinsPrices[skin.market_hash_name] = {
suggested_price: skin.suggested_price,
min_price: skin.min_price,
max_price: skin.max_price,
mean_price: skin.mean_price,
median_price: skin.median_price,
};
}
});
return data;
} catch (error) {
console.error("Error parsing JSON:", error);
return null;
}
};
export const fetchSkinsData = async () => {
try {
const response = await fetch(
`https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en/skins.json`,
);
const data = await response.json();
data.forEach((skin) => {
if (skin.market_hash_name) {
csSkinsData[skin.market_hash_name] = skin;
} else if (skin.name) {
csSkinsData[skin.name] = skin;
}
});
return data;
} catch (error) {
console.error("Error fetching skins data:", error);
return null;
}
};

View File

@@ -12,7 +12,7 @@ export async function handleInfoCommand(req, res, client) {
try {
// Fetch the guild object from the client
const guild = await client.guilds.fetch(guild_id);
const guild = client.guilds.cache.get(guild_id);
// Fetch all members to ensure the cache is up to date
await guild.members.fetch();

View File

@@ -5,11 +5,14 @@ import {
InteractionResponseFlags,
} from "discord-interactions";
import { activeInventories, skins } from "../../game/state.js";
import { getUserInventory } from "../../database/index.js";
import * as skinService from "../../services/skin.service.js";
import * as csSkinService from "../../services/csSkin.service.js";
import { RarityToColor } from "../../utils/cs.utils.js";
import { resolveMember } from "../../utils/index.js";
/**
* Handles the /inventory slash command.
* Displays a paginated, interactive embed of a user's Valorant skin inventory.
* Displays a paginated, interactive embed of a user's skin inventory.
*
* @param {object} req - The Express request object.
* @param {object} res - The Express response object.
@@ -26,16 +29,22 @@ export async function handleInventoryCommand(req, res, client, interactionId) {
});
const { member, guild_id, token, data } = req.body;
const commandUserId = member.user.id;
// User can specify another member, otherwise it defaults to themself
const targetUserId = data.options && data.options.length > 0 ? data.options[0].value : commandUserId;
try {
// --- 1. Fetch Data ---
const guild = await client.guilds.fetch(guild_id);
const targetMember = await guild.members.fetch(targetUserId);
const inventorySkins = getUserInventory.all({ user_id: targetUserId });
const guild = client.guilds.cache.get(guild_id);
const targetMember = await resolveMember(guild, targetUserId);
// Fetch both Valorant and CS2 inventories
const valoSkins = await skinService.getUserInventory(targetUserId);
const csSkins = await csSkinService.getUserCsInventory(targetUserId);
// Combine into a unified list with a type marker
const inventorySkins = [
...csSkins.map((s) => ({ ...s, _type: "cs" })),
...valoSkins.map((s) => ({ ...s, _type: "valo" })),
];
// --- 2. Handle Empty Inventory ---
if (inventorySkins.length === 0) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
@@ -44,64 +53,30 @@ export async function handleInventoryCommand(req, res, client, interactionId) {
{
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
description: "Cet inventaire est vide.",
color: 0x4f545c, // Discord Gray
color: 0x4f545c,
},
],
},
});
}
// --- 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
akhyId: targetUserId,
userId: commandUserId,
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
inventorySkins: inventorySkins,
};
// --- 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);
const totalPrice = inventorySkins.reduce((sum, skin) => {
return sum + (skin._type === "cs" ? skin.price || 0 : skin.currentPrice || 0);
}, 0);
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? "💠 " : "◾ ";
}
return result || "N/A";
};
const embed = buildSkinEmbed(currentSkin, targetMember, 1, inventorySkins.length, totalPrice);
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName
.replace(/[\r\n]+/g, " ")
.replace(skinInfo.displayName, "")
.trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return "Base";
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
// --- 5. Build Initial Components (Buttons) ---
const components = [
{
type: MessageComponentTypes.BUTTON,
@@ -117,38 +92,10 @@ export async function handleInventoryCommand(req, res, client, interactionId) {
},
];
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) },
},
],
embeds: [embed],
components: [
{ type: MessageComponentTypes.ACTION_ROW, components: components },
{
@@ -170,3 +117,47 @@ export async function handleInventoryCommand(req, res, client, interactionId) {
return res.status(500).json({ error: "Failed to generate inventory." });
}
}
/**
* Builds an embed for a single skin (CS2 or Valorant).
*/
export function buildSkinEmbed(skin, targetMember, page, total, totalPrice) {
if (skin._type === "cs") {
const badges = [
skin.isStattrak ? "StatTrak™" : null,
skin.isSouvenir ? "Souvenir" : null,
].filter(Boolean).join(" | ");
return {
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: RarityToColor[skin.rarity] || 0xf2f3f3,
footer: {
text: `Page ${page}/${total} | Valeur Totale : ${totalPrice} Flopos`,
},
fields: [
{
name: `${skin.displayName} | ${skin.price} Flopos`,
value: `${skin.rarity}${badges ? ` | ${badges}` : ""}\n${skin.wearState} (float: ${skin.float?.toFixed(8)})\n${skin.weaponType || ""}`,
},
],
image: skin.imageUrl ? { url: skin.imageUrl } : undefined,
};
}
// Valorant skin fallback
const skinData = skins.find((s) => s.uuid === skin.uuid);
return {
title: `Inventaire de ${targetMember.user.globalName || targetMember.user.username}`,
color: parseInt(skin.tierColor, 16) || 0xf2f3f3,
footer: {
text: `Page ${page}/${total} | Valeur Totale : ${totalPrice} Flopos`,
},
fields: [
{
name: `${skin.displayName} | ${(skin.currentPrice || 0).toFixed(0)} Flopos`,
value: `${skin.tierText || "Valorant"}\nLvl : **${skin.currentLvl}**/${skinData?.levels?.length || "?"}`,
},
],
image: skinData ? { url: skinData.displayIcon } : undefined,
};
}

View File

@@ -5,7 +5,8 @@ import {
ButtonStyleTypes,
} from "discord-interactions";
import { activeSearchs, skins } from "../../game/state.js";
import { getAllSkins } from "../../database/index.js";
import * as skinService from "../../services/skin.service.js";
import { resolveMember } from "../../utils/index.js";
/**
* Handles the /search slash command.
@@ -23,7 +24,7 @@ export async function handleSearchCommand(req, res, client, interactionId) {
try {
// --- 1. Fetch and Filter Data ---
const allDbSkins = getAllSkins.all();
const allDbSkins = await skinService.getAllSkins();
const resultSkins = allDbSkins.filter(
(skin) =>
skin.displayName.toLowerCase().includes(searchValue) || skin.tierText.toLowerCase().includes(searchValue),
@@ -52,7 +53,7 @@ export async function handleSearchCommand(req, res, client, interactionId) {
};
// --- 4. Prepare Initial Embed Content ---
const guild = await client.guilds.fetch(guild_id);
const guild = client.guilds.cache.get(guild_id);
const currentSkin = resultSkins[0];
const skinData = skins.find((s) => s.uuid === currentSkin.uuid);
if (!skinData) {
@@ -61,12 +62,12 @@ export async function handleSearchCommand(req, res, client, interactionId) {
// Fetch owner details if the skin is owned
let ownerText = "";
if (currentSkin.user_id) {
if (currentSkin.userId) {
try {
const owner = await guild.members.fetch(currentSkin.user_id);
const owner = await resolveMember(guild, currentSkin.userId);
ownerText = `| **@${owner.user.globalName || owner.user.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
console.warn(`Could not fetch owner for user ID: ${currentSkin.userId}`);
ownerText = "| Appartenant à un utilisateur inconnu";
}
}

View File

@@ -1,5 +1,6 @@
import { InteractionResponseType } from "discord-interactions";
import { getTopSkins } from "../../database/index.js";
import * as skinService from "../../services/skin.service.js";
import { resolveMember } from "../../utils/index.js";
/**
* Handles the /skins slash command.
@@ -13,8 +14,8 @@ export async function handleSkinsCommand(req, res, client) {
try {
// --- 1. Fetch Data ---
const topSkins = getTopSkins.all();
const guild = await client.guilds.fetch(guild_id);
const topSkins = await skinService.getTopSkins();
const guild = client.guilds.cache.get(guild_id);
const fields = [];
// --- 2. Build Embed Fields Asynchronously ---
@@ -23,14 +24,14 @@ export async function handleSkinsCommand(req, res, client) {
let ownerText = "Libre"; // Default text if the skin has no owner
// If the skin has an owner, fetch their details
if (skin.user_id) {
if (skin.userId) {
try {
const owner = await guild.members.fetch(skin.user_id);
const owner = await resolveMember(guild, skin.userId);
// 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}`);
console.warn(`Could not fetch owner for user ID: ${skin.userId}`);
ownerText = "Appartient à un utilisateur inconnu";
}
}

View File

@@ -5,11 +5,11 @@ import {
ButtonStyleTypes,
} from "discord-interactions";
import { formatTime, getOnlineUsersWithRole } from "../../utils/index.js";
import { formatTime, getOnlineUsersWithRole, resolveMember } 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 * as userService from "../../services/user.service.js";
/**
* Handles the /timeout slash command.
@@ -28,9 +28,9 @@ export async function handleTimeoutCommand(req, res, client) {
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);
const guild = client.guilds.cache.get(guild_id);
const fromMember = await resolveMember(guild, userId);
const toMember = await resolveMember(guild, targetUserId);
// --- Validation Checks ---
// 1. Check if a poll is already running for the target user
@@ -102,12 +102,14 @@ export async function handleTimeoutCommand(req, res, client) {
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 = (
await Promise.all(
poll.voters.map(async (voterId) => {
const user = await userService.getUser(voterId);
return `- ${user?.globalName || "Utilisateur Inconnu"}`;
}),
)
).join("\n");
try {
await DiscordRequest(poll.endpoint, {
@@ -143,12 +145,14 @@ export async function handleTimeoutCommand(req, res, client) {
// --- 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");
const votersList = (
await Promise.all(
poll.voters.map(async (voterId) => {
const user = await userService.getUser(voterId);
return `- ${user?.globalName || "Utilisateur Inconnu"}`;
}),
)
).join("\n");
await DiscordRequest(poll.endpoint, {
method: "PATCH",

View File

@@ -1,7 +1,9 @@
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 * as userService from "../../services/user.service.js";
import * as skinService from "../../services/skin.service.js";
import * as logService from "../../services/log.service.js";
import { skins } from "../../game/state.js";
/**
@@ -27,7 +29,7 @@ export async function handleValorantCommand(req, res, client) {
try {
// --- 1. Verify and process payment ---
const commandUser = getUser.get(userId);
const commandUser = await userService.getUser(userId);
if (!commandUser) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
@@ -47,18 +49,15 @@ export async function handleValorantCommand(req, res, client) {
});
}
insertLog.run({
await logService.insertLog({
id: `${userId}-${Date.now()}`,
user_id: userId,
userId: 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,
targetUserId: null,
coinsAmount: -valoPrice,
userNewAmount: commandUser.coins - valoPrice,
});
await userService.updateUserCoins(userId, commandUser.coins - valoPrice);
// --- 2. Send Initial "Opening" Response ---
// Acknowledge the interaction immediately with a loading message.
@@ -77,7 +76,7 @@ export async function handleValorantCommand(req, res, client) {
const webhookEndpoint = `webhooks/${process.env.APP_ID}/${token}/messages/@original`;
try {
// --- Skin Selection ---
const availableSkins = getAllAvailableSkins.all();
const availableSkins = await skinService.getAllAvailableSkins();
if (availableSkins.length === 0) {
throw new Error("No available skins to award.");
}
@@ -105,9 +104,9 @@ export async function handleValorantCommand(req, res, client) {
const finalPrice = calculatePrice();
// --- Update Database ---
await updateSkin.run({
await skinService.updateSkin({
uuid: randomSkinData.uuid,
user_id: userId,
userId: userId,
currentLvl: randomLevel,
currentChroma: randomChroma,
currentPrice: finalPrice,

View File

@@ -6,7 +6,9 @@ import {
} from "discord-interactions";
import { DiscordRequest } from "../../api/discord.js";
import { activeInventories, skins } from "../../game/state.js";
import { activeInventories } from "../../game/state.js";
import { buildSkinEmbed } from "../commands/inventory.js";
import { resolveMember } from "../../utils/index.js";
/**
* Handles navigation button clicks (Previous/Next) for the inventory embed.
@@ -18,13 +20,10 @@ export async function handleInventoryNav(req, res, client) {
const { member, data, guild_id } = req.body;
const { custom_id } = data;
// Extract direction ('prev' or 'next') and the original interaction ID from the custom_id
const [direction, page, interactionId] = custom_id.split("_");
// --- 1. Retrieve the interactive session ---
const inventorySession = activeInventories[interactionId];
// --- 2. Validation Checks ---
if (!inventorySession) {
return res.send({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
@@ -35,7 +34,6 @@ export async function handleInventoryNav(req, res, client) {
});
}
// 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,
@@ -46,7 +44,6 @@ export async function handleInventoryNav(req, res, client) {
});
}
// --- 3. Update Page Number ---
const { amount } = inventorySession;
if (direction === "next") {
inventorySession.page = (inventorySession.page + 1) % amount;
@@ -55,49 +52,18 @@ export async function handleInventoryNav(req, res, client) {
}
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 { inventorySkins } = inventorySession;
const currentPage = inventorySession.page;
const currentSkin = inventorySkins[currentPage];
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 guild = client.guilds.cache.get(guild_id);
const targetMember = await resolveMember(guild, inventorySession.akhyId);
const totalPrice = inventorySkins.reduce((sum, skin) => {
return sum + (skin._type === "cs" ? skin.price || 0 : skin.currentPrice || 0);
}, 0);
// --- Helper functions for formatting ---
const getChromaText = (skin, skinInfo) => {
let result = "";
for (let i = 1; i <= skinInfo.chromas.length; i++) {
result += skin.currentChroma === i ? "💠 " : "◾ ";
}
return result || "N/A";
};
const embed = buildSkinEmbed(currentSkin, targetMember, currentPage + 1, amount, totalPrice);
const getChromaName = (skin, skinInfo) => {
if (skin.currentChroma > 1) {
const name = skinInfo.chromas[skin.currentChroma - 1]?.displayName
.replace(/[\r\n]+/g, " ")
.replace(skinInfo.displayName, "")
.trim();
const match = name.match(/Variante\s*[0-9\s]*-\s*([^)]+)/i);
return match ? match[1].trim() : name;
}
return "Base";
};
const getImageUrl = (skin, skinInfo) => {
if (skin.currentLvl === skinInfo.levels.length) {
const chroma = skinInfo.chromas[skin.currentChroma - 1];
return chroma?.fullRender || chroma?.displayIcon || skinInfo.displayIcon;
}
const level = skinInfo.levels[skin.currentLvl - 1];
return level?.displayIcon || skinInfo.displayIcon || skinInfo.chromas[0].fullRender;
};
// --- 5. Rebuild Components (Buttons) ---
let components = [
{
type: MessageComponentTypes.BUTTON,
@@ -113,38 +79,10 @@ export async function handleInventoryNav(req, res, client) {
},
];
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) },
},
],
embeds: [embed],
components: [
{ type: MessageComponentTypes.ACTION_ROW, components: components },
{
@@ -162,14 +100,9 @@ export async function handleInventoryNav(req, res, client) {
},
});
// --- 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: {

View File

@@ -2,7 +2,7 @@ import { InteractionResponseType, InteractionResponseFlags } from "discord-inter
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 * as userService from "../../services/user.service.js";
/**
* Handles clicks on the 'Yes' or 'No' buttons of a timeout poll.
@@ -75,7 +75,14 @@ export async function handlePollVote(req, res) {
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 = (
await Promise.all(
poll.voters.map(async (vId) => {
const user = await userService.getUser(vId);
return `- ${user?.globalName || "Utilisateur Inconnu"}`;
}),
)
).join("\n");
// --- 4. Check for Majority ---
if (isVotingFor && poll.for >= poll.requiredMajority) {

View File

@@ -7,6 +7,7 @@ import {
import { DiscordRequest } from "../../api/discord.js";
import { activeSearchs, skins } from "../../game/state.js";
import { resolveUser } from "../../utils/index.js";
/**
* Handles navigation button clicks (Previous/Next) for the search results embed.
@@ -65,12 +66,12 @@ export async function handleSearchNav(req, res, client) {
// Fetch owner details if the skin is owned
let ownerText = "";
if (currentSkin.user_id) {
if (currentSkin.userId) {
try {
const owner = await client.users.fetch(currentSkin.user_id);
const owner = await resolveUser(client, currentSkin.userId);
ownerText = `| **@${owner.globalName || owner.username}** ✅`;
} catch (e) {
console.warn(`Could not fetch owner for user ID: ${currentSkin.user_id}`);
console.warn(`Could not fetch owner for user ID: ${currentSkin.userId}`);
ownerText = "| Appartenant à un utilisateur inconnu";
}
}

View File

@@ -9,7 +9,9 @@ import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "disc
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 * as userService from "../../services/user.service.js";
import * as skinService from "../../services/skin.service.js";
import * as logService from "../../services/log.service.js";
/**
* Handles the click of the 'Upgrade' button on a skin in the inventory.
@@ -65,7 +67,7 @@ export async function handleUpgradeSkin(req, res) {
// --- 2. Handle Payment ---
const upgradePrice = parseFloat(process.env.VALO_UPGRADE_PRICE) || parseFloat(skinToUpgrade.maxPrice) / 10;
const commandUser = getUser.get(userId);
const commandUser = await userService.getUser(userId);
if (!commandUser) {
return res.send({
@@ -86,18 +88,15 @@ export async function handleUpgradeSkin(req, res) {
});
}
insertLog.run({
await logService.insertLog({
id: `${userId}-${Date.now()}`,
user_id: userId,
userId: 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),
targetUserId: null,
coinsAmount: -upgradePrice.toFixed(0),
userNewAmount: commandUser.coins - upgradePrice.toFixed(0),
});
await userService.updateUserCoins(userId, commandUser.coins - upgradePrice.toFixed(0));
// --- 3. Show Loading Animation ---
// Acknowledge the click immediately and then edit the message to show a loading state.
@@ -151,9 +150,9 @@ export async function handleUpgradeSkin(req, res) {
};
skinToUpgrade.currentPrice = calculatePrice();
await updateSkin.run({
await skinService.updateSkin({
uuid: skinToUpgrade.uuid,
user_id: skinToUpgrade.user_id,
userId: skinToUpgrade.userId,
currentLvl: skinToUpgrade.currentLvl,
currentChroma: skinToUpgrade.currentChroma,
currentPrice: skinToUpgrade.currentPrice,
@@ -165,7 +164,7 @@ export async function handleUpgradeSkin(req, res) {
// --- 6. Send Final Result ---
setTimeout(async () => {
// Fetch the latest state of the skin from the database
const finalSkinState = getSkin.get(skinToUpgrade.uuid);
const finalSkinState = await skinService.getSkin(skinToUpgrade.uuid);
const finalEmbed = buildFinalEmbed(succeeded, finalSkinState, skinData);
const finalComponents = buildFinalComponents(succeeded, skinData, finalSkinState, interactionId);

View File

@@ -1,5 +1,7 @@
import { handleMessageCreate } from "./handlers/messageCreate.js";
import { getAkhys } from "../utils/index.js";
import { fetchSuggestedPrices, fetchSkinsData } from "../api/cs.js";
import { buildPriceIndex, buildWeaponRarityPriceMap } from "../utils/cs.state.js";
/**
* Initializes and attaches all necessary event listeners to the Discord client.
@@ -18,6 +20,10 @@ export function initializeEvents(client, io) {
await getAkhys(client);
console.log("[Startup] Setting up scheduled tasks...");
//setupCronJobs(client, io);
await fetchSuggestedPrices();
await fetchSkinsData();
buildPriceIndex();
buildWeaponRarityPriceMap();
console.log("--- FlopoBOT is fully operational ---");
});

View File

@@ -1,4 +1,5 @@
import { sleep } from "openai/core";
import { AttachmentBuilder } from "discord.js";
import {
buildAiMessages,
buildParticipantsMap,
@@ -9,23 +10,19 @@ import {
MAX_ATTS_PER_MESSAGE,
stripMentionsOfBot,
} from "../../utils/ai.js";
import { calculateBasePrice, calculateMaxPrice, formatTime, getAkhys } from "../../utils/index.js";
import { calculateBasePrice, calculateMaxPrice, formatTime, getAkhys, resolveMember } from "../../utils/index.js";
import { channelPointsHandler, initTodaysSOTD, randomSkinPrice, slowmodesHandler } from "../../game/points.js";
import { activePolls, activeSlowmodes, requestTimestamps, skins } from "../../game/state.js";
import {
flopoDB,
getAllSkins,
getAllUsers,
getUser,
hardUpdateSkin,
insertLog,
updateManyUsers,
updateSkin,
updateUserAvatar,
updateUserCoins,
} from "../../database/index.js";
import prisma from "../../prisma/client.js";
import * as userService from "../../services/user.service.js";
import * as skinService from "../../services/skin.service.js";
import * as logService from "../../services/log.service.js";
import { client } from "../client.js";
import { drawCaseContent, drawCaseSkin, getDummySkinUpgradeProbs } from "../../utils/caseOpening.js";
import { fetchSuggestedPrices, fetchSkinsData } from "../../api/cs.js";
import { csSkinsData, csSkinsPrices } from "../../utils/cs.state.js";
import { getRandomSkinWithRandomSpecs, RarityToColor } from "../../utils/cs.utils.js";
import * as csSkinService from "../../services/csSkin.service.js";
// Constants for the AI rate limiter
const MAX_REQUESTS_PER_INTERVAL = parseInt(process.env.MAX_REQUESTS || "5");
@@ -52,10 +49,10 @@ export async function handleMessageCreate(message, client, io) {
// --- Main Guild Features (Points & Slowmode) ---
if (message.guildId === process.env.GUILD_ID) {
// Award points for activity
const pointsAwarded = channelPointsHandler(message);
if (pointsAwarded) {
io.emit("data-updated", { table: "users", action: "update" });
}
// const pointsAwarded = channelPointsHandler(message);
// if (pointsAwarded) {
// io.emit("data-updated", { table: "users", action: "update" });
// }
// Enforce active slowmodes
const wasSlowmoded = await slowmodesHandler(message, activeSlowmodes);
@@ -88,7 +85,7 @@ export async function handleMessageCreate(message, client, io) {
// --- Sub-handler for AI Logic ---
async function handleAiMention(message, client, io) {
const authorId = message.author.id;
let authorDB = getUser.get(authorId);
let authorDB = await userService.getUser(authorId);
if (!authorDB) return; // Should not happen if user is in DB, but good practice
// --- Rate Limiting ---
@@ -104,12 +101,12 @@ async function handleAiMention(message, client, io) {
authorDB.warned = 1;
authorDB.warns += 1;
authorDB.allTimeWarns += 1;
updateManyUsers([authorDB]);
await userService.updateManyUsers([authorDB]);
// Apply timeout if warn count is too high
if (authorDB.warns > (parseInt(process.env.MAX_WARNS) || 10)) {
try {
const member = await message.guild.members.fetch(authorId);
const member = await resolveMember(message.guild, authorId);
const time = parseInt(process.env.SPAM_TIMEOUT_TIME);
await member.timeout(time, "Spam excessif du bot AI.");
message.channel
@@ -134,7 +131,7 @@ async function handleAiMention(message, client, io) {
authorDB.warned = 0;
authorDB.warns = 0;
authorDB.totalRequests += 1;
updateManyUsers([authorDB]);
await userService.updateManyUsers([authorDB]);
// --- AI Processing ---
try {
@@ -197,22 +194,22 @@ async function handleAdminCommands(message) {
switch (command) {
case "?sp":
let msgText = ""
let msgText = "";
for (let skinTierRank = 1; skinTierRank <= 4; skinTierRank++) {
msgText += `\n--- Tier Rank: ${skinTierRank} ---\n`;
let skinMaxLevels = 4;
let skinMaxChromas = 4;
for (let skinLevel = 1; skinLevel < skinMaxLevels; skinLevel++) {
msgText += (`Levels: ${skinLevel}/${skinMaxLevels}, MaxChromas: ${1}/${skinMaxChromas} - `);
msgText += (`${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).successProb.toFixed(4)}, `);
msgText += (`${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).destructionProb.toFixed(4)}, `);
msgText += (`${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).upgradePrice}\n`);
msgText += `Levels: ${skinLevel}/${skinMaxLevels}, MaxChromas: ${1}/${skinMaxChromas} - `;
msgText += `${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).successProb.toFixed(4)}, `;
msgText += `${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).destructionProb.toFixed(4)}, `;
msgText += `${getDummySkinUpgradeProbs(skinLevel, 1, skinTierRank, skinMaxLevels, skinMaxChromas, 15).upgradePrice}\n`;
}
for (let skinChroma = 1; skinChroma < skinMaxChromas; skinChroma++) {
msgText += (`Levels: ${skinMaxLevels}/${skinMaxLevels}, MaxChromas: ${skinChroma}/${skinMaxChromas} - `);
msgText += (`${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).successProb.toFixed(4)}, `);
msgText += (`${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).destructionProb.toFixed(4)}, `);
msgText += (`${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).upgradePrice}\n`);
msgText += `Levels: ${skinMaxLevels}/${skinMaxLevels}, MaxChromas: ${skinChroma}/${skinMaxChromas} - `;
msgText += `${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).successProb.toFixed(4)}, `;
msgText += `${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).destructionProb.toFixed(4)}, `;
msgText += `${getDummySkinUpgradeProbs(skinMaxLevels, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, 15).upgradePrice}\n`;
}
message.reply(msgText);
msgText = "";
@@ -238,17 +235,19 @@ async function handleAdminCommands(message) {
message.reply("New Solitaire of the Day initialized.");
break;
case `${prefix}:users`:
console.log(getAllUsers.all());
console.log(await userService.getAllUsers());
break;
case `${prefix}:sql`:
const sqlCommand = args.join(" ");
try {
const stmt = flopoDB.prepare(sqlCommand);
const result = sqlCommand.trim().toUpperCase().startsWith("SELECT") ? stmt.all() : stmt.run();
console.log(result);
message.reply("```json\n" + JSON.stringify(result, null, 2).substring(0, 1900) + "\n```");
const result = sqlCommand.trim().toUpperCase().startsWith("SELECT")
? await prisma.$queryRawUnsafe(sqlCommand)
: await prisma.$executeRawUnsafe(sqlCommand);
const jsonString = JSON.stringify(result, null, 2);
const buffer = Buffer.from(jsonString, "utf-8");
const attachment = new AttachmentBuilder(buffer, { name: "sql-result.json" });
message.reply({ content: "SQL query executed successfully:", files: [attachment] });
} catch (e) {
console.error(e);
message.reply(`SQL Error: ${e.message}`);
}
break;
@@ -256,7 +255,7 @@ async function handleAdminCommands(message) {
await getAkhys(client);
break;
case `${prefix}:avatars`:
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const guild = client.guilds.cache.get(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));
@@ -265,16 +264,16 @@ async function handleAdminCommands(message) {
avatarUrl: akhy.user.displayAvatarURL({ dynamic: true, size: 256 }),
}));
usersToUpdate.forEach((user) => {
for (const user of usersToUpdate) {
try {
updateUserAvatar.run(user);
await userService.updateUserAvatar(user.id, user.avatarUrl);
} catch (err) {}
});
}
break;
case `${prefix}:rework-skins`:
console.log("Reworking all skin prices...");
const dbSkins = getAllSkins.all();
dbSkins.forEach((skin) => {
const dbSkins = await skinService.getAllSkins();
for (const skin of dbSkins) {
const fetchedSkin = skins.find((s) => s.uuid === skin.uuid);
const basePrice = calculateBasePrice(fetchedSkin, skin.tierRank)?.toFixed(0);
const calculatePrice = () => {
@@ -285,12 +284,12 @@ async function handleAdminCommands(message) {
return parseFloat(result.toFixed(0));
};
const maxPrice = calculateMaxPrice(basePrice, fetchedSkin).toFixed(0);
hardUpdateSkin.run({
await skinService.hardUpdateSkin({
uuid: skin.uuid,
displayName: skin.displayName,
contentTierUuid: skin.contentTierUuid,
displayIcon: skin.displayIcon,
user_id: skin.user_id,
userId: skin.userId,
tierRank: skin.tierRank,
tierColor: skin.tierColor,
tierText: skin.tierText,
@@ -300,7 +299,7 @@ async function handleAdminCommands(message) {
currentPrice: skin.currentPrice ? calculatePrice() : null,
maxPrice: maxPrice,
});
});
}
console.log("Reworked", dbSkins.length, "skins.");
break;
case `${prefix}:cases-test`:
@@ -326,7 +325,7 @@ async function handleAdminCommands(message) {
for (let i = 0; i < caseCount; i++) {
const skins = await drawCaseContent(caseType);
const result = drawCaseSkin(skins);
const result = await drawCaseSkin(skins);
totalResValue += result.finalPrice;
if (result.finalPrice > highestSkinPrice) highestSkinPrice = result.finalPrice;
if (result.finalPrice > 0 && result.finalPrice < 100) priceTiers["0"] += 1;
@@ -356,37 +355,145 @@ async function handleAdminCommands(message) {
break;
case `${prefix}:refund-skins`:
try {
const DBskins = getAllSkins.all();
for (const skin of DBskins) {
const owner = getUser.get(skin.user_id);
if (owner) {
updateUserCoins.run({
id: owner.id,
coins: owner.coins + skin.currentPrice,
});
insertLog.run({
id: `${skin.uuid}-skin-refund-${Date.now()}`,
user_id: owner.id,
target_user_id: null,
action: "SKIN_REFUND",
coins_amount: skin.currentPrice,
user_new_amount: owner.coins + skin.currentPrice,
});
const allCsSkins = await csSkinService.getAllOwnedCsSkins();
let refundedCount = 0;
let totalRefunded = 0;
for (const skin of allCsSkins) {
const price = skin.price || 0;
let owner = null;
try {
owner = await userService.getUser(skin.userId);
} catch {
//
}
updateSkin.run({
uuid: skin.uuid,
user_id: null,
currentPrice: null,
currentLvl: null,
currentChroma: null,
});
if (owner) {
await userService.updateUserCoins(owner.id, owner.coins + price);
await logService.insertLog({
id: `${skin.id}-cs-skin-refund-${Date.now()}`,
userId: owner.id,
targetUserId: null,
action: "CS_SKIN_REFUND",
coinsAmount: price,
userNewAmount: owner.coins + price,
});
totalRefunded += price;
refundedCount++;
}
await csSkinService.deleteCsSkin(skin.id);
}
message.reply("All skins refunded.");
message.reply(`Refunded ${refundedCount} CS skins (${totalRefunded} FlopoCoins total).`);
} catch (e) {
console.log(e);
message.reply(`Error during refund skins ${e.message}`);
}
break;
case `${prefix}:cs-search`:
try {
const searchTerm = args.join(" ");
if (!searchTerm) {
message.reply("Please provide a search term.");
return;
}
const filteredData = csSkinsData
? Object.values(csSkinsData).filter((skin) => {
const name = skin.market_hash_name.toLowerCase();
return args.every((word) => name.includes(word.toLowerCase()));
})
: [];
if (filteredData.length === 0) {
message.reply(`No skins found matching "${searchTerm}".`);
return;
} else if (filteredData.length <= 10) {
const skinList = filteredData
.map(
(skin) =>
`${skin.market_hash_name} - ${
csSkinsPrices[skin.market_hash_name]
? "Sug " +
csSkinsPrices[skin.market_hash_name].suggested_price +
" | Min " +
csSkinsPrices[skin.market_hash_name].min_price +
" | Max " +
csSkinsPrices[skin.market_hash_name].max_price +
" | Avg " +
csSkinsPrices[skin.market_hash_name].mean_price +
" | Med " +
csSkinsPrices[skin.market_hash_name].median_price
: "N/A"
}`,
)
.join("\n");
message.reply(`Skins matching "${searchTerm}":\n${skinList}`);
} else {
message.reply(`Found ${filteredData.length} skins matching "${searchTerm}".`);
}
} catch (e) {
console.log(e);
message.reply(`Error searching CS:GO skins: ${e.message}`);
}
break;
case `${prefix}:open-cs`:
try {
const randomSkin = await getRandomSkinWithRandomSpecs(args[0] ? parseFloat(args[0]) : null);
const created = await csSkinService.insertCsSkin({
marketHashName: randomSkin.name,
displayName: randomSkin.data.name || randomSkin.name,
imageUrl: randomSkin.data.image || null,
rarity: randomSkin.data.rarity.name,
rarityColor: RarityToColor[randomSkin.data.rarity.name]?.toString(16) || null,
weaponType: randomSkin.data.weapon?.name || null,
float: randomSkin.float,
wearState: randomSkin.wearState,
isStattrak: randomSkin.isStattrak,
isSouvenir: randomSkin.isSouvenir,
price: parseInt(randomSkin.price),
userId: message.author.id,
});
message.reply(
`You opened a CS:GO case and got: ${randomSkin.name} (${randomSkin.data.rarity.name}, ${
randomSkin.isStattrak ? "StatTrak, " : ""
}${randomSkin.isSouvenir ? "Souvenir, " : ""}${randomSkin.wearState} - float ${randomSkin.float})\nBase Price: ${
randomSkin.price ?? "N/A"
} Flopos\nSkin ID: ${created.id}\nImage url: [url](${randomSkin.data.image || "N/A"})`,
);
} catch (e) {
console.log(e);
message.reply(`Error opening CS:GO case: ${e.message}`);
}
break;
case `${prefix}:simulate-cs`:
try {
const caseCount = parseInt(args[0]) || 100;
const caseType = args[1] || "default";
let totalResValue = 0;
let highestSkinPrice = 0;
const priceTiers = {
"Consumer Grade": 0,
"Industrial Grade": 0,
"Mil-Spec Grade": 0,
"Restricted": 0,
"Classified": 0,
"Covert": 0,
"Extraordinary": 0,
};
for (let i = 0; i < caseCount; i++) {
const result = await getRandomSkinWithRandomSpecs();
totalResValue += parseInt(result.price);
if (parseInt(result.price) > highestSkinPrice) {
highestSkinPrice = parseInt(result.price);
}
priceTiers[result.data.rarity.name]++;
}
console.log(totalResValue / caseCount);
message.reply(
`${totalResValue / caseCount} average skin price over ${caseCount} ${caseType} cases.\nHighest skin price: ${highestSkinPrice}\nPrice tier distribution: ${JSON.stringify(priceTiers)}`,
);
} catch (e) {
console.log(e);
message.reply(`Error during case simulation: ${e.message}`);
}
break;
}
}

View File

@@ -1,873 +0,0 @@
import Database from "better-sqlite3";
export const flopoDB = new Database(process.env.DB_PATH || "flopobot.db");
/* -------------------------
CREATE ALL TABLES FIRST
----------------------------*/
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
);
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 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
);
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
);
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
);
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 elos
(
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
);
CREATE TABLE IF NOT EXISTS sotd_stats
(
id
TEXT
PRIMARY
KEY,
user_id
TEXT
REFERENCES
users,
time
INTEGER,
moves
INTEGER,
score
INTEGER
);
`);
/* -----------------------------------------------------
PREPARE ANY CREATE TABLE STATEMENT OBJECTS (kept for parity)
------------------------------------------------------*/
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
)
`);
stmtUsers.run();
export const stmtSkins = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS skins
(
uuid
TEXT
PRIMARY
KEY,
displayName
TEXT,
contentTierUuid
TEXT,
displayIcon
TEXT,
user_id
TEXT
REFERENCES
users,
tierRank
TEXT,
tierColor
TEXT,
tierText
TEXT,
basePrice
TEXT,
currentLvl
INTEGER
DEFAULT
NULL,
currentChroma
INTEGER
DEFAULT
NULL,
currentPrice
INTEGER
DEFAULT
NULL,
maxPrice
INTEGER
DEFAULT
NULL
)
`);
stmtSkins.run();
export const 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 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
)
`);
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
)
`);
stmtGames.run();
export const stmtElos = flopoDB.prepare(`
CREATE TABLE IF NOT EXISTS elos
(
id
PRIMARY
KEY
REFERENCES
users,
elo
INTEGER
)
`);
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
)
`);
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
)
`);
stmtSOTDStats.run();
/* -------------------------
USER statements
----------------------------*/
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",
);
/* -------------------------
SKINS statements
----------------------------*/
export const insertSkin = flopoDB.prepare(
`INSERT INTO skins (uuid, displayName, contentTierUuid, displayIcon, user_id, tierRank, tierColor, tierText,
basePrice, maxPrice)
VALUES (@uuid, @displayName, @contentTierUuid, @displayIcon, @user_id, @tierRank, @tierColor, @tierText,
@basePrice, @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");
/* -------------------------
MARKET / BIDS / OFFERS
----------------------------*/
export const getMarketOffers = flopoDB.prepare(`
SELECT *
FROM market_offers
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 getMarketOffersBySkin = 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.skin_uuid = ?
`);
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 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
----------------------------*/
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 deleteBid = flopoDB.prepare(`
DELETE
FROM bids
WHERE id = ?
`);
/* -------------------------
BULK TRANSACTIONS (synchronous)
----------------------------*/
export const insertManyUsers = flopoDB.transaction((users) => {
for (const user of users)
try {
insertUser.run(user);
} catch (e) {}
});
export const updateManyUsers = flopoDB.transaction((users) => {
for (const user of users)
try {
updateUser.run(user);
} catch (e) {
console.log(`User update failed`);
}
});
export const insertManySkins = flopoDB.transaction((skins) => {
for (const skin of skins)
try {
insertSkin.run(skin);
} catch (e) {}
});
export const updateManySkins = flopoDB.transaction((skins) => {
for (const skin of skins)
try {
updateSkin.run(skin);
} catch (e) {}
});
/* -------------------------
LOGS
----------------------------*/
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");
/* -------------------------
GAMES
----------------------------*/
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",
);
/* -------------------------
ELOS
----------------------------*/
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",
);
/* -------------------------
SOTD
----------------------------*/
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 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 = ?`);
/* -------------------------
Market queries already declared above (kept for completeness)
----------------------------*/
/* -------------------------
pruneOldLogs
----------------------------*/
export async function pruneOldLogs() {
const users = flopoDB
.prepare(
`
SELECT user_id
FROM logs
GROUP BY user_id
HAVING COUNT(*) > ${process.env.LOGS_BY_USER}
`,
)
.all();
const transaction = flopoDB.transaction(() => {
for (const { user_id } of users) {
flopoDB
.prepare(
`
DELETE
FROM logs
WHERE id IN (SELECT id
FROM (SELECT id,
ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn
FROM logs
WHERE user_id = ?)
WHERE rn > ${process.env.LOGS_BY_USER})
`,
)
.run(user_id);
}
});
transaction();
}

View File

@@ -3,7 +3,8 @@
// Inspired by your poker helpers API style.
import { emitToast } from "../server/socket.js";
import { getUser, insertLog, updateUserCoins } from "../database/index.js";
import * as userService from "../services/user.service.js";
import * as logService from "../services/log.service.js";
import { client } from "../bot/client.js";
import { EmbedBuilder } from "discord.js";
@@ -299,21 +300,18 @@ export async function settleAll(room) {
p.totalDelta += res.delta;
p.totalBets++;
if (res.result === "win" || res.result === "push" || res.result === "blackjack") {
const userDB = getUser.get(p.id);
const userDB = await userService.getUser(p.id);
if (userDB) {
const coins = userDB.coins;
try {
updateUserCoins.run({
id: p.id,
coins: coins + hand.bet + res.delta,
});
insertLog.run({
await userService.updateUserCoins(p.id, coins + hand.bet + res.delta);
await logService.insertLog({
id: `${p.id}-blackjack-${Date.now()}`,
user_id: p.id,
target_user_id: null,
userId: p.id,
targetUserId: null,
action: "BLACKJACK_PAYOUT",
coins_amount: res.delta + hand.bet,
user_new_amount: coins + hand.bet + res.delta,
coinsAmount: res.delta + hand.bet,
userNewAmount: coins + hand.bet + res.delta,
});
p.bank = coins + hand.bet + res.delta;
} catch (e) {
@@ -325,8 +323,8 @@ export async function settleAll(room) {
hand.result = res.result;
hand.delta = res.delta;
try {
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const generalChannel = await guild.channels.fetch(process.env.BOT_CHANNEL_ID);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const msg = await generalChannel.messages.fetch(p.msgId);
const updatedEmbed = new EmbedBuilder()
.setDescription(`<@${p.id}> joue au Blackjack.`)
@@ -363,7 +361,10 @@ export function applyAction(room, playerId, action) {
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;
if (isBust(hand.cards)) {
hand.busted = true;
p.activeHand++;
}
return "hit";
}
case "stand": {

View File

@@ -1,6 +1,8 @@
import { getUser, getUserElo, insertElos, insertGame, updateElo } from "../database/index.js";
import * as userService from "../services/user.service.js";
import * as gameService from "../services/game.service.js";
import { ButtonStyle, EmbedBuilder } from "discord.js";
import { client } from "../bot/client.js";
import { resolveUser } from "../utils/index.js";
/**
* Handles Elo calculation for a standard 1v1 game.
@@ -10,25 +12,25 @@ import { client } from "../bot/client.js";
* @param {number} p2Score - The score for player 2.
* @param {string} type - The type of game being played (e.g., 'TICTACTOE', 'CONNECT4').
*/
export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type, scores = null) {
// --- 1. Fetch Player Data ---
const p1DB = getUser.get(p1Id);
const p2DB = getUser.get(p2Id);
const p1DB = await userService.getUser(p1Id);
const p2DB = await userService.getUser(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 = await gameService.getUserElo(p1Id);
let p2EloData = await gameService.getUserElo(p2Id);
// --- 2. Initialize Elo if it doesn't exist ---
if (!p1EloData) {
await insertElos.run({ id: p1Id, elo: 1000 });
await gameService.insertElo(p1Id, 1000);
p1EloData = { id: p1Id, elo: 1000 };
}
if (!p2EloData) {
await insertElos.run({ id: p2Id, elo: 1000 });
await gameService.insertElo(p2Id, 1000);
p2EloData = { id: p2Id, elo: 1000 };
}
@@ -43,9 +45,26 @@ export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
const expectedP1 = 1 / (1 + Math.pow(10, (p2CurrentElo - p1CurrentElo) / 400));
const expectedP2 = 1 / (1 + Math.pow(10, (p1CurrentElo - p2CurrentElo) / 400));
// Calculate raw Elo changes
const p1Change = K_FACTOR * (p1Score - expectedP1);
const p2Change = K_FACTOR * (p2Score - expectedP2);
// Make losing friendlier: loser loses 70% of what winner gains
let finalP1Change = p1Change;
let finalP2Change = p2Change;
if (p1Score > p2Score) {
// P1 won, P2 lost
finalP2Change = p2Change * 0.7;
} else if (p2Score > p1Score) {
// P2 won, P1 lost
finalP1Change = p1Change * 0.7;
}
// If it's a draw (p1Score === p2Score), keep the original changes
// Calculate new Elo ratings
const p1NewElo = Math.round(p1CurrentElo + K_FACTOR * (p1Score - expectedP1));
const p2NewElo = Math.round(p2CurrentElo + K_FACTOR * (p2Score - expectedP2));
const p1NewElo = Math.round(p1CurrentElo + finalP1Change);
const p2NewElo = Math.round(p2CurrentElo + finalP2Change);
// Ensure Elo doesn't drop below a certain threshold (e.g., 100)
const finalP1Elo = Math.max(0, p1NewElo);
@@ -54,9 +73,9 @@ export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
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.BOT_CHANNEL_ID);
const user1 = await client.users.fetch(p1Id);
const user2 = await client.users.fetch(p2Id);
const generalChannel = client.channels.cache.get(process.env.BOT_CHANNEL_ID);
const user1 = await resolveUser(client, p1Id);
const user2 = await resolveUser(client, p2Id);
const diff1 = finalP1Elo - p1CurrentElo;
const diff2 = finalP2Elo - p2CurrentElo;
const embed = new EmbedBuilder()
@@ -74,22 +93,38 @@ export async function eloHandler(p1Id, p2Id, p1Score, p2Score, type) {
}
// --- 4. Update Database ---
updateElo.run({ id: p1Id, elo: finalP1Elo });
updateElo.run({ id: p2Id, elo: finalP2Elo });
await gameService.updateElo(p1Id, finalP1Elo);
await gameService.updateElo(p2Id, 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(),
});
if (scores) {
await gameService.insertGame({
id: `${p1Id}-${p2Id}-${Date.now()}`,
p1: p1Id,
p2: p2Id,
p1Score: scores.p1,
p2Score: scores.p2,
p1Elo: p1CurrentElo,
p2Elo: p2CurrentElo,
p1NewElo: finalP1Elo,
p2NewElo: finalP2Elo,
type: type,
timestamp: Date.now(),
});
} else {
await gameService.insertGame({
id: `${p1Id}-${p2Id}-${Date.now()}`,
p1: p1Id,
p2: p2Id,
p1Score: p1Score,
p2Score: p2Score,
p1Elo: p1CurrentElo,
p2Elo: p2CurrentElo,
p1NewElo: finalP1Elo,
p2NewElo: finalP2Elo,
type: type,
timestamp: Date.now(),
});
}
}
/**
@@ -106,11 +141,14 @@ export async function pokerEloHandler(room) {
if (playerIds.length < 2) return; // Not enough players to calculate Elo
// Fetch all players' Elo data at once
const dbPlayers = playerIds.map((id) => {
const user = getUser.get(id);
const elo = getUserElo.get({ id })?.elo || 1000;
return { ...user, elo };
});
const dbPlayers = await Promise.all(
playerIds.map(async (id) => {
const user = await userService.getUser(id);
const eloData = await gameService.getUserElo(id);
const elo = eloData?.elo || 1000;
return { ...user, elo };
}),
);
const winnerIds = new Set(room.winners);
const playerCount = dbPlayers.length;
@@ -118,7 +156,7 @@ export async function pokerEloHandler(room) {
const averageElo = dbPlayers.reduce((sum, p) => sum + p.elo, 0) / playerCount;
dbPlayers.forEach((player) => {
for (const player of dbPlayers) {
// 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));
@@ -140,23 +178,23 @@ export async function pokerEloHandler(room) {
console.log(
`Elo Update (POKER) for ${player.globalName}: ${player.elo} -> ${newElo} (Δ: ${eloChange.toFixed(2)})`,
);
updateElo.run({ id: player.id, elo: newElo });
await gameService.updateElo(player.id, newElo);
insertGame.run({
await gameService.insertGame({
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,
p1Score: actualScore,
p2Score: null,
p1Elo: player.elo,
p2Elo: Math.round(averageElo), // Log the average opponent Elo for context
p1NewElo: newElo,
p2NewElo: null,
type: "POKER_ROUND",
timestamp: Date.now(),
});
} else {
console.error(`Error calculating new Elo for ${player.globalName}.`);
}
});
}
}

View File

@@ -1,15 +1,7 @@
import {
clearSOTDStats,
deleteSOTD,
getAllSkins,
getAllSOTDStats,
getUser,
insertGame,
insertLog,
insertSOTD,
pruneOldLogs,
updateUserCoins
} from "../database/index.js";
import * as userService from "../services/user.service.js";
import * as skinService from "../services/skin.service.js";
import * as logService from "../services/log.service.js";
import * as solitaireService from "../services/solitaire.service.js";
import { activeSlowmodes, activeSolitaireGames, messagesTimestamps, skins } from "./state.js";
import { createDeck, createSeededRNG, deal, seededShuffle } from "./solitaire.js";
import { emitSolitaireUpdate } from "../server/socket.js";
@@ -22,7 +14,7 @@ import { emitSolitaireUpdate } from "../server/socket.js";
*/
export async function channelPointsHandler(message) {
const author = message.author;
const authorDB = getUser.get(author.id);
const authorDB = await userService.getUser(author.id);
if (!authorDB) {
// User not in our database, do nothing.
@@ -53,21 +45,18 @@ export async function channelPointsHandler(message) {
const coinsToAdd = recentTimestamps.length === 10 ? 50 : 10;
const newCoinTotal = authorDB.coins + coinsToAdd;
updateUserCoins.run({
id: author.id,
coins: newCoinTotal,
});
await userService.updateUserCoins(author.id, newCoinTotal);
insertLog.run({
await logService.insertLog({
id: `${author.id}-${now}`,
user_id: author.id,
userId: author.id,
action: "AUTO_COINS",
target_user_id: null,
coins_amount: coinsToAdd,
user_new_amount: newCoinTotal,
targetUserId: null,
coinsAmount: coinsToAdd,
userNewAmount: newCoinTotal,
});
await pruneOldLogs();
await logService.pruneOldLogs();
return true; // Indicate that points were awarded
}
@@ -116,8 +105,8 @@ export async function slowmodesHandler(message) {
* Used for testing and simulations.
* @returns {string} The calculated random price as a string.
*/
export function randomSkinPrice() {
const dbSkins = getAllSkins.all();
export async function randomSkinPrice() {
const dbSkins = await skinService.getAllSkins();
if (dbSkins.length === 0) return "0.00";
const randomDbSkin = dbSkins[Math.floor(Math.random() * dbSkins.length)];
@@ -144,30 +133,30 @@ export function randomSkinPrice() {
* Initializes the Solitaire of the Day.
* This function clears previous stats, awards the winner, and generates a new daily seed.
*/
export function initTodaysSOTD() {
export async function initTodaysSOTD() {
console.log(`Initializing new Solitaire of the Day...`);
// 1. Award previous day's winner
const rankings = getAllSOTDStats.all();
const rankings = await solitaireService.getAllSOTDStats();
if (rankings.length > 0) {
const winnerId = rankings[0].user_id;
const secondPlaceId = rankings[1] ? rankings[1].user_id : null;
const thirdPlaceId = rankings[2] ? rankings[2].user_id : null;
const winnerUser = getUser.get(winnerId);
const secondPlaceUser = secondPlaceId ? getUser.get(secondPlaceId) : null;
const thirdPlaceUser = thirdPlaceId ? getUser.get(thirdPlaceId) : null;
const winnerId = rankings[0].userId;
const secondPlaceId = rankings[1] ? rankings[1].userId : null;
const thirdPlaceId = rankings[2] ? rankings[2].userId : null;
const winnerUser = await userService.getUser(winnerId);
const secondPlaceUser = secondPlaceId ? await userService.getUser(secondPlaceId) : null;
const thirdPlaceUser = thirdPlaceId ? await userService.getUser(thirdPlaceId) : null;
if (winnerUser) {
const reward = 2500;
const newCoinTotal = winnerUser.coins + reward;
updateUserCoins.run({ id: winnerId, coins: newCoinTotal });
insertLog.run({
await userService.updateUserCoins(winnerId, newCoinTotal);
await logService.insertLog({
id: `${winnerId}-sotd-win-${Date.now()}`,
target_user_id: null,
user_id: winnerId,
targetUserId: null,
userId: winnerId,
action: "SOTD_FIRST_PLACE",
coins_amount: reward,
user_new_amount: newCoinTotal,
coinsAmount: reward,
userNewAmount: newCoinTotal,
});
console.log(
`${winnerUser.globalName || winnerUser.username} won the previous SOTD and received ${reward} coins.`,
@@ -176,14 +165,14 @@ export function initTodaysSOTD() {
if (secondPlaceUser) {
const reward = 1500;
const newCoinTotal = secondPlaceUser.coins + reward;
updateUserCoins.run({ id: secondPlaceId, coins: newCoinTotal });
insertLog.run({
await userService.updateUserCoins(secondPlaceId, newCoinTotal);
await logService.insertLog({
id: `${secondPlaceId}-sotd-second-${Date.now()}`,
target_user_id: null,
user_id: secondPlaceId,
targetUserId: null,
userId: secondPlaceId,
action: "SOTD_SECOND_PLACE",
coins_amount: reward,
user_new_amount: newCoinTotal,
coinsAmount: reward,
userNewAmount: newCoinTotal,
});
console.log(
`${secondPlaceUser.globalName || secondPlaceUser.username} got second place in the previous SOTD and received ${reward} coins.`,
@@ -192,14 +181,14 @@ export function initTodaysSOTD() {
if (thirdPlaceUser) {
const reward = 750;
const newCoinTotal = thirdPlaceUser.coins + reward;
updateUserCoins.run({ id: thirdPlaceId, coins: newCoinTotal });
insertLog.run({
await userService.updateUserCoins(thirdPlaceId, newCoinTotal);
await logService.insertLog({
id: `${thirdPlaceId}-sotd-third-${Date.now()}`,
target_user_id: null,
user_id: thirdPlaceId,
targetUserId: null,
userId: thirdPlaceId,
action: "SOTD_THIRD_PLACE",
coins_amount: reward,
user_new_amount: newCoinTotal,
coinsAmount: reward,
userNewAmount: newCoinTotal,
});
console.log(
`${thirdPlaceUser.globalName || thirdPlaceUser.username} got third place in the previous SOTD and received ${reward} coins.`,
@@ -221,9 +210,9 @@ export function initTodaysSOTD() {
// 3. Clear old stats and save the new game state to the database
try {
clearSOTDStats.run();
deleteSOTD.run();
insertSOTD.run({
await solitaireService.clearSOTDStats();
await solitaireService.deleteSOTD();
await solitaireService.insertSOTD({
tableauPiles: JSON.stringify(todaysSOTD.tableauPiles),
foundationPiles: JSON.stringify(todaysSOTD.foundationPiles),
stockPile: JSON.stringify(todaysSOTD.stockPile),

View File

@@ -11,6 +11,9 @@ export let activeConnect4Games = {};
// Stores active Tic-Tac-Toe games, keyed by a unique game ID.
export let activeTicTacToeGames = {};
// Stores active Snake games, keyed by a unique game ID.
export let activeSnakeGames = {};
// Stores active Solitaire games, keyed by user ID.
export let activeSolitaireGames = {};
@@ -20,6 +23,8 @@ export let pokerRooms = {};
// Stores active erinyes rooms, keyed by a unique room ID (uuidv4).
export let erinyesRooms = {};
export let monkePaths = {};
// --- User and Session State ---
// Stores active user inventories for paginated embeds, keyed by the interaction ID.
@@ -52,6 +57,9 @@ export let tictactoeQueue = [];
// Stores user IDs waiting to play Connect 4.
export let connect4Queue = [];
// Stores user IDs waiting to play Snake 1v1.
export let snakeQueue = [];
export let queueMessagesEndpoints = [];
// --- Rate Limiting and Caching ---

5
src/prisma/client.js Normal file
View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -11,6 +11,8 @@ import { solitaireRoutes } from "./routes/solitaire.js";
import { getSocketIo } from "./socket.js";
import { blackjackRoutes } from "./routes/blackjack.js";
import { marketRoutes } from "./routes/market.js";
import { monkeRoutes } from "./routes/monke.js";
import { authRoutes } from "./routes/auth.js";
// --- EXPRESS APP INITIALIZATION ---
const app = express();
@@ -24,7 +26,7 @@ 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",
"Content-Type, Authorization, X-API-Key, ngrok-skip-browser-warning, Cache-Control, Pragma, Expires",
);
next();
});
@@ -36,6 +38,9 @@ app.post("/interactions", verifyKeyMiddleware(process.env.PUBLIC_KEY), async (re
await handleInteraction(req, res, client);
});
// Stripe webhook endpoint needs raw body for signature verification
app.use("/api/buy-coins", express.raw({ type: "application/json" }));
// JSON Body Parser Middleware
app.use(express.json());
@@ -44,6 +49,9 @@ app.use("/public", express.static("public"));
// --- API ROUTES ---
// Auth routes (Discord OAuth2, no client/io needed)
app.use("/api/auth", authRoutes());
// General API routes (users, polls, etc.)
app.use("/api", apiRoutes(client, io));
@@ -62,4 +70,7 @@ app.use("/api/market-place", marketRoutes(client, io));
// erinyes-specific routes
// app.use("/api/erinyes", erinyesRoutes(client, io));
// monke-specific routes
app.use("/api/monke-game", monkeRoutes(client, io));
export { app };

View File

@@ -0,0 +1,63 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET;
/**
* Middleware that requires a valid JWT token in the Authorization header.
* Sets req.userId to the authenticated Discord user ID.
*/
export function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required." });
}
const token = authHeader.split("Bearer ")[1];
try {
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.discordId;
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token." });
}
}
/**
* Optional auth middleware - attaches userId if token is present, but doesn't block.
* Useful for routes that work for both authenticated and unauthenticated users.
*/
export function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.split("Bearer ")[1];
try {
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.discordId;
} catch {
// Token invalid, continue without userId
}
}
next();
}
/**
* Signs a JWT token for a given Discord user ID.
* @param {string} discordId - The Discord user ID.
* @returns {string} The signed JWT token.
*/
export function signToken(discordId) {
return jwt.sign({ discordId }, JWT_SECRET, { expiresIn: "7d" });
}
/**
* Verifies a JWT token and returns the payload.
* @param {string} token - The JWT token to verify.
* @returns {object|null} The decoded payload or null if invalid.
*/
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch {
return null;
}
}

File diff suppressed because it is too large Load Diff

118
src/server/routes/auth.js Normal file
View File

@@ -0,0 +1,118 @@
import express from "express";
import axios from "axios";
import { signToken } from "../middleware/auth.js";
import * as userService from "../../services/user.service.js";
const router = express.Router();
const DISCORD_API = "https://discord.com/api/v10";
const DISCORD_CLIENT_ID = process.env.APP_ID;
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
const API_URL = process.env.DEV_SITE === "true" ? process.env.API_URL_DEV : process.env.API_URL;
const FLAPI_URL = process.env.DEV_SITE === "true" ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL;
const REDIRECT_URI = `${API_URL}/api/auth/discord/callback`;
/**
* GET /api/auth/discord
* Redirects the user to Discord's OAuth2 authorization page.
*/
router.get("/discord", (req, res) => {
const params = new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "identify",
});
res.redirect(`${DISCORD_API}/oauth2/authorize?${params.toString()}`);
});
/**
* GET /api/auth/discord/callback
* Handles the OAuth2 callback from Discord.
* Exchanges the authorization code for tokens, fetches user info,
* creates a JWT, and redirects the user back to the frontend.
*/
router.get("/discord/callback", async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).json({ error: "Missing authorization code." });
}
try {
// Exchange the authorization code for an access token
const tokenResponse = await axios.post(
`${DISCORD_API}/oauth2/token`,
new URLSearchParams({
client_id: DISCORD_CLIENT_ID,
client_secret: DISCORD_CLIENT_SECRET,
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
}),
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } },
);
const { access_token } = tokenResponse.data;
// Fetch the user's Discord profile
const userResponse = await axios.get(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${access_token}` },
});
const discordUser = userResponse.data;
// Ensure the user exists in our database
const existingUser = await userService.getUser(discordUser.id);
if (existingUser) {
// Update avatar if it changed
const avatarUrl = discordUser.avatar
? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png?size=256`
: null;
if (avatarUrl) {
await userService.updateUserAvatar(discordUser.id, avatarUrl);
}
}
// Sign a JWT with the verified Discord ID
const token = signToken(discordUser.id);
// Redirect back to the frontend with the token
res.redirect(`${FLAPI_URL}/auth/callback?token=${token}&discordId=${discordUser.id}`);
} catch (error) {
console.error("Discord OAuth2 error:", error.response?.data || error.message);
console.log("Status:", error.response?.status);
console.log("Headers:", JSON.stringify(error.response?.headers));
res.redirect(`${FLAPI_URL}/auth/callback?error=auth_failed`);
}
});
/**
* GET /api/auth/me
* Returns the authenticated user's info. Requires a valid JWT.
*/
router.get("/me", async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required." });
}
const token = authHeader.split("Bearer ")[1];
const { verifyToken } = await import("../middleware/auth.js");
const payload = verifyToken(token);
if (!payload) {
return res.status(401).json({ error: "Invalid or expired token." });
}
const user = await userService.getUser(payload.discordId);
if (!user) {
console.warn("User not found for Discord ID in token:", payload.discordId);
return res.json({ discordId: payload.discordId });
}
res.json({ user, discordId: user.id });
});
export function authRoutes() {
return router;
}

View File

@@ -15,10 +15,13 @@ import {
} from "../../game/blackjack.js";
// Optional: hook into your DB & Discord systems if available
import { getUser, insertLog, updateUserCoins } from "../../database/index.js";
import * as userService from "../../services/user.service.js";
import * as logService from "../../services/log.service.js";
import { client } from "../../bot/client.js";
import { emitToast, emitUpdate } from "../socket.js";
import { EmbedBuilder } from "discord.js";
import { emitToast, emitUpdate, emitPlayerUpdate } from "../socket.js";
import { EmbedBuilder, time } from "discord.js";
import { requireAuth } from "../middleware/auth.js";
import { resolveUser } from "../../utils/index.js";
export function blackjackRoutes(io) {
const router = express.Router();
@@ -77,23 +80,25 @@ export function blackjackRoutes(io) {
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;
try {
if (!p.inRound) continue;
// Handle all remaining hands (important after splits)
for (let i = p.activeHand; i < p.hands.length; i++) {
const h = p.hands[i];
if (!h || h.busted || h.stood || h.surrendered) continue;
h.stood = true;
h.hasActed = true;
changed = true;
}
if (changed) {
p.activeHand = p.hands.length;
emitToast({ type: "player-auto-stand", userId: p.id });
}
} catch (e) {
console.log(e);
}
}
if (changed) emitUpdate("auto-surrender", snapshot(room));
//if (changed) emitUpdate("auto-surrender", snapshot(room));
return changed;
}
@@ -118,13 +123,12 @@ export function blackjackRoutes(io) {
// --- 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" });
router.post("/join", requireAuth, async (req, res) => {
const userId = req.userId;
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 resolveUser(client, userId);
const bank = (await userService.getUser(userId))?.coins ?? 0;
room.players[userId] = {
id: userId,
@@ -152,8 +156,8 @@ export function blackjackRoutes(io) {
};
try {
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const generalChannel = await guild.channels.fetch(process.env.BOT_CHANNEL_ID);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setDescription(`<@${userId}> joue au Blackjack`)
.addFields(
@@ -178,16 +182,21 @@ export function blackjackRoutes(io) {
}
emitUpdate("player-joined", snapshot(room));
emitPlayerUpdate({
id: userId,
msg: `${user?.globalName || user?.username} a rejoint la table de Blackjack.`,
timestamp: Date.now(),
});
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(403).json({ message: "not in room" });
router.post("/leave", requireAuth, async (req, res) => {
const userId = req.userId;
if (!room.players[userId]) return res.status(403).json({ message: "not in room" });
try {
const guild = await client.guilds.fetch(process.env.GUILD_ID);
const generalChannel = await guild.channels.fetch(process.env.BOT_CHANNEL_ID);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const msg = await generalChannel.messages.fetch(room.players[userId].msgId);
const updatedEmbed = new EmbedBuilder()
.setDescription(`<@${userId}> a quitté la table de Blackjack.`)
@@ -218,12 +227,19 @@ export function blackjackRoutes(io) {
} else {
delete room.players[userId];
emitUpdate("player-left", snapshot(room));
const user = await resolveUser(client, userId);
emitPlayerUpdate({
id: userId,
msg: `${user?.globalName || user?.username} a quitté la table de Blackjack.`,
timestamp: Date.now(),
});
return res.status(200).json({ message: "left" });
}
});
router.post("/bet", (req, res) => {
const { userId, amount } = req.body;
router.post("/bet", requireAuth, async (req, res) => {
const userId = req.userId;
const { 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" });
@@ -232,17 +248,17 @@ export function blackjackRoutes(io) {
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 userDB = await userService.getUser(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({
await userService.updateUserCoins(userId, coins - bet);
await logService.insertLog({
id: `${userId}-blackjack-${Date.now()}`,
user_id: userId,
target_user_id: null,
userId: userId,
targetUserId: null,
action: "BLACKJACK_BET",
coins_amount: -bet,
user_new_amount: coins - bet,
coinsAmount: -bet,
userNewAmount: coins - bet,
});
p.bank = coins - bet;
}
@@ -254,8 +270,8 @@ export function blackjackRoutes(io) {
return res.status(200).json({ message: "bet-accepted" });
});
router.post("/action/:action", (req, res) => {
const { userId } = req.body;
router.post("/action/:action", requireAuth, async (req, res) => {
const userId = req.userId;
const action = req.params.action;
const p = room.players[userId];
if (!p) return res.status(404).json({ message: "not in room" });
@@ -263,36 +279,36 @@ export function blackjackRoutes(io) {
// Handle extra coin lock for double
if (action === "double" && !room.settings.fakeMoney) {
const userDB = getUser.get(userId);
const userDB = await userService.getUser(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({
await userService.updateUserCoins(userId, coins - hand.bet);
await logService.insertLog({
id: `${userId}-blackjack-${Date.now()}`,
user_id: userId,
target_user_id: null,
userId: userId,
targetUserId: null,
action: "BLACKJACK_DOUBLE",
coins_amount: -hand.bet,
user_new_amount: coins - hand.bet,
coinsAmount: -hand.bet,
userNewAmount: 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 userDB = await userService.getUser(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({
await userService.updateUserCoins(userId, coins - hand.bet);
await logService.insertLog({
id: `${userId}-blackjack-${Date.now()}`,
user_id: userId,
target_user_id: null,
userId: userId,
targetUserId: null,
action: "BLACKJACK_SPLIT",
coins_amount: -hand.bet,
user_new_amount: coins - hand.bet,
coinsAmount: -hand.bet,
userNewAmount: coins - hand.bet,
});
p.bank = coins - hand.bet;
// effective bet size is handled in settlement via hand.doubled flag
@@ -352,6 +368,12 @@ export function blackjackRoutes(io) {
// Remove leavers
for (const userId of Object.keys(room.leavingAfterRound)) {
delete room.players[userId];
const user = await resolveUser(client, userId);
emitPlayerUpdate({
id: userId,
msg: `${user?.globalName || user?.username} a quitté la table de Blackjack.`,
timestamp: Date.now(),
});
}
// Prepare next round
startBetting(room, now);

View File

@@ -2,6 +2,7 @@ import express from "express";
import { v4 as uuidv4 } from "uuid";
import { erinyesRooms } from "../../game/state.js";
import { socketEmit } from "../socket.js";
import { resolveUser } from "../../utils/index.js";
const router = express.Router();
@@ -35,7 +36,7 @@ export function erinyesRoutes(client, io) {
res.status(404).json({ message: "You are already in a room." });
}
const creator = await client.users.fetch(creatorId);
const creator = await resolveUser(client, creatorId);
const id = uuidv4();
createRoom({

View File

@@ -5,20 +5,14 @@ import express from "express";
// --- Utility and API Imports ---
// --- Discord.js Builder Imports ---
import { ButtonStyle } from "discord.js";
import {
getMarketOfferById,
getMarketOffers,
getMarketOffersBySkin,
getOfferBids,
getSkin,
getUser,
insertBid,
insertLog,
insertMarketOffer,
updateUserCoins,
} from "../../database/index.js";
import * as userService from "../../services/user.service.js";
import * as skinService from "../../services/skin.service.js";
import * as logService from "../../services/log.service.js";
import * as marketService from "../../services/market.service.js";
import * as csSkinService from "../../services/csSkin.service.js";
import { emitMarketUpdate } from "../socket.js";
import { handleNewMarketOffer, handleNewMarketOfferBid } from "../../utils/marketNotifs.js";
import { requireAuth } from "../middleware/auth.js";
// Create a new router instance
const router = express.Router();
@@ -32,25 +26,30 @@ const router = express.Router();
export function marketRoutes(client, io) {
router.get("/offers", async (req, res) => {
try {
const offers = getMarketOffers.all();
offers.forEach((offer) => {
offer.skin = getSkin.get(offer.skin_uuid);
offer.seller = getUser.get(offer.seller_id);
offer.buyer = getUser.get(offer.buyer_id) || null;
offer.bids = getOfferBids.all(offer.id) || {};
offer.bids.forEach((bid) => {
bid.bidder = getUser.get(bid.bidder_id);
});
});
const offers = await marketService.getMarketOffers();
for (const offer of offers) {
if (offer.csSkinId) {
offer.csSkin = await csSkinService.getCsSkin(offer.csSkinId);
} else if (offer.skinUuid) {
offer.skin = await skinService.getSkin(offer.skinUuid);
}
offer.seller = await userService.getUser(offer.sellerId);
offer.buyer = offer.buyerId ? await userService.getUser(offer.buyerId) : null;
offer.bids = (await marketService.getOfferBids(offer.id)) || {};
for (const bid of offer.bids) {
bid.bidder = await userService.getUser(bid.bidderId);
}
}
res.status(200).send({ offers });
} catch (e) {
console.log(e);
res.status(500).send({ error: e });
}
});
router.get("/offers/:id", async (req, res) => {
try {
const offer = getMarketOfferById.get(req.params.id);
const offer = await marketService.getMarketOfferById(req.params.id);
if (offer) {
res.status(200).send({ offer });
} else {
@@ -63,24 +62,39 @@ export function marketRoutes(client, io) {
router.get("/offers/:id/bids", async (req, res) => {
try {
const bids = getOfferBids.get(req.params.id);
const bids = await marketService.getOfferBids(req.params.id);
res.status(200).send({ bids });
} catch (e) {
res.status(500).send({ error: e });
}
});
router.post("/place-offer", async (req, res) => {
const { seller_id, skin_uuid, starting_price, delay, duration, timestamp } = req.body;
router.post("/place-offer", requireAuth, async (req, res) => {
const seller_id = req.userId;
const { skin_uuid, cs_skin_id, starting_price, delay, duration, timestamp } = req.body;
const now = Date.now();
try {
const skin = getSkin.get(skin_uuid);
if (!skin) return res.status(404).send({ error: "Skin not found" });
const seller = getUser.get(seller_id);
const seller = await userService.getUser(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);
let skinRef; // { skinUuid, csSkinId } - one or the other
if (cs_skin_id) {
const csSkin = await csSkinService.getCsSkin(cs_skin_id);
if (!csSkin) return res.status(404).send({ error: "CS skin not found" });
if (csSkin.userId !== seller.id) return res.status(403).send({ error: "You do not own this skin" });
skinRef = { csSkinId: csSkin.id };
} else if (skin_uuid) {
const skin = await skinService.getSkin(skin_uuid);
if (!skin) return res.status(404).send({ error: "Skin not found" });
if (skin.userId !== seller.id) return res.status(403).send({ error: "You do not own this skin" });
skinRef = { skinUuid: skin.uuid };
} else {
return res.status(400).send({ error: "Must provide skin_uuid or cs_skin_id" });
}
const existingOffers = skinRef.skinUuid
? await marketService.getMarketOffersBySkin(skinRef.skinUuid)
: await marketService.getMarketOffersByCsSkin(skinRef.csSkinId);
if (
existingOffers.length > 0 &&
existingOffers.some((offer) => offer.status === "open" || offer.status === "pending")
@@ -91,16 +105,17 @@ export function marketRoutes(client, io) {
const opening_at = now + delay;
const closing_at = opening_at + duration;
const offerId = Date.now() + "-" + seller.id + "-" + skin.uuid;
insertMarketOffer.run({
const offerId = Date.now() + "-" + seller.id + "-" + (skinRef.skinUuid || skinRef.csSkinId);
await marketService.insertMarketOffer({
id: offerId,
skin_uuid: skin.uuid,
seller_id: seller.id,
starting_price: starting_price,
buyout_price: null,
skinUuid: skinRef.skinUuid || null,
csSkinId: skinRef.csSkinId || null,
sellerId: seller.id,
startingPrice: starting_price,
buyoutPrice: null,
status: delay > 0 ? "pending" : "open",
opening_at: opening_at,
closing_at: closing_at,
openingAt: opening_at,
closingAt: closing_at,
});
await emitMarketUpdate();
await handleNewMarketOffer(offerId, client);
@@ -111,65 +126,66 @@ export function marketRoutes(client, io) {
}
});
router.post("/offers/:id/place-bid", async (req, res) => {
const { buyer_id, bid_amount, timestamp } = req.body;
router.post("/offers/:id/place-bid", requireAuth, async (req, res) => {
const buyer_id = req.userId;
const { bid_amount, timestamp } = req.body;
try {
const offer = getMarketOfferById.get(req.params.id);
const offer = await marketService.getMarketOfferById(req.params.id);
if (!offer) return res.status(404).send({ error: "Offer not found" });
if (offer.closing_at < timestamp) return res.status(403).send({ error: "Bidding period has ended" });
if (offer.closingAt < timestamp) return res.status(403).send({ error: "Bidding period has ended" });
if (buyer_id === offer.seller_id) return res.status(403).send({ error: "You can't bid on your own offer" });
if (buyer_id === offer.sellerId) return res.status(403).send({ error: "You can't bid on your own offer" });
const offerBids = getOfferBids.all(offer.id);
const offerBids = await marketService.getOfferBids(offer.id);
const lastBid = offerBids[0];
if (lastBid) {
if (lastBid?.bidder_id === buyer_id)
if (lastBid?.bidderId === buyer_id)
return res.status(403).send({ error: "You are already the highest bidder" });
if (bid_amount < lastBid?.offer_amount + 10) {
if (bid_amount < lastBid?.offerAmount + 10) {
return res.status(403).send({ error: "Bid amount is below minimum" });
}
} else {
if (bid_amount < offer.starting_price + 10) {
if (bid_amount < offer.startingPrice + 10) {
return res.status(403).send({ error: "Bid amount is below minimum" });
}
}
const bidder = getUser.get(buyer_id);
const bidder = await userService.getUser(buyer_id);
if (!bidder) return res.status(404).send({ error: "Bidder not found" });
if (bidder.coins < bid_amount)
return res.status(403).send({ error: "You do not have enough coins to place this bid" });
const bidId = Date.now() + "-" + buyer_id + "-" + offer.id;
insertBid.run({
await marketService.insertBid({
id: bidId,
bidder_id: buyer_id,
market_offer_id: offer.id,
offer_amount: bid_amount,
bidderId: buyer_id,
marketOfferId: offer.id,
offerAmount: bid_amount,
});
const newCoinsAmount = bidder.coins - bid_amount;
updateUserCoins.run({ id: buyer_id, coins: newCoinsAmount });
insertLog.run({
await userService.updateUserCoins(buyer_id, newCoinsAmount);
await logService.insertLog({
id: `${buyer_id}-bid-${offer.id}-${Date.now()}`,
user_id: buyer_id,
userId: buyer_id,
action: "BID_PLACED",
target_user_id: null,
coins_amount: bid_amount,
user_new_amount: newCoinsAmount,
targetUserId: null,
coinsAmount: bid_amount,
userNewAmount: newCoinsAmount,
});
// 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({
const previousBidder = await userService.getUser(lastBid.bidderId);
const refundedCoinsAmount = previousBidder.coins + lastBid.offerAmount;
await userService.updateUserCoins(previousBidder.id, refundedCoinsAmount);
await logService.insertLog({
id: `${previousBidder.id}-bid-refund-${offer.id}-${Date.now()}`,
user_id: previousBidder.id,
userId: previousBidder.id,
action: "BID_REFUNDED",
target_user_id: null,
coins_amount: lastBid.offer_amount,
user_new_amount: refundedCoinsAmount,
targetUserId: null,
coinsAmount: lastBid.offerAmount,
userNewAmount: refundedCoinsAmount,
});
}

136
src/server/routes/monke.js Normal file
View File

@@ -0,0 +1,136 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import { monkePaths } from "../../game/state.js";
import { socketEmit } from "../socket.js";
import * as userService from "../../services/user.service.js";
import * as logService from "../../services/log.service.js";
import { init } from "openai/_shims/index.mjs";
import { requireAuth } from "../middleware/auth.js";
const router = express.Router();
/**
* Factory function to create and configure the monke API routes.
* @param {object} client - The Discord.js client instance.
* @param {object} io - The Socket.IO server instance.
* @returns {object} The configured Express router.
*/
export function monkeRoutes(client, io) {
// --- Router Management Endpoints
router.get("/:userId", async (req, res) => {
const { userId } = req.params;
if (!userId) return res.status(400).json({ error: "User ID is required" });
const user = await userService.getUser(userId);
if (!user) return res.status(404).json({ error: "User not found" });
const userGamePath = monkePaths[userId] || null;
if (!userGamePath) return res.status(404).json({ error: "No active game found for this user" });
return res.status(200).json({ userGamePath });
});
router.post("/:userId/start", requireAuth, async (req, res) => {
const userId = req.userId;
const { initialBet } = req.body;
const user = await userService.getUser(userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!initialBet) return res.status(400).json({ error: "Initial bet is required" });
if (initialBet > user.coins) return res.status(400).json({ error: "Insufficient coins for the initial bet" });
try {
const newCoins = user.coins - initialBet;
await userService.updateUserCoins(userId, newCoins);
await logService.insertLog({
id: `${userId}-monke-bet-${Date.now()}`,
userId: userId,
targetUserId: null,
action: "MONKE_BET",
coinsAmount: -initialBet,
userNewAmount: newCoins,
});
} catch (error) {
return res.status(500).json({ error: "Failed to update user coins" });
}
monkePaths[userId] = [
{ round: 0, choice: null, result: null, bet: initialBet, extractValue: null, timestamp: Date.now() },
];
return res.status(200).json({ message: "Monke game started", userGamePath: monkePaths[userId] });
});
router.post("/:userId/play", requireAuth, async (req, res) => {
const userId = req.userId;
const { choice, step } = req.body;
const user = await userService.getUser(userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!monkePaths[userId]) return res.status(400).json({ error: "No active game found for this user" });
const currentRound = monkePaths[userId].length - 1;
if (step !== currentRound) return res.status(400).json({ error: "Invalid step for the current round" });
if (monkePaths[userId][currentRound].choice !== null)
return res.status(400).json({ error: "This round has already been played" });
const randomLoseChoice = Math.floor(Math.random() * 3); // 0, 1, or 2
if (choice !== randomLoseChoice) {
monkePaths[userId][currentRound].choice = choice;
monkePaths[userId][currentRound].result = randomLoseChoice;
monkePaths[userId][currentRound].extractValue = Math.round(monkePaths[userId][currentRound].bet * 1.33);
monkePaths[userId][currentRound].timestamp = Date.now();
monkePaths[userId].push({
round: currentRound + 1,
choice: null,
result: null,
bet: monkePaths[userId][currentRound].extractValue,
extractValue: null,
timestamp: Date.now(),
});
return res.status(200).json({ message: "Round won", userGamePath: monkePaths[userId], lost: false });
} else {
monkePaths[userId][currentRound].choice = choice;
monkePaths[userId][currentRound].result = randomLoseChoice;
monkePaths[userId][currentRound].extractValue = 0;
monkePaths[userId][currentRound].timestamp = Date.now();
const userGamePath = monkePaths[userId];
delete monkePaths[userId];
return res.status(200).json({ message: "Round lost", userGamePath, lost: true });
}
});
router.post("/:userId/stop", requireAuth, async (req, res) => {
const userId = req.userId;
const user = await userService.getUser(userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!monkePaths[userId]) return res.status(400).json({ error: "No active game found for this user" });
const userGamePath = monkePaths[userId];
delete monkePaths[userId];
const extractValue = userGamePath[userGamePath.length - 1].bet;
const coins = user.coins || 0;
const newCoins = coins + extractValue;
try {
await userService.updateUserCoins(userId, newCoins);
await logService.insertLog({
id: `${userId}-monke-withdraw-${Date.now()}`,
userId: userId,
targetUserId: null,
action: "MONKE_WITHDRAW",
coinsAmount: extractValue,
userNewAmount: newCoins,
});
return res.status(200).json({ message: "Game stopped", userGamePath });
} catch (error) {
return res.status(500).json({ error: "Failed to update user coins" });
}
});
return router;
}

View File

@@ -10,12 +10,14 @@ import {
getNextActivePlayer,
initialShuffledCards,
} from "../../game/poker.js";
import { getUser, insertLog, updateUserCoins } from "../../database/index.js";
import * as userService from "../../services/user.service.js";
import * as logService from "../../services/log.service.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 { formatAmount, resolveUser } from "../../utils/index.js";
import { requireAuth } from "../middleware/auth.js";
const { Hand } = pkg;
@@ -43,16 +45,16 @@ export function pokerRoutes(client, io) {
}
});
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", requireAuth, async (req, res) => {
const creatorId = req.userId;
const { minBet, fakeMoney } = req.body;
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 guild = client.guilds.cache.get(process.env.GUILD_ID);
const creator = await resolveUser(client, creatorId);
const id = uuidv4();
const name = uniqueNamesGenerator({
dictionaries: [adjectives, ["Poker"]],
@@ -89,7 +91,7 @@ export function pokerRoutes(client, io) {
await emitPokerUpdate({ room: pokerRooms[id], type: "room-created" });
try {
const generalChannel = await guild.channels.fetch(process.env.BOT_CHANNEL_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const embed = new EmbedBuilder()
.setTitle("Flopoker 🃏")
.setDescription(`<@${creatorId}> a créé une table de poker`)
@@ -124,14 +126,18 @@ export function pokerRoutes(client, io) {
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." });
router.post("/join", requireAuth, async (req, res) => {
const userId = req.userId;
const { roomId } = req.body;
if (!roomId) return res.status(400).json({ message: "Room ID is 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)) {
if (
!pokerRooms[roomId].fakeMoney &&
pokerRooms[roomId].minBet > ((await userService.getUser(userId))?.coins ?? 0)
) {
return res.status(403).json({ message: "You do not have enough coins to join this room." });
}
@@ -139,27 +145,25 @@ export function pokerRoutes(client, io) {
res.status(200).json({ message: "Successfully joined." });
});
router.post("/accept", async (req, res) => {
const { hostId, playerId, roomId } = req.body;
router.post("/accept", requireAuth, async (req, res) => {
const hostId = req.userId;
const { 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);
const userDB = await userService.getUser(playerId);
if (userDB) {
updateUserCoins.run({
id: playerId,
coins: userDB.coins - room.minBet,
});
insertLog.run({
await userService.updateUserCoins(playerId, userDB.coins - room.minBet);
await logService.insertLog({
id: `${playerId}-poker-${Date.now()}`,
user_id: playerId,
target_user_id: null,
userId: playerId,
targetUserId: null,
action: "POKER_JOIN",
coins_amount: -room.minBet,
user_new_amount: userDB.coins - room.minBet,
coinsAmount: -room.minBet,
userNewAmount: userDB.coins - room.minBet,
});
}
}
@@ -171,8 +175,9 @@ export function pokerRoutes(client, io) {
res.status(200).json({ message: "Player accepted." });
});
router.post("/leave", async (req, res) => {
const { userId, roomId } = req.body;
router.post("/leave", requireAuth, async (req, res) => {
const userId = req.userId;
const { 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" });
@@ -199,7 +204,7 @@ export function pokerRoutes(client, io) {
}
try {
updatePlayerCoins(
await updatePlayerCoins(
pokerRooms[roomId].players[userId],
pokerRooms[roomId].players[userId].bank,
pokerRooms[roomId].fakeMoney,
@@ -222,8 +227,9 @@ export function pokerRoutes(client, io) {
return res.status(200);
});
router.post("/kick", async (req, res) => {
const { commandUserId, userId, roomId } = req.body;
router.post("/kick", requireAuth, async (req, res) => {
const commandUserId = req.userId;
const { 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" });
@@ -239,7 +245,7 @@ export function pokerRoutes(client, io) {
}
try {
updatePlayerCoins(
await updatePlayerCoins(
pokerRooms[roomId].players[userId],
pokerRooms[roomId].players[userId].bank,
pokerRooms[roomId].fakeMoney,
@@ -264,7 +270,7 @@ export function pokerRoutes(client, io) {
// --- Game Action Endpoints ---
router.post("/start", async (req, res) => {
router.post("/start", requireAuth, async (req, res) => {
const { roomId } = req.body;
const room = pokerRooms[roomId];
if (!room) return res.status(404).json({ message: "Room not found." });
@@ -275,7 +281,7 @@ export function pokerRoutes(client, io) {
});
// NEW: Endpoint to start the next hand
router.post("/next-hand", async (req, res) => {
router.post("/next-hand", requireAuth, async (req, res) => {
const { roomId } = req.body;
const room = pokerRooms[roomId];
if (!room || !room.waiting_for_restart) {
@@ -285,8 +291,9 @@ export function pokerRoutes(client, io) {
res.status(200).json({ message: "Next hand started." });
});
router.post("/action/:action", async (req, res) => {
const { playerId, amount, roomId } = req.body;
router.post("/action/:action", requireAuth, async (req, res) => {
const playerId = req.userId;
const { amount, roomId } = req.body;
const { action } = req.params;
const room = pokerRooms[roomId];
@@ -358,8 +365,8 @@ export function pokerRoutes(client, io) {
// --- Helper Functions ---
async function joinRoom(roomId, userId, io) {
const user = await client.users.fetch(userId);
const userDB = getUser.get(userId);
const user = await resolveUser(client, userId);
const userDB = await userService.getUser(userId);
const room = pokerRooms[roomId];
const playerObject = {
@@ -380,14 +387,14 @@ async function joinRoom(roomId, userId, io) {
} else {
room.players[userId] = playerObject;
if (!room.fakeMoney) {
updateUserCoins.run({ id: userId, coins: userDB.coins - room.minBet });
insertLog.run({
await userService.updateUserCoins(userId, userDB.coins - room.minBet);
await logService.insertLog({
id: `${userId}-poker-${Date.now()}`,
user_id: userId,
target_user_id: null,
userId: userId,
targetUserId: null,
action: "POKER_JOIN",
coins_amount: -room.minBet,
user_new_amount: userDB.coins - room.minBet,
coinsAmount: -room.minBet,
userNewAmount: userDB.coins - room.minBet,
});
}
}
@@ -539,29 +546,29 @@ function updatePlayerHandSolves(room) {
}
}
function updatePlayerCoins(player, amount, isFake) {
async function updatePlayerCoins(player, amount, isFake) {
if (isFake) return;
const user = getUser.get(player.id);
const user = await userService.getUser(player.id);
if (!user) return;
const userDB = getUser.get(player.id);
updateUserCoins.run({ id: player.id, coins: userDB.coins + amount });
insertLog.run({
const userDB = await userService.getUser(player.id);
await userService.updateUserCoins(player.id, userDB.coins + amount);
await logService.insertLog({
id: `${player.id}-poker-${Date.now()}`,
user_id: player.id,
target_user_id: null,
userId: player.id,
targetUserId: null,
action: `POKER_${amount > 0 ? "WIN" : "LOSE"}`,
coins_amount: amount,
user_new_amount: userDB.coins + amount,
coinsAmount: amount,
userNewAmount: userDB.coins + amount,
});
}
async function clearAfkPlayers(room) {
Object.keys(room.afk).forEach((playerId) => {
for (const playerId of Object.keys(room.afk)) {
if (room.players[playerId]) {
updatePlayerCoins(room.players[playerId], room.players[playerId].bank, room.fakeMoney);
await updatePlayerCoins(room.players[playerId], room.players[playerId].bank, room.fakeMoney);
delete room.players[playerId];
}
});
}
room.afk = {};
}

View File

@@ -19,17 +19,11 @@ import {
// --- Game State & Database Imports ---
import { activeSolitaireGames } from "../../game/state.js";
import {
getSOTD,
getUser,
insertSOTDStats,
deleteUserSOTDStats,
getUserSOTDStats,
updateUserCoins,
insertLog,
getAllSOTDStats,
} from "../../database/index.js";
import * as userService from "../../services/user.service.js";
import * as logService from "../../services/log.service.js";
import * as solitaireService from "../../services/solitaire.service.js";
import { socketEmit } from "../socket.js";
import { requireAuth } from "../middleware/auth.js";
// Create a new router instance
const router = express.Router();
@@ -43,9 +37,9 @@ const router = express.Router();
export function solitaireRoutes(client, io) {
// --- 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", requireAuth, (req, res) => {
const userId = req.userId;
const { userSeed, hardMode } = req.body;
// If a game already exists for the user, return it instead of creating a new one.
if (activeSolitaireGames[userId] && !activeSolitaireGames[userId].isSOTD) {
@@ -85,11 +79,11 @@ export function solitaireRoutes(client, io) {
res.json({ success: true, gameState });
});
router.post("/start/sotd", (req, res) => {
const { userId } = req.body;
router.post("/start/sotd", requireAuth, async (req, res) => {
const userId = req.userId;
/*if (!userId || !getUser.get(userId)) {
return res.status(404).json({ error: 'User not found.' });
}*/
return res.status(404).json({ error: 'User not found.' });
}*/
if (activeSolitaireGames[userId]?.isSOTD) {
return res.json({
@@ -98,7 +92,7 @@ export function solitaireRoutes(client, io) {
});
}
const sotd = getSOTD.get();
const sotd = await solitaireService.getSOTD();
if (!sotd) {
return res.status(500).json({ error: "Solitaire of the Day is not configured." });
}
@@ -126,9 +120,9 @@ export function solitaireRoutes(client, io) {
// --- Game State & Action Endpoints ---
router.get("/sotd/rankings", (req, res) => {
router.get("/sotd/rankings", async (req, res) => {
try {
const rankings = getAllSOTDStats.all();
const rankings = await solitaireService.getAllSOTDStats();
res.json({ rankings });
} catch (e) {
res.status(500).json({ error: "Failed to fetch SOTD rankings." });
@@ -145,16 +139,17 @@ export function solitaireRoutes(client, io) {
}
});
router.post("/reset", (req, res) => {
const { userId } = req.body;
router.post("/reset", requireAuth, (req, res) => {
const userId = req.userId;
if (activeSolitaireGames[userId]) {
delete activeSolitaireGames[userId];
}
res.json({ success: true, message: "Game reset." });
});
router.post("/move", async (req, res) => {
const { userId, ...moveData } = req.body;
router.post("/move", requireAuth, async (req, res) => {
const userId = req.userId;
const { ...moveData } = req.body;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: "Game not found." });
@@ -183,8 +178,8 @@ export function solitaireRoutes(client, io) {
}
});
router.post("/draw", (req, res) => {
const { userId } = req.body;
router.post("/draw", requireAuth, (req, res) => {
const userId = req.userId;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: "Game not found." });
@@ -199,8 +194,8 @@ export function solitaireRoutes(client, io) {
res.json({ success: true, gameState });
});
router.post("/undo", (req, res) => {
const { userId } = req.body;
router.post("/undo", requireAuth, (req, res) => {
const userId = req.userId;
const gameState = activeSolitaireGames[userId];
if (!gameState) return res.status(404).json({ error: "Game not found." });
@@ -237,20 +232,20 @@ function updateGameStats(gameState, actionType, moveData = {}) {
/** Handles the logic when a game is won. */
async function handleWin(userId, gameState, io) {
const currentUser = getUser.get(userId);
const currentUser = await userService.getUser(userId);
if (!currentUser) return;
if (gameState.hardMode) {
const bonus = 100;
const newCoins = currentUser.coins + bonus;
updateUserCoins.run({ id: userId, coins: newCoins });
insertLog.run({
await userService.updateUserCoins(userId, newCoins);
await logService.insertLog({
id: `${userId}-hardmode-solitaire-${Date.now()}`,
user_id: userId,
userId: userId,
action: "HARDMODE_SOLITAIRE_WIN",
target_user_id: null,
coins_amount: bonus,
user_new_amount: newCoins,
targetUserId: null,
coinsAmount: bonus,
userNewAmount: newCoins,
});
await socketEmit("data-updated", { table: "users" });
}
@@ -260,20 +255,20 @@ async function handleWin(userId, gameState, io) {
gameState.endTime = Date.now();
const timeTaken = gameState.endTime - gameState.startTime;
const existingStats = getUserSOTDStats.get(userId);
const existingStats = await solitaireService.getUserSOTDStats(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({
await userService.updateUserCoins(userId, newCoins);
await logService.insertLog({
id: `${userId}-sotd-complete-${Date.now()}`,
user_id: userId,
userId: userId,
action: "SOTD_WIN",
target_user_id: null,
coins_amount: bonus,
user_new_amount: newCoins,
targetUserId: null,
coinsAmount: bonus,
userNewAmount: newCoins,
});
await socketEmit("data-updated", { table: "users" });
}
@@ -288,10 +283,10 @@ async function handleWin(userId, gameState, io) {
timeTaken < existingStats.time);
if (isNewBest) {
deleteUserSOTDStats.run(userId);
insertSOTDStats.run({
await solitaireService.deleteUserSOTDStats(userId);
await solitaireService.insertSOTDStats({
id: userId,
user_id: userId,
userId: userId,
time: timeTaken,
moves: gameState.moves,
score: gameState.score,

View File

@@ -2,9 +2,11 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "disc
import {
activeConnect4Games,
activeTicTacToeGames,
activeSnakeGames,
connect4Queue,
queueMessagesEndpoints,
tictactoeQueue,
snakeQueue,
} from "../game/state.js";
import {
C4_ROWS,
@@ -14,6 +16,8 @@ import {
formatConnect4BoardForDiscord,
} from "../game/various.js";
import { eloHandler } from "../game/elo.js";
import { verifyToken } from "./middleware/auth.js";
import { resolveUser } from "../utils/index.js";
// --- Module-level State ---
let io;
@@ -23,21 +27,40 @@ let io;
export function initializeSocket(server, client) {
io = server;
// Authenticate socket connections via JWT (optional - allows unauthenticated connections for health checks)
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (token) {
const payload = verifyToken(token);
if (!payload) {
return next(new Error("Invalid or expired token"));
}
socket.userId = payload.discordId;
}
next();
});
io.on("connection", (socket) => {
socket.on("user-connected", async (userId) => {
if (!userId) return;
await refreshQueuesForUser(userId, client);
socket.on("user-connected", async () => {
if (!socket.userId) return;
await refreshQueuesForUser(socket.userId, client);
});
registerTicTacToeEvents(socket, client);
registerConnect4Events(socket, client);
registerSnakeEvents(socket, client);
socket.on("tictactoe:queue:leave", async ({ discordId }) => await refreshQueuesForUser(discordId, client));
socket.on("blackjack:chat", (data) => {
io.emit("blackjack:chat", data);
});
socket.on("tictactoe:queue:leave", async () => await refreshQueuesForUser(socket.userId, client));
socket.on("connect4:queue:leave", async () => await refreshQueuesForUser(socket.userId, client));
socket.on("snake:queue:leave", async () => await refreshQueuesForUser(socket.userId, client));
// catch tab kills / network drops
socket.on("disconnecting", async () => {
const discordId = socket.handshake.auth?.discordId; // or your mapping
await refreshQueuesForUser(discordId, client);
await refreshQueuesForUser(socket.userId, client);
});
socket.on("disconnect", () => {
@@ -55,17 +78,24 @@ export function getSocketIo() {
// --- 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", () => refreshQueuesForUser(socket.userId, client));
socket.on("tictactoequeue", () => onQueueJoin(client, "tictactoe", socket.userId));
socket.on("tictactoeplaying", (e) => onTicTacToeMove(client, { ...e, playerId: socket.userId }));
socket.on("tictactoegameOver", (e) => onGameOver(client, "tictactoe", socket.userId, 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", () => refreshQueuesForUser(socket.userId, client));
socket.on("connect4queue", () => onQueueJoin(client, "connect4", socket.userId));
socket.on("connect4playing", (e) => onConnect4Move(client, { ...e, playerId: socket.userId }));
socket.on("connect4NoTime", (e) => onGameOver(client, "connect4", socket.userId, e.winner, "(temps écoulé)"));
}
function registerSnakeEvents(socket, client) {
socket.on("snakeconnection", () => refreshQueuesForUser(socket.userId, client));
socket.on("snakequeue", () => onQueueJoin(client, "snake", socket.userId));
socket.on("snakegamestate", (e) => onSnakeGameStateUpdate(client, { ...e, playerId: socket.userId }));
socket.on("snakegameOver", (e) => onGameOver(client, "snake", socket.userId, e.winner));
}
// --- Core Handlers (Preserving Original Logic) ---
@@ -74,18 +104,25 @@ async function onQueueJoin(client, gameType, playerId) {
if (!playerId) return;
const { queue, activeGames, title, url } = getGameAssets(gameType);
if (
queue.includes(playerId) ||
Object.values(activeGames).some((g) => g.p1.id === playerId || g.p2.id === playerId)
) {
// Check if player is already in queue or in an active game
if (queue.includes(playerId)) {
console.log(`[${title}] Player ${playerId} already in queue, ignoring duplicate join.`);
return;
}
if (Object.values(activeGames).some((g) => g.p1.id === playerId || g.p2.id === playerId)) {
console.log(`[${title}] Player ${playerId} already in active game, ignoring queue join.`);
return;
}
queue.push(playerId);
console.log(`[${title}] Player ${playerId} joined the queue.`);
console.log(`[${title}] Player ${playerId} joined the queue. Queue size: ${queue.length}`);
if (queue.length === 1) await postQueueToDiscord(client, playerId, title, url);
if (queue.length >= 2) await createGame(client, gameType);
if (queue.length >= 2) {
// Process matchmaking immediately to avoid race conditions
await createGame(client, gameType);
}
await emitQueueUpdate(client, gameType);
}
@@ -189,7 +226,57 @@ async function onConnect4Move(client, eventData) {
await onGameOver(client, "connect4", playerId, winnerId);
}
async function onGameOver(client, gameType, playerId, winnerId, reason = "") {
async function onSnakeGameStateUpdate(client, eventData) {
const { playerId, snake, food, score, gameOver, win } = eventData;
const lobby = Object.values(activeSnakeGames).find(
(l) => (l.p1.id === playerId || l.p2.id === playerId) && !l.gameOver,
);
if (!lobby) return;
const player = lobby.p1.id === playerId ? lobby.p1 : lobby.p2;
player.snake = snake;
player.food = food;
player.score = score;
player.gameOver = gameOver === true ? true : false;
player.win = win;
lobby.lastmove = Date.now();
// Broadcast the updated state to both players in this specific game
io.emit("snakegamestate", {
gameKey: lobby.gameKey,
lobby: {
p1: lobby.p1,
p2: lobby.p2,
},
});
// Check if game should end
if (lobby.p1.gameOver && lobby.p2.gameOver) {
// Both players finished - determine winner
let winnerId = null;
if (lobby.p1.win && !lobby.p2.win) {
winnerId = lobby.p1.id;
} else if (lobby.p2.win && !lobby.p1.win) {
winnerId = lobby.p2.id;
} else if (lobby.p1.score > lobby.p2.score) {
winnerId = lobby.p1.id;
} else if (lobby.p2.score > lobby.p1.score) {
winnerId = lobby.p2.id;
}
// If scores are equal, winnerId remains null (draw)
io.emit("snakegameOver", { gameKey: lobby.gameKey, game: lobby, winner: winnerId });
await onGameOver(client, "snake", playerId, winnerId, "", { p1: lobby.p1.score, p2: lobby.p2.score });
} else if (lobby.p1.win || lobby.p2.win) {
// One player won by filling the grid
const winnerId = lobby.p1.win ? lobby.p1.id : lobby.p2.id;
io.emit("snakegameOver", { gameKey: lobby.gameKey, game: lobby, winner: winnerId });
await onGameOver(client, "snake", playerId, winnerId, "", { p1: lobby.p1.score, p2: lobby.p2.score });
}
delete activeSnakeGames[lobby.gameKey];
}
export async function onGameOver(client, gameType, playerId, winnerId, reason = "", scores = null) {
const { activeGames, title } = getGameAssets(gameType);
const gameKey = Object.keys(activeGames).find((key) => key.includes(playerId));
const game = gameKey ? activeGames[gameKey] : undefined;
@@ -198,24 +285,29 @@ async function onGameOver(client, gameType, playerId, winnerId, reason = "") {
game.gameOver = true;
let resultText;
if (winnerId === null) {
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase());
await eloHandler(game.p1.id, game.p2.id, 0.5, 0.5, title.toUpperCase(), scores);
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(),
);
// Temp fix: Don't update ELO for Snake since it's not in a stable state yet.
if (gameType !== "snake") {
await eloHandler(
game.p1.id,
game.p2.id,
game.p1.id === winnerId ? 1 : 0,
game.p2.id === winnerId ? 1 : 0,
title.toUpperCase(),
scores,
);
}
const winnerName = game.p1.id === winnerId ? game.p1.name : game.p2.name;
resultText = `Victoire de ${winnerName}`;
}
await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
if (gameType === "tictactoe") io.emit("tictactoegameOver", { game, winner: winnerId });
if (gameType === "connect4") io.emit("connect4gameOver", { game, winner: winnerId });
if (gameType === "snake") io.emit("snakegameOver", { gameKey: game.gameKey, game, winner: winnerId });
await updateDiscordMessage(client, game, title, `${resultText} ${reason}`);
if (gameKey) {
setTimeout(() => delete activeGames[gameKey], 1000);
@@ -228,7 +320,7 @@ 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 [p1, p2] = await Promise.all([resolveUser(client, p1Id), resolveUser(client, p2Id)]);
let lobby;
if (gameType === "tictactoe") {
@@ -251,8 +343,7 @@ async function createGame(client, gameType) {
gameOver: false,
lastmove: Date.now(),
};
} else {
// connect4
} else if (gameType === "connect4") {
lobby = {
p1: {
id: p1Id,
@@ -272,15 +363,59 @@ async function createGame(client, gameType) {
lastmove: Date.now(),
winningPieces: [],
};
} else if (gameType === "snake") {
const gameKey = `${p1Id}-${p2Id}-${Date.now()}`;
lobby = {
gameKey: gameKey,
p1: {
id: p1Id,
name: p1.globalName,
avatar: p1.displayAvatarURL({ dynamic: true, size: 256 }),
snake: [],
food: null,
score: 0,
gameOver: false,
win: false,
},
p2: {
id: p2Id,
name: p2.globalName,
avatar: p2.displayAvatarURL({ dynamic: true, size: 256 }),
snake: [],
food: null,
score: 0,
gameOver: false,
win: false,
},
gameOver: false,
lastmove: Date.now(),
};
activeGames[gameKey] = lobby;
}
const msgId = await updateDiscordMessage(client, lobby, title);
lobby.msgId = msgId;
const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby;
if (gameType !== "snake") {
const gameKey = `${p1Id}-${p2Id}`;
activeGames[gameKey] = lobby;
}
io.emit(`${gameType}playing`, { allPlayers: Object.values(activeGames) });
// For Snake, also emit a specific match notification to the two players
if (gameType === "snake") {
io.emit("snakematch", {
gameKey: lobby.gameKey,
p1Id: p1Id,
p2Id: p2Id,
lobby: {
p1: lobby.p1,
p2: lobby.p2,
},
});
}
await emitQueueUpdate(client, gameType);
}
@@ -292,8 +427,9 @@ async function refreshQueuesForUser(userId, client) {
if (index > -1) {
tictactoeQueue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const user = await client.users.fetch(userId);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const user = await resolveUser(client, userId);
const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]);
const updatedEmbed = new EmbedBuilder()
.setTitle("Tic Tac Toe")
@@ -311,8 +447,9 @@ async function refreshQueuesForUser(userId, client) {
if (index > -1) {
connect4Queue.splice(index, 1);
try {
const generalChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const user = await client.users.fetch(userId);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const user = await resolveUser(client, userId);
const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]);
const updatedEmbed = new EmbedBuilder()
.setTitle("Puissance 4")
@@ -326,15 +463,36 @@ async function refreshQueuesForUser(userId, client) {
}
}
index = snakeQueue.indexOf(userId);
if (index > -1) {
snakeQueue.splice(index, 1);
try {
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const generalChannel = guild.channels.cache.get(process.env.BOT_CHANNEL_ID);
const user = await resolveUser(client, userId);
const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]);
const updatedEmbed = new EmbedBuilder()
.setTitle("Snake 1v1")
.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, "snake");
}
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);
const user = await resolveUser(client, id).catch(() => null);
return user?.globalName || user?.username;
}),
);
@@ -359,13 +517,20 @@ function getGameAssets(gameType) {
title: "Puissance 4",
url: "/connect-4",
};
if (gameType === "snake")
return {
queue: snakeQueue,
activeGames: activeSnakeGames,
title: "Snake 1v1",
url: "/snake",
};
return { queue: [], activeGames: {} };
}
async function postQueueToDiscord(client, playerId, title, url) {
try {
const generalChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const user = await client.users.fetch(playerId);
const generalChannel = client.channels.cache.get(process.env.BOT_CHANNEL_ID);
const user = await resolveUser(client, playerId);
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`)
@@ -388,7 +553,7 @@ async function postQueueToDiscord(client, playerId, title, url) {
}
async function updateDiscordMessage(client, game, title, resultText = "") {
const channel = await client.channels.fetch(process.env.BOT_CHANNEL_ID).catch(() => null);
const channel = client.channels.cache.get(process.env.BOT_CHANNEL_ID);
if (!channel) return null;
let description;
@@ -399,8 +564,10 @@ async function updateDiscordMessage(client, game, title, resultText = "") {
if (i % 3 === 0) gridText += "\n";
}
description = `### **❌ ${game.p1.name}** vs **${game.p2.name} ⭕**\n${gridText}`;
} else {
} else if (title === "Puissance 4") {
description = `**🔴 ${game.p1.name}** vs **${game.p2.name} 🟡**\n\n${formatConnect4BoardForDiscord(game.board)}`;
} else if (title === "Snake 1v1") {
description = `**🐍 ${game.p1.name}** (${game.p1.score}) vs (${game.p2.score}) **${game.p2.name}** `;
}
if (resultText) description += `\n### ${resultText}`;
@@ -436,6 +603,7 @@ function cleanupStaleGames() {
};
cleanup(activeTicTacToeGames, "TicTacToe");
cleanup(activeConnect4Games, "Connect4");
cleanup(activeSnakeGames, "Snake");
}
/* EMITS */
@@ -456,6 +624,7 @@ export async function emitPokerToast(data) {
}
export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, room });
export const emitPlayerUpdate = (data) => io.emit("blackjack:chat", data);
export const emitToast = (payload) => io.emit("blackjack:toast", payload);
export const emitSolitaireUpdate = (userId, moves) => io.emit("solitaire:update", { userId, moves });

View File

@@ -0,0 +1,49 @@
import prisma from "../prisma/client.js";
export async function getCsSkin(id) {
return prisma.csSkin.findUnique({ where: { id } });
}
export async function getUserCsInventory(userId) {
return prisma.csSkin.findMany({
where: { userId },
orderBy: { price: "desc" },
});
}
export async function getUserCsSkinsByRarity(userId, rarity) {
return prisma.csSkin.findMany({
where: { userId, rarity },
orderBy: { price: "desc" },
});
}
export async function getAllOwnedCsSkins() {
return prisma.csSkin.findMany({
where: { userId: { not: null } },
});
}
export async function insertCsSkin(data) {
return prisma.csSkin.create({ data });
}
export async function updateCsSkin(data) {
const { id, ...rest } = data;
return prisma.csSkin.update({ where: { id }, data: rest });
}
export async function findReferenceSkin(marketHashName, isStattrak, isSouvenir) {
return prisma.csSkin.findFirst({
where: { marketHashName, isStattrak, isSouvenir, price: { not: null }, float: { not: null } },
orderBy: { price: "desc" },
});
}
export async function deleteCsSkin(id) {
return prisma.csSkin.delete({ where: { id } });
}
export async function deleteManyCsSkins(ids) {
return prisma.csSkin.deleteMany({ where: { id: { in: ids } } });
}

View File

@@ -0,0 +1,47 @@
import prisma from "../prisma/client.js";
export async function getUserElo(id) {
return prisma.elo.findUnique({ where: { id } });
}
export async function insertElo(id, elo) {
return prisma.elo.create({ data: { id, elo } });
}
export async function updateElo(id, elo) {
return prisma.elo.update({ where: { id }, data: { elo } });
}
export async function getUsersByElo() {
const users = await prisma.user.findMany({
include: { elo: true },
orderBy: { elo: { elo: "desc" } },
});
return users.filter((u) => u.elo).map((u) => ({ ...u, elo: u.elo?.elo ?? null }));
}
function toGame(game) {
return { ...game, timestamp: game.timestamp != null ? game.timestamp.getTime() : null };
}
export async function insertGame(data) {
return prisma.game.create({
data: {
...data,
timestamp: data.timestamp != null ? new Date(data.timestamp) : null,
},
});
}
export async function getGames() {
const games = await prisma.game.findMany();
return games.map(toGame);
}
export async function getUserGames(userId) {
const games = await prisma.game.findMany({
where: { OR: [{ p1: userId }, { p2: userId }] },
orderBy: { timestamp: "asc" },
});
return games.map(toGame);
}

View File

@@ -0,0 +1,33 @@
import prisma from "../prisma/client.js";
export async function insertLog(data) {
return prisma.log.create({ data });
}
export async function getLogs() {
return prisma.log.findMany();
}
export async function getUserLogs(userId) {
return prisma.log.findMany({ where: { userId } });
}
export async function pruneOldLogs() {
const limit = parseInt(process.env.LOGS_BY_USER);
const usersWithExcess = await prisma.$queryRawUnsafe(
`SELECT user_id as userId FROM logs GROUP BY user_id HAVING COUNT(*) > ?`,
limit,
);
for (const { userId } of usersWithExcess) {
await prisma.$executeRawUnsafe(
`DELETE FROM logs WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn
FROM logs WHERE user_id = ?
) WHERE rn > ?
)`,
userId,
limit,
);
}
}

View File

@@ -0,0 +1,132 @@
import prisma from "../prisma/client.js";
function toOffer(offer) {
return { ...offer, openingAt: Number(offer.openingAt), closingAt: Number(offer.closingAt) };
}
export async function getMarketOffers() {
const offers = await prisma.marketOffer.findMany({ orderBy: { postedAt: "desc" } });
return offers.map(toOffer);
}
export async function getMarketOfferById(id) {
const offer = await prisma.marketOffer.findUnique({
where: { id },
include: {
skin: { select: { displayName: true, displayIcon: true } },
csSkin: { select: { displayName: true, imageUrl: true, rarity: true, wearState: true, float: true, isStattrak: true, isSouvenir: true } },
seller: { select: { username: true, globalName: true } },
buyer: { select: { username: true, globalName: true } },
},
});
if (!offer) return null;
const skinData = offer.csSkin || offer.skin;
return toOffer({
...offer,
skinName: skinData?.displayName,
skinIcon: offer.skin?.displayIcon || offer.csSkin?.imageUrl,
sellerName: offer.seller?.username,
sellerGlobalName: offer.seller?.globalName,
buyerName: offer.buyer?.username ?? null,
buyerGlobalName: offer.buyer?.globalName ?? null,
});
}
export async function getMarketOffersBySkin(skinUuid) {
const offers = await prisma.marketOffer.findMany({
where: { skinUuid },
include: {
skin: { select: { displayName: true, displayIcon: true } },
seller: { select: { username: true, globalName: true } },
buyer: { select: { username: true, globalName: true } },
},
});
return offers.map((offer) =>
toOffer({
...offer,
skinName: offer.skin?.displayName,
skinIcon: offer.skin?.displayIcon,
sellerName: offer.seller?.username,
sellerGlobalName: offer.seller?.globalName,
buyerName: offer.buyer?.username ?? null,
buyerGlobalName: offer.buyer?.globalName ?? null,
}),
);
}
export async function getMarketOffersByCsSkin(csSkinId) {
const offers = await prisma.marketOffer.findMany({
where: { csSkinId },
include: {
csSkin: { select: { displayName: true, imageUrl: true } },
seller: { select: { username: true, globalName: true } },
buyer: { select: { username: true, globalName: true } },
},
});
return offers.map((offer) =>
toOffer({
...offer,
skinName: offer.csSkin?.displayName,
skinIcon: offer.csSkin?.imageUrl,
sellerName: offer.seller?.username,
sellerGlobalName: offer.seller?.globalName,
buyerName: offer.buyer?.username ?? null,
buyerGlobalName: offer.buyer?.globalName ?? null,
}),
);
}
export async function insertMarketOffer(data) {
return prisma.marketOffer.create({
data: {
...data,
openingAt: new Date(data.openingAt),
closingAt: new Date(data.closingAt),
},
});
}
export async function updateMarketOffer(data) {
const { id, ...rest } = data;
return prisma.marketOffer.update({ where: { id }, data: rest });
}
export async function deleteMarketOffer(id) {
return prisma.marketOffer.delete({ where: { id } });
}
// --- Bids ---
export async function getBids() {
const bids = await prisma.bid.findMany({
include: { bidder: { select: { username: true, globalName: true } } },
orderBy: [{ offerAmount: "desc" }, { offeredAt: "asc" }],
});
return bids.map((bid) => ({
...bid,
bidderName: bid.bidder?.username,
bidderGlobalName: bid.bidder?.globalName,
}));
}
export async function getBidById(id) {
return prisma.bid.findUnique({ where: { id } });
}
export async function getOfferBids(marketOfferId) {
const bids = await prisma.bid.findMany({
where: { marketOfferId },
orderBy: [{ offerAmount: "desc" }, { offeredAt: "asc" }],
});
return bids.map((bid) => ({
...bid,
}));
}
export async function insertBid(data) {
return prisma.bid.create({ data });
}
export async function deleteBid(id) {
return prisma.bid.delete({ where: { id } });
}

View File

@@ -0,0 +1,59 @@
import prisma from "../prisma/client.js";
export async function getSkin(uuid) {
return prisma.skin.findUnique({ where: { uuid } });
}
export async function getAllSkins() {
return prisma.skin.findMany({ orderBy: { maxPrice: "desc" } });
}
export async function getAllAvailableSkins() {
return prisma.skin.findMany({ where: { userId: null } });
}
export async function getUserInventory(userId) {
return prisma.skin.findMany({
where: { userId },
orderBy: { currentPrice: "desc" },
});
}
export async function getTopSkins() {
return prisma.skin.findMany({ orderBy: { maxPrice: "desc" }, take: 10 });
}
export async function insertSkin(data) {
return prisma.skin.create({ data });
}
export async function updateSkin(data) {
const { uuid, ...rest } = data;
return prisma.skin.update({ where: { uuid }, data: rest });
}
export async function hardUpdateSkin(data) {
const { uuid, ...rest } = data;
return prisma.skin.update({ where: { uuid }, data: rest });
}
export async function insertManySkins(skins) {
return prisma.$transaction(
skins.map((skin) =>
prisma.skin.upsert({
where: { uuid: skin.uuid },
update: {},
create: skin,
}),
),
);
}
export async function updateManySkins(skins) {
return prisma.$transaction(
skins.map((skin) => {
const { uuid, ...data } = skin;
return prisma.skin.update({ where: { uuid }, data });
}),
);
}

View File

@@ -0,0 +1,39 @@
import prisma from "../prisma/client.js";
export async function getSOTD() {
return prisma.sotd.findUnique({ where: { id: 0 } });
}
export async function insertSOTD(data) {
return prisma.sotd.create({ data: { id: 0, ...data } });
}
export async function deleteSOTD() {
return prisma.sotd.delete({ where: { id: 0 } }).catch(() => {});
}
export async function getAllSOTDStats() {
const stats = await prisma.sotdStat.findMany({
include: { user: { select: { username: true, globalName: true, avatarUrl: true } } },
orderBy: [{ score: "desc" }, { moves: "asc" }, { time: "asc" }],
});
return stats.map((s) => ({
...s,
}));
}
export async function getUserSOTDStats(userId) {
return prisma.sotdStat.findFirst({ where: { userId } });
}
export async function insertSOTDStats(data) {
return prisma.sotdStat.create({ data });
}
export async function clearSOTDStats() {
return prisma.sotdStat.deleteMany();
}
export async function deleteUserSOTDStats(userId) {
return prisma.sotdStat.deleteMany({ where: { userId } });
}

View File

@@ -0,0 +1,20 @@
import prisma from "../prisma/client.js";
export async function insertTransaction(data) {
return prisma.transaction.create({ data });
}
export async function getTransactionBySessionId(sessionId) {
return prisma.transaction.findUnique({ where: { sessionId } });
}
export async function getAllTransactions() {
return prisma.transaction.findMany({ orderBy: { createdAt: "desc" } });
}
export async function getUserTransactions(userId) {
return prisma.transaction.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
}

View File

@@ -0,0 +1,75 @@
import prisma from "../prisma/client.js";
import { socketEmit } from "../server/socket.js";
export async function getUser(id) {
const user = await prisma.user.findUnique({
where: { id },
include: { elo: true },
});
if (!user) return null;
return { ...user, elo: user.elo?.elo ?? null };
}
export async function getAllUsers() {
const users = await prisma.user.findMany({
include: { elo: true },
orderBy: { coins: "desc" },
});
return users.map((u) => ({ ...u, elo: u.elo?.elo ?? null }));
}
export async function getAllAkhys() {
const users = await prisma.user.findMany({
where: { isAkhy: 1 },
include: { elo: true },
orderBy: { coins: "desc" },
});
return users.map((u) => ({ ...u, elo: u.elo?.elo ?? null }));
}
export async function insertUser(data) {
return prisma.user.create({ data });
}
export async function updateUser(data) {
const { id, ...rest } = data;
return prisma.user.update({ where: { id }, data: rest });
}
export async function updateUserCoins(id, coins) {
await socketEmit("data-updated", { table: "users", action: "update", userId: id, newCoins: coins });
return prisma.user.update({ where: { id }, data: { coins } });
}
export async function updateUserAvatar(id, avatarUrl) {
return prisma.user.update({ where: { id }, data: { avatarUrl } });
}
export async function queryDailyReward(id) {
return prisma.user.update({ where: { id }, data: { dailyQueried: 1 } });
}
export async function resetDailyReward() {
return prisma.user.updateMany({ data: { dailyQueried: 0 } });
}
export async function insertManyUsers(users) {
return prisma.$transaction(
users.map((user) =>
prisma.user.upsert({
where: { id: user.id },
update: {},
create: user,
}),
),
);
}
export async function updateManyUsers(users) {
return prisma.$transaction(
users.map((user) => {
const { id, elo, ...data } = user;
return prisma.user.update({ where: { id }, data });
}),
);
}

View File

@@ -1,21 +1,22 @@
import { getAllAvailableSkins, getSkin } from "../database/index.js";
import * as skinService from "../services/skin.service.js";
import { skins } from "../game/state.js";
import { isChampionsSkin } from "./index.js";
export async function drawCaseContent(caseType = "standard", poolSize = 100) {
if (caseType === "esport") {
// Esport case: return all esport skins
try {
const dbSkins = getAllAvailableSkins.all();
const esportSkins = skins
.filter((s) => dbSkins.find((dbSkin) => dbSkin.displayName.includes("Classic (VCT") && dbSkin.uuid === s.uuid))
.map((s) => {
const dbSkin = getSkin.get(s.uuid);
return {
...s, // Shallow copy to avoid mutating the imported 'skins' object
tierColor: dbSkin?.tierColor,
};
try {
const dbSkins = await skinService.getAllAvailableSkins();
const esportSkins = [];
for (const s of skins.filter((s) =>
dbSkins.find((dbSkin) => dbSkin.displayName.includes("Classic (VCT") && dbSkin.uuid === s.uuid),
)) {
const dbSkin = await skinService.getSkin(s.uuid);
esportSkins.push({
...s,
tierColor: dbSkin?.tierColor,
});
}
return esportSkins;
} catch (e) {
console.log(e);
@@ -55,32 +56,36 @@ export async function drawCaseContent(caseType = "standard", poolSize = 100) {
}
try {
const dbSkins = getAllAvailableSkins.all();
const weightedPool = skins
const dbSkins = await skinService.getAllAvailableSkins();
const filtered = skins
.filter((s) => dbSkins.find((dbSkin) => dbSkin.uuid === s.uuid))
.filter((s) => {
if (caseType === "ultra") {
return !(s.displayName.toLowerCase().includes("vct") && s.displayName.toLowerCase().includes("classic"))
return !(s.displayName.toLowerCase().includes("vct") && s.displayName.toLowerCase().includes("classic"));
} else {
return !s.displayName.toLowerCase().includes("vct");
}
})
.filter((s) => {
if (caseType === "ultra") {
return true
return true;
} else {
return isChampionsSkin(s.displayName) === false;
}
})
.map((s) => {
const dbSkin = getSkin.get(s.uuid);
return {
...s, // Shallow copy to avoid mutating the imported 'skins' object
});
const weightedPool = [];
for (const s of filtered) {
const dbSkin = await skinService.getSkin(s.uuid);
const weight = tierWeights[s.contentTierUuid] ?? 0;
if (weight > 0) {
// <--- CRITICAL: Remove 0 weight skins
weightedPool.push({
...s,
tierColor: dbSkin?.tierColor,
weight: tierWeights[s.contentTierUuid] ?? 0,
};
})
.filter((s) => s.weight > 0); // <--- CRITICAL: Remove 0 weight skins
weight,
});
}
}
function weightedSample(arr, count) {
let totalWeight = arr.reduce((sum, x) => sum + x.weight, 0);
@@ -88,7 +93,7 @@ export async function drawCaseContent(caseType = "standard", poolSize = 100) {
const result = [];
// 2. Adjust count if the pool is smaller than requested
const actualCount = Math.min(count, list.length) ;
const actualCount = Math.min(count, list.length);
for (let i = 0; i < actualCount; i++) {
let r = Math.random() * totalWeight;
@@ -123,7 +128,7 @@ export async function drawCaseContent(caseType = "standard", poolSize = 100) {
}
}
export function drawCaseSkin(caseContent) {
export async function drawCaseSkin(caseContent) {
let randomSelectedSkinIndex;
let randomSelectedSkinUuid;
try {
@@ -134,7 +139,7 @@ export function drawCaseSkin(caseContent) {
throw new Error("Failed to draw a skin from the case content.");
}
const dbSkin = getSkin.get(randomSelectedSkinUuid);
const dbSkin = await skinService.getSkin(randomSelectedSkinUuid);
const randomSkinData = skins.find((skin) => skin.uuid === dbSkin.uuid);
if (!randomSkinData) {
throw new Error(`Could not find skin data for UUID: ${dbSkin.uuid}`);
@@ -170,10 +175,19 @@ export function drawCaseSkin(caseContent) {
export function getSkinUpgradeProbs(skin, skinData) {
const successProb =
(1 - (((skin.currentChroma + skin.currentLvl + skinData.chromas.length + skinData.levels.length) / 18) * (parseInt(skin.tierRank) / 4)))/1.5;
const destructionProb = ((skin.currentChroma + skinData.levels.length) / (skinData.chromas.length + skinData.levels.length)) * (parseInt(skin.tierRank) / 5) * 0.075;
(1 -
((skin.currentChroma + skin.currentLvl + skinData.chromas.length + skinData.levels.length) / 18) *
(parseInt(skin.tierRank) / 4)) /
1.5;
const destructionProb =
((skin.currentChroma + skinData.levels.length) / (skinData.chromas.length + skinData.levels.length)) *
(parseInt(skin.tierRank) / 5) *
0.075;
const nextLvl = skin.currentLvl < skinData.levels.length ? skin.currentLvl + 1 : skin.currentLvl;
const nextChroma = skin.currentLvl === skinData.levels.length && skin.currentChroma < skinData.chromas.length ? skin.currentChroma + 1 : skin.currentChroma;
const nextChroma =
skin.currentLvl === skinData.levels.length && skin.currentChroma < skinData.chromas.length
? skin.currentChroma + 1
: skin.currentChroma;
const calculateNextPrice = () => {
let result = parseFloat(skin.basePrice);
result *= 1 + nextLvl / Math.max(skinData.levels.length, 2);
@@ -185,10 +199,18 @@ export function getSkinUpgradeProbs(skin, skinData) {
return { successProb, destructionProb, upgradePrice };
}
export function getDummySkinUpgradeProbs(skinLevel, skinChroma, skinTierRank, skinMaxLevels, skinMaxChromas, skinMaxPrice) {
export function getDummySkinUpgradeProbs(
skinLevel,
skinChroma,
skinTierRank,
skinMaxLevels,
skinMaxChromas,
skinMaxPrice,
) {
const successProb =
1 - (((skinChroma + skinLevel + (skinMaxChromas + skinMaxLevels)) / 18) * (parseInt(skinTierRank) / 4));
const destructionProb = ((skinChroma + skinMaxLevels) / (skinMaxChromas + skinMaxLevels)) * (parseInt(skinTierRank) / 5) * 0.1;
const upgradePrice = Math.max(Math.floor((parseFloat(skinMaxPrice) * (1 - successProb))), 1);
1 - ((skinChroma + skinLevel + (skinMaxChromas + skinMaxLevels)) / 18) * (parseInt(skinTierRank) / 4);
const destructionProb =
((skinChroma + skinMaxLevels) / (skinMaxChromas + skinMaxLevels)) * (parseInt(skinTierRank) / 5) * 0.1;
const upgradePrice = Math.max(Math.floor(parseFloat(skinMaxPrice) * (1 - successProb)), 1);
return { successProb, destructionProb, upgradePrice };
}

80
src/utils/cs.state.js Normal file
View File

@@ -0,0 +1,80 @@
export let csSkinsData = {};
export let csSkinsPrices = {};
// Structured index: baseSkinName -> { base, stattrak, souvenir } -> wearState -> priceData
export let csSkinsPriceIndex = {};
// weaponType -> rarity -> [baseSkinName, ...] (only skins that have Skinport prices)
export let weaponRarityPriceMap = {};
const wearRegex = /\s*\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)\s*$/;
function parseSkinportKey(key) {
const wearMatch = key.match(wearRegex);
if (!wearMatch) return null;
const wearState = wearMatch[1];
let baseName = key.slice(0, wearMatch.index);
let variant = "base";
if (baseName.startsWith("★ StatTrak™ ")) {
variant = "stattrak";
baseName = "★ " + baseName.slice("★ StatTrak™ ".length);
} else if (baseName.startsWith("StatTrak™ ")) {
variant = "stattrak";
baseName = baseName.slice("StatTrak™ ".length);
} else if (baseName.startsWith("Souvenir ")) {
variant = "souvenir";
baseName = baseName.slice("Souvenir ".length);
}
return { baseName, variant, wearState };
}
export function buildPriceIndex() {
csSkinsPriceIndex = {};
for (const [key, priceData] of Object.entries(csSkinsPrices)) {
const parsed = parseSkinportKey(key);
if (!parsed) continue;
const { baseName, variant, wearState } = parsed;
if (!csSkinsPriceIndex[baseName]) {
csSkinsPriceIndex[baseName] = {};
}
if (!csSkinsPriceIndex[baseName][variant]) {
csSkinsPriceIndex[baseName][variant] = {};
}
csSkinsPriceIndex[baseName][variant][wearState] = priceData;
}
const indexedCount = Object.keys(csSkinsPriceIndex).length;
const totalSkins = Object.keys(csSkinsData).length;
const coverage = totalSkins > 0 ? ((indexedCount / totalSkins) * 100).toFixed(1) : 0;
console.log(`[Skinport] Price index built: ${indexedCount} skins indexed, ${totalSkins} total skins (${coverage}% coverage)`);
}
export function buildWeaponRarityPriceMap() {
weaponRarityPriceMap = {};
for (const [skinName, skinData] of Object.entries(csSkinsData)) {
// Only include skins that have at least one Skinport price entry
if (!csSkinsPriceIndex[skinName]) continue;
const weapon = skinData.weapon?.name;
const rarity = skinData.rarity?.name;
if (!weapon || !rarity) continue;
if (!weaponRarityPriceMap[weapon]) {
weaponRarityPriceMap[weapon] = {};
}
if (!weaponRarityPriceMap[weapon][rarity]) {
weaponRarityPriceMap[weapon][rarity] = [];
}
weaponRarityPriceMap[weapon][rarity].push(skinName);
}
console.log(`[Skinport] Weapon/rarity price map built: ${Object.keys(weaponRarityPriceMap).length} weapon types`);
}

231
src/utils/cs.utils.js Normal file
View File

@@ -0,0 +1,231 @@
import { csSkinsData, csSkinsPriceIndex, weaponRarityPriceMap } from "./cs.state.js";
const StateFactoryNew = "Factory New";
const StateMinimalWear = "Minimal Wear";
const StateFieldTested = "Field-Tested";
const StateWellWorn = "Well-Worn";
const StateBattleScarred = "Battle-Scarred";
const EUR_TO_FLOPOS = parseInt(process.env.EUR_TO_FLOPOS) || 6;
const FLOAT_MODIFIER_MAX = 0.05;
const STATTRAK_FALLBACK_MULTIPLIER = 3.5;
const SOUVENIR_FALLBACK_MULTIPLIER = 6;
const WEAR_STATE_ORDER = [StateFactoryNew, StateMinimalWear, StateFieldTested, StateWellWorn, StateBattleScarred];
const WEAR_STATE_RANGES = {
[StateFactoryNew]: { min: 0.00, max: 0.07 },
[StateMinimalWear]: { min: 0.07, max: 0.15 },
[StateFieldTested]: { min: 0.15, max: 0.38 },
[StateWellWorn]: { min: 0.38, max: 0.45 },
[StateBattleScarred]: { min: 0.45, max: 1.00 },
};
export const RarityToColor = {
Gold: 0xffd700, // Standard Gold
Extraordinary: 0xffae00, // Orange
Covert: 0xeb4b4b, // Red
Classified: 0xd32ce6, // Pink/Magenta
Restricted: 0x8847ff, // Purple
"Mil-Spec Grade": 0x4b69ff, // Dark Blue
"Industrial Grade": 0x5e98d9, // Light Blue
"Consumer Grade": 0xb0c3d9, // Light Grey/White
};
// Last-resort fallback price ranges in EUR (used only when Skinport has no data)
const basePriceRanges = {
"Consumer Grade": { min: 0.03, max: 0.10 },
"Industrial Grade": { min: 0.05, max: 0.30 },
"Mil-Spec Grade": { min: 0.10, max: 1.50 },
"Restricted": { min: 1.00, max: 10.00 },
"Classified": { min: 5.00, max: 40.00 },
"Covert": { min: 25.00, max: 150.00 },
"Extraordinary": { min: 100.00, max: 800.00 },
};
export const TRADE_UP_MAP = {
"Consumer Grade": "Industrial Grade",
"Industrial Grade": "Mil-Spec Grade",
"Mil-Spec Grade": "Restricted",
"Restricted": "Classified",
"Classified": "Covert",
};
export function randomSkinRarity() {
const roll = Math.random();
const goldLimit = 0.003;
const extraLimit = goldLimit + 0.014;
const classifiedLimit = extraLimit + 0.04;
const restrictedLimit = classifiedLimit + 0.2;
const milSpecLimit = restrictedLimit + 0.5;
const industrialLimit = milSpecLimit + 0.2;
if (roll < goldLimit) return "Covert";
if (roll < extraLimit) return "Extraordinary";
if (roll < classifiedLimit) return "Classified";
if (roll < restrictedLimit) return "Restricted";
if (roll < milSpecLimit) return "Mil-Spec Grade";
if (roll < industrialLimit) return "Industrial Grade";
return "Consumer Grade";
}
function getSkinportPrice(priceData) {
if (!priceData) return null;
return priceData.suggested_price ?? priceData.median_price ?? priceData.mean_price ?? priceData.min_price ?? null;
}
function applyFloatModifier(basePrice, float, wearState) {
const range = WEAR_STATE_RANGES[wearState];
if (!range) return basePrice;
const span = range.max - range.min;
if (span <= 0) return basePrice;
// 0 = best float in range, 1 = worst
const positionInRange = (float - range.min) / span;
const modifier = 1 + FLOAT_MODIFIER_MAX * (1 - 2 * positionInRange);
return basePrice * modifier;
}
function getAdjacentWearStates(wearState) {
const idx = WEAR_STATE_ORDER.indexOf(wearState);
if (idx === -1) return [];
// Return wear states ordered by proximity
const adjacent = [];
for (let dist = 1; dist < WEAR_STATE_ORDER.length; dist++) {
if (idx - dist >= 0) adjacent.push(WEAR_STATE_ORDER[idx - dist]);
if (idx + dist < WEAR_STATE_ORDER.length) adjacent.push(WEAR_STATE_ORDER[idx + dist]);
}
return adjacent;
}
function lookupSkinportEurPrice(skinName, wearState, isStattrak, isSouvenir) {
const skinEntry = csSkinsPriceIndex[skinName];
if (!skinEntry) return null;
const variant = isSouvenir ? "souvenir" : isStattrak ? "stattrak" : "base";
// 1. Exact match: correct variant + wear state
let price = getSkinportPrice(skinEntry[variant]?.[wearState]);
if (price !== null) return price;
// 2. Drop variant: use base price × multiplier
if (variant !== "base") {
const basePrice = getSkinportPrice(skinEntry["base"]?.[wearState]);
if (basePrice !== null) {
const multiplier = isSouvenir ? SOUVENIR_FALLBACK_MULTIPLIER : STATTRAK_FALLBACK_MULTIPLIER;
return basePrice * multiplier;
}
}
// 3. Adjacent wear state (same variant, then base with multiplier)
for (const adjWear of getAdjacentWearStates(wearState)) {
const adjPrice = getSkinportPrice(skinEntry[variant]?.[adjWear]);
if (adjPrice !== null) return adjPrice;
if (variant !== "base") {
const adjBase = getSkinportPrice(skinEntry["base"]?.[adjWear]);
if (adjBase !== null) {
const multiplier = isSouvenir ? SOUVENIR_FALLBACK_MULTIPLIER : STATTRAK_FALLBACK_MULTIPLIER;
return adjBase * multiplier;
}
}
}
return null;
}
function findSimilarSkinPrice(skinName, rarity, wearState) {
const skinData = csSkinsData[skinName];
const weapon = skinData?.weapon?.name;
if (!weapon) return null;
const candidates = weaponRarityPriceMap[weapon]?.[rarity];
if (!candidates || candidates.length === 0) return null;
// Pick a random candidate that has a price for this wear state
const shuffled = [...candidates].sort(() => Math.random() - 0.5);
for (const candidate of shuffled) {
if (candidate === skinName) continue;
const entry = csSkinsPriceIndex[candidate];
if (!entry) continue;
// Try base variant first
const price = getSkinportPrice(entry["base"]?.[wearState]);
if (price !== null) return price;
// Try any wear state
for (const ws of WEAR_STATE_ORDER) {
const wsPrice = getSkinportPrice(entry["base"]?.[ws]);
if (wsPrice !== null) return wsPrice;
}
}
return null;
}
export function generatePrice(skinName, rarity, float, isStattrak, isSouvenir) {
const wearState = getWearState(float);
let eurPrice = lookupSkinportEurPrice(skinName, wearState, isStattrak, isSouvenir);
if (eurPrice === null) {
// 4. Similar skin: same weapon + same rarity
eurPrice = findSimilarSkinPrice(skinName, rarity, wearState);
}
if (eurPrice === null) {
// 5. Last resort: rarity-based random range (already in EUR-ish scale)
const ranges = basePriceRanges[rarity] || basePriceRanges["Industrial Grade"];
eurPrice = ranges.min + Math.random() * (ranges.max - ranges.min);
}
let finalPrice = Math.round(eurPrice * EUR_TO_FLOPOS);
finalPrice = applyFloatModifier(finalPrice, float, wearState);
finalPrice = Math.max(Math.round(finalPrice), 1);
return finalPrice.toFixed(0);
}
export function rollStattrak(canBeStattrak) {
if (!canBeStattrak) return false;
return Math.random() < 0.15;
}
export function rollSouvenir(canBeSouvenir) {
if (!canBeSouvenir) return false;
return Math.random() < 0.15;
}
export function getRandomFloatInRange(min, max) {
return min + Math.random() * (max - min);
}
export function getWearState(wear) {
const clamped = Math.max(0.0, Math.min(1.0, wear));
if (clamped < 0.07) return StateFactoryNew;
if (clamped < 0.15) return StateMinimalWear;
if (clamped < 0.38) return StateFieldTested;
if (clamped < 0.45) return StateWellWorn;
return StateBattleScarred;
}
export async function getRandomSkinWithRandomSpecs(u_float, forcedRarity) {
const skinNames = Object.keys(csSkinsData);
const selectedRarity = forcedRarity || randomSkinRarity();
const filteredSkinNames = skinNames.filter(name => csSkinsData[name].rarity.name === selectedRarity);
const randomIndex = Math.floor(Math.random() * filteredSkinNames.length);
const skinName = filteredSkinNames[randomIndex];
const skinData = csSkinsData[skinName];
const float = (u_float !== null && u_float !== undefined) ? u_float : getRandomFloatInRange(skinData.min_float, skinData.max_float);
const wearState = getWearState(float);
const skinIsStattrak = rollStattrak(skinData.stattrak);
const skinIsSouvenir = rollSouvenir(skinData.souvenir);
return {
name: skinName,
data: skinData,
isStattrak: skinIsStattrak,
isSouvenir: skinIsSouvenir,
wearState,
float,
price: generatePrice(skinName, skinData.rarity.name, float, skinIsStattrak, skinIsSouvenir),
};
}

View File

@@ -5,23 +5,10 @@ import cron from "node-cron";
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 * as userService from "../services/user.service.js";
import * as skinService from "../services/skin.service.js";
import * as marketService from "../services/market.service.js";
import * as csSkinService from "../services/csSkin.service.js";
import { activeInventories, activePredis, activeSearchs, pokerRooms, skins } from "../game/state.js";
import { emitMarketUpdate } from "../server/socket.js";
import { handleMarketOfferClosing, handleMarketOfferOpening } from "./marketNotifs.js";
@@ -49,8 +36,8 @@ export async function InstallGlobalCommands(appId, commands) {
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 initial_akhys = (await userService.getAllUsers()).length;
const guild = client.guilds.cache.get(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));
@@ -67,14 +54,14 @@ export async function getAkhys(client) {
}));
if (usersToInsert.length > 0) {
usersToInsert.forEach((user) => {
for (const user of usersToInsert) {
try {
insertUser.run(user);
await userService.insertUser(user);
} catch (err) {}
});
}
}
const new_akhys = getAllUsers.all().length;
const new_akhys = (await userService.getAllUsers()).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})`,
@@ -97,17 +84,17 @@ export async function getAkhys(client) {
displayName: skin.displayName,
contentTierUuid: skin.contentTierUuid,
displayIcon: skin.displayIcon,
user_id: null,
tierRank: tier.rank,
userId: null,
tierRank: tier.rank != null ? String(tier.rank) : null,
tierColor: tier.highlightColor?.slice(0, 6) || "F2F3F3",
tierText: formatTierText(tier.rank, skin.displayName),
basePrice: basePrice.toFixed(0),
maxPrice: calculateMaxPrice(basePrice, skin).toFixed(0),
maxPrice: parseInt(calculateMaxPrice(basePrice, skin).toFixed(0)),
};
});
if (skinsToInsert.length > 0) {
insertManySkins(skinsToInsert);
await skinService.insertManySkins(skinsToInsert);
}
console.log(`[Sync] Fetched and synced ${skinsToInsert.length} Valorant skins.`);
} catch (err) {
@@ -175,7 +162,7 @@ export function setupCronJobs(client, io) {
cron.schedule(process.env.CRON_EXPR, async () => {
console.log("[Cron] Running daily midnight tasks...");
try {
resetDailyReward.run();
await userService.resetDailyReward();
console.log("[Cron] Daily rewards have been reset for all users.");
//if (!getSOTD.get()) {
initTodaysSOTD();
@@ -184,16 +171,16 @@ export function setupCronJobs(client, io) {
console.error("[Cron] Error during daily reset:", e);
}
try {
const offers = getMarketOffers.all();
const offers = await marketService.getMarketOffers();
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);
if (now >= offer.closingAt + TWO_DAYS) {
const offerBids = await marketService.getOfferBids(offer.id);
for (const bid of offerBids) {
deleteBid.run(bid.id);
await marketService.deleteBid(bid.id);
}
deleteMarketOffer.run(offer.id);
await marketService.deleteMarketOffer(offer.id);
console.log(`[Cron] Deleted expired market offer ID: ${offer.id}`);
}
}
@@ -207,14 +194,11 @@ export function setupCronJobs(client, io) {
console.log("[Cron] Running daily 7 AM data sync...");
await getAkhys(client);
try {
const akhys = getAllAkhys.all();
const akhys = await userService.getAllAkhys();
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 }),
});
await userService.updateUserAvatar(akhy.id, user.displayAvatarURL({ dynamic: true, size: 256 }));
} catch (err) {
console.error(`[Cron] Error updating avatar for user ID: ${akhy.id}`, err);
}
@@ -276,51 +260,57 @@ export async function postAPOBuy(userId, amount) {
// --- Miscellaneous Helpers ---
function handleMarketOffersUpdate() {
async function handleMarketOffersUpdate() {
const now = Date.now();
const offers = getMarketOffers.all();
const offers = await marketService.getMarketOffers();
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" });
if (now >= offer.openingAt && offer.status === "pending") {
await marketService.updateMarketOffer({ id: offer.id, finalPrice: null, buyerId: 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 (now >= offer.closingAt && offer.status !== "closed") {
const bids = await marketService.getOfferBids(offer.id);
if (bids.length === 0) {
// No bids placed, mark as closed without a sale
updateMarketOffer.run({
await marketService.updateMarketOffer({
id: offer.id,
buyer_id: null,
final_price: null,
buyerId: null,
finalPrice: null,
status: "closed",
});
await emitMarketUpdate();
} else {
const lastBid = bids[0];
const seller = getUser.get(offer.seller_id);
const buyer = getUser.get(lastBid.bidder_id);
const seller = await userService.getUser(offer.sellerId);
const buyer = await userService.getUser(lastBid.bidderId);
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({
// Change skin ownership (supports both Valorant and CS2 skins)
if (offer.csSkinId) {
const csSkin = await csSkinService.getCsSkin(offer.csSkinId);
if (!csSkin) throw new Error(`CS skin not found for offer ID: ${offer.id}`);
await csSkinService.updateCsSkin({ id: csSkin.id, userId: buyer.id });
} else if (offer.skinUuid) {
const skin = await skinService.getSkin(offer.skinUuid);
if (!skin) throw new Error(`Skin not found for offer ID: ${offer.id}`);
await skinService.updateSkin({
userId: buyer.id,
currentLvl: skin.currentLvl,
currentChroma: skin.currentChroma,
currentPrice: skin.currentPrice,
uuid: skin.uuid,
});
}
await marketService.updateMarketOffer({
id: offer.id,
buyer_id: buyer.id,
final_price: lastBid.offer_amount,
buyerId: buyer.id,
finalPrice: lastBid.offerAmount,
status: "closed",
});
const newUserCoins = seller.coins + lastBid.offer_amount;
updateUserCoins.run({ id: seller.id, coins: newUserCoins });
const newUserCoins = seller.coins + lastBid.offerAmount;
await userService.updateUserCoins(seller.id, newUserCoins);
await emitMarketUpdate();
} catch (e) {
console.error(`[Market Cron] Error processing offer ID: ${offer.id}`, e);
@@ -449,10 +439,26 @@ function formatTierText(rank, displayName) {
export function isMeleeSkin(skinName) {
const name = skinName.toLowerCase();
return !(name.includes("classic") || name.includes("shorty") || name.includes("frenzy") || name.includes("ghost") || name.includes("sheriff") || name.includes("stinger") || name.includes("spectre") ||
name.includes("bucky") || name.includes("judge") || name.includes("bulldog") || name.includes("guardian") ||
name.includes("vandal") || name.includes("phantom") || name.includes("marshal") || name.includes("outlaw") ||
name.includes("operator") || name.includes("ares") || name.includes("odin"));
return !(
name.includes("classic") ||
name.includes("shorty") ||
name.includes("frenzy") ||
name.includes("ghost") ||
name.includes("sheriff") ||
name.includes("stinger") ||
name.includes("spectre") ||
name.includes("bucky") ||
name.includes("judge") ||
name.includes("bulldog") ||
name.includes("guardian") ||
name.includes("vandal") ||
name.includes("phantom") ||
name.includes("marshal") ||
name.includes("outlaw") ||
name.includes("operator") ||
name.includes("ares") ||
name.includes("odin")
);
}
export function isVCTSkin(skinName) {
@@ -461,31 +467,78 @@ export function isVCTSkin(skinName) {
}
const VCT_TEAMS = {
"vct-am": [
/x 100t\)$/g, /x c9\)$/g, /x eg\)$/g, /x fur\)$/g, /x krü\)$/g, /x lev\)$/g, /x loud\)$/g,
/x mibr\)$/g, /x sen\)$/g, /x nrg\)$/g, /x g2\)$/g, /x nv\)$/g, /x 2g\)$/g
],
"vct-emea": [
/x bbl\)$/g, /x fnc\)$/g, /x fut\)$/g, /x m8\)$/g, /x gx\)$/g, /x kc\)$/g, /x navi\)$/g,
/x th\)$/g, /x tl\)$/g, /x vit\)$/g, /x ulf\)$/g, /x pcf\)$/g, /x koi\)$/g, /x apk\)$/g
],
"vct-pcf": [
/x dfm\)$/g, /x drx\)$/g, /x fs\)$/g, /x gen\)$/g, /x ge\)$/g, /x prx\)$/g, /x rrq\)$/g,
/x t1\)$/g, /x ts\)$/g, /x zeta\)$/g, /x vl\)$/g, /x ns\)$/g, /x tln\)$/g, /x boom\)$/g, /x bld\)$/g
],
"vct-cn": [
/x ag\)$/g, /x blg\)$/g, /x edg\)$/g, /x fpx\)$/g, /x jdg\)$/g, /x nova\)$/g, /x tec\)$/g,
/x te\)$/g, /x tyl\)$/g, /x wol\)$/g, /x xlg\)$/g, /x xlg\)$/g, /x drg\)$/g, /x drg\)$/g
]
"vct-am": [
/x 100t\)$/g,
/x c9\)$/g,
/x eg\)$/g,
/x fur\)$/g,
/x k\)$/g,
/x lev\)$/g,
/x loud\)$/g,
/x mibr\)$/g,
/x sen\)$/g,
/x nrg\)$/g,
/x g2\)$/g,
/x nv\)$/g,
/x 2g\)$/g,
],
"vct-emea": [
/x bbl\)$/g,
/x fnc\)$/g,
/x fut\)$/g,
/x m8\)$/g,
/x gx\)$/g,
/x kc\)$/g,
/x navi\)$/g,
/x th\)$/g,
/x tl\)$/g,
/x vit\)$/g,
/x ulf\)$/g,
/x pcf\)$/g,
/x koi\)$/g,
/x apk\)$/g,
],
"vct-pcf": [
/x dfm\)$/g,
/x drx\)$/g,
/x fs\)$/g,
/x gen\)$/g,
/x ge\)$/g,
/x prx\)$/g,
/x rrq\)$/g,
/x t1\)$/g,
/x ts\)$/g,
/x zeta\)$/g,
/x vl\)$/g,
/x ns\)$/g,
/x tln\)$/g,
/x boom\)$/g,
/x bld\)$/g,
],
"vct-cn": [
/x ag\)$/g,
/x blg\)$/g,
/x edg\)$/g,
/x fpx\)$/g,
/x jdg\)$/g,
/x nova\)$/g,
/x tec\)$/g,
/x te\)$/g,
/x tyl\)$/g,
/x wol\)$/g,
/x xlg\)$/g,
/x xlg\)$/g,
/x drg\)$/g,
],
};
export function getVCTRegion(skinName) {
if (!isVCTSkin(skinName)) return null;
const name = skinName.toLowerCase().trim();
for (const [region, regexes] of Object.entries(VCT_TEAMS)) {
if (regexes.some(regex => regex.test(name))) {
return region;
}
if (regexes.some((regex) => regex.test(name))) {
return region;
}
}
return null;
}
@@ -493,4 +546,12 @@ export function getVCTRegion(skinName) {
export function isChampionsSkin(skinName) {
const name = skinName.toLowerCase();
return name.includes("champions");
}
}
export async function resolveUser(client, userId) {
return client.users.cache.get(userId) || await client.users.fetch(userId);
}
export async function resolveMember(guild, userId) {
return guild.members.cache.get(userId) || await guild.members.fetch(userId);
}

View File

@@ -1,20 +1,38 @@
import { getMarketOfferById, getOfferBids, getSkin, getUser } from "../database/index.js";
import * as userService from "../services/user.service.js";
import * as skinService from "../services/skin.service.js";
import * as csSkinService from "../services/csSkin.service.js";
import * as marketService from "../services/market.service.js";
import { EmbedBuilder } from "discord.js";
import { resolveUser } from "./index.js";
/**
* Gets the skin display name and icon from an offer, supporting both Valorant and CS2 skins.
*/
async function getOfferSkinInfo(offer) {
if (offer.csSkinId) {
const csSkin = await csSkinService.getCsSkin(offer.csSkinId);
return { name: csSkin?.displayName || offer.csSkinId, icon: csSkin?.imageUrl || null };
}
if (offer.skinUuid) {
const skin = await skinService.getSkin(offer.skinUuid);
return { name: skin?.displayName || offer.skinUuid, icon: skin?.displayIcon || null };
}
return { name: "Unknown", icon: null };
}
export async function handleNewMarketOffer(offerId, client) {
const offer = getMarketOfferById.get(offerId);
const offer = await marketService.getMarketOfferById(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
const { name: skinName, icon: skinIcon } = await getOfferSkinInfo(offer);
const discordUserSeller = await client.users.fetch(offer.seller_id);
const discordUserSeller = await resolveUser(client, offer.sellerId);
try {
const userSeller = getUser.get(offer.seller_id);
const userSeller = await userService.getUser(offer.sellerId);
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
.setDescription(`Ton offre pour le skin **${skinName}** a bien été créée !`)
.setColor(0x5865f2)
.addFields(
{
name: "📌 Statut",
@@ -23,59 +41,59 @@ export async function handleNewMarketOffer(offerId, client) {
},
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
value: `\`${offer.startingPrice} coins\``,
inline: true,
},
{
name: "⏰ Ouverture",
value: `<t:${Math.floor(offer.opening_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.openingAt / 1000)}:F>`,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.closingAt / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
name: "🆔 ID de l'offre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
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 guildChannel = client.channels.cache.get(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
.setDescription(`Une offre pour le skin **${skinName}** a été créée !`)
.setColor(0x5865f2)
.addFields(
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
value: `\`${offer.startingPrice} coins\``,
inline: true,
},
{
name: "⏰ Ouverture",
value: `<t:${Math.floor(offer.opening_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.openingAt / 1000)}:F>`,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.closingAt / 1000)}:F>`,
},
{
name: "Créée par",
value: `<@${offer.seller_id}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""}`,
value: `<@${offer.sellerId}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""}`,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
@@ -83,21 +101,20 @@ export async function handleNewMarketOffer(offerId, client) {
}
export async function handleMarketOfferOpening(offerId, client) {
const offer = getMarketOfferById.get(offerId);
const offer = await marketService.getMarketOfferById(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
const { name: skinName, icon: skinIcon } = await getOfferSkinInfo(offer);
try {
const discordUserSeller = await client.users.fetch(offer.seller_id);
const userSeller = getUser.get(offer.seller_id);
const discordUserSeller = await resolveUser(client, offer.sellerId);
const userSeller = await userService.getUser(offer.sellerId);
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 !`,
`Les enchères sur ton offre pour le skin **${skinName}** viennent de commencer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.addFields(
{
name: "📌 Statut",
@@ -106,49 +123,49 @@ export async function handleMarketOfferOpening(offerId, client) {
},
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
value: `\`${offer.startingPrice} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.closingAt / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
name: "🆔 ID de l'offre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
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 guildChannel = client.channels.cache.get(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 !`,
`Les enchères sur l'offre pour le skin **${skinName}** viennent de commencer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.addFields(
{
name: "💰 Prix de départ",
value: `\`${offer.starting_price} coins\``,
value: `\`${offer.startingPrice} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.closingAt / 1000)}:F>`,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
guildChannel.send({ embeds: [embed] }).catch(console.error);
} catch (e) {
console.error(e);
@@ -156,23 +173,23 @@ export async function handleMarketOfferOpening(offerId, client) {
}
export async function handleMarketOfferClosing(offerId, client) {
const offer = getMarketOfferById.get(offerId);
const offer = await marketService.getMarketOfferById(offerId);
if (!offer) return;
const skin = getSkin.get(offer.skin_uuid);
const bids = getOfferBids.all(offer.id);
const { name: skinName, icon: skinIcon } = await getOfferSkinInfo(offer);
const bids = await marketService.getOfferBids(offer.id);
const discordUserSeller = await client.users.fetch(offer.seller_id);
const discordUserSeller = await resolveUser(client, offer.sellerId);
try {
const userSeller = getUser.get(offer.seller_id);
const userSeller = await userService.getUser(offer.sellerId);
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 !`,
`Les enchères sur ton offre pour le skin **${skinName}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
if (bids.length === 0) {
embed.addFields(
@@ -181,21 +198,21 @@ export async function handleMarketOfferClosing(offerId, client) {
value: "Tu conserves ce skin dans ton inventaire.",
},
{
name: "🆔 ID de loffre",
name: "🆔 ID de l'offre",
value: `\`${offer.id}\``,
inline: false,
},
);
} else {
const highestBid = bids[0];
const highestBidderUser = await client.users.fetch(highestBid.bidder_id);
const highestBidderUser = await resolveUser(client, highestBid.bidderId);
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 + ")" : ""}.`,
value: `Ton skin a été vendu pour \`${highestBid.offerAmount} coins\` à <@${highestBid.bidderId}> ${highestBidderUser ? "(" + highestBidderUser.username + ")" : ""}.`,
},
{
name: "🆔 ID de loffre",
name: "🆔 ID de l'offre",
value: `\`${offer.id}\``,
inline: false,
},
@@ -208,18 +225,17 @@ export async function handleMarketOfferClosing(offerId, client) {
console.error(e);
}
// Send notification in guild channel
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const guild = client.guilds.cache.get(process.env.GUILD_ID);
const guildChannel = guild.channels.cache.get(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 !`,
`Les enchères sur l'offre pour le skin **${skinName}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
if (bids.length === 0) {
embed.addFields({
@@ -228,29 +244,28 @@ export async function handleMarketOfferClosing(offerId, client) {
});
} else {
const highestBid = bids[0];
const highestBidderUser = await client.users.fetch(highestBid.bidder_id);
const highestBidderUser = await resolveUser(client, highestBid.bidderId);
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 + ")" : ""}.`,
value: `Le skin de <@${offer.sellerId}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""} a été vendu pour \`${highestBid.offerAmount} coins\` à <@${highestBid.bidderId}> ${highestBidderUser ? "(" + highestBidderUser.username + ")" : ""}.`,
});
const discordUserBidder = await client.users.fetch(highestBid.bidder_id);
const userBidder = getUser.get(highestBid.bidder_id);
const discordUserBidder = await resolveUser(client, highestBid.bidderId);
const userBidder = await userService.getUser(highestBid.bidderId);
if (discordUserBidder && userBidder?.isAkhy) {
const embed = new EmbedBuilder()
const bidderEmbed = 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 !`,
`Les enchères sur l'offre pour le skin **${skinName}** viennent de se terminer !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.setTimestamp();
const highestBid = bids[0];
embed.addFields({
if (skinIcon) bidderEmbed.setThumbnail(skinIcon);
bidderEmbed.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.`,
value: `Tu as acheté ce skin pour \`${highestBid.offerAmount} coins\` à <@${offer.sellerId}> ${discordUserSeller ? "(" + discordUserSeller.username + ")" : ""}. Il a été ajouté à ton inventaire.`,
});
discordUserBidder.send({ embeds: [embed] }).catch(console.error);
discordUserBidder.send({ embeds: [bidderEmbed] }).catch(console.error);
}
}
guildChannel.send({ embeds: [embed] }).catch(console.error);
@@ -260,48 +275,47 @@ export async function handleMarketOfferClosing(offerId, client) {
}
export async function handleNewMarketOfferBid(offerId, bidId, client) {
// Notify Seller and Bidder
const offer = getMarketOfferById.get(offerId);
const offer = await marketService.getMarketOfferById(offerId);
if (!offer) return;
const bid = getOfferBids.get(offerId);
const bid = (await marketService.getOfferBids(offerId))[0];
if (!bid) return;
const skin = getSkin.get(offer.skin_uuid);
const { name: skinName, icon: skinIcon } = await getOfferSkinInfo(offer);
const bidderUser = client.users.fetch(bid.bidder_id);
const bidderUser = await resolveUser(client, bid.bidderId);
try {
const discordUserSeller = await client.users.fetch(offer.seller_id);
const userSeller = getUser.get(offer.seller_id);
const discordUserSeller = await resolveUser(client, offer.sellerId);
const userSeller = await userService.getUser(offer.sellerId);
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}**.`,
`Il y a eu une nouvelle enchère sur ton offre pour le skin **${skinName}**.`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.addFields(
{
name: "👤 Enchérisseur",
value: `<@${bid.bidder_id}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
value: `<@${bid.bidderId}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
inline: true,
},
{
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
name: "💰 Montant de l'enchère",
value: `\`${bid.offerAmount} coins\``,
inline: true,
},
{
name: "⏰ Fermeture",
value: `<t:${Math.floor(offer.closing_at / 1000)}:F>`,
value: `<t:${Math.floor(offer.closingAt / 1000)}:F>`,
},
{
name: "🆔 ID de loffre",
name: "🆔 ID de l'offre",
value: `\`${offer.id}\``,
inline: false,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
discordUserSeller.send({ embeds: [embed] }).catch(console.error);
}
@@ -310,22 +324,22 @@ export async function handleNewMarketOfferBid(offerId, bidId, client) {
}
try {
const discordUserNewBidder = await client.users.fetch(bid.bidder_id);
const userNewBidder = getUser.get(bid.bidder_id);
const discordUserNewBidder = await resolveUser(client, bid.bidderId);
const userNewBidder = await userService.getUser(bid.bidderId);
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!`,
`Ton enchère sur l'offre pour le skin **${skinName}** a bien été placée!`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.addFields({
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
name: "💰 Montant de l'enchère",
value: `\`${bid.offerAmount} coins\``,
inline: true,
})
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
discordUserNewBidder.send({ embeds: [embed] }).catch(console.error);
}
@@ -334,54 +348,52 @@ export async function handleNewMarketOfferBid(offerId, bidId, client) {
}
try {
const offerBids = getOfferBids.all(offer.id);
if (offerBids.length < 2) return; // No previous bidder to notify
const offerBids = await marketService.getOfferBids(offer.id);
if (offerBids.length < 2) return;
const discordUserPreviousBidder = await client.users.fetch(offerBids[1].bidder_id);
const userPreviousBidder = getUser.get(offerBids[1].bidder_id);
const discordUserPreviousBidder = await resolveUser(client, offerBids[1].bidderId);
const userPreviousBidder = await userService.getUser(offerBids[1].bidderId);
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 !`,
`Quelqu'un a surenchéri sur l'offre pour le skin **${skinName}**, tu n'es plus le meilleur enchérisseur !`,
)
.setThumbnail(skin.displayIcon)
.setColor(0x5865f2) // Discord blurple
.setColor(0x5865f2)
.addFields(
{
name: "👤 Enchérisseur",
value: `<@${bid.bidder_id}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
value: `<@${bid.bidderId}> ${bidderUser ? "(" + bidderUser.username + ")" : ""}`,
inline: true,
},
{
name: "💰 Montant de lenchère",
value: `\`${bid.offer_amount} coins\``,
name: "💰 Montant de l'enchère",
value: `\`${bid.offerAmount} coins\``,
inline: true,
},
)
.setTimestamp();
if (skinIcon) embed.setThumbnail(skinIcon);
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);
const discordUser = await resolveUser(client, userId);
const skin = await skinService.getSkin(skinUuid);
try {
const guildChannel = await client.channels.fetch(process.env.BOT_CHANNEL_ID);
const guildChannel = client.channels.cache.get(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
.setColor(skin.tierColor)
.addFields(
{
name: "💰 Valeur estimée",