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.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, 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(MongoId 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(MongoId sessionId) { return Insured[sessionId]; } public void ResetInsurance(MongoId 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, MongoId 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; } // EoD has 30% faster returns if (globals.Configuration.Insurance.EditionSendingMessageTime.TryGetValue(pmcData.Info.GameVersion, out var editionModifier)) { randomisedReturnTimeSeconds *= editionModifier.Multiplier; } // 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 /// /// Player/session id /// Gear to store - generated by GetGearLostInRaid() public void StoreGearLostInRaidToSendLater(MongoId 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(MongoId sessionId, IEnumerable 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, IEnumerable 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 it has been processed pmcData.InsuredItems = pmcData.InsuredItems.Where(item => 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(MongoId sessionId, MongoId traderId) { if (!Insured.TryGetValue(sessionId, out var value)) { return false; } return value?.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(MongoId sessionId, MongoId traderId) { Insured[sessionId][traderId] = []; } /// /// Store insured item /// /// Player id (session id) /// Trader item insured with /// Insured item (with children) public void AddInsuranceItemToArray(MongoId sessionId, MongoId 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, MongoId traderId) { var price = itemHelper.GetStaticItemPrice(inventoryItem.Template) * (traderHelper.GetLoyaltyLevel(traderId, pmcData).InsurancePriceCoefficient / 100); return Math.Ceiling(price ?? 1); } }