Files
SPT-Server-Build/Libraries/SPTarkov.Server.Core/Services/RagfairPriceService.cs
T
2025-07-07 13:31:39 +01:00

557 lines
19 KiB
C#

using SPTarkov.DI.Annotations;
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,
ServerLocalisationService serverLocalisationService,
ConfigServer configServer
)
{
private 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();
RefreshDynamicPrices();
}
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>
/// Copy the prices.json data into our dynamic price dictionary
/// </summary>
public void RefreshDynamicPrices()
{
// TODO: remove as redundant?
}
/// <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 flea price for an offers items + children
/// </summary>
/// <param name="offerItems">offer item + children to process</param>
/// <returns>Rouble price</returns>
public double GetFleaPriceForOfferItems(List<Item> offerItems)
{
// Preset weapons take the direct prices.json value, otherwise they're massively inflated
if (itemHelper.IsOfBaseclass(offerItems[0].Template, BaseClasses.WEAPON))
{
return GetFleaPriceForItem(offerItems[0].Template);
}
return offerItems.Sum(item => GetFleaPriceForItem(item.Template));
}
/// <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>
/// Get the rouble price for an assorts barter scheme
/// </summary>
/// <param name="barterScheme"></param>
/// <returns>Rouble price</returns>
public double GetBarterPrice(List<BarterScheme> barterScheme)
{
var price = 0d;
foreach (var item in barterScheme)
{
price += GetStaticPriceForItem(item.Template).Value * item.Count.Value;
}
return Math.Round(price);
}
/// <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(
List<Item> offerItems,
string 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, 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);
}
/// <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,
List<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, 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.FromRUB(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,
List<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="presets">weapon presets to choose from</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; }
}
}