50c7a26a58
This is the first pass of ESLint on the codebase. ESLint formatting is less strict when it comes to line-length and line-breaks then dprint/biome, so if you see formatting that you don't like... fix it! It shouldn't require a configuration change. - This should merge clean into master (when the time comes). - This will not merge clean into `3.9.0-DEV`, but the conflicts aren't that bad.
556 lines
20 KiB
TypeScript
556 lines
20 KiB
TypeScript
import { inject, injectable } from "tsyringe";
|
|
import { OnLoad } from "@spt-aki/di/OnLoad";
|
|
import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper";
|
|
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
|
|
import { PresetHelper } from "@spt-aki/helpers/PresetHelper";
|
|
import { TraderHelper } from "@spt-aki/helpers/TraderHelper";
|
|
import { MinMax } from "@spt-aki/models/common/MinMax";
|
|
import { IPreset } from "@spt-aki/models/eft/common/IGlobals";
|
|
import { HandbookItem } from "@spt-aki/models/eft/common/tables/IHandbookBase";
|
|
import { Item } from "@spt-aki/models/eft/common/tables/IItem";
|
|
import { IBarterScheme } from "@spt-aki/models/eft/common/tables/ITrader";
|
|
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
|
|
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
|
|
import { Money } from "@spt-aki/models/enums/Money";
|
|
import { IRagfairConfig, IUnreasonableModPrices } from "@spt-aki/models/spt/config/IRagfairConfig";
|
|
import { IRagfairServerPrices } from "@spt-aki/models/spt/ragfair/IRagfairServerPrices";
|
|
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
|
|
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
|
|
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
|
|
import { LocalisationService } from "@spt-aki/services/LocalisationService";
|
|
import { RandomUtil } from "@spt-aki/utils/RandomUtil";
|
|
|
|
/**
|
|
* Stores flea prices for items as well as methods to interact with them
|
|
*/
|
|
@injectable()
|
|
export class RagfairPriceService implements OnLoad
|
|
{
|
|
protected ragfairConfig: IRagfairConfig;
|
|
protected generatedDynamicPrices: boolean;
|
|
protected generatedStaticPrices: boolean;
|
|
|
|
protected prices: IRagfairServerPrices = { static: {}, dynamic: {} };
|
|
|
|
constructor(
|
|
@inject("HandbookHelper") protected handbookHelper: HandbookHelper,
|
|
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
|
|
@inject("WinstonLogger") protected logger: ILogger,
|
|
@inject("ItemHelper") protected itemHelper: ItemHelper,
|
|
@inject("PresetHelper") protected presetHelper: PresetHelper,
|
|
@inject("TraderHelper") protected traderHelper: TraderHelper,
|
|
@inject("RandomUtil") protected randomUtil: RandomUtil,
|
|
@inject("LocalisationService") protected localisationService: LocalisationService,
|
|
@inject("ConfigServer") protected configServer: ConfigServer,
|
|
)
|
|
{
|
|
this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR);
|
|
}
|
|
|
|
/**
|
|
* Generate static (handbook) and dynamic (prices.json) flea prices, store inside class as dictionaries
|
|
*/
|
|
public async onLoad(): Promise<void>
|
|
{
|
|
if (!this.generatedStaticPrices)
|
|
{
|
|
this.generateStaticPrices();
|
|
}
|
|
|
|
if (!this.generatedDynamicPrices)
|
|
{
|
|
this.generateDynamicPrices();
|
|
}
|
|
}
|
|
|
|
public getRoute(): string
|
|
{
|
|
return "RagfairPriceService";
|
|
}
|
|
|
|
/**
|
|
* Iterate over all items of type "Item" in db and get template price, store in cache
|
|
*/
|
|
public generateStaticPrices(): void
|
|
{
|
|
for (
|
|
const item of Object.values(this.databaseServer.getTables().templates.items).filter(x =>
|
|
x._type === "Item",
|
|
)
|
|
)
|
|
{
|
|
this.prices.static[item._id] = Math.round(this.handbookHelper.getTemplatePrice(item._id));
|
|
}
|
|
|
|
this.generatedStaticPrices = true;
|
|
}
|
|
|
|
/**
|
|
* Create a dictionary and store prices from prices.json in it
|
|
*/
|
|
public generateDynamicPrices(): void
|
|
{
|
|
Object.assign(this.prices.dynamic, this.databaseServer.getTables().templates.prices);
|
|
|
|
this.generatedDynamicPrices = true;
|
|
}
|
|
|
|
/**
|
|
* Get the dynamic price for an item. If value doesn't exist, use static (handbook) value.
|
|
* if no static value, return 1
|
|
* @param tplId Item tpl id to get price for
|
|
* @returns price in roubles
|
|
*/
|
|
public getFleaPriceForItem(tplId: string): number
|
|
{
|
|
// Get dynamic price (templates/prices), if that doesnt exist get price from static array (templates/handbook)
|
|
let itemPrice = this.getDynamicPriceForItem(tplId) || this.getStaticPriceForItem(tplId);
|
|
if (itemPrice === undefined)
|
|
{
|
|
this.logger.warning(
|
|
this.localisationService.getText("ragfair-unable_to_find_item_price_for_item_in_flea_handbook", tplId),
|
|
);
|
|
}
|
|
|
|
// If no price in dynamic/static, set to 1
|
|
itemPrice = itemPrice || 1;
|
|
|
|
return itemPrice;
|
|
}
|
|
|
|
/**
|
|
* Get the flea price for an offers items + children
|
|
* @param offerItems offer item + children to process
|
|
* @returns Rouble price
|
|
*/
|
|
public getFleaPriceForOfferItems(offerItems: Item[]): number
|
|
{
|
|
// Preset weapons take the direct prices.json value, otherwise they're massivly inflated
|
|
if (this.itemHelper.isOfBaseclass(offerItems[0]._tpl, BaseClasses.WEAPON))
|
|
{
|
|
return this.getFleaPriceForItem(offerItems[0]._tpl);
|
|
}
|
|
|
|
let totalPrice = 0;
|
|
for (const item of offerItems)
|
|
{
|
|
totalPrice += this.getFleaPriceForItem(item._tpl);
|
|
}
|
|
|
|
return totalPrice;
|
|
}
|
|
|
|
/**
|
|
* get the dynamic (flea) price for an item
|
|
* Grabs prices from prices.json and stores in class if none currently exist
|
|
* @param itemTpl item template id to look up
|
|
* @returns price in roubles
|
|
*/
|
|
public getDynamicPriceForItem(itemTpl: string): number
|
|
{
|
|
if (!this.generatedDynamicPrices)
|
|
{
|
|
this.generateDynamicPrices();
|
|
}
|
|
|
|
return this.prices.dynamic[itemTpl];
|
|
}
|
|
|
|
/**
|
|
* Grab the static (handbook) for an item by its tplId
|
|
* @param itemTpl item template id to look up
|
|
* @returns price in roubles
|
|
*/
|
|
public getStaticPriceForItem(itemTpl: string): number
|
|
{
|
|
if (!this.generatedStaticPrices)
|
|
{
|
|
this.generateStaticPrices();
|
|
}
|
|
|
|
return this.prices.static[itemTpl];
|
|
}
|
|
|
|
/**
|
|
* Get prices for all items on flea, prioritize handbook prices first, use prices from prices.json if missing
|
|
* @returns Dictionary of item tpls and rouble cost
|
|
*/
|
|
public getAllFleaPrices(): Record<string, number>
|
|
{
|
|
// assign dynamic (prices.json) values first, then overwrite them with static (handbook.json)
|
|
// any values not stored in static data will be covered by dynamic data
|
|
return { ...this.prices.dynamic, ...this.prices.static };
|
|
}
|
|
|
|
public getAllStaticPrices(): Record<string, number>
|
|
{
|
|
return { ...this.prices.static };
|
|
}
|
|
|
|
/**
|
|
* Get the percentage difference between two values
|
|
* @param a numerical value a
|
|
* @param b numerical value b
|
|
* @returns different in percent
|
|
*/
|
|
protected getPriceDifference(a: number, b: number): number
|
|
{
|
|
return 100 * a / (a + b);
|
|
}
|
|
|
|
/**
|
|
* Get the rouble price for an assorts barter scheme
|
|
* @param barterScheme
|
|
* @returns Rouble price
|
|
*/
|
|
public getBarterPrice(barterScheme: IBarterScheme[]): number
|
|
{
|
|
let price = 0;
|
|
|
|
for (const item of barterScheme)
|
|
{
|
|
price += this.prices.static[item._tpl] * item.count;
|
|
}
|
|
|
|
return Math.round(price);
|
|
}
|
|
|
|
/**
|
|
* Generate a currency cost for an item and its mods
|
|
* @param offerItems Item with mods to get price for
|
|
* @param desiredCurrency Currency price desired in
|
|
* @param isPackOffer Price is for a pack type offer
|
|
* @returns cost of item in desired currency
|
|
*/
|
|
public getDynamicOfferPriceForOffer(offerItems: Item[], desiredCurrency: string, isPackOffer: boolean): number
|
|
{
|
|
// Price to return.
|
|
let price = 0;
|
|
|
|
// Iterate over each item in the offer.
|
|
for (const item of offerItems)
|
|
{
|
|
// Skip over armour inserts as those are not factored into item prices.
|
|
if (this.itemHelper.isOfBaseclass(item._tpl, BaseClasses.BUILT_IN_INSERTS))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
price += this.getDynamicItemPrice(item._tpl, desiredCurrency, item, offerItems, isPackOffer);
|
|
|
|
// Check if the item is a weapon preset.
|
|
if (item?.upd?.sptPresetId && this.presetHelper.isPresetBaseClass(item.upd.sptPresetId, BaseClasses.WEAPON))
|
|
{
|
|
// This is a weapon preset, which has it's own price calculation that takes into account the mods in the
|
|
// preset. Since we've already calculated the price for the preset entire preset in
|
|
// `getDynamicItemPrice`, we can skip the rest of the items in the offer.
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Math.round(price);
|
|
}
|
|
|
|
/**
|
|
* @param itemTemplateId items tpl value
|
|
* @param desiredCurrency Currency to return result in
|
|
* @param item Item object (used for weapon presets)
|
|
* @param offerItems
|
|
* @param isPackOffer
|
|
* @returns
|
|
*/
|
|
public getDynamicItemPrice(
|
|
itemTemplateId: string,
|
|
desiredCurrency: string,
|
|
item?: Item,
|
|
offerItems?: Item[],
|
|
isPackOffer?: boolean,
|
|
): number
|
|
{
|
|
let isPreset = false;
|
|
let price = this.getFleaPriceForItem(itemTemplateId);
|
|
|
|
// Adjust price if below handbook price, based on config.
|
|
if (this.ragfairConfig.dynamic.offerAdjustment.adjustPriceWhenBelowHandbookPrice)
|
|
{
|
|
price = this.adjustPriceIfBelowHandbook(price, itemTemplateId);
|
|
}
|
|
|
|
// Use trader price if higher, based on config.
|
|
if (this.ragfairConfig.dynamic.useTraderPriceForOffersIfHigher)
|
|
{
|
|
const traderPrice = this.traderHelper.getHighestSellToTraderPrice(itemTemplateId);
|
|
if (traderPrice > price)
|
|
{
|
|
price = traderPrice;
|
|
}
|
|
}
|
|
|
|
// Prices for weapon presets are handled differently.
|
|
if (
|
|
item?.upd?.sptPresetId
|
|
&& offerItems
|
|
&& this.presetHelper.isPresetBaseClass(item.upd.sptPresetId, BaseClasses.WEAPON)
|
|
)
|
|
{
|
|
price = this.getWeaponPresetPrice(item, offerItems, price);
|
|
isPreset = true;
|
|
}
|
|
|
|
// Check for existence of manual price adjustment multiplier
|
|
const multiplier = this.ragfairConfig.dynamic.itemPriceMultiplier[itemTemplateId];
|
|
if (multiplier)
|
|
{
|
|
price *= multiplier;
|
|
}
|
|
|
|
// The quality of the item affects the price.
|
|
if (item)
|
|
{
|
|
const qualityModifier = this.itemHelper.getItemQualityModifier(item);
|
|
price *= qualityModifier;
|
|
}
|
|
|
|
// Make adjustments for unreasonably priced items.
|
|
for (const baseClassTemplateId of Object.keys(this.ragfairConfig.dynamic.unreasonableModPrices))
|
|
{
|
|
if (this.itemHelper.isOfBaseclass(itemTemplateId, baseClassTemplateId))
|
|
{
|
|
// Found an unreasonable price type.
|
|
const unreasonableModifier: IUnreasonableModPrices
|
|
= this.ragfairConfig.dynamic.unreasonableModPrices[baseClassTemplateId];
|
|
|
|
if (unreasonableModifier.enabled)
|
|
{
|
|
price = this.adjustUnreasonablePrice(
|
|
this.databaseServer.getTables().templates.handbook.Items,
|
|
unreasonableModifier,
|
|
itemTemplateId,
|
|
price,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vary the price based on the type of offer.
|
|
const range = this.getOfferTypeRangeValues(isPreset, isPackOffer);
|
|
price = this.randomiseOfferPrice(price, range);
|
|
|
|
// Convert to different currency if required.
|
|
const roublesId = Money.ROUBLES;
|
|
if (desiredCurrency !== roublesId)
|
|
{
|
|
price = this.handbookHelper.fromRUB(price, desiredCurrency);
|
|
}
|
|
|
|
if (price < 1)
|
|
{
|
|
return 1;
|
|
}
|
|
return price;
|
|
}
|
|
|
|
/**
|
|
* using data from config, adjust an items price to be relative to its handbook price
|
|
* @param handbookPrices Prices of items in handbook
|
|
* @param unreasonableItemChange Change object from config
|
|
* @param itemTpl Item being adjusted
|
|
* @param price Current price of item
|
|
* @returns Adjusted price of item
|
|
*/
|
|
protected adjustUnreasonablePrice(
|
|
handbookPrices: HandbookItem[],
|
|
unreasonableItemChange: IUnreasonableModPrices,
|
|
itemTpl: string,
|
|
price: number,
|
|
): number
|
|
{
|
|
const itemHandbookPrice = handbookPrices.find(handbookItem => handbookItem.Id === itemTpl);
|
|
if (!itemHandbookPrice)
|
|
{
|
|
return price;
|
|
}
|
|
|
|
// Flea price is over handbook price
|
|
if (price > itemHandbookPrice.Price * unreasonableItemChange.handbookPriceOverMultiplier)
|
|
{
|
|
// Skip extreme values
|
|
if (price <= 1)
|
|
{
|
|
return price;
|
|
}
|
|
|
|
// Price is over limit, adjust
|
|
return itemHandbookPrice.Price * unreasonableItemChange.newPriceHandbookMultiplier;
|
|
}
|
|
|
|
return price;
|
|
}
|
|
|
|
/**
|
|
* Get different min/max price multipliers for different offer types (preset/pack/default)
|
|
* @param isPreset Offer is a preset
|
|
* @param isPack Offer is a pack
|
|
* @returns MinMax values
|
|
*/
|
|
protected getOfferTypeRangeValues(isPreset: boolean, isPack: boolean): MinMax
|
|
{
|
|
// Use different min/max values if the item is a preset or pack
|
|
const priceRanges = this.ragfairConfig.dynamic.priceRanges;
|
|
if (isPreset)
|
|
{
|
|
return priceRanges.preset;
|
|
}
|
|
|
|
if (isPack)
|
|
{
|
|
return priceRanges.pack;
|
|
}
|
|
|
|
return priceRanges.default;
|
|
}
|
|
|
|
/**
|
|
* Check to see if an items price is below its handbook price and adjust according to values set to config/ragfair.json
|
|
* @param itemPrice price of item
|
|
* @param itemTpl item template Id being checked
|
|
* @returns adjusted price value in roubles
|
|
*/
|
|
protected adjustPriceIfBelowHandbook(itemPrice: number, itemTpl: string): number
|
|
{
|
|
const itemHandbookPrice = this.getStaticPriceForItem(itemTpl);
|
|
const priceDifferencePercent = this.getPriceDifference(itemHandbookPrice, itemPrice);
|
|
|
|
// Only adjust price if difference is > a percent AND item price passes threshold set in config
|
|
if (
|
|
priceDifferencePercent > this.ragfairConfig.dynamic.offerAdjustment.maxPriceDifferenceBelowHandbookPercent
|
|
&& itemPrice >= this.ragfairConfig.dynamic.offerAdjustment.priceThreshholdRub
|
|
)
|
|
{
|
|
// const itemDetails = this.itemHelper.getItem(itemTpl);
|
|
// this.logger.debug(`item below handbook price ${itemDetails[1]._name} handbook: ${itemHandbookPrice} flea: ${itemPrice} ${priceDifferencePercent}%`);
|
|
return Math.round(itemHandbookPrice * this.ragfairConfig.dynamic.offerAdjustment.handbookPriceMultipier);
|
|
}
|
|
|
|
return itemPrice;
|
|
}
|
|
|
|
/**
|
|
* Multiply the price by a randomised curve where n = 2, shift = 2
|
|
* @param existingPrice price to alter
|
|
* @param rangeValues min and max to adjust price by
|
|
* @returns multiplied price
|
|
*/
|
|
protected randomiseOfferPrice(existingPrice: number, rangeValues: MinMax): number
|
|
{
|
|
// Multiply by 100 to get 2 decimal places of precision
|
|
const multiplier = this.randomUtil.getBiasedRandomNumber(rangeValues.min * 100, rangeValues.max * 100, 2, 2);
|
|
|
|
// return multiplier back to its original decimal place location
|
|
return existingPrice * (multiplier / 100);
|
|
}
|
|
|
|
/**
|
|
* Calculate the cost of a weapon preset by adding together the price of its mods + base price of default weapon preset
|
|
* @param weaponRootItem base weapon
|
|
* @param weaponWithChildren weapon plus mods
|
|
* @param existingPrice price of existing base weapon
|
|
* @returns price of weapon in roubles
|
|
*/
|
|
protected getWeaponPresetPrice(weaponRootItem: Item, weaponWithChildren: Item[], existingPrice: number): number
|
|
{
|
|
// Get the default preset for this weapon
|
|
const presetResult = this.getWeaponPreset(weaponRootItem);
|
|
if (presetResult.isDefault)
|
|
{
|
|
return this.getFleaPriceForItem(weaponRootItem._tpl);
|
|
}
|
|
|
|
// Get mods on current gun not in default preset
|
|
const newOrReplacedModsInPresetVsDefault = weaponWithChildren.filter(x =>
|
|
!presetResult.preset._items.some(y => y._tpl === x._tpl),
|
|
);
|
|
|
|
// Add up extra mods price
|
|
let extraModsPrice = 0;
|
|
for (const mod of newOrReplacedModsInPresetVsDefault)
|
|
{
|
|
// Use handbook or trader price, whatever is higher (dont use dynamic flea price as purchased item cannot be relisted)
|
|
extraModsPrice += this.getHighestHandbookOrTraderPriceAsRouble(mod._tpl);
|
|
}
|
|
|
|
// Only deduct cost of replaced mods if there's replaced/new mods
|
|
if (newOrReplacedModsInPresetVsDefault.length >= 1)
|
|
{
|
|
// Add up cost of mods replaced
|
|
const modsReplacedByNewMods = newOrReplacedModsInPresetVsDefault.filter(x =>
|
|
presetResult.preset._items.some(y => y.slotId === x.slotId),
|
|
);
|
|
|
|
// Add up replaced mods price
|
|
let replacedModsPrice = 0;
|
|
for (const replacedMod of modsReplacedByNewMods)
|
|
{
|
|
replacedModsPrice += this.getHighestHandbookOrTraderPriceAsRouble(replacedMod._tpl);
|
|
}
|
|
|
|
// Subtract replaced mods total from extra mods total
|
|
extraModsPrice -= replacedModsPrice;
|
|
}
|
|
|
|
// return extra mods price + base gun price
|
|
return existingPrice + extraModsPrice;
|
|
}
|
|
|
|
/**
|
|
* Get the highest price for an item that is stored in handbook or trader assorts
|
|
* @param itemTpl Item to get highest price of
|
|
* @returns rouble cost
|
|
*/
|
|
protected getHighestHandbookOrTraderPriceAsRouble(itemTpl: string): number
|
|
{
|
|
let price = this.getStaticPriceForItem(itemTpl);
|
|
const traderPrice = this.traderHelper.getHighestSellToTraderPrice(itemTpl);
|
|
if (traderPrice > price)
|
|
{
|
|
price = traderPrice;
|
|
}
|
|
|
|
return price;
|
|
}
|
|
|
|
/**
|
|
* Attempt to get the default preset for a weapon, failing that get the first preset in the array
|
|
* (assumes default = has encyclopedia entry)
|
|
* @param presets weapon presets to choose from
|
|
* @returns Default preset object
|
|
*/
|
|
protected getWeaponPreset(weapon: Item): { isDefault: boolean, preset: IPreset }
|
|
{
|
|
const defaultPreset = this.presetHelper.getDefaultPreset(weapon._tpl);
|
|
if (defaultPreset)
|
|
{
|
|
return { isDefault: true, preset: defaultPreset };
|
|
}
|
|
const nonDefaultPresets = this.presetHelper.getPresets(weapon._tpl);
|
|
if (nonDefaultPresets.length === 1)
|
|
{
|
|
this.logger.debug(
|
|
`Item Id: ${weapon._tpl} has no default encyclopedia entry but only one preset (${
|
|
nonDefaultPresets[0]._name
|
|
}), choosing preset (${nonDefaultPresets[0]._name})`,
|
|
);
|
|
}
|
|
else
|
|
{
|
|
this.logger.debug(
|
|
`Item Id: ${weapon._tpl} has no default encyclopedia entry, choosing first preset (${
|
|
nonDefaultPresets[0]._name
|
|
}) of ${nonDefaultPresets.length}`,
|
|
);
|
|
}
|
|
|
|
return { isDefault: false, preset: nonDefaultPresets[0] };
|
|
}
|
|
}
|