Skip to content

Instantly share code, notes, and snippets.

@No3371
Last active August 13, 2023 09:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save No3371/5ff2b0fd2e9d62fe4a0b39120859651d to your computer and use it in GitHub Desktop.
Save No3371/5ff2b0fd2e9d62fe4a0b39120859651d to your computer and use it in GitHub Desktop.
mod.ts
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