Last active
August 13, 2023 09:54
mod.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment