using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Spt.Services; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using Insurance = SPTarkov.Server.Core.Models.Eft.Profile.Insurance; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class InsuranceService( ISptLogger _logger, DatabaseService _databaseService, RandomUtil _randomUtil, ItemHelper _itemHelper, TimeUtil _timeUtil, SaveServer _saveServer, TraderHelper _traderHelper, ProfileHelper _profileHelper, ServerLocalisationService _serverLocalisationService, MailSendService _mailSendService, ConfigServer _configServer ) { protected readonly InsuranceConfig _insuranceConfig = _configServer.GetConfig(); protected readonly Dictionary>?> _insured = new(); /// /// Does player have insurance dictionary exists /// /// Player id /// True if exists public bool InsuranceDictionaryExists(string sessionId) { return _insured.TryGetValue(sessionId, out _); } /// /// Get all insured items by all traders for a profile /// /// Profile id (session id) /// Item list public Dictionary>? GetInsurance(string sessionId) { return _insured[sessionId]; } public void ResetInsurance(string sessionId) { if (!_insured.TryAdd(sessionId, new Dictionary>())) { _insured[sessionId] = new Dictionary>(); } } /// /// Sends 'I will go look for your stuff' trader message + /// Store lost insurance items inside profile for later retrieval /// /// Profile to send insured items to /// SessionId of current player /// Id of the location player died/exited that caused the insurance to be issued on public void StartPostRaidInsuranceLostProcess(PmcData pmcData, string sessionID, string mapId) { // Get insurance items for each trader var globals = _databaseService.GetGlobals(); foreach (var traderKvP in GetInsurance(sessionID)) { var traderBase = _traderHelper.GetTrader(traderKvP.Key, sessionID); if (traderBase is null) { _logger.Error( _serverLocalisationService.GetText( "insurance-unable_to_find_trader_by_id", traderKvP.Key ) ); continue; } var dialogueTemplates = _databaseService.GetTrader(traderKvP.Key).Dialogue; if (dialogueTemplates is null) { _logger.Error( _serverLocalisationService.GetText( "insurance-trader_lacks_dialogue_property", traderKvP.Key ) ); continue; } var systemData = new SystemData { Date = _timeUtil.GetBsgDateMailFormat(), Time = _timeUtil.GetBsgTimeMailFormat(), Location = mapId, }; // Send "i will go look for your stuff" message from trader to player _mailSendService.SendLocalisedNpcMessageToPlayer( sessionID, traderKvP.Key, MessageType.NpcTraderMessage, _randomUtil.GetArrayValue( dialogueTemplates["insuranceStart"] ?? ["INSURANCE START MESSAGE MISSING"] ), null, _timeUtil.GetHoursAsSeconds( (int)globals.Configuration?.Insurance?.MaxStorageTimeInHour ), systemData ); // Store insurance to send to player later in profile // Store insurance return details in profile + "hey i found your stuff, here you go!" message details to send to player at a later date _saveServer .GetProfile(sessionID) .InsuranceList.Add( new Insurance { ScheduledTime = (int)GetInsuranceReturnTimestamp(pmcData, traderBase), TraderId = traderKvP.Key, MaxStorageTime = (int)GetMaxInsuranceStorageTime(traderBase), SystemData = systemData, MessageType = MessageType.InsuranceReturn, MessageTemplateId = _randomUtil.GetArrayValue( dialogueTemplates["insuranceFound"] ), Items = GetInsurance(sessionID)[traderKvP.Key], } ); } ResetInsurance(sessionID); } /// /// Get a timestamp of when insurance items should be sent to player based on trader used to insure /// Apply insurance return bonus if found in profile /// /// Player profile /// Trader base used to insure items /// Timestamp to return items to player in seconds protected double GetInsuranceReturnTimestamp(PmcData pmcData, TraderBase trader) { // If override in config is non-zero, use that instead of trader values if (_insuranceConfig.ReturnTimeOverrideSeconds > 0) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Insurance override used: returning in {_insuranceConfig.ReturnTimeOverrideSeconds} seconds" ); } return _timeUtil.GetTimeStamp() + _insuranceConfig.ReturnTimeOverrideSeconds; } var insuranceReturnTimeBonusSum = pmcData.GetBonusValueFromProfile( BonusType.InsuranceReturnTime ); // A negative bonus implies a faster return, since we subtract later, invert the value here var insuranceReturnTimeBonusPercent = -(insuranceReturnTimeBonusSum / 100); var traderMinReturnAsSeconds = trader.Insurance.MinReturnHour * TimeUtil.OneHourAsSeconds; var traderMaxReturnAsSeconds = trader.Insurance.MaxReturnHour * TimeUtil.OneHourAsSeconds; var randomisedReturnTimeSeconds = _randomUtil.GetDouble( traderMinReturnAsSeconds.Value, traderMaxReturnAsSeconds.Value ); // Check for Mark of The Unheard in players special slots (only slot item can fit) var globals = _databaseService.GetGlobals(); var hasMarkOfUnheard = _itemHelper.HasItemWithTpl( pmcData.Inventory.Items, ItemTpl.MARKOFUNKNOWN_MARK_OF_THE_UNHEARD, "SpecialSlot" ); if (hasMarkOfUnheard) // Reduce return time by globals multiplier value { randomisedReturnTimeSeconds *= globals .Configuration .Insurance .CoefOfHavingMarkOfUnknown .Value; } // EoD has 30% faster returns if ( globals.Configuration.Insurance.EditionSendingMessageTime.TryGetValue( pmcData.Info.GameVersion, out var editionModifier ) ) { randomisedReturnTimeSeconds *= editionModifier.Multiplier.Value; } // Calculate the final return time based on our bonus percent var finalReturnTimeSeconds = randomisedReturnTimeSeconds * (1d - insuranceReturnTimeBonusPercent); return _timeUtil.GetTimeStamp() + finalReturnTimeSeconds; } protected double GetMaxInsuranceStorageTime(TraderBase traderBase) { if (_insuranceConfig.StorageTimeOverrideSeconds > 0) // Override exists, use instead of traders value { return _insuranceConfig.StorageTimeOverrideSeconds; } return _timeUtil.GetHoursAsSeconds((int)traderBase.Insurance.MaxStorageTime); } /// /// Store lost gear post-raid inside profile, ready for later code to pick it up and mail it /// /// Gear to store - generated by GetGearLostInRaid() public void StoreGearLostInRaidToSendLater( string sessionID, List equipmentPkg ) { // Process all insured items lost in-raid foreach (var gear in equipmentPkg) { AddGearToSend(gear); } } /// /// For the passed in items, find the trader it was insured against /// /// Session id /// Insured items lost in a raid /// Player profile /// InsuranceEquipmentPkg list public List MapInsuredItemsToTrader( string sessionId, List lostInsuredItems, PmcData pmcProfile ) { List result = []; foreach (var lostItem in lostInsuredItems) { var insuranceDetails = pmcProfile.InsuredItems.FirstOrDefault(insuredItem => insuredItem.ItemId == lostItem.Id ); if (insuranceDetails is null) { _logger.Error( $"unable to find insurance details for item id: {lostItem.Id} with tpl: {lostItem.Template}" ); continue; } if (ItemCannotBeLostOnDeath(lostItem, pmcProfile.Inventory.Items)) { continue; } // Add insured item + details to return array result.Add( new InsuranceEquipmentPkg { SessionId = sessionId, ItemToReturnToPlayer = lostItem, PmcData = pmcProfile, TraderId = insuranceDetails.TId, } ); } return result; } /// /// Some items should never be returned in insurance but BSG send them in the request /// /// Item being returned in insurance /// Player inventory /// True if item protected bool ItemCannotBeLostOnDeath(Item lostItem, List inventoryItems) { if (lostItem.SlotId?.StartsWith("specialslot", StringComparison.OrdinalIgnoreCase) ?? false) { return true; } // We check secure container items even tho they are omitted from lostInsuredItems, just in case if (lostItem.ItemIsInsideContainer("SecuredContainer", inventoryItems)) { return true; } return false; } /// /// Add gear item to InsuredItems list in player profile /// /// Gear to send protected void AddGearToSend(InsuranceEquipmentPkg gear) { var sessionId = gear.SessionId; var pmcData = gear.PmcData; var itemToReturnToPlayer = gear.ItemToReturnToPlayer; var traderId = gear.TraderId; // Ensure insurance array is init if (!InsuranceDictionaryExists(sessionId)) { ResetInsurance(sessionId); } // init trader insurance array if (!InsuranceTraderArrayExists(sessionId, traderId)) { ResetInsuranceTraderArray(sessionId, traderId); } AddInsuranceItemToArray(sessionId, traderId, itemToReturnToPlayer); // Remove item from insured items array as its been processed pmcData.InsuredItems = pmcData .InsuredItems.Where(item => { return item.ItemId != itemToReturnToPlayer.Id; }) .ToList(); } /// /// Does insurance exist for a player and by trader /// /// Player id (session id) /// Trader items insured with /// True if exists protected bool InsuranceTraderArrayExists(string sessionId, string traderId) { return _insured[sessionId].GetValueOrDefault(traderId) is not null; } /// /// Empty out list holding insured items by sessionid + traderid /// /// Player id (session id) /// Trader items insured with public void ResetInsuranceTraderArray(string sessionId, string traderId) { _insured[sessionId][traderId] = []; } /// /// Store insured item /// /// Player id (session id) /// Trader item insured with /// Insured item (with children) public void AddInsuranceItemToArray(string sessionId, string traderId, Item itemToAdd) { _insured[sessionId][traderId].Add(itemToAdd); } /// /// Get price of insurance * multiplier from config /// /// Player profile /// Item to be insured /// Trader item is insured with /// price in roubles public double GetRoublePriceToInsureItemWithTrader( PmcData? pmcData, Item inventoryItem, string traderId ) { var price = _itemHelper.GetStaticItemPrice(inventoryItem.Template) * (_traderHelper.GetLoyaltyLevel(traderId, pmcData).InsurancePriceCoefficient / 100); return Math.Ceiling(price ?? 1); } }