From 2ad2540b53be19a327afd093982e30c4eae6595a Mon Sep 17 00:00:00 2001 From: CWX Date: Fri, 24 Jan 2025 19:34:50 +0000 Subject: [PATCH] implement HideoutHelper --- .../Core/Controllers/HideoutController.cs | 4 +- Libraries/Core/Helpers/HideoutHelper.cs | 1092 +++++++++++++++-- ...outContinuousProductionStartRequestData.cs | 7 + 3 files changed, 1006 insertions(+), 97 deletions(-) diff --git a/Libraries/Core/Controllers/HideoutController.cs b/Libraries/Core/Controllers/HideoutController.cs index 629c07c8..9aebf329 100644 --- a/Libraries/Core/Controllers/HideoutController.cs +++ b/Libraries/Core/Controllers/HideoutController.cs @@ -666,7 +666,7 @@ public class HideoutController( continue; } - if (_hideoutHelper.IsProductionType(production.Value)) + if (production.Value.GetType() == typeof(HideoutProduction)) { // Production or ScavCase if (production.Value.RecipeId == request.RecipeId) @@ -922,7 +922,7 @@ public class HideoutController( string? prodId = null; foreach (var production in ongoingProductions) { - if (_hideoutHelper.IsProductionType(production.Value)) + if (production.Value.GetType() == typeof(HideoutProduction)) { // Production or ScavCase if ((production.Value).RecipeId == request.RecipeId) diff --git a/Libraries/Core/Helpers/HideoutHelper.cs b/Libraries/Core/Helpers/HideoutHelper.cs index 5737ede2..f65ef00f 100644 --- a/Libraries/Core/Helpers/HideoutHelper.cs +++ b/Libraries/Core/Helpers/HideoutHelper.cs @@ -2,11 +2,16 @@ using SptCommon.Annotations; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Eft.Hideout; +using Core.Models.Eft.Inventory; using Core.Models.Eft.ItemEvent; using Core.Models.Enums; +using Core.Models.Spt.Config; using Core.Models.Utils; +using Core.Routers; +using Core.Servers; using Core.Services; using Core.Utils; +using Core.Utils.Cloners; namespace Core.Helpers; @@ -14,15 +19,28 @@ namespace Core.Helpers; public class HideoutHelper( ISptLogger _logger, TimeUtil _timeUtil, - LocalisationService _localisationService + LocalisationService _localisationService, + HashUtil _hashUtil, + DatabaseService _databaseService, + EventOutputHolder _eventOutputHolder, + HttpResponseUtil _httpResponseUtil, + ProfileHelper _profileHelper, + InventoryHelper _inventoryHelper, + PlayerService _playerService, + ItemHelper _itemHelper, + ConfigServer _configServer, + ICloner _cloner ) { + protected HideoutConfig hideoutConfig = _configServer.GetConfig(); + public const string BitcoinFarm = "5d5c205bd582a50d042a3c0e"; public const string CultistCircleCraftId = "66827062405f392b203a44cf"; public const string BitcoinProductionId = "5d5c205bd582a50d042a3c0e"; public const string WaterCollector = "5d5589c1f934db045e6c5492"; public const int MaxSkillPoint = 5000; - + protected List _idCheck = [HideoutHelper.BitcoinFarm, HideoutHelper.CultistCircleCraftId]; + /// /// Add production to profiles' Hideout.Production array /// @@ -31,13 +49,64 @@ public class HideoutHelper( /// Session id /// client response public void RegisterProduction( - PmcData profileData, - HideoutSingleProductionStartRequestData productionRequest, - string sessionId) + PmcData pmcData, + HideoutSingleProductionStartRequestData body, + string sessionID) { - throw new NotImplementedException(); + var recipe = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault((production) => production.Id == body.RecipeId); + if (recipe is null) + { + _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", body.RecipeId)); + + _httpResponseUtil.AppendErrorToOutput(_eventOutputHolder.GetOutput(sessionID)); + } + + // @Important: Here we need to be very exact: + // - normal recipe: Production time value is stored in attribute "productionType" with small "p" + // - scav case recipe: Production time value is stored in attribute "ProductionType" with capital "P" + if (pmcData.Hideout?.Production is null) + { + pmcData.Hideout.Production = new Dictionary(); + } + + var modifiedProductionTime = GetAdjustedCraftTimeWithSkills(pmcData, body.RecipeId); + + var production = InitProduction( + body.RecipeId, + modifiedProductionTime ?? 0, + recipe.NeedFuelForAllProductionTime + ); + + // Store the tools used for this production, so we can return them later + if (body is not null && body.Tools?.Count > 0) + { + production.SptRequiredTools = []; + + foreach (var tool in body.Tools) + { + var toolItem = _cloner.Clone(pmcData.Inventory.Items.FirstOrDefault((x) => x.Id == tool.Id)); + + // Make sure we only return as many as we took + _itemHelper.AddUpdObjectToItem(toolItem); + + toolItem.Upd.StackObjectsCount = tool.Count; + + production.SptRequiredTools.Add( + new Item + { + Id = _hashUtil.Generate(), + Template = toolItem.Template, + Upd = toolItem.Upd, + } + ); + } + } + + pmcData.Hideout.Production[body.RecipeId] = production; } - + /// /// Add production to profiles' Hideout.Production array /// @@ -46,11 +115,37 @@ public class HideoutHelper( /// Session id /// client response public void RegisterProduction( - PmcData profileData, - HideoutContinuousProductionStartRequestData productionRequest, - string sessionId) + PmcData pmcData, + HideoutContinuousProductionStartRequestData body, + string sessionID) { - throw new NotImplementedException(); + var recipe = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault((production) => production.Id == body.RecipeId); + if (recipe is null) + { + _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", body.RecipeId)); + + _httpResponseUtil.AppendErrorToOutput(_eventOutputHolder.GetOutput(sessionID)); + } + + // @Important: Here we need to be very exact: + // - normal recipe: Production time value is stored in attribute "productionType" with small "p" + // - scav case recipe: Production time value is stored in attribute "ProductionType" with capital "P" + if (pmcData.Hideout?.Production is null) + { + pmcData.Hideout.Production = new Dictionary(); + } + + var modifiedProductionTime = GetAdjustedCraftTimeWithSkills(pmcData, body.RecipeId); + + var production = InitProduction( + body.RecipeId, + modifiedProductionTime ?? 0, + recipe.NeedFuelForAllProductionTime + ); + + pmcData.Hideout.Production[body.RecipeId] = production; } /// @@ -59,20 +154,23 @@ public class HideoutHelper( /// public Production InitProduction( string recipeId, - int productionTime, - bool needFuelForAllProductionTime) + double productionTime, + bool? needFuelForAllProductionTime) { - throw new NotImplementedException(); - } - - /// - /// Is the provided object a Production type - /// - /// - /// - public bool IsProductionType(Production Production) - { - throw new NotImplementedException(); + return new Production + { + Progress = 0, + InProgress = true, + RecipeId = recipeId, + StartTimestamp = _timeUtil.GetTimeStamp(), + ProductionTime = productionTime, + Products = [], + GivenItemsInStart = [], + Interrupted = false, + NeedFuelForAllProductionTime = needFuelForAllProductionTime, // Used when sending to client + needFuelForAllProductionTime = needFuelForAllProductionTime, // used when stored in production.json + SkipTime = 0, + }; } /// @@ -83,7 +181,6 @@ public class HideoutHelper( public void ApplyPlayerUpgradesBonuses(PmcData profileData, Bonus bonus) { // Handle additional changes some bonuses need before being added - var bonusToAdd = new Bonus(); switch (bonus.Type) { case BonusType.StashSize: @@ -121,9 +218,14 @@ public class HideoutHelper( /// Process a players hideout, update areas that use resources + increment production timers /// /// Session id - public void UpdatePlayerHideout(string sessionId) + public void UpdatePlayerHideout(string sessionID) { - throw new NotImplementedException(); + var pmcData = _profileHelper.GetPmcProfile(sessionID); + var hideoutProperties = GetHideoutProperties(pmcData); + + UpdateAreasWithResources(sessionID, pmcData, hideoutProperties); + UpdateProductionTimers(pmcData, hideoutProperties); + pmcData.Hideout.SptUpdateLastRunTimestamp = _timeUtil.GetTimeStamp(); } /// @@ -131,14 +233,34 @@ public class HideoutHelper( /// /// Player profile /// Properties - protected (int btcFarmCGs, bool isGeneratorOn, bool waterCollectorHasFilter) GetHideoutProperties(PmcData profileData) + protected HideoutProperties GetHideoutProperties(PmcData pmcData) { - throw new NotImplementedException(); + var bitcoinFarm = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.BITCOIN_FARM); + var bitcoinCount = bitcoinFarm?.Slots.Where((slot) => slot.Items is not null).Count(); // Get slots with an item property + + var hideoutProperties = new HideoutProperties + { + BtcFarmGcs = bitcoinCount, + IsGeneratorOn = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.GENERATOR)?.Active ?? false, + WaterCollectorHasFilter = DoesWaterCollectorHaveFilter( + pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.WATER_COLLECTOR) + ), + }; + + return hideoutProperties; } protected bool DoesWaterCollectorHaveFilter(BotHideoutArea waterCollector) { - throw new NotImplementedException(); + // Can put filters in from L3 + if (waterCollector.Level == 3) + { + // Has filter in at least one slot + return waterCollector.Slots.Any(slot => slot.Items is not null); + } + + // No Filter + return false; } /// @@ -147,10 +269,84 @@ public class HideoutHelper( /// Profile to check for productions and update /// Hideout properties protected void UpdateProductionTimers( - PmcData profileData, - (int btcFarmCGs, bool isGeneratorOn, bool waterCollectorHasFilter) hideoutProperties) + PmcData pmcData, + HideoutProperties hideoutProperties) { - throw new NotImplementedException(); + var recipes = _databaseService.GetHideout().Production; + + // Check each production and handle edge cases if necessary + foreach (var prodId in pmcData.Hideout?.Production) + { + var craft = pmcData.Hideout.Production[prodId.Key]; + if (craft is null) + { + // Craft value is undefined, get rid of it (could be from cancelling craft that needs cleaning up) + pmcData.Hideout.Production.Remove(prodId.Key); + + continue; + } + + if (craft.Progress == null) + { + _logger.Warning( + _localisationService.GetText("hideout-craft_has_undefined_progress_value_defaulting", prodId) + ); + craft.Progress = 0; + } + + // Skip processing (Don't skip continious crafts like bitcoin farm or cultist circle) + if (IsCraftComplete(craft)) + { + continue; + } + + // Special handling required + if (IsCraftOfType(craft, HideoutAreas.SCAV_CASE)) + { + UpdateScavCaseProductionTimer(pmcData, prodId.Key); + + continue; + } + + if (IsCraftOfType(craft, HideoutAreas.WATER_COLLECTOR)) + { + UpdateWaterCollectorProductionTimer(pmcData, prodId.Key, hideoutProperties); + + continue; + } + + // Continious craft + if (IsCraftOfType(craft, HideoutAreas.BITCOIN_FARM)) + { + UpdateBitcoinFarm( + pmcData, + pmcData.Hideout.Production[prodId.Key], + hideoutProperties.BtcFarmGcs, + hideoutProperties.IsGeneratorOn + ); + + continue; + } + + // No recipe, needs special handling + if (IsCraftOfType(craft, HideoutAreas.CIRCLE_OF_CULTISTS)) + { + UpdateCultistCircleCraftProgress(pmcData, prodId.Key); + + continue; + } + + // Ensure recipe exists before using it in updateProductionProgress() + var recipe = recipes.Recipes.FirstOrDefault((r) => r.Id == prodId.Key); + if (recipe is null) + { + _logger.Error(_localisationService.GetText("hideout-missing_recipe_for_area", prodId)); + + continue; + } + + UpdateProductionProgress(pmcData, prodId.Key, recipe, hideoutProperties); + } } /// @@ -161,7 +357,20 @@ public class HideoutHelper( /// True if it is from that area protected bool IsCraftOfType(Production craft, HideoutAreas hideoutType) { - throw new NotImplementedException(); + switch (hideoutType) + { + case HideoutAreas.WATER_COLLECTOR: + return craft.RecipeId == HideoutHelper.WaterCollector; + case HideoutAreas.BITCOIN_FARM: + return craft.RecipeId == HideoutHelper.BitcoinFarm; + case HideoutAreas.SCAV_CASE: + return craft.SptIsScavCase ?? false; + case HideoutAreas.CIRCLE_OF_CULTISTS: + return craft.SptIsCultistCircle ?? false; + default: + _logger.Error($"Unhandled hideout area: {hideoutType}, assuming craft: {craft.RecipeId} is not of this type"); + return false; + } } /// @@ -172,7 +381,10 @@ public class HideoutHelper( /// True when craft is complete protected bool IsCraftComplete(Production craft) { - throw new NotImplementedException(); + return ( + craft.Progress >= craft.ProductionTime && + !_idCheck.Contains(craft.RecipeId) + ); } /// @@ -184,9 +396,13 @@ public class HideoutHelper( protected void UpdateWaterCollectorProductionTimer( PmcData pmcData, string productionId, - Dictionary hideoutProperties) + HideoutProperties hideoutProperties) { - throw new NotImplementedException(); + var timeElapsed = GetTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.IsGeneratorOn); + if (hideoutProperties.WaterCollectorHasFilter) + { + pmcData.Hideout.Production[productionId].Progress += timeElapsed; + } } /// @@ -200,19 +416,68 @@ public class HideoutHelper( PmcData pmcData, string prodId, HideoutProduction recipe, - Dictionary hideoutProperties) + HideoutProperties hideoutProperties) { - throw new NotImplementedException(); + // Production is complete, no need to do any calculations + if (DoesProgressMatchProductionTime(pmcData, prodId)) + { + return; + } + + // Get seconds since last hideout update + now + var timeElapsed = GetTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.IsGeneratorOn, recipe); + + // Increment progress by time passed + var production = pmcData.Hideout.Production[prodId]; + // Some items NEED power to craft (e.g. DSP) + production.Progress += (production.needFuelForAllProductionTime ?? false) && !hideoutProperties.IsGeneratorOn ? 0 : timeElapsed; + + // Limit progress to total production time if progress is over (dont run for continious crafts)) + if (!(recipe.Continuous ?? false)) + { + // If progress is larger than prod time, return ProductionTime, hard cap the vaue + production.Progress = Math.Min(production.Progress ?? 0, production.ProductionTime ?? 0); + } } protected void UpdateCultistCircleCraftProgress(PmcData pmcData, string prodId) { - throw new NotImplementedException(); + var production = pmcData.Hideout.Production[prodId]; + + // Check if we're already complete, skip + if (production.AvailableForFinish ?? false) + { + return; + } + + // Get seconds since last hideout update + var timeElapsedSeconds = _timeUtil.GetTimeStamp() - pmcData.Hideout.SptUpdateLastRunTimestamp; + + // Increment progress by time passed if progress is less than time needed + if (production.Progress < production.ProductionTime) + { + production.Progress += timeElapsedSeconds; + + // Check if craft is complete + if (production.Progress >= production.ProductionTime) + { + FlagCultistCircleCraftAsComplete(production); + } + + return; + } + + // Craft in complete + FlagCultistCircleCraftAsComplete(production); } - protected void FlagCultistCircleCraftAsComplete(Production Production) + protected void FlagCultistCircleCraftAsComplete(Production production) { - throw new NotImplementedException(); + // Craft is complete, flas as such + production.AvailableForFinish = true; + + // Reset progress so its not over production time + production.Progress = production.ProductionTime; } /// @@ -224,7 +489,7 @@ public class HideoutHelper( /// progress matches productionTime from recipe protected bool DoesProgressMatchProductionTime(PmcData pmcData, string prodId) { - throw new NotImplementedException(); + return pmcData.Hideout.Production[prodId].Progress == pmcData.Hideout.Production[prodId].ProductionTime; } /// @@ -234,7 +499,12 @@ public class HideoutHelper( /// Id of scav case production to update protected void UpdateScavCaseProductionTimer(PmcData pmcData, string productionId) { - throw new NotImplementedException(); + var timeElapsed = + _timeUtil.GetTimeStamp() - + pmcData.Hideout.Production[productionId].StartTimestamp - + pmcData.Hideout.Production[productionId].Progress; + + pmcData.Hideout.Production[productionId].Progress += timeElapsed; } /// @@ -246,9 +516,32 @@ public class HideoutHelper( protected void UpdateAreasWithResources( string sessionID, PmcData pmcData, - Dictionary hideoutProperties) + HideoutProperties hideoutProperties) { - throw new NotImplementedException(); + foreach (var area in pmcData.Hideout.Areas) + { + switch (area.Type) + { + case HideoutAreas.GENERATOR: + if (hideoutProperties.IsGeneratorOn) + { + UpdateFuel(area, pmcData, hideoutProperties.IsGeneratorOn); + } + + break; + case HideoutAreas.WATER_COLLECTOR: + UpdateWaterCollector(sessionID, pmcData, area, hideoutProperties); + break; + + case HideoutAreas.AIR_FILTERING: + if (hideoutProperties.IsGeneratorOn) + { + UpdateAirFilters(area, pmcData, hideoutProperties.IsGeneratorOn); + } + + break; + } + } } /// @@ -259,16 +552,155 @@ public class HideoutHelper( /// Is the generator turned on since last update protected void UpdateFuel(BotHideoutArea generatorArea, PmcData pmcData, bool isGeneratorOn) { - throw new NotImplementedException(); + // 1 resource last 14 min 27 sec, 1/14.45/60 = 0.00115 + // 10-10-2021 From wiki, 1 resource last 12 minutes 38 seconds, 1/12.63333/60 = 0.00131 + var fuelUsedSinceLastTick = + _databaseService.GetHideout().Settings.GeneratorFuelFlowRate * + GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn); + + // Get all fuel consumption bonuses, returns an empty array if none found + var profileFuelConsomptionBonusSum = _profileHelper.GetBonusValueFromProfile( + pmcData, + BonusType.FuelConsumption + ); + + // An increase in "bonus" consumption is actually an increase in consumption, so invert this for later use + var fuelConsumptionBonusRate = -(profileFuelConsomptionBonusSum / 100); + + // An increase in hideout management bonus is a decrease in consumption + var hideoutManagementConsumptionBonusRate = GetHideoutManagementConsumptionBonus(pmcData); + + var combinedBonus = 1.0 - (fuelConsumptionBonusRate + hideoutManagementConsumptionBonusRate); + + // Sanity check, never let fuel consumption go negative, otherwise it returns fuel to the player + if (combinedBonus < 0) + { + combinedBonus = 0; + } + + fuelUsedSinceLastTick *= combinedBonus; + + var hasFuelRemaining = false; + var pointsConsumed = 0D; + for (var i = 0; i < generatorArea.Slots.Count; i++) + { + var generatorSlot = generatorArea.Slots[i]; + if (generatorSlot?.Items is null) + { + // No item in slot, skip + continue; + } + + var fuelItemInSlot = generatorSlot?.Items[0]; + if (fuelItemInSlot is null) + { + // No item in slot, skip + continue; + } + + var fuelRemaining = fuelItemInSlot.Upd?.Resource?.Value; + if (fuelRemaining == 0) + { + // No fuel left, skip + continue; + } + + // Undefined fuel, fresh fuel item and needs its max fuel amount looked up + if (fuelRemaining is null) + { + var fuelItemTemplate = _itemHelper.GetItem(fuelItemInSlot.Template).Value; + pointsConsumed = fuelUsedSinceLastTick ?? 0; + fuelRemaining = fuelItemTemplate.Properties.MaxResource - fuelUsedSinceLastTick; + } + else + { + // Fuel exists already, deduct fuel from item remaining value + pointsConsumed = (double)((fuelItemInSlot.Upd.Resource.UnitsConsumed ?? 0) + fuelUsedSinceLastTick); + fuelRemaining -= fuelUsedSinceLastTick; + } + + // Round values to keep accuracy + fuelRemaining = Math.Round((fuelRemaining * 10000) ?? 0) / 10000; + pointsConsumed = Math.Round(pointsConsumed * 10000) / 10000; + + // Fuel consumed / 10 is over 1, add hideout management skill point + if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1) + { + _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1); + pointsConsumed -= 10; + } + + var isFuelItemFoundInRaid = fuelItemInSlot.Upd?.SpawnedInSession ?? false; + if (fuelRemaining > 0) + { + // Deducted all used fuel from this container, clean up and exit loop + fuelItemInSlot.Upd = GetAreaUpdObject(1, fuelRemaining, pointsConsumed, isFuelItemFoundInRaid); + + _logger.Debug($"Profile: {pmcData.Id} Generator has: {fuelRemaining} fuel left in slot {i + 1}"); + hasFuelRemaining = true; + + break; // Break to avoid updating all the fuel tanks + } + + fuelItemInSlot.Upd = GetAreaUpdObject(1, 0, 0, isFuelItemFoundInRaid); + + // Ran out of fuel items to deduct fuel from + fuelUsedSinceLastTick = Math.Abs(fuelRemaining ?? 0); + _logger.Debug($"Profile: {pmcData.Id} Generator ran out of fuel"); + } + + // Out of fuel, flag generator as offline + if (!hasFuelRemaining) + { + generatorArea.Active = false; + } } protected void UpdateWaterCollector( string sessionId, PmcData pmcData, BotHideoutArea area, - Dictionary hideoutProperties) + HideoutProperties hideoutProperties) { - throw new NotImplementedException(); + // Skip water collector when not level 3 (cant collect until 3) + if (area.Level != 3) + { + return; + } + + if (!hideoutProperties.WaterCollectorHasFilter) + { + return; + } + + // Canister with purified water craft exists + var purifiedWaterCraft = pmcData.Hideout.Production[HideoutHelper.WaterCollector]; + if (purifiedWaterCraft is not null && purifiedWaterCraft.GetType() == typeof(Production)) + { + // Update craft time to account for increases in players craft time skill + purifiedWaterCraft.ProductionTime = GetAdjustedCraftTimeWithSkills( + pmcData, + purifiedWaterCraft.RecipeId, + true + ); + + UpdateWaterFilters(area, purifiedWaterCraft, hideoutProperties.IsGeneratorOn, pmcData); + } + else + { + // continuousProductionStart() + // seem to not trigger consistently + HideoutSingleProductionStartRequestData recipe = new HideoutSingleProductionStartRequestData + { + RecipeId = HideoutHelper.WaterCollector, + Action = "HideoutSingleProductionStart", + Items = [], + Tools = [], + Timestamp = _timeUtil.GetTimeStamp(), + }; + + RegisterProduction(pmcData, recipe, sessionId); + } } /// @@ -278,12 +710,61 @@ public class HideoutHelper( /// Recipe being crafted /// Should the hideout management bonus be applied to the calculation /// Items craft time with bonuses subtracted - public double GetAdjustedCraftTimeWithSkills( - PmcData playerProfile, + public double? GetAdjustedCraftTimeWithSkills( + PmcData pmcData, string recipeId, bool applyHideoutManagementBonus = false) { - throw new NotImplementedException(); + var globalSkillsDb = _databaseService.GetGlobals().Configuration.SkillsSettings; + + var recipe = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault((production) => production.Id == recipeId); + if (recipe is null) + { + _logger.Error(_localisationService.GetText("hideout-missing_recipe_in_db", recipeId)); + + return null; + } + + var timeReductionSeconds = 0D; + + // Bitcoin farm is excluded from crafting skill cooldown reduction + if (recipeId != HideoutHelper.BitcoinFarm) + { + // Seconds to deduct from crafts total time + timeReductionSeconds += GetSkillProductionTimeReduction( + pmcData, + recipe.ProductionTime ?? 0, + SkillTypes.Crafting, + globalSkillsDb.Crafting.ProductionTimeReductionPerLevel ?? 0 + ); + } + + // Some crafts take into account hideout management, e.g. fuel, water/air filters + if (applyHideoutManagementBonus) + { + timeReductionSeconds += GetSkillProductionTimeReduction( + pmcData, + recipe.ProductionTime ?? 0, + SkillTypes.HideoutManagement, + globalSkillsDb.HideoutManagement.ConsumptionReductionPerLevel ?? 0 + ); + } + + var modifiedProductionTime = recipe.ProductionTime - timeReductionSeconds; + if (modifiedProductionTime > 0 && _profileHelper.IsDeveloperAccount(pmcData.Id)) + { + modifiedProductionTime = 40; + } + + // Sanity check, don't let anything craft in less than 5 seconds + if (modifiedProductionTime < 5) + { + modifiedProductionTime = 5; + } + + return modifiedProductionTime; } /// @@ -297,9 +778,90 @@ public class HideoutHelper( BotHideoutArea waterFilterArea, Production production, bool isGeneratorOn, - PmcData playerProfile) + PmcData pmcData) { - throw new NotImplementedException(); + var filterDrainRate = GetWaterFilterDrainRate(pmcData); + var craftProductionTime = GetTotalProductionTimeSeconds(HideoutHelper.WaterCollector); + var secondsSinceServerTick = GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn); + + filterDrainRate = GetTimeAdjustedWaterFilterDrainRate( + secondsSinceServerTick ?? 0, + craftProductionTime, + production.Progress ?? 0, + filterDrainRate + ); + + // Production hasn't completed + var pointsConsumed = 0D; + + // Check progress against the productions craft time (dont use base time as it doesnt include any time bonuses profile has) + if (production.Progress > production.ProductionTime) + { + // Craft is complete nothing to do + return; + } + + // Check all slots that take water filters until we find one with filter in it + for (var i = 0; i < waterFilterArea.Slots.Count; i++) + { + // No water filter in slot, skip + if (waterFilterArea.Slots[i].Items is null) + { + continue; + } + + var waterFilterItemInSlot = waterFilterArea.Slots[i].Items[0]; + + // How many units of filter are left + var resourceValue = waterFilterItemInSlot.Upd?.Resource is not null + ? waterFilterItemInSlot.Upd.Resource.Value + : null; + if (resourceValue is null) + { + // Missing, is new filter, add default and subtract usage + resourceValue = 100 - filterDrainRate; + pointsConsumed = filterDrainRate; + } + else + { + pointsConsumed = (waterFilterItemInSlot.Upd.Resource.UnitsConsumed ?? 0) + filterDrainRate; + resourceValue -= filterDrainRate; + } + + // Round to get values to 3dp + resourceValue = Math.Round((resourceValue * 1000) ?? 0) / 1000; + pointsConsumed = Math.Round(pointsConsumed * 1000) / 1000; + + // Check units consumed for possible increment of hideout mgmt skill point + if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1) + { + _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1); + pointsConsumed -= 10; + } + + // Filter has some fuel left in it after our adjustment + if (resourceValue > 0) + { + var isWaterFilterFoundInRaid = waterFilterItemInSlot.Upd.SpawnedInSession ?? false; + + // Set filters consumed amount + waterFilterItemInSlot.Upd = GetAreaUpdObject( + 1, + resourceValue, + pointsConsumed, + isWaterFilterFoundInRaid + ); + _logger.Debug($"Water filter has: {resourceValue} units left in slot {i + 1}"); + + break; // Break here to avoid iterating other filters now w're done + } + + // Filter ran out / used up + // biome-ignore lint/performance/noDelete: Delete is fine here, as we're seeking to entirely delete the water filter. + waterFilterArea.Slots[i].Items = null; + // Update remaining resources to be subtracted + filterDrainRate = Math.Abs(resourceValue ?? 0); + } } /// @@ -312,12 +874,18 @@ public class HideoutHelper( /// Base drain rate /// Drain rate (adjusted) protected double GetTimeAdjustedWaterFilterDrainRate( - double secondsSinceServerTick, + long secondsSinceServerTick, double totalProductionTime, double productionProgress, double baseFilterDrainRate) { - throw new NotImplementedException(); + var drainTimeSeconds = + secondsSinceServerTick > totalProductionTime + ? totalProductionTime - productionProgress // More time passed than prod time, get total minus the current progress + : secondsSinceServerTick; + + // Multiply base drain rate by time passed + return baseFilterDrainRate * drainTimeSeconds; } /// @@ -325,9 +893,31 @@ public class HideoutHelper( /// /// Player profile /// Drain rate - protected double GetWaterFilterDrainRate(PmcData playerProfile) + protected double GetWaterFilterDrainRate(PmcData pmcData) { - throw new NotImplementedException(); + var globalSkillsDb = _databaseService.GetGlobals().Configuration.SkillsSettings; + + // 100 resources last 8 hrs 20 min, 100/8.33/60/60 = 0.00333 + var filterDrainRate = 0.00333; + + var hideoutManagementConsumptionBonus = GetSkillBonusMultipliedBySkillLevel( + pmcData, + SkillTypes.HideoutManagement, + globalSkillsDb.HideoutManagement.ConsumptionReductionPerLevel ?? 0 + ); + var craftSkillTimeReductionMultipler = GetSkillBonusMultipliedBySkillLevel( + pmcData, + SkillTypes.Crafting, + globalSkillsDb.Crafting.CraftTimeReductionPerLevel ?? 0 + ); + + // Never let bonus become 0 + var reductionBonus = + hideoutManagementConsumptionBonus + craftSkillTimeReductionMultipler == 0 + ? 1 + : 1 - (hideoutManagementConsumptionBonus + craftSkillTimeReductionMultipler); + + return filterDrainRate * reductionBonus; } /// @@ -337,7 +927,15 @@ public class HideoutHelper( /// Seconds to produce item protected double GetTotalProductionTimeSeconds(string prodId) { - throw new NotImplementedException(); + return ( + _databaseService.GetHideout() + .Production.Recipes.FirstOrDefault( + (prod) => + prod.Id == prodId + ) + ?.ProductionTime ?? + 0 + ); } /// @@ -348,26 +946,175 @@ public class HideoutHelper( /// /// Upd protected Upd GetAreaUpdObject( - int stackCount, - double resourceValue, - int resourceUnitsConsumed, + double stackCount, + double? resourceValue, + double resourceUnitsConsumed, bool isFoundInRaid) { - throw new NotImplementedException(); + return new Upd + { + StackObjectsCount = stackCount, + Resource = new UpdResource { Value = resourceValue, UnitsConsumed = resourceUnitsConsumed }, + SpawnedInSession = isFoundInRaid, + }; } - protected void UpdateAirFilters(BotHideoutArea airFilterArea, PmcData playerProfile, bool isGeneratorOn) + protected void UpdateAirFilters(BotHideoutArea airFilterArea, PmcData pmcData, bool isGeneratorOn) { - throw new NotImplementedException(); + // 300 resources last 20 hrs, 300/20/60/60 = 0.00416 + /* 10-10-2021 from WIKI (https://escapefromtarkov.fandom.com/wiki/FP-100_filter_absorber) + Lasts for 17 hours 38 minutes and 49 seconds (23 hours 31 minutes and 45 seconds with elite hideout management skill), + 300/17.64694/60/60 = 0.004722 + */ + var filterDrainRate = + _databaseService.GetHideout().Settings.AirFilterUnitFlowRate * + GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn); + + // Hideout management resource consumption bonus: + var hideoutManagementConsumptionBonus = 1.0 - GetHideoutManagementConsumptionBonus(pmcData); + filterDrainRate *= hideoutManagementConsumptionBonus; + var pointsConsumed = 0D; + + for (var i = 0; i < airFilterArea.Slots.Count; i++) + { + if (airFilterArea.Slots[i].Items is not null) + { + var resourceValue = airFilterArea.Slots[i].Items[0].Upd?.Resource is not null + ? airFilterArea.Slots[i].Items[0].Upd.Resource.Value + : null; + + if (resourceValue is null) + { + resourceValue = 300 - filterDrainRate; + pointsConsumed = filterDrainRate ?? 0; + } + else + { + pointsConsumed = ((airFilterArea.Slots[i].Items[0].Upd.Resource.UnitsConsumed ?? 0) + filterDrainRate) ?? 0; + resourceValue -= filterDrainRate; + } + + resourceValue = Math.Round((resourceValue * 10000) ?? 0) / 10000; + pointsConsumed = Math.Round(pointsConsumed * 10000) / 10000; + + // check unit consumed for increment skill point + if (pmcData is not null && Math.Floor(pointsConsumed / 10) >= 1) + { + _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.HideoutManagement, 1); + pointsConsumed -= 10; + } + + if (resourceValue > 0) + { + airFilterArea.Slots[i].Items[0].Upd = new Upd + { + StackObjectsCount = 1, + Resource = new UpdResource { Value = resourceValue, UnitsConsumed = pointsConsumed }, + }; + _logger.Debug($"Air filter: {resourceValue} filter left on slot {i + 1}"); + break; // Break here to avoid updating all filters + } + + airFilterArea.Slots[i].Items = null; + // Update remaining resources to be subtracted + filterDrainRate = Math.Abs(resourceValue ?? 0); + } + } } protected void UpdateBitcoinFarm( - PmcData playerProfile, + PmcData pmcData, Production btcProduction, - int btcFarmCGs, + int? btcFarmCGs, bool isGeneratorOn) { - throw new NotImplementedException(); + var isBtcProd = btcProduction.GetType() == typeof(Production); + if (!isBtcProd) + { + return; + } + + // The wiki has a wrong formula! + // Do not change unless you validate it with the Client code files! + // This formula was found on the client files: + // ******************************************************* + /* + public override int InstalledSuppliesCount + { + get + { + return this.int_1; + } + protected set + { + if (this.int_1 === value) + { + return; + } + this.int_1 = value; + base.Single_0 = ((this.int_1 === 0) ? 0f : (1f + (float)(this.int_1 - 1) * this.float_4)); + } + } + */ + // ********************************************************** + // At the time of writing this comment, this was GClass1667 + // To find it in case of weird results, use DNSpy and look for usages on class AreaData + // Look for a GClassXXXX that has a method called "InitDetails" and the only parameter is the AreaData + // That should be the bitcoin farm production. To validate, try to find the snippet below: + /* + protected override void InitDetails(AreaData data) + { + base.InitDetails(data); + this.gclass1678_1.Type = EDetailsType.Farming; + } + */ + // Needs power to function + if (!isGeneratorOn) + { + // Return with no changes + return; + } + + var coinSlotCount = GetBTCSlots(pmcData); + + // Full of bitcoins, halt progress + if (btcProduction.Products.Count >= coinSlotCount) + { + // Set progress to 0 + btcProduction.Progress = 0; + + return; + } + + var bitcoinProdData = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault((production) => production.Id == HideoutHelper.BitcoinProductionId); + + // BSG finally fixed their settings, they now get loaded from the settings and used in the client + var adjustedCraftTime = + (_profileHelper.IsDeveloperAccount(pmcData.SessionId) ? 40 : bitcoinProdData.ProductionTime) / + (1 + (btcFarmCGs - 1) * _databaseService.GetHideout().Settings.GpuBoostRate); + + // The progress should be adjusted based on the GPU boost rate, but the target is still the base productionTime + var timeMultiplier = bitcoinProdData.ProductionTime / adjustedCraftTime; + var timeElapsedSeconds = GetTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn); + btcProduction.Progress += Math.Floor((timeElapsedSeconds * timeMultiplier) ?? 0); + + while (btcProduction.Progress >= bitcoinProdData.ProductionTime) + { + if (btcProduction.Products.Count < coinSlotCount) + { + // Has space to add a coin to production rewards + AddBtcToProduction(btcProduction, bitcoinProdData.ProductionTime ?? 0); + } + else + { + // Filled up bitcoin storage + btcProduction.Progress = 0; + } + } + + btcProduction.StartTimestamp = _timeUtil.GetTimeStamp(); } /// @@ -377,7 +1124,17 @@ public class HideoutHelper( /// Time to craft a bitcoin protected void AddBtcToProduction(Production btcProd, double coinCraftTimeSeconds) { - throw new NotImplementedException(); + btcProd.Products.Add( + new Item + { + Id = _hashUtil.Generate(), + Template = ItemTpl.BARTER_PHYSICAL_BITCOIN, + Upd = new Upd { StackObjectsCount = 1 }, + } + ); + + // Deduct time spent crafting from progress + btcProd.Progress -= coinCraftTimeSeconds; } /// @@ -387,12 +1144,27 @@ public class HideoutHelper( /// Is the generator on for the duration of elapsed time /// Hideout production recipe being crafted we need the ticks for /// Amount of time elapsed in seconds - protected double GetTimeElapsedSinceLastServerTick( - PmcData playerProfile, + protected long? GetTimeElapsedSinceLastServerTick( + PmcData pmcData, bool isGeneratorOn, HideoutProduction recipe = null) { - throw new NotImplementedException(); + // Reduce time elapsed (and progress) when generator is off + var timeElapsed = _timeUtil.GetTimeStamp() - pmcData.Hideout.SptUpdateLastRunTimestamp; + + if (recipe is not null) { + var hideoutArea = _databaseService.GetHideout().Areas.FirstOrDefault((area) => area.Type == recipe.AreaType); + if (!(hideoutArea.NeedsFuel ?? false)) { + // e.g. Lavatory works at 100% when power is on / off + return timeElapsed; + } + } + + if (!isGeneratorOn) { + timeElapsed *= (long)_databaseService.GetHideout().Settings.GeneratorSpeedWithoutFuel; + } + + return timeElapsed; } /// @@ -400,17 +1172,25 @@ public class HideoutHelper( /// /// Profile to look up /// Coin slot count - protected int GetBTCSlots(PmcData profileData) + protected double GetBTCSlots(PmcData pmcData) { - throw new NotImplementedException(); + var bitcoinProductions = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault((production) => production.Id == HideoutHelper.BitcoinFarm); + var productionSlots = bitcoinProductions?.ProductionLimitCount ?? 3; // Default to 3 if none found + var hasManagementSkillSlots = _profileHelper.HasEliteSkillLevel(SkillTypes.HideoutManagement, pmcData); + var managementSlotsCount = GetEliteSkillAdditionalBitcoinSlotCount() ?? 2; + + return productionSlots + (hasManagementSkillSlots ? managementSlotsCount : 0); } /// /// Get a count of how many additional bitcoins player hideout can hold with elite skill /// - protected int GetEliteSkillAdditionalBitcoinSlotCount() + protected double? GetEliteSkillAdditionalBitcoinSlotCount() { - throw new NotImplementedException(); + return _databaseService.GetGlobals().Configuration.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm + .Container; } /// @@ -419,9 +1199,25 @@ public class HideoutHelper( /// /// Profile to get hideout consumption level from /// Consumption bonus - protected double GetHideoutManagementConsumptionBonus(PmcData profileData) + protected double? GetHideoutManagementConsumptionBonus(PmcData pmcData) { - throw new NotImplementedException(); + var hideoutManagementSkill = _profileHelper.GetSkillFromProfile(pmcData, SkillTypes.HideoutManagement); + if (hideoutManagementSkill is null || hideoutManagementSkill.Progress == 0) { + return 0; + } + + // If the level is 51 we need to round it at 50 so on elite you dont get 25.5% + // at level 1 you already get 0.5%, so it goes up until level 50. For some reason the wiki + // says that it caps at level 51 with 25% but as per dump data that is incorrect apparently + var roundedLevel = Math.Floor((hideoutManagementSkill.Progress / 100) ?? 0D); + roundedLevel = roundedLevel == 51 ? roundedLevel - 1 : roundedLevel; + + return ( + (roundedLevel * + _databaseService.GetGlobals().Configuration.SkillsSettings.HideoutManagement + .ConsumptionReductionPerLevel) / + 100 + ); } /// @@ -431,9 +1227,20 @@ public class HideoutHelper( /// Player skill from profile /// Value from globals.config.SkillsSettings - `PerLevel` /// Multiplier from 0 to 1 - protected double GetSkillBonusMultipliedBySkillLevel(PmcData profileData, SkillTypes skill, double valuePerLevel) + protected double GetSkillBonusMultipliedBySkillLevel(PmcData pmcData, SkillTypes skill, double valuePerLevel) { - throw new NotImplementedException(); + var profileSkill = _profileHelper.GetSkillFromProfile(pmcData, skill); + if (profileSkill is null || profileSkill.Progress == 0) { + return 0; + } + + // If the level is 51 we need to round it at 50 so on elite you dont get 25.5% + // at level 1 you already get 0.5%, so it goes up until level 50. For some reason the wiki + // says that it caps at level 51 with 25% but as per dump data that is incorrect apparently + var roundedLevel = Math.Floor((profileSkill.Progress / 100) ?? 0D); + roundedLevel = roundedLevel == 51 ? roundedLevel - 1 : roundedLevel; + + return (roundedLevel * valuePerLevel) / 100; } /// @@ -444,17 +1251,14 @@ public class HideoutHelper( /// Skill bonus amount to apply /// Seconds to reduce craft time by public double GetSkillProductionTimeReduction( - PmcData profileData, + PmcData pmcData, double productionTime, SkillTypes skill, double amountPerLevel) { - throw new NotImplementedException(); - } + var skillTimeReductionMultipler = GetSkillBonusMultipliedBySkillLevel(pmcData, skill, amountPerLevel); - public bool IsProduction(Production Production) - { - throw new NotImplementedException(); + return productionTime * skillTimeReductionMultipler; } /// @@ -466,12 +1270,58 @@ public class HideoutHelper( /// Session id /// Output object to update public void GetBTC( - PmcData profileData, + PmcData pmcData, HideoutTakeProductionRequestData request, string sessionId, ItemEventRouterResponse output) { - throw new NotImplementedException(); + // Get how many coins were crafted and ready to pick up + var craftedCoinCount = pmcData.Hideout.Production[HideoutHelper.BitcoinFarm]?.Products?.Count; + if (craftedCoinCount is null) { + var errorMsg = _localisationService.GetText("hideout-no_bitcoins_to_collect"); + _logger.Error(errorMsg); + + _httpResponseUtil.AppendErrorToOutput(output, errorMsg); + + return; + } + + List> itemsToAdd = []; + for (var index = 0; index < craftedCoinCount; index++) { + itemsToAdd.Add([new Item + { + Id = _hashUtil.Generate(), + Template = ItemTpl.BARTER_PHYSICAL_BITCOIN, + Upd = new Upd { StackObjectsCount = 1 }, + }, + ]); + } + + // Create request for what we want to add to stash + AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest { + ItemsWithModsToAdd = itemsToAdd, + FoundInRaid = true, + UseSortingTable = false, + Callback = null, + }; + + // Add FiR coins to player inventory + _inventoryHelper.AddItemsToStash(sessionId, addItemsRequest, pmcData, output); + if (output.Warnings?.Count > 0) { + return; + } + + // Is at max capacity + we collected all coins - reset production start time + var coinSlotCount = GetBTCSlots(pmcData); + if (pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].Products.Count >= coinSlotCount) { + // Set start to now + pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].StartTimestamp = _timeUtil + .GetTimeStamp(); + } + + // Remove crafted coins from production in profile now they've been collected + // Can only collect all coins, not individially + pmcData.Hideout.Production[HideoutHelper.BitcoinFarm].Products = []; } /// @@ -505,7 +1355,7 @@ public class HideoutHelper( /// true if complete protected bool HideoutImprovementIsComplete(HideoutImprovement improvement) { - throw new NotImplementedException(); + return improvement?.Completed ?? false; } /// @@ -533,9 +1383,34 @@ public class HideoutHelper( /// Add/remove bonus combat skill based on number of dogtags in place of fame hideout area /// /// Player profile - public void ApplyPlaceOfFameDogtagBonus(PmcData profileData) + public void ApplyPlaceOfFameDogtagBonus(PmcData pmcData) { - throw new NotImplementedException(); + var fameAreaProfile = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.PLACE_OF_FAME); + + // Get hideout area 16 bonus array + var fameAreaDb = _databaseService + .GetHideout() + .Areas.FirstOrDefault((area) => area.Type == HideoutAreas.PLACE_OF_FAME); + + // Get SkillGroupLevelingBoost object + var combatBoostBonusDb = fameAreaDb.Stages[fameAreaProfile.Level.ToString()].Bonuses.FirstOrDefault( + (bonus) => bonus.Type.ToString() == "SkillGroupLevelingBoost" + ); + + // Get SkillGroupLevelingBoost object in profile + var combatBonusProfile = pmcData.Bonuses.FirstOrDefault((bonus) => bonus.Id == combatBoostBonusDb.Id); + + // Get all slotted dogtag items + var activeDogtags = pmcData.Inventory.Items.Where((item) => item?.SlotId?.StartsWith("dogtag") ?? false).ToList(); + + // Calculate bonus percent (apply hideoutManagement bonus) + var hideoutManagementSkill = _profileHelper.GetSkillFromProfile(pmcData, SkillTypes.HideoutManagement); + var hideoutManagementSkillBonusPercent = 1 + hideoutManagementSkill.Progress / 10000; // 5100 becomes 0.51, add 1 to it, 1.51 + var bonus = + GetDogtagCombatSkillBonusPercent(pmcData, activeDogtags) * hideoutManagementSkillBonusPercent; + + // Update bonus value to above calcualted value + combatBonusProfile.Value = Math.Round((bonus ?? 0), 2); } /// @@ -545,9 +1420,24 @@ public class HideoutHelper( /// Player profile /// Active dogtags in place of fame dogtag slots /// Combat bonus - protected double GetDogtagCombatSkillBonusPercent(PmcData profileData, List activeDogtags) + protected double GetDogtagCombatSkillBonusPercent(PmcData pmcData, List activeDogtags) { - throw new NotImplementedException(); + // Not own dogtag + // Side = opposite of player + var result = 0D; + foreach (var dogtag in activeDogtags) { + if (dogtag.Upd.Dogtag is null) { + continue; + } + + if (int.Parse(dogtag.Upd.Dogtag?.AccountId) == pmcData.Aid) { + continue; + } + + result += (0.01 * dogtag.Upd.Dogtag.Level) ?? 0; + } + + return result; } /// @@ -556,8 +1446,20 @@ public class HideoutHelper( /// /// Hideout area data /// Player profile - public void RemoveHideoutWallBuffsAndDebuffs(HideoutArea wallAreaData, PmcData profileData) + public void RemoveHideoutWallBuffsAndDebuffs(HideoutArea wallAreaDb, PmcData pmcData) { - throw new NotImplementedException(); + // Smush all stage bonuses into one array for easy iteration + var wallBonuses = wallAreaDb.Stages.SelectMany((stage) => stage.Value.Bonuses); + + // Get all bonus Ids that the wall adds + List bonusIdsToRemove = []; + foreach (var bonus in wallBonuses) { + bonusIdsToRemove.Add(bonus.Id); + } + + _logger.Debug($"Removing: {bonusIdsToRemove.Count} bonuses from profile"); + + // Remove the wall bonuses from profile by id + pmcData.Bonuses = pmcData.Bonuses.Where((bonus) => !bonusIdsToRemove.Contains(bonus.Id)).ToList(); } } diff --git a/Libraries/Core/Models/Eft/Hideout/HideoutContinuousProductionStartRequestData.cs b/Libraries/Core/Models/Eft/Hideout/HideoutContinuousProductionStartRequestData.cs index 279ca4dd..dd1c3c83 100644 --- a/Libraries/Core/Models/Eft/Hideout/HideoutContinuousProductionStartRequestData.cs +++ b/Libraries/Core/Models/Eft/Hideout/HideoutContinuousProductionStartRequestData.cs @@ -11,3 +11,10 @@ public record HideoutContinuousProductionStartRequestData : BaseInteractionReque [JsonPropertyName("timestamp")] public double? Timestamp { get; set; } } + +public record HideoutProperties +{ + public int? BtcFarmGcs { get; set; } + public bool IsGeneratorOn { get; set; } + public bool WaterCollectorHasFilter { get; set; } +}