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.Health; using SPTarkov.Server.Core.Models.Eft.ItemEvent; using SPTarkov.Server.Core.Models.Eft.Trade; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Routers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; namespace SPTarkov.Server.Core.Controllers; [Injectable] public class HealthController( ISptLogger logger, EventOutputHolder eventOutputHolder, ItemHelper itemHelper, PaymentService paymentService, InventoryHelper inventoryHelper, ServerLocalisationService serverLocalisationService, HttpResponseUtil httpResponseUtil, ICloner cloner ) { /// /// When healing in menu /// /// Player profile /// Healing request /// Player id /// ItemEventRouterResponse public ItemEventRouterResponse OffRaidHeal(PmcData pmcData, OffraidHealRequestData request, MongoId sessionID) { var output = eventOutputHolder.GetOutput(sessionID); // Update medkit used (hpresource) var healingItemToUse = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == request.Item); if (healingItemToUse is null) { var errorMessage = serverLocalisationService.GetText("health-healing_item_not_found", request.Item); logger.Error(errorMessage); return httpResponseUtil.AppendErrorToOutput(output, errorMessage); } // Ensure item has an upd object healingItemToUse.AddUpd(); if (healingItemToUse.Upd.MedKit is not null) { healingItemToUse.Upd.MedKit.HpResource -= request.Count; } else { // Get max healing from db var maxHp = itemHelper.GetItem(healingItemToUse.Template).Value.Properties.MaxHpResource; healingItemToUse.Upd.MedKit = new UpdMedKit { HpResource = maxHp - request.Count }; // Subtract amout used from max // request.count appears to take into account healing effects removed, e.g. bleeds // Salewa heals limb for 20 and fixes light bleed = (20+45 = 65) } // Resource in medkit is spent, delete it if (healingItemToUse.Upd.MedKit.HpResource <= 0) { inventoryHelper.RemoveItem(pmcData, request.Item, sessionID, output); } var healingItemDbDetails = itemHelper.GetItem(healingItemToUse.Template); var healItemEffectDetails = healingItemDbDetails.Value.Properties.EffectsDamage; var bodyPartToHeal = pmcData.Health.BodyParts.GetValueOrDefault(request.Part); if (bodyPartToHeal is null) { logger.Warning($"Player: {sessionID} Tried to heal a non-existent body part: {request.Part}"); return output; } // Get initial heal amount var amountToHealLimb = request.Count; // Check if healing item removes negative effects var itemRemovesEffects = healingItemDbDetails.Value.Properties.EffectsDamage.Count > 0; if (itemRemovesEffects && bodyPartToHeal.Effects is not null) { // Can remove effects and limb has effects to remove foreach (var (effectId, _) in bodyPartToHeal.Effects) { // Check enum has effectType if (!Enum.TryParse(effectId, out var effect)) // Enum doesn't contain this key { continue; } // Check if healing item removes the effect on limb if (!healItemEffectDetails.TryGetValue(effect, out var matchingEffectFromHealingItem)) // Healing item doesn't have matching effect, it doesn't remove the effect { continue; } // Adjust limb heal amount based on if it's fixing an effect (request.count is TOTAL cost of hp resource on heal item, NOT amount to heal limb) amountToHealLimb -= (int)(matchingEffectFromHealingItem.Cost ?? 0); bodyPartToHeal.Effects.Remove(effectId); } } // Adjust body part hp value bodyPartToHeal.Health.Current += amountToHealLimb; // Ensure we've not healed beyond the limbs max hp if (bodyPartToHeal.Health.Current > bodyPartToHeal.Health.Maximum) { bodyPartToHeal.Health.Current = bodyPartToHeal.Health.Maximum; } return output; } /// /// Handle Eat event /// Consume food/water outside a raid /// /// Player profile /// Eat request /// Session id /// ItemEventRouterResponse public ItemEventRouterResponse OffRaidEat(PmcData pmcData, OffraidEatRequestData request, MongoId sessionID) { var output = eventOutputHolder.GetOutput(sessionID); var resourceLeft = 0d; var itemToConsume = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == request.Item); if (itemToConsume is null) { // Item not found, very bad return httpResponseUtil.AppendErrorToOutput( output, serverLocalisationService.GetText("health-unable_to_find_item_to_consume", request.Item) ); } var foodItemDbDetails = itemHelper.GetItem(itemToConsume.Template).Value; var consumedItemMaxResource = foodItemDbDetails.Properties.MaxResource; if (consumedItemMaxResource > 1) { // Ensure item has an upd object itemToConsume.AddUpd(); if (itemToConsume.Upd.FoodDrink is null) { itemToConsume.Upd.FoodDrink = new UpdFoodDrink { HpPercent = consumedItemMaxResource - request.Count }; } else { itemToConsume.Upd.FoodDrink.HpPercent -= request.Count; } resourceLeft = itemToConsume.Upd.FoodDrink.HpPercent.Value; } // Remove item from inventory if resource has dropped below threshold if (consumedItemMaxResource == 1 || resourceLeft < 1) { inventoryHelper.RemoveItem(pmcData, request.Item, sessionID, output); } // Check what effect eating item has and handle var foodItemEffectDetails = foodItemDbDetails.Properties.EffectsHealth; var foodIsSingleUse = foodItemDbDetails.Properties.MaxResource == 1; foreach (var (key, effectProps) in foodItemEffectDetails) { switch (key) { case HealthFactor.Hydration: ApplyEdibleEffect(pmcData.Health.Hydration, effectProps, foodIsSingleUse, request); break; case HealthFactor.Energy: ApplyEdibleEffect(pmcData.Health.Energy, effectProps, foodIsSingleUse, request); break; default: logger.Warning($"Unhandled effect after consuming: {itemToConsume.Template}, {key}"); break; } } return output; } /// /// Apply effects to profile from consumable used /// /// Hydration/Energy /// Properties of consumed item /// Single use item /// Client request protected void ApplyEdibleEffect( CurrentMinMax bodyValue, EffectsHealthProps consumptionDetails, bool foodIsSingleUse, OffraidEatRequestData request ) { if (foodIsSingleUse) // Apply whole value from passed in parameter { bodyValue.Current += consumptionDetails.Value; } else { bodyValue.Current += request.Count; } // Ensure current never goes over max if (bodyValue.Current > bodyValue.Maximum) { bodyValue.Current = bodyValue.Maximum; return; } // Same as above but for the lower bound if (bodyValue.Current < 0) { bodyValue.Current = 0; } } /// /// Handle RestoreHealth event /// Occurs on post-raid healing page /// /// player profile /// Request data from client /// Session id /// public ItemEventRouterResponse HealthTreatment(PmcData pmcData, HealthTreatmentRequestData healthTreatmentRequest, MongoId sessionID) { var output = eventOutputHolder.GetOutput(sessionID); var payMoneyRequest = new ProcessBuyTradeRequestData { Action = healthTreatmentRequest.Action, TransactionId = Traders.THERAPIST, SchemeItems = healthTreatmentRequest.Items, Type = "", ItemId = MongoId.Empty(), Count = 0, SchemeId = 0, }; paymentService.PayMoney(pmcData, payMoneyRequest, sessionID, output); if (output.Warnings.Count > 0) { return output; } foreach (var (key, partValues) in healthTreatmentRequest.Difference?.BodyParts) { // Get body part from request + from pmc profile if (!pmcData.Health.BodyParts.TryGetValue(key, out var profilePart)) { // Profile somehow doesn't have part therapist health, skip continue; } // Update hp value when health value is above 0, indicating healing was performed if (partValues.Health > 0) { profilePart.Health.Current = profilePart.Health.Maximum; } // Check for effects to remove if (partValues.Effects?.Count > 0) { // Found effects that have been healed by therapist // key e.g. "LightBleeding" foreach (var effectKey in partValues.Effects) { profilePart.Effects.Remove(effectKey); } // Remove empty effect object to match what live data shows if (profilePart.Effects.Count == 0) { profilePart.Effects = null; } } } // Inform client of new post-raid, post-therapist heal values output.ProfileChanges[sessionID].Health = cloner.Clone(pmcData.Health); return output; } /// /// applies skills from hideout workout. /// /// Player profile /// Request data /// session id public void ApplyWorkoutChanges(PmcData? pmcData, WorkoutData request, MongoId sessionId) { pmcData.Skills.Common = request.Skills.Common; } }