import https from "https"; import { DependencyContainer } from "tsyringe"; import { IPostDBLoadMod } from "@spt-aki/models/external/IPostDBLoadMod"; import { RagfairServer } from "@spt-aki/servers/RagfairServer"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { LogTextColor } from "@spt-aki/models/spt/logging/LogTextColor"; import { LogBackgroundColor } from "@spt-aki/models/spt/logging/LogBackgroundColor"; import { RagfairPriceService } from "@spt-aki/services/RagfairPriceService"; import { RagfairOfferService } from "@spt-aki/services/RagfairOfferService"; import { IBarterScheme, ITrader } from "@spt-aki/models/eft/common/tables/ITrader" import { Traders } from "@spt-aki/models/enums/Traders" import { Item } from "@spt-aki/models/eft/common/tables/IItem" import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem" import { HandbookItem, IHandbookBase } from "@spt-aki/models/eft/common/tables/IHandbookBase" import { IDatabaseTables } from "@spt-aki/models/spt/server/IDatabaseTables" import pkg from "../package.json"; import modConfig from "../config/config.json"; const query = "{\"query\":\"{\\n\\t\\titems(type: any){\\n\\t\\t\\tid\\n\\t\\t\\tbasePrice\\n\\t\\t\\tfleaMarketFee\\n\\t\\t\\tavg24hPrice\\n\\t\\t\\tlastOfferCount\\n\\t\\t\\tsellFor {\\n\\t\\t\\tprice\\n\\t\\t\\t}\\n\\t\\t\\tbuyFor{\\n\\t\\t\\t\\tprice\\n\\t\\t\\t}\\n\\t\\t\\t\\thistoricalPrices{\\n\\t\\t\\t\\tprice\\n\\t\\t\\t\\ttimestamp\\n\\t\\t\\t}\\n\\t\\t}\\n\\t}\\n\"}" const headers = { hostname: "api.tarkov.dev", path: "/graphql", method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", } }; class Mod implements IPostDBLoadMod { private static modName: string = `${pkg.author}-${pkg.name}`; private static updateInterval: number = (!modConfig?.UpdateIntervalSecond || typeof(modConfig.UpdateIntervalSecond) !== "number" || modConfig.UpdateIntervalSecond < 600 ? 600 : modConfig.UpdateIntervalSecond)*1000; private static container: DependencyContainer; protected static updateTimer: number; private static database: DatabaseServer private static tables: IDatabaseTables private static handbook: IHandbookBase private static items: Record<string, ITemplateItem> private static traders: Record<string, ITrader> private static traderList: ITrader[] private static euroRatio: number private static dollarRatio: number private static fleaPrices: Record<string, number> public postDBLoad(container: DependencyContainer): void { // S P T 3 . 0 . 0 Mod.container = container; const logger = container.resolve<ILogger>("WinstonLogger"); logger.info(`Loading: ${Mod.modName} ${pkg.version}${modConfig.Enabled ? ` - Update Interval: ${(Mod.updateInterval/60000).toFixed(0)} mins` : " [Disabled]"}`); if (!modConfig.Enabled) { return; } Mod.database = container.resolve<DatabaseServer>("DatabaseServer") Mod.tables = Mod.database.getTables() Mod.items = Mod.tables.templates.items Mod.fleaPrices = Object.assign({}, Mod.database.getTables().templates.prices) Mod.handbook = Mod.tables.templates.handbook Mod.euroRatio = Mod.handbook.Items.find((x) => x.Id == "569668774bdc2da2298b4568").Price Mod.dollarRatio = Mod.handbook.Items.find((x) => x.Id == "5696686a4bdc2da3298b456a").Price Mod.traders = Mod.tables.traders // Hardcode list for best buy_price_coef Mod.traderList = [ Mod.traders[Traders.THERAPIST], Mod.traders[Traders.RAGMAN], Mod.traders[Traders.JAEGER], Mod.traders[Traders.MECHANIC], Mod.traders[Traders.PRAPOR], Mod.traders[Traders.SKIER], Mod.traders[Traders.PEACEKEEPER], ] Mod.updatePrices(true); Mod.updateTimer = setInterval(Mod.updatePrices, Mod.updateInterval, false); } static updatePrices(init: Boolean = false): void { const logger = Mod.container.resolve<ILogger>("WinstonLogger"); const databaseServer = Mod.container.resolve<DatabaseServer>("DatabaseServer"); const pricesTable = databaseServer.getTables().templates.prices; const itemsTable = databaseServer.getTables().templates.items; logger.log(`${Mod.modName} - Requesting price data from the api server...`, LogTextColor.BLACK, LogBackgroundColor.YELLOW); const req = https.request(headers, res => { // Hooray, we have the data and alive api server if (res.statusCode == 200) { // Thinking let response = ""; res.on("data", function(d) { response += d; }); // And more thinking res.on("end", function(d) { // Harder try { // Parse the data from the api server const respond = JSON.parse(response); // Configs const updateFilters = modConfig?.UpdateFilters; const updateBannedItems = modConfig?.UpdatePricesFromTradersForBannedItems; let priceFilter = updateFilters.Prices.toString() || "avg"; if (priceFilter != "avg" && priceFilter != "lowest" && priceFilter != "highest" && priceFilter != "med") { priceFilter = "avg"; logger.error(`${Mod.modName}: Config "UpdateFilters" - "Prices" has bad value "${updateFilters?.Prices}", using default value: avg`); } let priceTimeLimit = Number(updateFilters.LimitPriceDataWithinTime_Hour) || 24; // UwU let updateCount = 0; // What's dis? for (const i in respond.data.items) { const apiData = respond.data.items[i]; const itemId = apiData.id; const avg24hPrice = apiData.avg24hPrice; let price = 0; // Blacklist check let skip = false; for (const j in modConfig.Blacklist) { if (modConfig.Blacklist[j] === itemId) { skip = true; break; } } if (skip === true) continue; // Bitcoin price from Therapist /* No longer used since item has banned from live flea market and no data to use if (id === "59faff1d86f7746c51718c9c") { for (const trader in datas.data.items[i].traderPrices) { if (datas.data.items[i].traderPrices[trader].trader.name.toLowerCase() === "therapist") { price = datas.data.items[i].traderPrices[trader].price; break; } } } */ // Skip undefiend items /* but we don't need to skip it, we might needs when we playing BE or Up-Versions if (!pricesTable[id]) { continue; } */ // Skip or update prices from traders for banned items if (avg24hPrice < 1 && !apiData.historicalPrices?.length) { // No thx by user's choice or no data to use at all if (!updateBannedItems?.Enabled || (!apiData.sellFor?.length && !apiData.buyFor?.length)) { continue; } let pickedPrice = -1; if (apiData.buyFor?.length > 0) { for (const p in apiData.buyFor) { if (pickedPrice == -1) pickedPrice = apiData.buyFor[p].price pickedPrice = Math.min(apiData.buyFor[p].price, pickedPrice); } } else if (apiData.sellFor?.length > 0) { for (const p in apiData.sellFor) { if (pickedPrice == -1) pickedPrice = apiData.sellFor[p].price pickedPrice = Math.max(apiData.sellFor[p].price, pickedPrice); } } if (updateBannedItems?.LowPricedItemsOnly) { if (pricesTable[itemId] < pickedPrice) { price = pickedPrice; } } else if (pickedPrice > 0) { // Update the item price anyway, by user's choice price = pickedPrice; } else { // Nothing to update continue; } } else // We have actual price data from the api server { // Ez price if (priceFilter == "avg" && priceTimeLimit === 24 && avg24hPrice > 0) { price = avg24hPrice; } // Time to do some math else if (apiData.historicalPrices?.length) { let pickedPrice = []; for (const p in apiData.historicalPrices) { const data = apiData.historicalPrices[p]; switch(priceFilter) { case "lowest": { pickedPrice[0] = Math.min(data.price, pickedPrice[0]); break; } case "avg": { pickedPrice.push(data.price); break; } case "highest": { pickedPrice[0] = Math.max(data.price, pickedPrice[0]); break; } case "med": { pickedPrice.push(data.price); break; } } } if (pickedPrice.length) { if (priceFilter == "med") { pickedPrice = pickedPrice.sort() price = pickedPrice[Math.floor(pickedPrice.length / 2)] } else if (priceFilter != "avg") { price = pickedPrice[0]; } else // avg { price = pickedPrice.reduce((a, b) => a + b, 0) / pickedPrice.length; } } else // No data at all, wtf. { continue; } } else { // No data at all, wtf. if (avg24hPrice < 1) { continue; } else // I guess this is what we've got now { price = avg24hPrice; } } } // Finall we got a new price? if (pricesTable[itemId] !== price && price > 0) { updateCount++; const itemBarters = Mod.bartersResolver(Mod.traderList, itemId) let traderBarter : ResolvedBarter let traderBarterResource let traderPrice for (const barter of itemBarters) { if (traderBarter && barter.barterLoyaltyLevel > traderBarter.barterLoyaltyLevel) continue for (const resource of barter.barterResources) { if (resource._tpl == "5449016a4bdc2d6f028b456f") { traderBarter = barter traderBarterResource = resource break } else if (resource._tpl == "569668774bdc2da2298b4568") { traderBarter = barter traderBarterResource = resource break } else if (resource._tpl == "5696686a4bdc2da3298b456a") { traderBarter = barter traderBarterResource = resource break } } if (traderBarterResource) { switch (traderBarterResource._tpl) { case "5449016a4bdc2d6f028b456f": traderPrice = traderBarterResource.count break case "569668774bdc2da2298b4568": traderPrice = traderBarterResource.count * Mod.euroRatio break case "5696686a4bdc2da3298b456a": traderPrice = traderBarterResource.count * Mod.dollarRatio break } } } let isEquipment = false let noFlea = apiData.fleaMarketFee == null || apiData.fleaMarketFee == undefined if (Mod.items[itemId] && Mod.checkParent(Mod.items[itemId], [ "5448fe124bdc2da5018b4567", "55802f3e4bdc2de7118b4584", "55802f4a4bdc2ddb688b4569", "550aa4154bdc2dd8348b456b", "5448e54d4bdc2dcc718b4568", "5422acb9af1c889c16000029" ])) { isEquipment = true } let origPrice = Mod.getDefaultPrice(itemId) if (traderBarter && traderBarter.barterLoyaltyLevel == 1 && traderPrice) { if (price < traderPrice) pricesTable[itemId] = price else pricesTable[itemId] = traderPrice } else if (price < origPrice) pricesTable[itemId] = price else if (isEquipment && price / origPrice > 100) pricesTable[itemId] = (origPrice + price / 50) / 2; else if (isEquipment && price / origPrice > 50) pricesTable[itemId] = (origPrice + price / 23) / 2; else if (price / origPrice > 200) pricesTable[itemId] = (origPrice + price / 50) / 2; else if (price / origPrice > 100) pricesTable[itemId] = (origPrice + price / 20) / 2; else if (price / origPrice > 50) pricesTable[itemId] = (origPrice + price / 8) / 2; else if (isEquipment && price / origPrice > 12) pricesTable[itemId] = (origPrice + price / 3) / 2; else if (isEquipment && price / origPrice > 6) pricesTable[itemId] = (origPrice + price / 2) / 2; else pricesTable[itemId] = price; if (!noFlea && !(traderPrice && traderBarter?.barterLoyaltyLevel == 1)) { let overpricedFactor = apiData.fleaMarketFee / apiData.basePrice if (overpricedFactor > 10 && apiData.basePrice < 2500) pricesTable[itemId] = (origPrice * 3 + pricesTable[itemId]) / 4 else if (overpricedFactor > 8 && apiData.basePrice < 4500) pricesTable[itemId] = (origPrice * 4 + pricesTable[itemId]) / 5 else if (apiData.lastOfferCount <= 1 && overpricedFactor > 3) pricesTable[itemId] = (origPrice * 2 + pricesTable[itemId]) / 3 } if (traderBarter && traderBarter.barterLoyaltyLevel == 2) { if (traderPrice < pricesTable[itemId]) pricesTable[itemId] = (traderPrice * 2 + pricesTable[itemId]) / 3 } if (traderBarter && traderBarter.barterLoyaltyLevel == 3) { if (traderPrice < pricesTable[itemId]) pricesTable[itemId] = (traderPrice + pricesTable[itemId]) / 2 } logger.info(`${Mod.modName} - "${Mod.items[itemId]?._name}" ${origPrice} -> ${price} -> ${pricesTable[itemId]} (E: ${isEquipment}, trader: ${traderBarter?.traderID} ll${traderBarter?.barterLoyaltyLevel} - ${traderBarterResource?._tpl} * ${traderBarterResource?.count} => ${traderPrice})`, LogTextColor.BLACK, LogBackgroundColor.YELLOW); } } // Did we do it? if (updateCount) { logger.log(`${Mod.modName}: Updated market data, Total ${updateCount} items`, LogTextColor.BLACK, LogBackgroundColor.CYAN); } else { logger.log(`${Mod.modName}: Already up to date!`, LogTextColor.WHITE, LogBackgroundColor.MAGENTA); } // Generate flea market offers with new prices, Only once upon load if (init) { const traders = databaseServer.getTables().traders; for (const traderId in traders) { traders[traderId].base.refreshTraderRagfairOffers = true; } Mod.container.resolve<RagfairOfferService>("RagfairOfferService").offers = []; Mod.container.resolve<RagfairPriceService>("RagfairPriceService").generateDynamicPrices(); Mod.container.resolve<RagfairServer>("RagfairServer").load(); logger.log(`${Mod.modName}: Generated initial flea market offers`, LogTextColor.WHITE, LogBackgroundColor.MAGENTA); } } catch (error) { logger.error(`${Mod.modName}: ${error}`); } }); } else { let reason = "Failed to update market data"; if (res.statusCode >= 400 && res.statusCode <= 403) { reason = "Tarkov.dev might banned your IP for an hour or having trouble on the server"; } else if (res.statusCode == 503) { reason = "Tarkov.dev is offline"; } logger.error(`${Mod.modName}: (${res.statusCode}) ${reason}. Retry in ${(Mod.updateInterval/60000).toFixed(0)} mins`); } }).on("error", error => { logger.error(`${Mod.modName}: ${error} - Mod disabled`); clearInterval(Mod.updateTimer); }); req.write(query); req.end(); } static bartersResolver(traderList : ITrader[],itemID: string): ResolvedBarter[] { const logger = Mod.container.resolve<ILogger>("WinstonLogger"); const itemBarters: ResolvedBarter[] = [] try { traderList.forEach((trader) => { const allTraderBarters = trader.assort.items const traderBarters = allTraderBarters.filter((x) => x._tpl == itemID) const barters = traderBarters .map((barter) => recursion(barter)) // find and get list of "parent items" for a passed component .map((barter) => ({ // reset parentItem for actual parent items because of recursion function. // can be done in a more elegant way, but i'm too tired after a night of debugging. who cares anyway, it works. parentItem: barter.originalItemID ? (barter.originalItemID == itemID ? null : barter.originalItemID) : null, barterResources: trader.assort.barter_scheme[barter._id][0], barterLoyaltyLevel: trader.assort.loyal_level_items[barter._id], traderID: trader.base._id, })) itemBarters.push(...barters) function recursion(barter: PlaceholderItem): PlaceholderItem { if (barter.parentId == "hideout") { return barter } else { let parentBarter try { // spent literary 12 hours debugging this feature... KMP. // all because of one item, SWORD International Mk-18 not having proper .parentId is assort table. who would have thought. thx Nikita parentBarter = allTraderBarters.find((x) => x._id == barter.parentId) parentBarter.originalItemID = parentBarter._tpl } catch (error) { return barter // FML } return recursion(parentBarter) } } }) } catch (error) { logger.warning(`\n[ItemInfo] bartersResolver failed because of another mod. Send bug report. Continue safely.`) } return itemBarters } static checkParent(item: ITemplateItem, ids: string[]) { if (item._parent) { if (ids.includes(item._parent)) return true return Mod.checkParent(Mod.items[item._parent], ids) } else return false } static getDefaultPrice(itemID: string): number { if (typeof Mod.fleaPrices[itemID] != "undefined") { // Forgot quotes, typeof returns string.. return Mod.fleaPrices[itemID] } else { return Mod.getItemInHandbook(itemID)?.Price } } static getItemInHandbook(itemID: string): HandbookItem { try { return Mod.handbook.Items.find((i) => i.Id === itemID) // Outs: @Id, @ParentId, @Price } catch (error) { const logger = Mod.container.resolve<ILogger>("WinstonLogger"); logger.error(error) } } } module.exports = { mod: new Mod() } // A silly solution to some weird recursion logic that adds values to an object that shouldn't have them interface PlaceholderItem extends Item { originalItemID?: string } interface ResolvedBarter { parentItem: string barterResources: IBarterScheme[] barterLoyaltyLevel: number traderID: string }