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; using SptCommon.Annotations; using LogLevel = Core.Models.Spt.Logging.LogLevel; namespace Core.Helpers; [Injectable] public class HideoutHelper( ISptLogger _logger, TimeUtil _timeUtil, LocalisationService _localisationService, HashUtil _hashUtil, DatabaseService _databaseService, EventOutputHolder _eventOutputHolder, HttpResponseUtil _httpResponseUtil, ProfileHelper _profileHelper, InventoryHelper _inventoryHelper, PlayerService _playerService, ItemHelper _itemHelper, ConfigServer _configServer, ICloner _cloner ) { 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 HashSet _idCheck = [BitcoinFarm, CultistCircleCraftId]; protected HideoutConfig hideoutConfig = _configServer.GetConfig(); /// /// Add production to profiles' Hideout.Production array /// /// Profile to add production to /// Production request /// Session id /// client response public void RegisterProduction( PmcData pmcData, HideoutSingleProductionStartRequestData body, string sessionID) { 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 /// /// Profile to add production to /// Production request /// Session id /// client response public void RegisterProduction( PmcData pmcData, HideoutContinuousProductionStartRequestData body, string sessionID) { 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; } /// /// This convenience function initializes new Production Object /// with all the constants. /// public Production InitProduction( string recipeId, double productionTime, bool? needFuelForAllProductionTime) { 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 }; } /// /// Apply bonus to player profile given after completing hideout upgrades /// /// Profile to add bonus to /// Bonus to add to profile public void ApplyPlayerUpgradesBonuses(PmcData profileData, Bonus bonus) { // Handle additional changes some bonuses need before being added switch (bonus.Type) { case BonusType.StashSize: { // Find stash item and adjust tpl to new tpl from bonus var stashItem = profileData.Inventory.Items.FirstOrDefault(x => x.Id == profileData.Inventory.Stash); if (stashItem is null) { _logger.Warning(_localisationService.GetText("hideout-unable_to_apply_stashsize_bonus_no_stash_found", profileData.Inventory.Stash)); } stashItem.Template = bonus.TemplateId; break; } case BonusType.MaximumEnergyReserve: // Amend max energy in profile profileData.Health.Energy.Maximum += bonus.Value; break; case BonusType.TextBonus: // Delete values before they're added to profile bonus.IsPassive = null; bonus.IsProduction = null; bonus.IsVisible = null; break; } // Add bonus to player bonuses array in profile // EnergyRegeneration, HealthRegeneration, RagfairCommission, ScavCooldownTimer, SkillGroupLevelingBoost, ExperienceRate, QuestMoneyReward etc if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"Adding bonus: {bonus.Type} to profile, value: {bonus.Value}"); } profileData.Bonuses.Add(bonus); } /// /// Process a players hideout, update areas that use resources + increment production timers /// /// Session id public void UpdatePlayerHideout(string sessionID) { var pmcData = _profileHelper.GetPmcProfile(sessionID); var hideoutProperties = GetHideoutProperties(pmcData); pmcData.Hideout.SptUpdateLastRunTimestamp ??= _timeUtil.GetTimeStamp(); UpdateAreasWithResources(sessionID, pmcData, hideoutProperties); UpdateProductionTimers(pmcData, hideoutProperties); pmcData.Hideout.SptUpdateLastRunTimestamp = _timeUtil.GetTimeStamp(); } /// /// Get various properties that will be passed to hideout update-related functions /// /// Player profile /// Properties protected HideoutProperties GetHideoutProperties(PmcData pmcData) { var bitcoinFarm = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == HideoutAreas.BITCOIN_FARM); var bitcoinCount = (bitcoinFarm?.Slots).Count(slot => slot.Items is not null); // 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) { // 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; } /// /// Iterate over productions and update their progress timers /// /// Profile to check for productions and update /// Hideout properties protected void UpdateProductionTimers( PmcData pmcData, HideoutProperties hideoutProperties) { 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); } } /// /// Is a craft from a particular hideout area /// /// Craft to check /// Type to check craft against /// True if it is from that area protected bool IsCraftOfType(Production craft, HideoutAreas hideoutType) { switch (hideoutType) { case HideoutAreas.WATER_COLLECTOR: return craft.RecipeId == WaterCollector; case HideoutAreas.BITCOIN_FARM: return craft.RecipeId == 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; } } /// /// Has the craft completed /// Ignores bitcoin farm/cultist circle as they're continuous crafts /// /// Craft to check /// True when craft is complete protected bool IsCraftComplete(Production craft) { return craft.Progress >= craft.ProductionTime && !_idCheck.Contains(craft.RecipeId); } /// /// Update progress timer for water collector /// /// profile to update /// id of water collection production to update /// Hideout properties protected void UpdateWaterCollectorProductionTimer( PmcData pmcData, string productionId, HideoutProperties hideoutProperties) { var timeElapsed = GetTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.IsGeneratorOn); if (hideoutProperties.WaterCollectorHasFilter) { pmcData.Hideout.Production[productionId].Progress += timeElapsed; } } /// /// Update a productions progress value based on the amount of time that has passed /// /// Player profile /// Production id being crafted /// Recipe data being crafted /// protected void UpdateProductionProgress( PmcData pmcData, string prodId, HideoutProduction recipe, HideoutProperties hideoutProperties) { // 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) { var production = pmcData.Hideout.Production[prodId]; // Check if we're already complete, skip if ((production.AvailableForFinish ?? false) && !production.InProgress.GetValueOrDefault(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) { // Craft is complete, flas as such production.AvailableForFinish = true; // The client expects `Progress` to be 0, and `inProgress` to be false when a circle is complete production.Progress = 0; production.InProgress = false; } /// /// Check if a productions progress value matches its corresponding recipes production time value /// /// Player profile /// Production id /// Recipe being crafted /// progress matches productionTime from recipe protected bool DoesProgressMatchProductionTime(PmcData pmcData, string prodId) { return pmcData.Hideout.Production[prodId].Progress == pmcData.Hideout.Production[prodId].ProductionTime; } /// /// Update progress timer for scav case /// /// Profile to update /// Id of scav case production to update protected void UpdateScavCaseProductionTimer(PmcData pmcData, string productionId) { var timeElapsed = _timeUtil.GetTimeStamp() - pmcData.Hideout.Production[productionId].StartTimestamp - pmcData.Hideout.Production[productionId].Progress; pmcData.Hideout.Production[productionId].Progress += timeElapsed; } /// /// Iterate over hideout areas that use resources (fuel/filters etc) and update associated values /// /// Session id /// Profile to update areas of /// hideout properties protected void UpdateAreasWithResources( string sessionID, PmcData pmcData, HideoutProperties hideoutProperties) { 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; } } } /// /// Decrease fuel from generator slots based on amount of time since last time this occurred /// /// Hideout area /// Player profile /// Is the generator turned on since last update protected void UpdateFuel(BotHideoutArea generatorArea, PmcData pmcData, bool isGeneratorOn) { // 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); if (_logger.IsLogEnabled(LogLevel.Debug)) { _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); if (_logger.IsLogEnabled(LogLevel.Debug)) { _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, HideoutProperties hideoutProperties) { // 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[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 var recipe = new HideoutSingleProductionStartRequestData { RecipeId = WaterCollector, Action = "HideoutSingleProductionStart", Items = [], Tools = [], Timestamp = _timeUtil.GetTimeStamp() }; RegisterProduction(pmcData, recipe, sessionId); } } /// /// Get craft time and make adjustments to account for dev profile + crafting skill level /// /// Player profile making craft /// Recipe being crafted /// Should the hideout management bonus be applied to the calculation /// Items craft time with bonuses subtracted public double? GetAdjustedCraftTimeWithSkills( PmcData pmcData, string recipeId, bool applyHideoutManagementBonus = false) { 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 != 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; } /// /// Adjust water filter objects resourceValue or delete when they reach 0 resource /// /// Water filter area to update /// Production object /// Is generator enabled /// Player profile protected void UpdateWaterFilters( BotHideoutArea waterFilterArea, Production production, bool isGeneratorOn, PmcData pmcData) { var filterDrainRate = GetWaterFilterDrainRate(pmcData); var craftProductionTime = GetTotalProductionTimeSeconds(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 ); if (_logger.IsLogEnabled(LogLevel.Debug)) { _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); } } /// /// Get an adjusted water filter drain rate based on time elapsed since last run, /// handle edge case when craft time has gone on longer than total production time /// /// Time passed /// Total time collecting water /// How far water collector has progressed /// Base drain rate /// Drain rate (adjusted) protected double GetTimeAdjustedWaterFilterDrainRate( long secondsSinceServerTick, double totalProductionTime, double productionProgress, double baseFilterDrainRate) { 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; } /// /// Get the water filter drain rate based on hideout bonuses player has /// /// Player profile /// Drain rate protected double GetWaterFilterDrainRate(PmcData pmcData) { 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 craftSkillTimeReductionMultiplier = GetSkillBonusMultipliedBySkillLevel( pmcData, SkillTypes.Crafting, globalSkillsDb.Crafting.CraftTimeReductionPerLevel ?? 0 ); // Never let bonus become 0 var reductionBonus = hideoutManagementConsumptionBonus + craftSkillTimeReductionMultiplier == 0 ? 1 : 1 - (hideoutManagementConsumptionBonus + craftSkillTimeReductionMultiplier); return filterDrainRate * reductionBonus; } /// /// Get the production time in seconds for the desired production /// /// Id, e.g. Water collector id /// Seconds to produce item protected double GetTotalProductionTimeSeconds(string prodId) { return _databaseService.GetHideout() .Production.Recipes.FirstOrDefault( prod => prod.Id == prodId ) ?.ProductionTime ?? 0; } /// /// Create a upd object using passed in parameters /// /// /// /// /// Upd protected Upd GetAreaUpdObject( double stackCount, double? resourceValue, double resourceUnitsConsumed, bool isFoundInRaid) { return new Upd { StackObjectsCount = stackCount, Resource = new UpdResource { Value = resourceValue, UnitsConsumed = resourceUnitsConsumed }, SpawnedInSession = isFoundInRaid }; } protected void UpdateAirFilters(BotHideoutArea airFilterArea, PmcData pmcData, bool isGeneratorOn) { // 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 } }; if (_logger.IsLogEnabled(LogLevel.Debug)) { _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 pmcData, Production btcProduction, int? btcFarmCGs, bool isGeneratorOn) { 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 == 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(); } /// /// Add bitcoin object to btc production products array and set progress time /// /// Bitcoin production object /// Time to craft a bitcoin protected void AddBtcToProduction(Production btcProd, double coinCraftTimeSeconds) { 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; } /// /// Get number of ticks that have passed since hideout areas were last processed, reduced when generator is off /// /// Player profile /// 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 long? GetTimeElapsedSinceLastServerTick( PmcData pmcData, bool isGeneratorOn, HideoutProduction recipe = null) { // 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; } /// /// Get a count of how many possible BTC can be gathered by the profile /// /// Profile to look up /// Coin slot count protected double GetBTCSlots(PmcData pmcData) { var bitcoinProductions = _databaseService .GetHideout() .Production.Recipes.FirstOrDefault(production => production.Id == 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 double? GetEliteSkillAdditionalBitcoinSlotCount() { return _databaseService.GetGlobals() .Configuration.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm .Container; } /// /// HideoutManagement skill gives a consumption bonus the higher the level /// 0.5% per level per 1-51, (25.5% at max) /// /// Profile to get hideout consumption level from /// Consumption bonus protected double? GetHideoutManagementConsumptionBonus(PmcData pmcData) { 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; } /// /// Get a multiplier based on player's skill level and value per level /// /// Player profile /// Player skill from profile /// Value from globals.config.SkillsSettings - `PerLevel` /// Multiplier from 0 to 1 protected double GetSkillBonusMultipliedBySkillLevel(PmcData pmcData, SkillTypes skill, double valuePerLevel) { 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; } /// /// /// Player profile /// Time to complete hideout craft in seconds /// Skill bonus to get reduction from /// Skill bonus amount to apply /// Seconds to reduce craft time by public double GetSkillProductionTimeReduction( PmcData pmcData, double productionTime, SkillTypes skill, double amountPerLevel) { var skillTimeReductionMultiplier = GetSkillBonusMultipliedBySkillLevel(pmcData, skill, amountPerLevel); return productionTime * skillTimeReductionMultiplier; } /// /// Gather crafted BTC from hideout area and add to inventory /// Reset production start timestamp if hideout area at full coin capacity /// /// Player profile /// Take production request /// Session id /// Output object to update public void GetBTC( PmcData pmcData, HideoutTakeProductionRequestData request, string sessionId, ItemEventRouterResponse output) { // Get how many coins were crafted and ready to pick up var craftedCoinCount = pmcData.Hideout.Production[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 var 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[BitcoinFarm].Products.Count >= coinSlotCount) // Set start to now { pmcData.Hideout.Production[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[BitcoinFarm].Products = []; } /// /// Upgrade hideout wall from starting level to interactable level if necessary stations have been upgraded /// /// Profile to upgrade wall in public void UnlockHideoutWallInProfile(PmcData profileData) { var profileHideoutAreas = profileData.Hideout.Areas; var waterCollector = profileHideoutAreas.FirstOrDefault(x => x.Type == HideoutAreas.WATER_COLLECTOR); var medStation = profileHideoutAreas.FirstOrDefault(x => x.Type == HideoutAreas.MEDSTATION); var wall = profileHideoutAreas.FirstOrDefault(x => x.Type == HideoutAreas.EMERGENCY_WALL); // No collector or med station, skip if (waterCollector is null && medStation is null) { return; } // If med-station > level 1 AND water collector > level 1 AND wall is level 0 if (waterCollector?.Level >= 1 && medStation?.Level >= 1 && wall?.Level <= 0) { wall.Level = 3; } } /// /// Hideout improvement is flagged as complete /// /// hideout improvement object /// true if complete protected bool HideoutImprovementIsComplete(HideoutImprovement improvement) { return improvement?.Completed ?? false; } /// /// Iterate over hideout improvements not completed and check if they need to be adjusted /// /// Profile to adjust public void SetHideoutImprovementsToCompleted(PmcData profileData) { foreach (var improvementId in profileData.Hideout.Improvements) { if (!profileData.Hideout.Improvements.TryGetValue(improvementId.Key, out var improvementDetails)) { continue; } if (improvementDetails.Completed == false && improvementDetails.ImproveCompleteTimestamp < _timeUtil.GetTimeStamp() ) { improvementDetails.Completed = true; } } } /// /// Add/remove bonus combat skill based on number of dogtags in place of fame hideout area /// /// Player profile public void ApplyPlaceOfFameDogtagBonus(PmcData pmcData) { 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); } /// /// Calculate the raw dogtag combat skill bonus for place of fame based on number of dogtags /// Reverse engineered from client code /// /// Player profile /// Active dogtags in place of fame dogtag slots /// Combat bonus protected double GetDogtagCombatSkillBonusPercent(PmcData pmcData, List activeDogtags) { // 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; } /// /// The wall pollutes a profile with various temp buffs/debuffs, /// Remove them all /// /// Hideout area data /// Player profile public void RemoveHideoutWallBuffsAndDebuffs(HideoutArea wallAreaDb, PmcData pmcData) { // 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 HashSet bonusIdsToRemove = []; foreach (var bonus in wallBonuses) { bonusIdsToRemove.Add(bonus.Id); } if (_logger.IsLogEnabled(LogLevel.Debug)) { _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(); } }