using System.Text.Json.Serialization; using SPTarkov.Common.Extensions; 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.ItemEvent; using SPTarkov.Server.Core.Models.Eft.Repair; using SPTarkov.Server.Core.Models.Eft.Trade; 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 BonusSettings = SPTarkov.Server.Core.Models.Spt.Config.BonusSettings; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class RepairService( ISptLogger logger, RandomUtil randomUtil, DatabaseService databaseService, ItemHelper itemHelper, TraderHelper traderHelper, PaymentService paymentService, ProfileHelper profileHelper, RepairHelper repairHelper, ServerLocalisationService serverLocalisationService, ConfigServer configServer, WeightedRandomHelper weightedRandomHelper ) { protected readonly RepairConfig RepairConfig = configServer.GetConfig(); /// /// Use trader to repair an items durability /// /// Session id /// Profile to find item to repair in /// Details of the item to repair /// Trader being used to repair item /// RepairDetails object public RepairDetails RepairItemByTrader(MongoId sessionID, PmcData pmcData, RepairItem repairItemDetails, MongoId traderId) { var itemToRepair = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == repairItemDetails.Id); if (itemToRepair is null) { logger.Error(serverLocalisationService.GetText("repair-unable_to_find_item_in_inventory_cant_repair", repairItemDetails.Id)); } var priceCoef = traderHelper.GetLoyaltyLevel(traderId, pmcData).RepairPriceCoefficient; var traderRepairDetails = traderHelper.GetTrader(traderId, sessionID)?.Repair; if (traderRepairDetails is null) { logger.Error(serverLocalisationService.GetText("repair-unable_to_find_trader_details_by_id", traderId)); } var repairQualityMultiplier = traderRepairDetails.Quality; var repairRate = priceCoef <= 0 ? 1 : priceCoef / 100 + 1; var items = databaseService.GetItems(); var itemToRepairDetails = items[itemToRepair.Template]; var repairItemIsArmor = itemToRepairDetails.Properties.ArmorMaterial is not null; repairHelper.UpdateItemDurability( itemToRepair, itemToRepairDetails, repairItemIsArmor, repairItemDetails.Count.Value, false, repairQualityMultiplier.Value, repairQualityMultiplier != 0 && RepairConfig.ApplyRandomizeDurabilityLoss ); // get repair price var itemRepairCost = items[itemToRepair.Template].Properties.RepairCost; if (itemRepairCost is null) { logger.Error(serverLocalisationService.GetText("repair-unable_to_find_item_repair_cost", itemToRepair.Template)); } var repairCost = Math.Round(itemRepairCost.Value * repairItemDetails.Count.Value * repairRate.Value * RepairConfig.PriceMultiplier); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"item base repair cost: {itemRepairCost}"); logger.Debug($"price multiplier: {RepairConfig.PriceMultiplier}"); logger.Debug($"repair cost: {repairCost}"); } return new RepairDetails { RepairCost = repairCost, RepairedItem = itemToRepair, RepairedItemIsArmor = repairItemIsArmor, RepairAmount = repairItemDetails.Count, RepairedByKit = false, }; } /// /// /// Session id /// Profile to take money from /// Repaired item id /// Cost to repair item in roubles /// Id of the trader who repaired the item / who is paid /// Client response public void PayForRepair( MongoId sessionID, PmcData pmcData, string repairedItemId, double repairCost, MongoId traderId, ItemEventRouterResponse output ) { var options = new ProcessBuyTradeRequestData { SchemeItems = [new IdWithCount { Count = Math.Round(repairCost), Id = Money.ROUBLES }], TransactionId = traderId, Action = "SptRepair", Type = string.Empty, ItemId = MongoId.Empty(), Count = 0, SchemeId = 0, }; paymentService.PayMoney(pmcData, options, sessionID, output); } /// /// Add skill points to profile after repairing an item /// /// Session id /// Details of item repaired, cost/item /// Profile to add points to public void AddRepairSkillPoints(MongoId sessionId, RepairDetails repairDetails, PmcData pmcData) { // Handle kit repair of weapon if ( repairDetails.RepairedByKit.GetValueOrDefault(false) && itemHelper.IsOfBaseclass(repairDetails.RepairedItem.Template, BaseClasses.WEAPON) ) { var skillPoints = GetWeaponRepairSkillPoints(repairDetails); if (skillPoints > 0) { logger.Debug($"Added: {skillPoints} WEAPON_TREATMENT points to skill"); profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.WeaponTreatment, skillPoints, true); } } // Handle kit repair of armor if ( repairDetails.RepairedByKit.GetValueOrDefault(false) && itemHelper.IsOfBaseclasses(repairDetails.RepairedItem.Template, [BaseClasses.ARMOR_PLATE, BaseClasses.BUILT_IN_INSERTS]) ) { var itemDetails = itemHelper.GetItem(repairDetails.RepairedItem.Template); if (!itemDetails.Key) { // No item found logger.Error(serverLocalisationService.GetText("repair-unable_to_find_item_in_db", repairDetails.RepairedItem.Template)); return; } var isHeavyArmor = itemDetails.Value.Properties.ArmorType == "Heavy"; var vestSkillToLevel = isHeavyArmor ? SkillTypes.HeavyVests : SkillTypes.LightVests; if (repairDetails.RepairPoints is null) { logger.Error(serverLocalisationService.GetText("repair-item_has_no_repair_points", repairDetails.RepairedItem.Template)); } var pointsToAddToVestSkill = repairDetails.RepairPoints * RepairConfig.ArmorKitSkillPointGainPerRepairPointMultiplier; logger.Debug($"Added: {pointsToAddToVestSkill} {vestSkillToLevel} skill"); profileHelper.AddSkillPointsToPlayer(pmcData, vestSkillToLevel, pointsToAddToVestSkill.GetValueOrDefault(0)); } // Handle giving INT to player - differs if using kit/trader and weapon vs armor var intellectGainedFromRepair = GetIntellectGainedFromRepair(repairDetails); if (intellectGainedFromRepair > 0) { logger.Debug($"Added: {intellectGainedFromRepair} intellect skill"); profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Intellect, intellectGainedFromRepair); } } protected double GetIntellectGainedFromRepair(RepairDetails repairDetails) { if (repairDetails.RepairedByKit.GetValueOrDefault(false)) { // Weapons/armor have different multipliers var intRepairMultiplier = itemHelper.IsOfBaseclass(repairDetails.RepairedItem.Template, BaseClasses.WEAPON) ? RepairConfig.RepairKitIntellectGainMultiplier.Weapon : RepairConfig.RepairKitIntellectGainMultiplier.Armor; // Limit gain to a max value defined in config.maxIntellectGainPerRepair if (repairDetails.RepairPoints is null) { logger.Error(serverLocalisationService.GetText("repair-item_has_no_repair_points", repairDetails.RepairedItem.Template)); } return Math.Min(repairDetails.RepairPoints.Value * intRepairMultiplier, RepairConfig.MaxIntellectGainPerRepair.Kit); } // Trader repair - Not as accurate as kit, needs data from live return Math.Min(repairDetails.RepairAmount.Value / 10, RepairConfig.MaxIntellectGainPerRepair.Trader); } /// /// Return an approximation of the amount of skill points live would return for the given repairDetails /// /// The repair details to calculate skill points for /// The number of skill points to reward the user protected double GetWeaponRepairSkillPoints(RepairDetails repairDetails) { var random = new Random(); // This formula and associated configs is calculated based on 30 repairs done on live // The points always came out 2-aligned, which is why there's a divide/multiply by 2 with ceil calls var gainMult = RepairConfig.WeaponTreatment.PointGainMultiplier; // First we get a baseline based on our repair amount, and gain multiplier with a bit of rounding var step1 = Math.Ceiling(repairDetails.RepairAmount.Value / 2) * gainMult; // Then we have to get the next even number var step2 = Math.Ceiling(step1 / 2) * 2; // Then multiply by 2 again to hopefully get to what live would give us var skillPoints = step2 * 2; // You can both crit fail and succeed at the same time, for fun (Balances out to 0 with default settings) // Add a random chance to crit-fail if (random.Next() <= RepairConfig.WeaponTreatment.CritFailureChance) { skillPoints -= RepairConfig.WeaponTreatment.CritFailureAmount; } // Add a random chance to crit-succeed if (random.Next() <= RepairConfig.WeaponTreatment.CritSuccessChance) { skillPoints += RepairConfig.WeaponTreatment.CritSuccessAmount; } return Math.Max(skillPoints, 0); } /// /// /// Session id /// Profile to update repaired item in /// List of Repair kits to use /// Item id to repair /// ItemEventRouterResponse /// Details of repair, item/price public RepairDetails RepairItemByKit( MongoId sessionId, PmcData pmcData, List repairKits, MongoId itemToRepairId, ItemEventRouterResponse output ) { // Find item to repair in inventory var itemToRepair = pmcData.Inventory.Items.FirstOrDefault(x => x.Id == itemToRepairId); if (itemToRepair is null) { logger.Error(serverLocalisationService.GetText("repair-item_not_found_unable_to_repair", itemToRepairId)); } var itemsDb = databaseService.GetItems(); var itemToRepairDetails = itemsDb[itemToRepair.Template]; var repairItemIsArmor = itemToRepairDetails.Properties.ArmorMaterial is not null; var repairAmount = repairKits[0].Count / GetKitDivisor(itemToRepairDetails, repairItemIsArmor, pmcData); var shouldApplyDurabilityLoss = ShouldRepairKitApplyDurabilityLoss(pmcData, RepairConfig.ApplyRandomizeDurabilityLoss); repairHelper.UpdateItemDurability( itemToRepair, itemToRepairDetails, repairItemIsArmor, repairAmount.Value, true, 1, shouldApplyDurabilityLoss ); // Find and use repair kit defined in body foreach (var repairKit in repairKits) { var repairKitInInventory = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == repairKit.Id); if (repairKitInInventory is null) { logger.Error(serverLocalisationService.GetText("repair-repair_kit_not_found_in_inventory", repairKit.Id)); } var repairKitDetails = itemsDb[repairKitInInventory.Template]; var repairKitReductionAmount = repairKit.Count; AddMaxResourceToKitIfMissing(repairKitDetails, repairKitInInventory); // reduce usages on repairkit used repairKitInInventory.Upd.RepairKit.Resource -= repairKitReductionAmount; output.ProfileChanges[sessionId].Items.ChangedItems.Add(repairKitInInventory); } return new RepairDetails { RepairPoints = repairKits[0].Count, RepairedItem = itemToRepair, RepairedItemIsArmor = repairItemIsArmor, RepairAmount = repairAmount, RepairedByKit = true, }; } /// /// Calculate value repairkit points need to be divided by to get the durability points to be added to an item /// /// Item to repair details /// Is the item being repaired armor /// Player profile /// Number to divide kit points by protected double GetKitDivisor(TemplateItem itemToRepairDetails, bool isArmor, PmcData pmcData) { var globals = databaseService.GetGlobals(); var globalConfig = globals.Configuration; var globalRepairSettings = globalConfig.RepairSettings; var intellectRepairPointsPerLevel = globalConfig.SkillsSettings.Intellect.RepairPointsCostReduction; var profileIntellectLevel = pmcData.GetSkillFromProfile(SkillTypes.Intellect)?.Progress ?? 0; var intellectPointReduction = intellectRepairPointsPerLevel * Math.Truncate(profileIntellectLevel / 100); if (isArmor) { var durabilityPointCostArmor = globalRepairSettings.DurabilityPointCostArmor; var repairArmorBonus = GetBonusMultiplierValue(BonusType.RepairArmorBonus, pmcData); var armorBonus = 1.0 - (repairArmorBonus - 1.0) - intellectPointReduction; var materialType = itemToRepairDetails.Properties.ArmorMaterial.Value; globalConfig.ArmorMaterials.TryGetValue(materialType, out var armorMaterial); var destructability = 1 + armorMaterial.Destructibility; var armorClass = itemToRepairDetails.Properties.ArmorClass.Value; var armorClassDivisor = globals.Configuration.RepairSettings.ArmorClassDivisor; var armorClassMultiplier = 1.0 + armorClass / armorClassDivisor; return durabilityPointCostArmor * armorBonus * destructability * armorClassMultiplier; } var repairWeaponBonus = GetBonusMultiplierValue(BonusType.RepairWeaponBonus, pmcData) - 1; var repairPointMultiplier = 1.0 - repairWeaponBonus - intellectPointReduction; var durabilityPointCostGuns = globals.Configuration.RepairSettings.DurabilityPointCostGuns; return durabilityPointCostGuns * repairPointMultiplier; } /// /// Get the bonus multiplier for a skill from a player profile /// /// Bonus to get multiplier of /// Player profile to look in for skill /// Multiplier value protected double GetBonusMultiplierValue(BonusType skillBonus, PmcData pmcData) { var bonusesMatched = pmcData?.Bonuses?.Where(b => b.Type == skillBonus); var value = 1d; if (bonusesMatched is not null) { var summedPercentage = bonusesMatched.Sum(x => x.Value ?? 0); value = 1 + summedPercentage / 100; } return value; } /// /// Should a repair kit apply total durability loss on repair /// /// Player profile /// Value from repair config /// True if loss should be applied protected bool ShouldRepairKitApplyDurabilityLoss(PmcData pmcData, bool applyRandomizeDurabilityLoss) { var shouldApplyDurabilityLoss = applyRandomizeDurabilityLoss; if (shouldApplyDurabilityLoss) { // Random loss not disabled via config, perform charisma check var hasEliteCharisma = profileHelper.HasEliteSkillLevel(SkillTypes.Charisma, pmcData); if (hasEliteCharisma) // 50/50 chance of loss being ignored at elite level { shouldApplyDurabilityLoss = randomUtil.GetChance100(50); } } return shouldApplyDurabilityLoss; } /// /// Update repair kits Resource object if it doesn't exist /// /// Repair kit details from db /// Repair kit to update protected void AddMaxResourceToKitIfMissing(TemplateItem repairKitDetails, Item repairKitInInventory) { var maxRepairAmount = repairKitDetails.Properties.MaxRepairResource; if (repairKitInInventory.Upd is null) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Repair kit: {repairKitInInventory.Id} in inventory lacks upd object, adding"); } repairKitInInventory.Upd = new Upd { RepairKit = new UpdRepairKit { Resource = maxRepairAmount } }; } if (repairKitInInventory.Upd.RepairKit?.Resource is null) { repairKitInInventory.Upd.RepairKit = new UpdRepairKit { Resource = maxRepairAmount }; } } /// /// Chance to apply buff to an item (Armor/weapon) if repaired by armor kit /// /// Repair details of item /// Player profile public void AddBuffToItem(RepairDetails repairDetails, PmcData pmcData) { // Buffs are repair kit only if (!repairDetails.RepairedByKit.GetValueOrDefault(false)) { return; } if (ShouldBuffItem(repairDetails, pmcData)) { if ( itemHelper.IsOfBaseclasses( repairDetails.RepairedItem.Template, [BaseClasses.ARMOR, BaseClasses.VEST, BaseClasses.HEADWEAR, BaseClasses.ARMOR_PLATE] ) ) { var armorConfig = RepairConfig.RepairKit.Armor; AddBuff(armorConfig, repairDetails.RepairedItem); } else if (itemHelper.IsOfBaseclass(repairDetails.RepairedItem.Template, BaseClasses.WEAPON)) { var weaponConfig = RepairConfig.RepairKit.Weapon; AddBuff(weaponConfig, repairDetails.RepairedItem); } // TODO: Knife repair kits may be added at some point, a bracket needs to be added here } } /// /// Add random buff to item /// /// weapon/armor config /// Item to repair public void AddBuff(BonusSettings itemConfig, Item item) { var bonusRarityName = weightedRandomHelper.GetWeightedValue(itemConfig.RarityWeight); var bonusTypeName = weightedRandomHelper.GetWeightedValue(itemConfig.BonusTypeWeight); var bonusRarity = bonusRarityName == "Rare" ? itemConfig.Rare : itemConfig.Common; var bonusValues = bonusRarity[bonusTypeName].ValuesMinMax; var bonusValue = randomUtil.GetDouble(bonusValues.Min, bonusValues.Max); var bonusThresholdPercents = bonusRarity[bonusTypeName].ActiveDurabilityPercentMinMax; var bonusThresholdPercent = randomUtil.GetDouble(bonusThresholdPercents.Min, bonusThresholdPercents.Max); item.Upd.Buff = new UpdBuff { Rarity = bonusRarityName, BuffType = Enum.Parse(bonusTypeName), Value = bonusValue, ThresholdDurability = randomUtil.GetPercentOfValue(bonusThresholdPercent, item.Upd.Repairable.Durability.Value, 0), }; } /// /// Check if item should be buffed by checking the item type and relevant player skill level /// /// Item that was repaired /// Player profile /// True if item should have buff applied protected bool ShouldBuffItem(RepairDetails repairDetails, PmcData pmcData) { var globals = databaseService.GetGlobals(); var hasTemplate = itemHelper.GetItem(repairDetails.RepairedItem.Template); if (!hasTemplate.Key) { return false; } var template = hasTemplate.Value; // Returns SkillTypes.LIGHT_VESTS/HEAVY_VESTS/WEAPON_TREATMENT var itemSkillType = GetItemSkillType(template); if (itemSkillType is null) { return false; } // Skill < level 10 + repairing weapon if (itemSkillType == SkillTypes.WeaponTreatment && pmcData.GetSkillFromProfile(SkillTypes.WeaponTreatment)?.Progress < 1000) { return false; } // Skill < level 10 + repairing armor if ( new HashSet { SkillTypes.LightVests, SkillTypes.HeavyVests }.Contains(itemSkillType.Value) && pmcData.GetSkillFromProfile(itemSkillType.Value)?.Progress < 1000 ) { return false; } var skillSettings = globals.Configuration.SkillsSettings.GetAllPropertiesAsDictionary(); BuffSettings? buffSettings = null; switch (itemSkillType) { case SkillTypes.LightVests: case SkillTypes.HeavyVests: buffSettings = ((ArmorSkills)skillSettings[itemSkillType.ToString()]).BuffSettings; break; case SkillTypes.WeaponTreatment: buffSettings = ((WeaponTreatment)skillSettings[itemSkillType.ToString()]).BuffSettings; break; default: logger.Error($"Unhandled buff type: {itemSkillType}"); break; } var commonBuffMinChanceValue = buffSettings.CommonBuffMinChanceValue; var commonBuffChanceLevelBonus = buffSettings.CommonBuffChanceLevelBonus; var receivedDurabilityMaxPercent = buffSettings.ReceivedDurabilityMaxPercent; var skillLevel = Math.Truncate((pmcData.GetSkillFromProfile(itemSkillType.Value)?.Progress ?? 0) / 100); if (repairDetails.RepairPoints is null) { logger.Error(serverLocalisationService.GetText("repair-item_has_no_repair_points", repairDetails.RepairedItem.Template)); } var durabilityToRestorePercent = repairDetails.RepairPoints / template.Properties.MaxDurability; var durabilityMultiplier = GetDurabilityMultiplier(receivedDurabilityMaxPercent, durabilityToRestorePercent.Value); var doBuff = commonBuffMinChanceValue + commonBuffChanceLevelBonus * skillLevel * durabilityMultiplier; var random = new Random(); return random.Next() <= doBuff; } /// /// Based on item, what underlying skill does this item use for buff settings /// /// Item to check for skill /// Skill name protected SkillTypes? GetItemSkillType(TemplateItem itemTemplate) { var isArmorRelated = itemHelper.IsOfBaseclasses( itemTemplate.Id, [BaseClasses.ARMOR, BaseClasses.VEST, BaseClasses.HEADWEAR, BaseClasses.ARMOR_PLATE] ); if (isArmorRelated) { var armorType = itemTemplate.Properties.ArmorType; if (armorType == "Light") { return SkillTypes.LightVests; } if (armorType == "Heavy") { return SkillTypes.HeavyVests; } } if (itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.WEAPON)) { return SkillTypes.WeaponTreatment; } if (itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.KNIFE)) { return SkillTypes.Melee; } return null; } /// /// Ensure multiplier is between 1 and 0.01 /// /// Max durability percent /// current durability percent /// durability multiplier value protected double GetDurabilityMultiplier(double receiveDurabilityMaxPercent, double receiveDurabilityPercent) { // Ensure the max percent is at least 0.01 var validMaxPercent = Math.Max(0.01, receiveDurabilityMaxPercent); // Calculate the ratio and constrain it between 0.01 and 1 return Math.Clamp(receiveDurabilityPercent / validMaxPercent, 0.01, 1); } } public class RepairDetails { [JsonPropertyName("repairCost")] public double? RepairCost { get; set; } [JsonPropertyName("repairPoints")] public double? RepairPoints { get; set; } [JsonPropertyName("repairedItem")] public Item? RepairedItem { get; set; } [JsonPropertyName("repairedItemIsArmor")] public bool? RepairedItemIsArmor { get; set; } [JsonPropertyName("repairAmount")] public double? RepairAmount { get; set; } [JsonPropertyName("repairedByKit")] public bool? RepairedByKit { get; set; } }