Files
SPT-Server-Build/Libraries/SPTarkov.Server.Core/Services/RagfairPriceService.cs
T
Chomp 55b819e1ec Removed GetItemRarityMultiplier, the values in items.json are garbage and should not be used
Added system to add multiplier to items that are used to craft in the hideout
2025-09-18 23:35:27 +01:00

577 lines
21 KiB
C#

using SPTarkov.DI.Annotations;
using SPTarkov.Server.Core.Extensions;
using SPTarkov.Server.Core.Helpers;
using SPTarkov.Server.Core.Models.Common;
using SPTarkov.Server.Core.Models.Eft.Common;
using SPTarkov.Server.Core.Models.Eft.Common.Tables;
using SPTarkov.Server.Core.Models.Enums;
using SPTarkov.Server.Core.Models.Spt.Config;
using SPTarkov.Server.Core.Models.Utils;
using SPTarkov.Server.Core.Servers;
using SPTarkov.Server.Core.Utils;
using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel;
namespace SPTarkov.Server.Core.Services;
/// <summary>
/// Stores flea prices for items as well as methods to interact with them.
/// </summary>
[Injectable(InjectionType.Singleton)]
public class RagfairPriceService(
ISptLogger<RagfairPriceService> logger,
RandomUtil randomUtil,
HandbookHelper handbookHelper,
TraderHelper traderHelper,
PresetHelper presetHelper,
ItemHelper itemHelper,
DatabaseService databaseService,
DatabaseServer databaseServer,
ServerLocalisationService serverLocalisationService,
ConfigServer configServer
)
{
protected readonly RagfairConfig RagfairConfig = configServer.GetConfig<RagfairConfig>();
protected Dictionary<MongoId, double>? StaticPrices;
/// <summary>
/// Generate static (handbook) and dynamic (prices.json) flea prices, store inside class as dictionaries
/// </summary>
public void Load()
{
RefreshStaticPrices();
if (RagfairConfig.Dynamic.GenerateBaseFleaPrices.UseHandbookPrice)
{
ReplaceFleaBasePrices();
}
}
public string GetRoute()
{
return "RagfairPriceService";
}
/// <summary>
/// Iterate over all items of type "Item" in db and get template price, store in cache
/// </summary>
public void RefreshStaticPrices()
{
StaticPrices = new Dictionary<MongoId, double>();
foreach (
var item in databaseService
.GetItems()
.Values.Where(item => string.Equals(item.Type, "Item", StringComparison.OrdinalIgnoreCase))
)
{
StaticPrices[item.Id] = handbookHelper.GetTemplatePrice(item.Id);
}
}
/// <summary>
/// Replace base item price used for flea
/// Use handbook as a base price
/// </summary>
public void ReplaceFleaBasePrices()
{
var config = RagfairConfig.Dynamic.GenerateBaseFleaPrices;
var pricePool = databaseServer.GetTables().Templates.Prices;
var hideoutCraftItems = GetHideoutCraftItemTpls();
foreach (var (itemTpl, handbookPrice) in StaticPrices)
{
// Get new price to use
var newBasePrice =
handbookPrice
* (GetFleaBasePriceMultiplier(itemTpl, config) + GetHideoutCraftMultiplier(itemTpl, config, hideoutCraftItems));
if (newBasePrice == 0)
{
continue;
}
if (config.PreventPriceBeingBelowTraderBuyPrice)
{
// Check if item can be sold to trader for a higher price than what we're going to set
var highestSellToTraderPrice = traderHelper.GetHighestSellToTraderPrice(itemTpl);
if (highestSellToTraderPrice > newBasePrice)
{
// Trader has higher sell price, use that value
newBasePrice = highestSellToTraderPrice;
}
}
pricePool.AddOrUpdate(itemTpl, newBasePrice);
}
}
/// <summary>
/// Get the multiplier to apply to items used in hideout crafts
/// If not hideout craft item, return 0
/// </summary>
/// <param name="itemTpl">Item to get multiplier for</param>
/// <param name="config">Ragfair config</param>
/// <param name="hideoutCraftItems">Craft item tpls</param>
/// <returns>Multiplier</returns>
protected double GetHideoutCraftMultiplier(MongoId itemTpl, GenerateFleaPrices config, HashSet<MongoId?> hideoutCraftItems)
{
if (!config.UseHideoutCraftMultiplier || !hideoutCraftItems.Contains(itemTpl))
{
return 0;
}
return config.HideoutCraftMultiplier;
}
/// <summary>
/// Get a set of item tpls used by hideout crafts as requirements
/// </summary>
/// <returns>Set</returns>
protected HashSet<MongoId?> GetHideoutCraftItemTpls()
{
var results = new HashSet<MongoId?>();
foreach (
var itemRequirements in databaseService
.GetHideout()
.Production.Recipes.Select(recipe => recipe.Requirements.Where(x => x.Type == "Item").Select(x => x.TemplateId))
)
{
results.UnionWith(itemRequirements);
}
return results;
}
/// <summary>
/// Get the multiplier to apply to a handbook price to create the base flea price of an item
/// </summary>
/// <param name="itemTpl">Item to look up multiplier of</param>
/// <param name="config"></param>
/// <returns>Multiplier value</returns>
protected double GetFleaBasePriceMultiplier(MongoId itemTpl, GenerateFleaPrices config)
{
// Specific item multiplier may exist, check for it
if (RagfairConfig.Dynamic.GenerateBaseFleaPrices.ItemTplMultiplierOverride.TryGetValue(itemTpl, out var specificItemMultiplier))
{
return specificItemMultiplier;
}
// Check if tpl is of each time, if it is, use that multi
foreach (var (itemType, multiplier) in RagfairConfig.Dynamic.GenerateBaseFleaPrices.ItemTypeMultiplierOverride)
{
if (itemHelper.IsOfBaseclass(itemTpl, itemType))
{
return multiplier;
}
}
return RagfairConfig.Dynamic.GenerateBaseFleaPrices.PriceMultiplier;
}
/// <summary>
/// Get the dynamic price for an item. If value doesn't exist, use static (handbook) value.
/// if no static value, return 1
/// </summary>
/// <param name="tplId">Item tpl id to get price for</param>
/// <returns>price in roubles</returns>
public double GetFleaPriceForItem(MongoId tplId)
{
// Get dynamic price (templates/prices), if that doesn't exist get price from static array (templates/handbook)
var itemPrice = itemHelper.GetDynamicItemPrice(tplId) ?? GetStaticPriceForItem(tplId);
if (itemPrice is null)
{
var itemFromDb = itemHelper.GetItem(tplId);
logger.Warning(
serverLocalisationService.GetText(
"ragfair-unable_to_find_item_price_for_item_in_flea_handbook",
new { tpl = tplId, name = itemFromDb.Value.Name ?? "" }
)
);
}
// If no price in dynamic/static, set to 1
if (itemPrice == 0)
{
itemPrice = 1;
}
return itemPrice.Value;
}
/// <summary>
/// Get the dynamic (flea) price for an item
/// </summary>
/// <param name="itemTpl"> Item template id to look up </param>
/// <returns> Price in roubles </returns>
public double? GetDynamicPriceForItem(MongoId itemTpl)
{
databaseService.GetPrices().TryGetValue(itemTpl, out var value);
return value;
}
/// <summary>
/// Grab the static (handbook) for an item by its tplId
/// </summary>
/// <param name="itemTpl">item template id to look up</param>
/// <returns>price in roubles</returns>
public double? GetStaticPriceForItem(MongoId itemTpl)
{
return handbookHelper.GetTemplatePrice(itemTpl);
}
/// <summary>
/// Get prices for all items on flea, prioritize handbook prices first, use prices from prices.json if missing
/// This will refresh the caches prior to building the output
/// </summary>
/// <returns>Dictionary of item tpls and rouble cost</returns>
public Dictionary<MongoId, double> GetAllFleaPrices()
{
var dynamicPrices = databaseService.GetPrices();
// Use dynamic prices first, fill in any gaps with data from static prices (handbook)
return dynamicPrices.Concat(StaticPrices).GroupBy(x => x.Key).ToDictionary(x => x.Key, x => x.First().Value);
}
public Dictionary<MongoId, double> GetAllStaticPrices()
{
// Refresh the cache so we include any newly added custom items
if (StaticPrices is null)
{
RefreshStaticPrices();
}
return StaticPrices;
}
/// <summary>
/// Get the percentage difference between two values
/// </summary>
/// <param name="a">numerical value a</param>
/// <param name="b">numerical value b</param>
/// <returns>different in percent</returns>
protected double GetPriceDifference(double a, double b)
{
return 100 * a / (a + b);
}
/// <summary>
/// Generate a currency cost for an item and its mods
/// </summary>
/// <param name="offerItems">Item with mods to get price for</param>
/// <param name="desiredCurrency">Currency price desired in</param>
/// <param name="isPackOffer">Price is for a pack type offer</param>
/// <returns>cost of item in desired currency</returns>
public double GetDynamicOfferPriceForOffer(IEnumerable<Item> offerItems, MongoId desiredCurrency, bool isPackOffer)
{
// Price to return.
var price = 0d;
// Iterate over each item in the offer.
foreach (var item in offerItems)
{
// Skip over armor inserts as those are not factored into item prices.
if (itemHelper.IsOfBaseclass(item.Template, BaseClasses.BUILT_IN_INSERTS))
{
continue;
}
price += GetDynamicItemPrice(item.Template, desiredCurrency, item, offerItems, isPackOffer).Value;
// Check if the item is a weapon preset.
if (item?.Upd?.SptPresetId is not null && presetHelper.IsPresetBaseClass(item.Upd.SptPresetId.Value, BaseClasses.WEAPON))
// This is a weapon preset, which has its 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);
}
/// <summary>
/// </summary>
/// <param name="itemTemplateId">items tpl value</param>
/// <param name="desiredCurrency">Currency to return result in</param>
/// <param name="item">Item object (used for weapon presets)</param>
/// <param name="offerItems"></param>
/// <param name="isPackOffer"></param>
/// <returns></returns>
public double? GetDynamicItemPrice(
MongoId itemTemplateId,
MongoId desiredCurrency,
Item? item = null,
IEnumerable<Item>? offerItems = null,
bool? isPackOffer = null
)
{
var isPreset = false;
var price = GetFleaPriceForItem(itemTemplateId);
// Adjust price if below handbook price, based on config.
if (RagfairConfig.Dynamic.OfferAdjustment.AdjustPriceWhenBelowHandbookPrice)
{
price = AdjustPriceIfBelowHandbook(price, itemTemplateId);
}
// Use trader price if higher, based on config.
if (RagfairConfig.Dynamic.UseTraderPriceForOffersIfHigher)
{
var traderPrice = traderHelper.GetHighestSellToTraderPrice(itemTemplateId);
if (traderPrice > price)
{
price = traderPrice;
}
}
// Prices for weapon presets are handled differently.
if (
item?.Upd?.SptPresetId is not null
&& offerItems is not null
&& presetHelper.IsPresetBaseClass(item.Upd.SptPresetId.Value, BaseClasses.WEAPON)
)
{
price = GetWeaponPresetPrice(item, offerItems, price);
isPreset = true;
}
// Check for existence of manual price adjustment multiplier
if (RagfairConfig.Dynamic.ItemPriceMultiplier.TryGetValue(itemTemplateId, out var multiplier))
{
price *= multiplier;
}
// The quality of the item affects the price + not on the ignore list
if (item is not null && !RagfairConfig.Dynamic.IgnoreQualityPriceVarianceBlacklist.Contains(itemTemplateId))
{
var qualityModifier = itemHelper.GetItemQualityModifier(item);
price *= qualityModifier;
}
// Make adjustments for unreasonably priced items.
foreach (var (key, value) in RagfairConfig.Dynamic.UnreasonableModPrices)
{
if (!value.Enabled || !itemHelper.IsOfBaseclass(itemTemplateId, key))
{
continue;
}
price = AdjustUnreasonablePrice(value, itemTemplateId, price);
}
// Vary the price based on the type of offer.
var range = GetOfferTypeRangeValues(isPreset, isPackOffer ?? false);
price = RandomiseOfferPrice(price, range);
// Convert to different currency if required.
if (desiredCurrency != Money.ROUBLES)
{
price = handbookHelper.FromRoubles(price, desiredCurrency);
}
if (price <= 0)
{
return 0.1d;
}
return price;
}
/// <summary>
/// using data from config, adjust an items price to be relative to its handbook price
/// </summary>
/// <param name="unreasonableItemChange">Change object from config</param>
/// <param name="itemTpl">Item being adjusted</param>
/// <param name="price">Current price of item</param>
/// <returns>Adjusted price of item</returns>
protected double AdjustUnreasonablePrice(UnreasonableModPrices unreasonableItemChange, MongoId itemTpl, double price)
{
var itemHandbookPrice = handbookHelper.GetTemplatePrice(itemTpl);
if (itemHandbookPrice > 0)
{
return price;
}
// Flea price is over handbook price
if (price > itemHandbookPrice * unreasonableItemChange.HandbookPriceOverMultiplier)
{
// Skip extreme values
if (price <= 1)
{
return price;
}
// Price is over limit, adjust
return itemHandbookPrice * unreasonableItemChange.NewPriceHandbookMultiplier;
}
return price;
}
/// <summary>
/// Get different min/max price multipliers for different offer types (preset/pack/default)
/// </summary>
/// <param name="isPreset">Offer is a preset</param>
/// <param name="isPack">Offer is a pack</param>
/// <returns>MinMax values</returns>
protected MinMax<double> GetOfferTypeRangeValues(bool isPreset, bool isPack)
{
// Use different min/max values if the item is a preset or pack
var priceRanges = RagfairConfig.Dynamic.PriceRanges;
if (isPreset)
{
return priceRanges.Preset;
}
if (isPack)
{
return priceRanges.Pack;
}
return priceRanges.Default;
}
/// <summary>
/// Check to see if an items price is below its handbook price and adjust according to values set to config/ragfair.json
/// </summary>
/// <param name="itemPrice">price of item</param>
/// <param name="itemTpl">item template Id being checked</param>
/// <returns>adjusted price value in roubles</returns>
protected double AdjustPriceIfBelowHandbook(double itemPrice, MongoId itemTpl)
{
var itemHandbookPrice = GetStaticPriceForItem(itemTpl);
var priceDifferencePercent = GetPriceDifference(itemHandbookPrice.Value, itemPrice);
var offerAdjustmentSettings = RagfairConfig.Dynamic.OfferAdjustment;
// Only adjust price if difference is > a percent AND item price passes threshold set in config
if (
priceDifferencePercent > offerAdjustmentSettings.MaxPriceDifferenceBelowHandbookPercent
&& itemPrice >= offerAdjustmentSettings.PriceThresholdRub
)
// var itemDetails = this.itemHelper.getItem(itemTpl);
// this.logger.debug(`item below handbook price {itemDetails[1]._name} handbook: {itemHandbookPrice} flea: ${itemPrice} {priceDifferencePercent}%`);
{
return Math.Round(itemHandbookPrice.Value * offerAdjustmentSettings.HandbookPriceMultiplier);
}
return itemPrice;
}
/// <summary>
/// Multiply the price by a randomised curve where n = 2, shift = 2
/// </summary>
/// <param name="existingPrice">price to alter</param>
/// <param name="rangeValues">min and max to adjust price by</param>
/// <returns>multiplied price</returns>
protected double RandomiseOfferPrice(double existingPrice, MinMax<double> rangeValues)
{
// Multiply by 100 to get 2 decimal places of precision
var multiplier = randomUtil.GetBiasedRandomNumber(rangeValues.Min * 100, rangeValues.Max * 100, 2, 2);
// return multiplier back to its original decimal place location
return existingPrice * (multiplier / 100);
}
/// <summary>
/// Calculate the cost of a weapon preset by adding together the price of its mods + base price of default weapon preset
/// </summary>
/// <param name="weaponRootItem">base weapon</param>
/// <param name="weaponWithChildren">weapon plus mods</param>
/// <param name="existingPrice">price of existing base weapon</param>
/// <returns>price of weapon in roubles</returns>
protected double GetWeaponPresetPrice(Item weaponRootItem, IEnumerable<Item> weaponWithChildren, double existingPrice)
{
// Get the default preset for this weapon
var presetResult = GetWeaponPreset(weaponRootItem);
if (presetResult.IsDefault)
{
return GetFleaPriceForItem(weaponRootItem.Template);
}
// Get mods on current gun not in default preset
var newOrReplacedModsInPresetVsDefault = weaponWithChildren.Where(x =>
!presetResult.Preset.Items.Any(y => y.Template == x.Template)
);
// Add up extra mods price
var extraModsPrice = 0d;
foreach (var mod in newOrReplacedModsInPresetVsDefault)
// Use handbook or trader price, whatever is higher (dont use dynamic flea price as purchased item cannot be relisted)
{
extraModsPrice += GetHighestHandbookOrTraderPriceAsRouble(mod.Template).Value;
}
// Only deduct cost of replaced mods if there's replaced/new mods
if (newOrReplacedModsInPresetVsDefault.Any())
{
// Add up cost of mods replaced
var modsReplacedByNewMods = newOrReplacedModsInPresetVsDefault.Where(x =>
presetResult.Preset.Items.Any(y => y.SlotId == x.SlotId)
);
// Add up replaced mods price
var replacedModsPrice = 0d;
foreach (var replacedMod in modsReplacedByNewMods)
{
replacedModsPrice += GetHighestHandbookOrTraderPriceAsRouble(replacedMod.Template).Value;
}
// Subtract replaced mods total from extra mods total
extraModsPrice -= replacedModsPrice;
}
// return extra mods price + base gun price
return existingPrice + extraModsPrice;
}
/// <summary>
/// Get the highest price for an item that is stored in handbook or trader assorts
/// </summary>
/// <param name="itemTpl">Item to get highest price of</param>
/// <returns>rouble cost</returns>
protected double? GetHighestHandbookOrTraderPriceAsRouble(MongoId itemTpl)
{
var price = GetStaticPriceForItem(itemTpl);
var traderPrice = traderHelper.GetHighestSellToTraderPrice(itemTpl);
if (traderPrice > price)
{
price = traderPrice;
}
return price;
}
/// <summary>
/// Attempt to get the default preset for a weapon, failing that get the first preset in the array
/// (assumes default = has encyclopedia entry)
/// </summary>
/// <param name="weapon">weapon item to get preset of</param>
/// <returns>Default preset object</returns>
protected WeaponPreset GetWeaponPreset(Item weapon)
{
var defaultPreset = presetHelper.GetDefaultPreset(weapon.Template);
if (defaultPreset is not null)
{
return new WeaponPreset { IsDefault = true, Preset = defaultPreset };
}
var nonDefaultPresets = presetHelper.GetPresets(weapon.Template);
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug(
nonDefaultPresets.Count == 1
? $"Item Id: {weapon.Template} has no default encyclopedia entry but only one preset: ({nonDefaultPresets[0].Name}), choosing preset: ({nonDefaultPresets[0].Name})"
: $"Item Id: {weapon.Template} has no default encyclopedia entry, choosing first preset({nonDefaultPresets[0].Name}) of {nonDefaultPresets.Count}"
);
}
return new WeaponPreset { IsDefault = false, Preset = nonDefaultPresets[0] };
}
public record WeaponPreset
{
public bool IsDefault { get; set; }
public Preset Preset { get; set; }
}
}