skins prices rework

This commit is contained in:
Milo
2026-03-15 19:43:43 +01:00
parent e4beb7f5be
commit 622522afa7
5 changed files with 224 additions and 80 deletions

View File

@@ -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 ---");
}); });

View File

@@ -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}`);

View File

@@ -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 });
} }

View File

@@ -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`);
}

View File

@@ -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),
}; };
} }