diff --git a/src/bot/events.js b/src/bot/events.js index f9d2d4e..2012d1a 100644 --- a/src/bot/events.js +++ b/src/bot/events.js @@ -1,6 +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. @@ -21,6 +22,8 @@ export function initializeEvents(client, io) { //setupCronJobs(client, io); await fetchSuggestedPrices(); await fetchSkinsData(); + buildPriceIndex(); + buildWeaponRarityPriceMap(); console.log("--- FlopoBOT is fully operational ---"); }); diff --git a/src/bot/handlers/messageCreate.js b/src/bot/handlers/messageCreate.js index 8310418..82edd55 100644 --- a/src/bot/handlers/messageCreate.js +++ b/src/bot/handlers/messageCreate.js @@ -355,34 +355,33 @@ async function handleAdminCommands(message) { break; case `${prefix}:refund-skins`: try { - const DBskins = await skinService.getAllSkins(); - for (const skin of DBskins) { + 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) + owner = await userService.getUser(skin.userId); } catch { // - }; + } if (owner) { - await userService.updateUserCoins(owner.id, owner.coins + skin.currentPrice); + await userService.updateUserCoins(owner.id, owner.coins + price); await logService.insertLog({ - id: `${skin.uuid}-skin-refund-${Date.now()}`, + id: `${skin.id}-cs-skin-refund-${Date.now()}`, userId: owner.id, targetUserId: null, - action: "SKIN_REFUND", - coinsAmount: skin.currentPrice, - userNewAmount: owner.coins + skin.currentPrice, + action: "CS_SKIN_REFUND", + coinsAmount: price, + userNewAmount: owner.coins + price, }); + totalRefunded += price; + refundedCount++; } - await skinService.updateSkin({ - uuid: skin.uuid, - userId: null, - currentPrice: null, - currentLvl: null, - currentChroma: null, - }); + 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}`); diff --git a/src/services/csSkin.service.js b/src/services/csSkin.service.js index 548b211..68a36ec 100644 --- a/src/services/csSkin.service.js +++ b/src/services/csSkin.service.js @@ -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) { return prisma.csSkin.create({ data }); } diff --git a/src/utils/cs.state.js b/src/utils/cs.state.js index 855aa50..d5f8f25 100644 --- a/src/utils/cs.state.js +++ b/src/utils/cs.state.js @@ -1,3 +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`); +} diff --git a/src/utils/cs.utils.js b/src/utils/cs.utils.js index 52741fd..7d630e8 100644 --- a/src/utils/cs.utils.js +++ b/src/utils/cs.utils.js @@ -1,5 +1,4 @@ -import { csSkinsData, csSkinsPrices } from "./cs.state.js"; -import { findReferenceSkin } from "../services/csSkin.service.js"; +import { csSkinsData, csSkinsPriceIndex, weaponRarityPriceMap } from "./cs.state.js"; const StateFactoryNew = "Factory New"; const StateMinimalWear = "Minimal Wear"; @@ -7,6 +6,20 @@ 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 @@ -18,14 +31,15 @@ export const RarityToColor = { "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: 1, max: 10 }, - "Industrial Grade": { min: 5, max: 50 }, - "Mil-Spec Grade": { min: 20, max: 150 }, - "Restricted": { min: 100, max: 1000 }, - "Classified": { min: 500, max: 4000 }, - "Covert": { min: 2500, max: 10000 }, - "Extraordinary": { min: 1500, max: 3000 }, + "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 = { @@ -55,71 +69,116 @@ export function randomSkinRarity() { return "Consumer Grade"; } -export async function generatePrice(skinName, rarity, float, isStattrak, isSouvenir) { - const ranges = basePriceRanges[rarity] || basePriceRanges["Industrial Grade"]; +function getSkinportPrice(priceData) { + if (!priceData) return null; + return priceData.suggested_price ?? priceData.median_price ?? priceData.mean_price ?? priceData.min_price ?? null; +} - let finalPrice; - const ref = await findReferenceSkin(skinName, isStattrak, isSouvenir); +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; +} - if (ref && ref.float !== null) { - // Derive base price from reference: refPrice = basePrice * (1 - refFloat) → basePrice = refPrice / (1 - refFloat) - const refBasePrice = ref.price / Math.max(1 - ref.float, 0.01); - finalPrice = refBasePrice * (1 - float); - } else { - // No reference: random base price, scaled by float - const basePrice = ranges.min + Math.random() * (ranges.max - ranges.min); - finalPrice = basePrice * (1 - float) + ranges.min * float; +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; + } } - const isGold = rarity === "Covert"; - if (isSouvenir && !isGold) { - finalPrice *= 7; - } else if (isStattrak && !isGold) { - finalPrice *= 4; + // 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; + } + } } - 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) - if (name.includes("marble fade")) { - finalPrice *= 1.35; - } else if (name.includes("gamma doppler")) { - finalPrice *= 1.4; - } else if (name.includes("doppler")) { - finalPrice *= 1.5; - } else if (name.includes("fade")) { - finalPrice *= 1.4; - } else if (name.includes("crimson web")) { - finalPrice *= 1.3; - } else if (name.includes("case hardened")) { - finalPrice *= 1.25; - } else if (name.includes("lore")) { - finalPrice *= 1.25; - } else if (name.includes("tiger tooth")) { - finalPrice *= 1.2; - } else if (name.includes("slaughter")) { - finalPrice *= 1.2; + 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; + } } - // Knife type boosts (more specific first) - if (name.includes("butterfly")) { - finalPrice *= 2; - } else if (name.includes("karambit")) { - finalPrice *= 1.8; - } else if (name.includes("m9 bayonet")) { - finalPrice *= 1.4; - } else if (name.includes("talon")) { - finalPrice *= 1.3; - } else if (name.includes("skeleton")) { - 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; + 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); } @@ -167,6 +226,6 @@ export async function getRandomSkinWithRandomSpecs(u_float, forcedRarity) { isSouvenir: skinIsSouvenir, wearState, float, - price: await generatePrice(skinName, skinData.rarity.name, float, skinIsStattrak, skinIsSouvenir), + price: generatePrice(skinName, skinData.rarity.name, float, skinIsStattrak, skinIsSouvenir), }; }