mirror of
https://github.com/cassoule/flopobot_v2.git
synced 2026-03-18 21:40:27 +01:00
skins prices rework
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { handleMessageCreate } from "./handlers/messageCreate.js";
|
import { handleMessageCreate } from "./handlers/messageCreate.js";
|
||||||
import { getAkhys } from "../utils/index.js";
|
import { getAkhys } from "../utils/index.js";
|
||||||
import { fetchSuggestedPrices, fetchSkinsData } from "../api/cs.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.
|
* Initializes and attaches all necessary event listeners to the Discord client.
|
||||||
@@ -21,6 +22,8 @@ export function initializeEvents(client, io) {
|
|||||||
//setupCronJobs(client, io);
|
//setupCronJobs(client, io);
|
||||||
await fetchSuggestedPrices();
|
await fetchSuggestedPrices();
|
||||||
await fetchSkinsData();
|
await fetchSkinsData();
|
||||||
|
buildPriceIndex();
|
||||||
|
buildWeaponRarityPriceMap();
|
||||||
console.log("--- FlopoBOT is fully operational ---");
|
console.log("--- FlopoBOT is fully operational ---");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -355,34 +355,33 @@ async function handleAdminCommands(message) {
|
|||||||
break;
|
break;
|
||||||
case `${prefix}:refund-skins`:
|
case `${prefix}:refund-skins`:
|
||||||
try {
|
try {
|
||||||
const DBskins = await skinService.getAllSkins();
|
const allCsSkins = await csSkinService.getAllOwnedCsSkins();
|
||||||
for (const skin of DBskins) {
|
let refundedCount = 0;
|
||||||
|
let totalRefunded = 0;
|
||||||
|
for (const skin of allCsSkins) {
|
||||||
|
const price = skin.price || 0;
|
||||||
let owner = null;
|
let owner = null;
|
||||||
try {
|
try {
|
||||||
owner = await userService.getUser(skin.userId)
|
owner = await userService.getUser(skin.userId);
|
||||||
} catch {
|
} catch {
|
||||||
//
|
//
|
||||||
};
|
}
|
||||||
if (owner) {
|
if (owner) {
|
||||||
await userService.updateUserCoins(owner.id, owner.coins + skin.currentPrice);
|
await userService.updateUserCoins(owner.id, owner.coins + price);
|
||||||
await logService.insertLog({
|
await logService.insertLog({
|
||||||
id: `${skin.uuid}-skin-refund-${Date.now()}`,
|
id: `${skin.id}-cs-skin-refund-${Date.now()}`,
|
||||||
userId: owner.id,
|
userId: owner.id,
|
||||||
targetUserId: null,
|
targetUserId: null,
|
||||||
action: "SKIN_REFUND",
|
action: "CS_SKIN_REFUND",
|
||||||
coinsAmount: skin.currentPrice,
|
coinsAmount: price,
|
||||||
userNewAmount: owner.coins + skin.currentPrice,
|
userNewAmount: owner.coins + price,
|
||||||
});
|
});
|
||||||
|
totalRefunded += price;
|
||||||
|
refundedCount++;
|
||||||
}
|
}
|
||||||
await skinService.updateSkin({
|
await csSkinService.deleteCsSkin(skin.id);
|
||||||
uuid: skin.uuid,
|
|
||||||
userId: null,
|
|
||||||
currentPrice: null,
|
|
||||||
currentLvl: null,
|
|
||||||
currentChroma: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
message.reply("All skins refunded.");
|
message.reply(`Refunded ${refundedCount} CS skins (${totalRefunded} FlopoCoins total).`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
message.reply(`Error during refund skins ${e.message}`);
|
message.reply(`Error during refund skins ${e.message}`);
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export async function getUserCsSkinsByRarity(userId, rarity) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllOwnedCsSkins() {
|
||||||
|
return prisma.csSkin.findMany({
|
||||||
|
where: { userId: { not: null } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function insertCsSkin(data) {
|
export async function insertCsSkin(data) {
|
||||||
return prisma.csSkin.create({ data });
|
return prisma.csSkin.create({ data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,80 @@
|
|||||||
export let csSkinsData = {};
|
export let csSkinsData = {};
|
||||||
|
|
||||||
export let csSkinsPrices = {};
|
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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { csSkinsData, csSkinsPrices } from "./cs.state.js";
|
import { csSkinsData, csSkinsPriceIndex, weaponRarityPriceMap } from "./cs.state.js";
|
||||||
import { findReferenceSkin } from "../services/csSkin.service.js";
|
|
||||||
|
|
||||||
const StateFactoryNew = "Factory New";
|
const StateFactoryNew = "Factory New";
|
||||||
const StateMinimalWear = "Minimal Wear";
|
const StateMinimalWear = "Minimal Wear";
|
||||||
@@ -7,6 +6,20 @@ const StateFieldTested = "Field-Tested";
|
|||||||
const StateWellWorn = "Well-Worn";
|
const StateWellWorn = "Well-Worn";
|
||||||
const StateBattleScarred = "Battle-Scarred";
|
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 = {
|
export const RarityToColor = {
|
||||||
Gold: 0xffd700, // Standard Gold
|
Gold: 0xffd700, // Standard Gold
|
||||||
Extraordinary: 0xffae00, // Orange
|
Extraordinary: 0xffae00, // Orange
|
||||||
@@ -18,14 +31,15 @@ export const RarityToColor = {
|
|||||||
"Consumer Grade": 0xb0c3d9, // Light Grey/White
|
"Consumer Grade": 0xb0c3d9, // Light Grey/White
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Last-resort fallback price ranges in EUR (used only when Skinport has no data)
|
||||||
const basePriceRanges = {
|
const basePriceRanges = {
|
||||||
"Consumer Grade": { min: 1, max: 10 },
|
"Consumer Grade": { min: 0.03, max: 0.10 },
|
||||||
"Industrial Grade": { min: 5, max: 50 },
|
"Industrial Grade": { min: 0.05, max: 0.30 },
|
||||||
"Mil-Spec Grade": { min: 20, max: 150 },
|
"Mil-Spec Grade": { min: 0.10, max: 1.50 },
|
||||||
"Restricted": { min: 100, max: 1000 },
|
"Restricted": { min: 1.00, max: 10.00 },
|
||||||
"Classified": { min: 500, max: 4000 },
|
"Classified": { min: 5.00, max: 40.00 },
|
||||||
"Covert": { min: 2500, max: 10000 },
|
"Covert": { min: 25.00, max: 150.00 },
|
||||||
"Extraordinary": { min: 1500, max: 3000 },
|
"Extraordinary": { min: 100.00, max: 800.00 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TRADE_UP_MAP = {
|
export const TRADE_UP_MAP = {
|
||||||
@@ -55,71 +69,116 @@ export function randomSkinRarity() {
|
|||||||
return "Consumer Grade";
|
return "Consumer Grade";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generatePrice(skinName, rarity, float, isStattrak, isSouvenir) {
|
function getSkinportPrice(priceData) {
|
||||||
const ranges = basePriceRanges[rarity] || basePriceRanges["Industrial Grade"];
|
if (!priceData) return null;
|
||||||
|
return priceData.suggested_price ?? priceData.median_price ?? priceData.mean_price ?? priceData.min_price ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
let finalPrice;
|
function applyFloatModifier(basePrice, float, wearState) {
|
||||||
const ref = await findReferenceSkin(skinName, isStattrak, isSouvenir);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (ref && ref.float !== null) {
|
function getAdjacentWearStates(wearState) {
|
||||||
// Derive base price from reference: refPrice = basePrice * (1 - refFloat) → basePrice = refPrice / (1 - refFloat)
|
const idx = WEAR_STATE_ORDER.indexOf(wearState);
|
||||||
const refBasePrice = ref.price / Math.max(1 - ref.float, 0.01);
|
if (idx === -1) return [];
|
||||||
finalPrice = refBasePrice * (1 - float);
|
// Return wear states ordered by proximity
|
||||||
} else {
|
const adjacent = [];
|
||||||
// No reference: random base price, scaled by float
|
for (let dist = 1; dist < WEAR_STATE_ORDER.length; dist++) {
|
||||||
const basePrice = ranges.min + Math.random() * (ranges.max - ranges.min);
|
if (idx - dist >= 0) adjacent.push(WEAR_STATE_ORDER[idx - dist]);
|
||||||
finalPrice = basePrice * (1 - float) + ranges.min * float;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGold = rarity === "Covert";
|
// 3. Adjacent wear state (same variant, then base with multiplier)
|
||||||
if (isSouvenir && !isGold) {
|
for (const adjWear of getAdjacentWearStates(wearState)) {
|
||||||
finalPrice *= 7;
|
const adjPrice = getSkinportPrice(skinEntry[variant]?.[adjWear]);
|
||||||
} else if (isStattrak && !isGold) {
|
if (adjPrice !== null) return adjPrice;
|
||||||
finalPrice *= 4;
|
|
||||||
|
if (variant !== "base") {
|
||||||
|
const adjBase = getSkinportPrice(skinEntry["base"]?.[adjWear]);
|
||||||
|
if (adjBase !== null) {
|
||||||
|
const multiplier = isSouvenir ? SOUVENIR_FALLBACK_MULTIPLIER : STATTRAK_FALLBACK_MULTIPLIER;
|
||||||
|
return adjBase * multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalPrice < 1) finalPrice = 1;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const name = skinName.toLowerCase();
|
function findSimilarSkinPrice(skinName, rarity, wearState) {
|
||||||
|
const skinData = csSkinsData[skinName];
|
||||||
|
const weapon = skinData?.weapon?.name;
|
||||||
|
if (!weapon) return null;
|
||||||
|
|
||||||
// Special pattern multipliers (more specific patterns first)
|
const candidates = weaponRarityPriceMap[weapon]?.[rarity];
|
||||||
if (name.includes("marble fade")) {
|
if (!candidates || candidates.length === 0) return null;
|
||||||
finalPrice *= 1.35;
|
|
||||||
} else if (name.includes("gamma doppler")) {
|
// Pick a random candidate that has a price for this wear state
|
||||||
finalPrice *= 1.4;
|
const shuffled = [...candidates].sort(() => Math.random() - 0.5);
|
||||||
} else if (name.includes("doppler")) {
|
for (const candidate of shuffled) {
|
||||||
finalPrice *= 1.5;
|
if (candidate === skinName) continue;
|
||||||
} else if (name.includes("fade")) {
|
const entry = csSkinsPriceIndex[candidate];
|
||||||
finalPrice *= 1.4;
|
if (!entry) continue;
|
||||||
} else if (name.includes("crimson web")) {
|
// Try base variant first
|
||||||
finalPrice *= 1.3;
|
const price = getSkinportPrice(entry["base"]?.[wearState]);
|
||||||
} else if (name.includes("case hardened")) {
|
if (price !== null) return price;
|
||||||
finalPrice *= 1.25;
|
// Try any wear state
|
||||||
} else if (name.includes("lore")) {
|
for (const ws of WEAR_STATE_ORDER) {
|
||||||
finalPrice *= 1.25;
|
const wsPrice = getSkinportPrice(entry["base"]?.[ws]);
|
||||||
} else if (name.includes("tiger tooth")) {
|
if (wsPrice !== null) return wsPrice;
|
||||||
finalPrice *= 1.2;
|
}
|
||||||
} else if (name.includes("slaughter")) {
|
|
||||||
finalPrice *= 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Knife type boosts (more specific first)
|
return null;
|
||||||
if (name.includes("butterfly")) {
|
}
|
||||||
finalPrice *= 2;
|
|
||||||
} else if (name.includes("karambit")) {
|
export function generatePrice(skinName, rarity, float, isStattrak, isSouvenir) {
|
||||||
finalPrice *= 1.8;
|
const wearState = getWearState(float);
|
||||||
} else if (name.includes("m9 bayonet")) {
|
let eurPrice = lookupSkinportEurPrice(skinName, wearState, isStattrak, isSouvenir);
|
||||||
finalPrice *= 1.4;
|
|
||||||
} else if (name.includes("talon")) {
|
if (eurPrice === null) {
|
||||||
finalPrice *= 1.3;
|
// 4. Similar skin: same weapon + same rarity
|
||||||
} else if (name.includes("skeleton")) {
|
eurPrice = findSimilarSkinPrice(skinName, rarity, wearState);
|
||||||
finalPrice *= 1.2;
|
|
||||||
} else if (name.includes("bayonet")) {
|
|
||||||
finalPrice *= 1.1;
|
|
||||||
} else if (name.includes("gut") || name.includes("navaja") || name.includes("falchion")) {
|
|
||||||
finalPrice *= 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
return finalPrice.toFixed(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +226,6 @@ export async function getRandomSkinWithRandomSpecs(u_float, forcedRarity) {
|
|||||||
isSouvenir: skinIsSouvenir,
|
isSouvenir: skinIsSouvenir,
|
||||||
wearState,
|
wearState,
|
||||||
float,
|
float,
|
||||||
price: await generatePrice(skinName, skinData.rarity.name, float, skinIsStattrak, skinIsSouvenir),
|
price: generatePrice(skinName, skinData.rarity.name, float, skinIsStattrak, skinIsSouvenir),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user