using SptCommon.Annotations; using Core.Models.Common; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.Profile; using Core.Models.Enums; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Servers; using Core.Services; using Core.Utils; using SptCommon.Extensions; namespace Core.Helpers; [Injectable] public class TraderHelper( ISptLogger _logger, DatabaseService _databaseService, ProfileHelper _profileHelper, HandbookHelper _handbookHelper, ItemHelper _itemHelper, PlayerService _playerService, LocalisationService _localisationService, FenceService _fenceService, TimeUtil _timeUtil, RandomUtil _randomUtil, ConfigServer _configServer ) { protected TraderConfig _traderConfig = _configServer.GetConfig(); protected Dictionary _highestTraderPriceItems = new(); protected List _gameVersions = [GameEditions.EDGE_OF_DARKNESS, GameEditions.UNHEARD]; /// /// Get a trader base object, update profile to reflect players current standing in profile /// when trader not found in profile /// /// Traders Id to get /// Players id /// Trader base public TraderBase? GetTrader(string traderID, string sessionID) { if (traderID == "ragfair") { return new() { Currency = "RUB" }; } var pmcData = _profileHelper.GetPmcProfile(sessionID); if (pmcData == null) throw new Exception(_localisationService.GetText("trader-unable_to_find_profile_with_id", sessionID)); // Profile has traderInfo dict (profile beyond creation stage) but no requested trader in profile if (pmcData?.TradersInfo != null && (pmcData?.TradersInfo?.ContainsKey(traderID) ?? false)) { // Add trader values to profile ResetTrader(sessionID, traderID); LevelUp(traderID, pmcData); } var traderBase = _databaseService.GetTrader(traderID).Base; if (traderBase == null) _logger.Error(_localisationService.GetText("trader-unable_to_find_trader_by_id", traderID)); return traderBase; } /// /// Get all assort data for a particular trader /// /// Trader to get assorts for /// TraderAssort public TraderAssort GetTraderAssortsByTraderId(string traderId) { return traderId == Traders.FENCE ? _fenceService.GetRawFenceAssorts() : _databaseService.GetTrader(traderId).Assort; } /// /// Retrieve the Item from a traders assort data by its id /// /// Trader to get assorts for /// Id of assort to find /// Item object public Item? GetTraderAssortItemByAssortId(string traderId, string assortId) { var traderAssorts = GetTraderAssortsByTraderId(traderId); if (traderAssorts is null) { _logger.Debug($"No assorts on trader: {traderId} found"); return null; } // Find specific assort in traders data var purchasedAssort = traderAssorts.Items.FirstOrDefault(item => item.Id == assortId); if (purchasedAssort is null) { _logger.Debug($"No assort {assortId} on trader: {traderId} found"); return null; } return purchasedAssort; } /// /// Reset a profiles trader data back to its initial state as seen by a level 1 player /// Does NOT take into account different profile levels /// /// session id of player /// trader id to reset public void ResetTrader(string sessionID, string traderID) { var profiles = _databaseService.GetProfiles(); var trader = _databaseService.GetTrader(traderID); var fullProfile = _profileHelper.GetFullProfile(sessionID); if (fullProfile is null) { throw new Exception(_localisationService.GetText("trader-unable_to_find_profile_by_id", sessionID)); } var pmcData = fullProfile.CharacterData.PmcData; ProfileTraderTemplate rawProfileTemplate = profiles.GetByJsonProp(fullProfile.ProfileInfo.Edition) .GetByJsonProp(pmcData.Info.Side.ToLower()) .Trader; var newTraderData = new TraderInfo { Disabled = false, LoyaltyLevel = rawProfileTemplate.InitialLoyaltyLevel.GetValueOrDefault(traderID, 1), SalesSum = rawProfileTemplate.InitialSalesSum, Standing = GetStartingStanding(traderID, rawProfileTemplate), NextResupply = trader.Base.NextResupply, Unlocked = trader.Base.UnlockedByDefault }; if (!pmcData.TradersInfo.TryAdd(traderID, newTraderData)) { pmcData.TradersInfo[traderID] = newTraderData; } // Check if trader should be locked by default if (rawProfileTemplate.LockedByDefaultOverride?.Contains(traderID) ?? false) { pmcData.TradersInfo[traderID].Unlocked = true; } if (rawProfileTemplate.PurchaseAllClothingByDefaultForTrader?.Contains(traderID) ?? false) { // Get traders clothing var clothing = _databaseService.GetTrader(traderID).Suits; if (clothing?.Count > 0) { // Force suit ids into profile AddSuitsToProfile( fullProfile, clothing.Select(suit => suit.SuiteId).ToList() ); } } if ((rawProfileTemplate.FleaBlockedDays ?? 0) > 0) { var newBanDateTime = _timeUtil.GetTimeStampFromNowDays(rawProfileTemplate.FleaBlockedDays ?? 0); var existingBan = pmcData.Info.Bans.FirstOrDefault(ban => ban.BanType == BanType.RAGFAIR); if (existingBan is not null) { existingBan.DateTime = newBanDateTime; } else { pmcData.Info.Bans.Add( new Ban { BanType = BanType.RAGFAIR, DateTime = newBanDateTime } ); } } if (traderID == Traders.JAEGER) { pmcData.TradersInfo[traderID].Unlocked = rawProfileTemplate.JaegerUnlocked; } } /// /// Get the starting standing of a trader based on the current profiles type (e.g. EoD, Standard etc) /// /// Trader id to get standing for /// Raw profile from profiles.json to look up standing from /// Standing value protected double? GetStartingStanding(string traderId, ProfileTraderTemplate rawProfileTemplate) { if (rawProfileTemplate.InitialStanding.TryGetValue(traderId, out var standing)) { // Edge case for Lightkeeper, 0 standing means seeing `Make Amends - Buyout` quest if (traderId == Traders.LIGHTHOUSEKEEPER && standing == 0) { return 0.01; } return standing; } return 0; } /// /// Add a list of suit ids to a profiles suit list, no duplicates /// /// Profile to add to /// Suit Ids to add protected void AddSuitsToProfile(SptProfile fullProfile, List suitIds) { if (fullProfile.Suits is null) { fullProfile.Suits = []; } foreach (var suitId in suitIds) { // Don't add dupes if (!fullProfile.Suits.Contains(suitId)) { fullProfile.Suits.Add(suitId); } } } /// /// Alter a traders unlocked status /// /// Trader to alter /// New status to use /// Session id of player public void SetTraderUnlockedState(string traderId, bool status, string sessionId) { var pmcData = _profileHelper.GetPmcProfile(sessionId); var profileTraderData = pmcData.TradersInfo[traderId]; if (profileTraderData is null) { _logger.Error($"Unable to set trader {traderId} unlocked state to: {status} as trader cannot be found in profile"); return; } profileTraderData.Unlocked = status; } /// /// Add standing to a trader and level them up if exp goes over level threshold /// /// Session id of player /// Traders id to add standing to /// Standing value to add to trader public void AddStandingToTrader(string sessionId, string traderId, double standingToAdd) { var fullProfile = _profileHelper.GetFullProfile(sessionId); var pmcTraderInfo = fullProfile.CharacterData.PmcData.TradersInfo[traderId]; // Add standing to trader pmcTraderInfo.Standing = AddStandingValuesTogether(pmcTraderInfo.Standing, standingToAdd); if (traderId == Traders.FENCE) { // Must add rep to scav profile to ensure consistency fullProfile.CharacterData.ScavData.TradersInfo[traderId].Standing = pmcTraderInfo.Standing; } this.LevelUp(traderId, fullProfile.CharacterData.PmcData); } /// /// Add standing to current standing and clamp value if it goes too low /// /// current trader standing /// standing to add to trader standing /// current standing + added standing (clamped if needed) protected double? AddStandingValuesTogether(double? currentStanding, double standingToAdd) { var newStanding = currentStanding + standingToAdd; // Never let standing fall below 0 return newStanding < 0 ? 0 : newStanding; } /// /// Iterate over a profile's traders and ensure they have the correct loyalty level for the player. /// /// Profile to check. public void ValidateTraderStandingsAndPlayerLevelForProfile(string sessionId) { var profile = _profileHelper.GetPmcProfile(sessionId); var traders = _databaseService.GetTraders(); foreach (var trader in traders) { this.LevelUp(trader.Key, profile); } } /// /// Calculate trader's level based on experience amount and increments level if over threshold. /// Also validates and updates player level if not correct based on XP value. /// /// Trader to check standing of. /// Profile to update trader in. public void LevelUp(string traderID, PmcData pmcData) { var loyaltyLevels = _databaseService.GetTrader(traderID).Base.LoyaltyLevels; // Level up player pmcData.Info.Level = _playerService.CalculateLevel(pmcData); // Level up traders var targetLevel = 0; // Round standing to 2 decimal places to address floating point inaccuracies pmcData.TradersInfo[traderID].Standing = Math.Round((pmcData.TradersInfo[traderID].Standing * 100) ?? 0) / 100; foreach (var loyaltyLevel in loyaltyLevels) { if (loyaltyLevel.MinLevel <= pmcData.Info.Level && loyaltyLevel.MinSalesSum <= pmcData.TradersInfo[traderID].SalesSum && loyaltyLevel.MinStanding <= pmcData.TradersInfo[traderID].Standing && targetLevel < 4 ) { // level reached targetLevel++; } } // set level pmcData.TradersInfo[traderID].LoyaltyLevel = targetLevel; } /// /// Get the next update timestamp for a trader. /// /// Trader to look up update value for. /// Future timestamp. public long GetNextUpdateTimestamp(string traderID) { var time = _timeUtil.GetTimeStamp(); var updateSeconds = GetTraderUpdateSeconds(traderID) ?? 0; return time + updateSeconds; } /// /// Get the reset time between trader assort refreshes in seconds. /// /// Trader to look up. /// Time in seconds. public long? GetTraderUpdateSeconds(string traderId) { var traderDetails = _traderConfig.UpdateTime.FirstOrDefault((x) => x.TraderId == traderId); if (traderDetails is null || traderDetails.Seconds?.Min is null || traderDetails.Seconds.Max is null) { _logger.Warning( _localisationService.GetText( "trader-missing_trader_details_using_default_refresh_time", new { traderId = traderId, updateTime = _traderConfig.UpdateTimeDefault, } ) ); _traderConfig.UpdateTime.Add( new UpdateTime // create temporary entry to prevent logger spam { TraderId = traderId, Seconds = new MinMax(_traderConfig.UpdateTimeDefault, _traderConfig.UpdateTimeDefault) } ); return null; } return _randomUtil.GetInt((int)traderDetails.Seconds.Min, (int)traderDetails.Seconds.Max); } public TraderLoyaltyLevel GetLoyaltyLevel(string traderID, PmcData pmcData) { var traderBase = _databaseService.GetTrader(traderID).Base; int? loyaltyLevel = null; if (pmcData.TradersInfo.TryGetValue(traderID, out var traderInfo)) { loyaltyLevel = traderInfo.LoyaltyLevel; } if (loyaltyLevel is null or < 1) { loyaltyLevel = 1; } if (loyaltyLevel > traderBase.LoyaltyLevels.Count) { loyaltyLevel = traderBase.LoyaltyLevels.Count; } return traderBase.LoyaltyLevels[(loyaltyLevel - 1) ?? 1]; } /// /// Store the purchase of an assort from a trader in the player profile /// /// Session id /// New item assort id + count public void AddTraderPurchasesToPlayerProfile( string sessionID, PurchaseDetails newPurchaseDetails, Item itemPurchased) { var profile = _profileHelper.GetFullProfile(sessionID); var traderId = newPurchaseDetails.TraderId; // Iterate over assorts bought and add to profile foreach (var purchasedItem in newPurchaseDetails.Items) { var currentTime = _timeUtil.GetTimeStamp(); // Nullguard traderPurchases profile.TraderPurchases ??= new Dictionary?>(); // Nullguard traderPurchases for this trader profile.TraderPurchases[traderId] ??= new Dictionary(); // Null guard when dict doesnt exist if (profile.TraderPurchases[traderId][purchasedItem.ItemId].PurchaseCount is null || profile.TraderPurchases[traderId][purchasedItem.ItemId].PurchaseTimestamp is null) { profile.TraderPurchases[traderId][purchasedItem.ItemId] = new TraderPurchaseData { PurchaseCount = purchasedItem.Count, PurchaseTimestamp = currentTime, }; continue; } if (profile.TraderPurchases[traderId][purchasedItem.ItemId].PurchaseCount + purchasedItem.Count > GetAccountTypeAdjustedTraderPurchaseLimit( (double)itemPurchased.Upd.BuyRestrictionMax, profile.CharacterData.PmcData.Info.GameVersion ) ) { throw new Exception( _localisationService.GetText( "trader-unable_to_purchase_item_limit_reached", new { traderId = traderId, limit = itemPurchased.Upd.BuyRestrictionMax, } ) ); } profile.TraderPurchases[traderId][purchasedItem.ItemId].PurchaseCount += purchasedItem.Count; profile.TraderPurchases[traderId][purchasedItem.ItemId].PurchaseTimestamp = currentTime; } } /// /// EoD and Unheard get a 20% bonus to personal trader limit purchases /// /// Existing value from trader item /// Profiles game version /// buyRestrictionMax value public double GetAccountTypeAdjustedTraderPurchaseLimit(double buyRestrictionMax, string gameVersion) { if (_gameVersions.Contains(gameVersion)) { return Math.Floor(buyRestrictionMax * 1.2); } return buyRestrictionMax; } /// /// Get the highest rouble price for an item from traders /// UNUSED /// /// Item to look up highest price for /// highest rouble cost for item public double GetHighestTraderPriceRouble(string tpl) { if (_highestTraderPriceItems is not null) { return (double)_highestTraderPriceItems[tpl]; } if (_highestTraderPriceItems is null) { _highestTraderPriceItems = new Dictionary(); } // Init dict and fill foreach (var traderName in Traders.TradersDictionary) { // Skip some traders if (traderName.Value == Traders.FENCE) { continue; } // Get assorts for trader, skip trader if no assorts found var traderAssorts = _databaseService.GetTrader(traderName.Value).Assort; if (traderAssorts is null) { continue; } // Get all item assorts that have parentid of hideout (base item and not a mod of other item) foreach (var item in traderAssorts.Items.Where(x => x.ParentId == "hideout")) { // Get barter scheme (contains cost of item) var barterScheme = traderAssorts.BarterScheme[item.Id].FirstOrDefault().FirstOrDefault(); // Convert into roubles var roubleAmount = barterScheme.Template == Money.ROUBLES ? barterScheme.Count : _handbookHelper.InRUB(barterScheme.Count ?? 1, barterScheme.Template); // Existing price smaller in dict than current iteration, overwrite if ((_highestTraderPriceItems[item.Template] ?? 0) < roubleAmount) { _highestTraderPriceItems[item.Template] = (int)roubleAmount; } } } return (double)_highestTraderPriceItems[tpl]; } /// /// Get the highest price item can be sold to trader for (roubles) /// /// Item to look up best trader sell-to price /// Rouble price public double GetHighestSellToTraderPrice(string tpl) { // Find highest trader price for item var highestPrice = 1; // Default price foreach (var trader in Traders.TradersDictionary) { // Get trader and check buy category allows tpl var traderBase = _databaseService.GetTrader(trader.Value).Base; // Skip traders that dont sell if (traderBase is null || !_itemHelper.IsOfBaseclasses(tpl, traderBase.ItemsBuy.Category)) { continue; } // Get loyalty level details player has achieved with this trader // Uses lowest loyalty level as this function is used before a player has logged into server // We have no idea what player loyalty is with traders var traderBuyBackPricePercent = traderBase.LoyaltyLevels.FirstOrDefault().BuyPriceCoefficient; var itemHandbookPrice = _handbookHelper.GetTemplatePrice(tpl); var priceTraderBuysItemAt = Math.Round( _randomUtil.GetPercentOfValue(traderBuyBackPricePercent ?? 0, itemHandbookPrice ?? 0) ); // Price from this trader is higher than highest found, update if (priceTraderBuysItemAt > highestPrice) { highestPrice = (int)priceTraderBuysItemAt; } } return highestPrice; } /// /// Get a trader enum key by its value /// /// Traders id /// Traders key public TradersEnum? GetTraderById(string traderId) { var kvp = Traders.TradersDictionary.Where(x => x.Value == traderId); if (!kvp.Any()) { _logger.Error(_localisationService.GetText("trader-unable_to_find_trader_in_enum", traderId)); return null; } return kvp.FirstOrDefault().Key; } /// /// Validates that the provided traderEnumValue exists in the Traders enum. If the value is valid, it returns the /// same enum value, effectively serving as a trader ID; otherwise, it logs an error and returns an empty string. /// This method provides a runtime check to prevent undefined behavior when using the enum as a dictionary key. /// /// For example, instead of this: /// const traderId = Traders[Traders.PRAPOR]; /// /// You can use safely use this: /// const traderId = this.traderHelper.getValidTraderIdByEnumValue(Traders.PRAPOR); /// /// /// The trader enum value to validate /// The validated trader enum value as a string, or an empty string if invalid public string GetValidTraderIdByEnumValue(string traderEnumValue) // TODO: param was Traders { var traderId = _databaseService.GetTraders(); var id = traderId.FirstOrDefault(x => x.Value.Base.Nickname.ToLower() == traderEnumValue.ToLower()).Key; return id; } /// /// Does the 'Traders' enum has a value that matches the passed in parameter /// /// Value to check for /// True, values exists in Traders enum as a value public bool TraderEnumHasKey(string key) { return Traders.TradersDictionary.Any(x => x.Value == key); } /// /// Accepts a trader id /// /// Trader id /// True if Traders enum has the param as a value public bool TraderEnumHasValue(string traderId) { return Traders.TradersDictionary.ContainsValue(traderId); } }