diff --git a/Libraries/Core/Controllers/HideoutController.cs b/Libraries/Core/Controllers/HideoutController.cs index c8be08d3..0c290c77 100644 --- a/Libraries/Core/Controllers/HideoutController.cs +++ b/Libraries/Core/Controllers/HideoutController.cs @@ -1,11 +1,14 @@ using SptCommon.Annotations; using Core.Generators; using Core.Helpers; +using Core.Models.Common; 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.Enums.Hideout; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Routers; @@ -44,7 +47,15 @@ public class HideoutController( ) { protected HideoutConfig _hideoutConfig = _configServer.GetConfig(); - protected string _nameTaskConditionCountersCraftingId = "673f5d6fdd6ed700c703afdc"; + public const string NameTaskConditionCountersCraftingId = "673f5d6fdd6ed700c703afdc"; + + protected List _hideoutAreas = + [ + HideoutAreas.AIR_FILTERING, + HideoutAreas.WATER_COLLECTOR, + HideoutAreas.GENERATOR, + HideoutAreas.BITCOIN_FARM, + ]; public void StartUpgrade(PmcData pmcData, HideoutUpgradeRequestData request, string sessionID, ItemEventRouterResponse output) { @@ -266,91 +277,991 @@ public class HideoutController( } } - private void AddMissingPresetStandItemsToProfile(string sessionId, Stage hideoutStage, PmcData pmcData, HideoutArea dbHideoutArea, ItemEventRouterResponse output) + private void AddUpdateInventoryItemToProfile(string sessionId, PmcData pmcData, HideoutArea dbHideoutArea, Stage hideoutStage) { - throw new NotImplementedException(); + var existingInventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == dbHideoutArea.Id); + if (existingInventoryItem is not null) + { + // Update existing items container tpl to point to new id (tpl) + existingInventoryItem.Template = hideoutStage.Container; + + return; + } + + // Add new item as none exists (don't inform client of newContainerItem, will be done in `profileChanges.changedHideoutStashes`) + var newContainerItem = new Item { Id = dbHideoutArea.Id, Template = hideoutStage.Container }; + pmcData.Inventory.Items.Add(newContainerItem); } - private void AddUpdateInventoryItemToProfile(string sessionId, PmcData pmcData, HideoutArea childDbArea, Stage childDbAreaStage) + private void AddContainerUpgradeToClientOutput(string sessionID, HideoutAreas? areaType, HideoutArea hideoutDbData, Stage hideoutStage, + ItemEventRouterResponse output) { - throw new NotImplementedException(); + if (output.ProfileChanges[sessionID].ChangedHideoutStashes is null) + { + output.ProfileChanges[sessionID].ChangedHideoutStashes = new Dictionary(); + } + + // Inform client of changes + output.ProfileChanges[sessionID].ChangedHideoutStashes[areaType.ToString()] = new HideoutStashItem + { + Id = hideoutDbData.Id, + Template = hideoutStage.Container, + }; } - private void AddContainerUpgradeToClientOutput(string sessionId, HideoutAreas? type, HideoutArea childDbArea, Stage childDbAreaStage, ItemEventRouterResponse output) + public ItemEventRouterResponse PutItemsInAreaSlots(PmcData pmcData, HideoutPutItemInRequestData addItemToHideoutRequest, string sessionID) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionID); + + var itemsToAdd = addItemToHideoutRequest.Items.Select( + kvp => + { + var item = pmcData.Inventory.Items.FirstOrDefault((invItem) => invItem.Id == kvp.Value.Id); + return new { inventoryItem = item, requestedItem = kvp.Value, slot = kvp.Key }; + } + ); + + var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == addItemToHideoutRequest.AreaType); + if (hideoutArea is null) + { + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_area_in_database", + addItemToHideoutRequest.AreaType + ) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + foreach (var item in itemsToAdd) + { + if (item.inventoryItem is null) + { + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_item_in_inventory", + new + { + itemId = item.requestedItem.Id, + area = hideoutArea.Type + } + ) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + // Add item to area.slots + var destinationLocationIndex = int.Parse(item.slot); + var hideoutSlotIndex = hideoutArea.Slots.FindIndex( + (slot) => slot.LocationIndex == destinationLocationIndex + ); + if (hideoutSlotIndex == -1) + { + _logger.Error( + $"Unable to put item: {item.requestedItem.Id} into slot as slot cannot be found for area: {addItemToHideoutRequest.AreaType}, skipping" + ); + continue; + } + + hideoutArea.Slots[hideoutSlotIndex].Items = + [ + new HideoutItem() + { + Id = item.inventoryItem.Id, + Template = item.inventoryItem.Template, + Upd = item.inventoryItem.Upd + }, + ]; + + _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionID, output); + } + + // Trigger a forced update + _hideoutHelper.UpdatePlayerHideout(sessionID); + + return output; } - public ItemEventRouterResponse PutItemsInAreaSlots(PmcData pmcData, HideoutPutItemInRequestData request, string sessionId) + public ItemEventRouterResponse TakeItemsFromAreaSlots(PmcData pmcData, HideoutTakeItemOutRequestData request, string sessionID) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionID); + + var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType); + if (hideoutArea is null) + { + _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + if (hideoutArea.Slots is null || hideoutArea.Slots.Count == 0) + { + _logger.Error( + _localisationService.GetText("hideout-unable_to_find_item_to_remove_from_area", hideoutArea.Type) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + // Handle areas that have resources that can be placed in/taken out of slots from the area + if ( + _hideoutAreas.Contains(hideoutArea.Type ?? HideoutAreas.NOTSET) + ) + { + var response = RemoveResourceFromArea(sessionID, pmcData, request, output, hideoutArea); + + // Force a refresh of productions/hideout areas with resources + _hideoutHelper.UpdatePlayerHideout(sessionID); + return response; + } + + throw new Exception( + _localisationService.GetText("hideout-unhandled_remove_item_from_area_request", hideoutArea.Type) + ); } - public ItemEventRouterResponse TakeItemsFromAreaSlots(PmcData pmcData, HideoutTakeItemOutRequestData request, string sessionId) + private ItemEventRouterResponse RemoveResourceFromArea(string sessionID, PmcData pmcData, HideoutTakeItemOutRequestData removeResourceRequest, + ItemEventRouterResponse output, BotHideoutArea hideoutArea) { - throw new NotImplementedException(); + var slotIndexToRemove = removeResourceRequest?.Slots.FirstOrDefault(); + if (slotIndexToRemove is null) + { + _logger.Warning( + $"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no slots found in request, RESTART CLIENT IMMEDIATELY" + ); + + return output; + } + + // Assume only one item in slot + var itemToReturn = hideoutArea.Slots.FirstOrDefault(slot => slot.LocationIndex == slotIndexToRemove)?.Items.FirstOrDefault(); + if (itemToReturn is null) + { + _logger.Warning($"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no item found, RESTART CLIENT IMMEDIATELY"); + + return output; + } + + AddItemDirectRequest request = new AddItemDirectRequest + { + ItemWithModsToAdd = [itemToReturn], + FoundInRaid = itemToReturn.Upd?.SpawnedInSession, + Callback = null, + UseSortingTable = false, + }; + + _inventoryHelper.AddItemToStash(sessionID, request, pmcData, output); + if (output.Warnings?.Count > 0) + { + // Adding to stash failed, drop out - dont remove item from hideout area slot + return output; + } + + // Remove items from slot, locationIndex remains + var hideoutSlotIndex = hideoutArea.Slots.FindIndex((slot) => slot.LocationIndex == slotIndexToRemove); + hideoutArea.Slots[hideoutSlotIndex].Items = null; + + return output; } - public ItemEventRouterResponse ToggleArea(PmcData pmcData, HideoutToggleAreaRequestData request, string sessionId) + public ItemEventRouterResponse ToggleArea(PmcData pmcData, HideoutToggleAreaRequestData request, string sessionID) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionID); + + // Force a production update (occur before area is toggled as it could be generator and doing it after generator enabled would cause incorrect calculaton of production progress) + _hideoutHelper.UpdatePlayerHideout(sessionID); + + var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType); + if (hideoutArea is null) + { + _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + hideoutArea.Active = request.Enabled; + + return output; } - public ItemEventRouterResponse SingleProductionStart(PmcData pmcData, HideoutSingleProductionStartRequestData request, string sessionId) + public ItemEventRouterResponse SingleProductionStart(PmcData pmcData, HideoutSingleProductionStartRequestData body, string sessionID) { - throw new NotImplementedException(); + // Start production + _hideoutHelper.RegisterProduction(pmcData, body, sessionID); + + // Find the recipe of the production + var recipe = _databaseService + .GetHideout() + .Production.Recipes.FirstOrDefault(production => production.Id == body.RecipeId); + + // Find the actual amount of items we need to remove because body can send weird data + var recipeRequirementsClone = _cloner.Clone( + recipe.Requirements.Where((r) => r.Type == "Item" || r.Type == "Tool") + ); + + List itemsToDelete = new List(); + var output = _eventOutputHolder.GetOutput(sessionID); + itemsToDelete.AddRange(body.Tools); + itemsToDelete.AddRange(body.Items); + + foreach (var itemToDelete in itemsToDelete) + { + var itemToCheck = pmcData.Inventory.Items.FirstOrDefault(i => i.Id == itemToDelete.Id); + var requirement = recipeRequirementsClone.FirstOrDefault( + requirement => requirement.TemplateId == itemToCheck.Template + ); + + // Handle tools not having a `count`, but always only requiring 1 + var requiredCount = requirement.Count ?? 1; + if (requiredCount <= 0) + { + continue; + } + + _inventoryHelper.RemoveItemByCount(pmcData, itemToDelete.Id, requiredCount, sessionID, output); + + // Tools don't have a count + if (requirement.Type != "Tool") + { + requirement.Count -= (int)itemToDelete.Count; + } + } + + return output; } - public ItemEventRouterResponse ScavCaseProductionStart(PmcData pmcData, HideoutScavCaseStartRequestData request, string sessionId) + public ItemEventRouterResponse ScavCaseProductionStart(PmcData pmcData, HideoutScavCaseStartRequestData body, string sessionID) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionID); + + foreach (var requestedItem in body.Items) + { + var inventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == requestedItem.Id); + if (inventoryItem is null) + { + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_scavcase_requested_item_in_profile_inventory", + requestedItem.Id + ) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + if (inventoryItem.Upd?.StackObjectsCount is not null && inventoryItem.Upd.StackObjectsCount > requestedItem.Count) + { + inventoryItem.Upd.StackObjectsCount -= requestedItem.Count; + } + else + { + _inventoryHelper.RemoveItem(pmcData, requestedItem.Id, sessionID, output); + } + } + + var recipe = _databaseService.GetHideout().Production.ScavRecipes.FirstOrDefault(r => r.Id == body.RecipeId); + if (recipe is null) + { + _logger.Error( + _localisationService.GetText("hideout-unable_to_find_scav_case_recipie_in_database", body.RecipeId) + ); + + return _httpResponseUtil.AppendErrorToOutput(output); + } + + // @Important: Here we need to be very exact: + // - normal recipe: Production time value is stored in attribute "productionTime" with small "p" + // - scav case recipe: Production time value is stored in attribute "ProductionTime" with capital "P" + var adjustedCraftTime = + recipe.ProductionTime - + _hideoutHelper.GetSkillProductionTimeReduction( + pmcData, + recipe.ProductionTime ?? 0, + SkillTypes.Crafting, + _databaseService.GetGlobals().Configuration.SkillsSettings.Crafting.CraftTimeReductionPerLevel ?? 0 + ); + + var modifiedScavCaseTime = GetScavCaseTime(pmcData, adjustedCraftTime); + + pmcData.Hideout.Production[body.RecipeId] = _hideoutHelper.InitProduction( + body.RecipeId, + (int)(_profileHelper.IsDeveloperAccount(sessionID) ? 40 : modifiedScavCaseTime), + false + ); + pmcData.Hideout.Production[body.RecipeId].SptIsScavCase = true; + + return output; } - public ItemEventRouterResponse ContinuousProductionStart(PmcData pmcData, HideoutContinuousProductionStartRequestData request, string sessionId) + private double? GetScavCaseTime(PmcData pmcData, double? productionTime) { - throw new NotImplementedException(); + var fenceLevel = _fenceService.GetFenceInfo(pmcData); + if (fenceLevel is null) + { + return productionTime; + } + + return productionTime * fenceLevel.ScavCaseTimeModifier; } - public ItemEventRouterResponse TakeProduction(PmcData pmcData, HideoutTakeProductionRequestData request, string sessionId) + public void AddScavCaseRewardsToProfile(PmcData pmcData, List rewards, string recipeId) { - throw new NotImplementedException(); + pmcData.Hideout.Production[$"ScavCase{recipeId}"] = new Production { Products = rewards, RecipeId = recipeId }; + } + + public ItemEventRouterResponse ContinuousProductionStart(PmcData pmcData, HideoutContinuousProductionStartRequestData request, string sessionID) + { + _hideoutHelper.RegisterProduction(pmcData, request, sessionID); + + return _eventOutputHolder.GetOutput(sessionID); + } + + public ItemEventRouterResponse TakeProduction(PmcData pmcData, HideoutTakeProductionRequestData request, string sessionID) + { + var output = _eventOutputHolder.GetOutput(sessionID); + var hideoutDb = _databaseService.GetHideout(); + + if (request.RecipeId == HideoutHelper.BitcoinFarm) + { + // Ensure server and client are in-sync when player presses 'get items' on farm + _hideoutHelper.UpdatePlayerHideout(sessionID); + _hideoutHelper.GetBTC(pmcData, request, sessionID, output); + + return output; + } + + var recipe = hideoutDb.Production.Recipes.FirstOrDefault(r => r.Id == request.RecipeId); + if (recipe is not null) + { + HandleRecipe(sessionID, recipe, pmcData, request, output); + + return output; + } + + var scavCase = hideoutDb.Production.ScavRecipes.FirstOrDefault(r => r.Id == request.RecipeId); + if (scavCase is not null) + { + HandleScavCase(sessionID, pmcData, request, output); + + return output; + } + + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_production_in_profile_by_recipie_id", + request.RecipeId + ) + ); + + return _httpResponseUtil.AppendErrorToOutput(output); + } + + private void HandleRecipe(string sessionID, HideoutProduction recipe, PmcData pmcData, HideoutTakeProductionRequestData request, + ItemEventRouterResponse output) + { + // Validate that we have a matching production + var productionDict = pmcData.Hideout.Production; + string? prodId = null; + foreach (var production in productionDict) + { + // Skip undefined production objects + if (production.Value is null) + { + continue; + } + + if (_hideoutHelper.IsProductionType(production.Value)) + { + // Production or ScavCase + if (production.Value.RecipeId == request.RecipeId) + { + prodId = production.Key; // Set to objects key + break; + } + } + } + + // If we're unable to find the production, send an error to the client + if (prodId is null) + { + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_production_in_profile_by_recipie_id", + request.RecipeId + ) + ); + + _httpResponseUtil.AppendErrorToOutput( + output, + _localisationService.GetText( + "hideout-unable_to_find_production_in_profile_by_recipie_id", + request.RecipeId + ) + ); + + return; + } + + // Variables for managemnet of skill + var craftingExpAmount = 0; + + var counterHoursCrafting = GetHoursCraftingTaskConditionCounter(pmcData, recipe); + var hoursCrafting = counterHoursCrafting.Value; + + // Array of arrays of item + children + List> itemAndChildrenToSendToPlayer = []; + + // Reward is weapon/armor preset, handle differently compared to 'normal' items + var rewardIsPreset = _presetHelper.HasPreset(recipe.EndProduct); + if (rewardIsPreset) + { + var defaultPreset = _presetHelper.GetDefaultPreset(recipe.EndProduct); + + // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory + List presetAndMods = _itemHelper.ReplaceIDs(defaultPreset.Items); + + _itemHelper.RemapRootItemId(presetAndMods); + + // Store preset items in array + itemAndChildrenToSendToPlayer = [presetAndMods]; + } + + var rewardIsStackable = _itemHelper.IsItemTplStackable(recipe.EndProduct); + if (rewardIsStackable ?? false) + { + // Create root item + Item rewardToAdd = new Item + { + Id = _hashUtil.Generate(), + Template = recipe.EndProduct, + Upd = new Upd { StackObjectsCount = recipe.Count }, + }; + + // Split item into separate items with acceptable stack sizes + var splitReward = _itemHelper.SplitStackIntoSeparateItems(rewardToAdd); + itemAndChildrenToSendToPlayer.AddRange(splitReward); + } + else + { + // Not stackable, may have to send send multiple of reward + + // Add the first reward item to array when not a preset (first preset added above earlier) + if (!rewardIsPreset) + { + itemAndChildrenToSendToPlayer.Add([new Item { Id = _hashUtil.Generate(), Template = recipe.EndProduct }]); + } + + // Add multiple of item if recipe requests it + // Start index at one so we ignore first item in array + var countOfItemsToReward = recipe.Count; + for (var index = 1; index < countOfItemsToReward; index++) + { + List itemAndMods = _itemHelper.ReplaceIDs(itemAndChildrenToSendToPlayer.FirstOrDefault()); + itemAndChildrenToSendToPlayer.AddRange([itemAndMods]); + } + } + + // Recipe has an `isEncoded` requirement for reward(s), Add `RecodableComponent` property + if (recipe.IsEncoded ?? false) + { + foreach (var reward in itemAndChildrenToSendToPlayer) + { + _itemHelper.AddUpdObjectToItem(reward.FirstOrDefault()); + + reward.FirstOrDefault().Upd.RecodableComponent = new UpdRecodableComponent { IsEncoded = true }; + } + } + + // Build an array of the tools that need to be returned to the player + List> toolsToSendToPlayer = []; + var hideoutProduction = pmcData.Hideout.Production[prodId]; + if (hideoutProduction.SptRequiredTools?.Count > 0) + { + foreach (var tool in hideoutProduction.SptRequiredTools) + { + toolsToSendToPlayer.AddRange([tool]); + } + } + + // Check if the recipe is the same as the last one - get bonus when crafting same thing multiple times + var area = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == recipe.AreaType); + if (area is not null && request.RecipeId != area.LastRecipe) + { + // 1 point per craft upon the end of production for alternating between 2 different crafting recipes in the same module + craftingExpAmount += _hideoutConfig.ExpCraftAmount; // Default is 10 + } + + // Update variable with time spent crafting item(s) + // 1 point per 8 hours of crafting + hoursCrafting += recipe.ProductionTime; + if (hoursCrafting / _hideoutConfig.HoursForSkillCrafting >= 1) + { + // Spent enough time crafting to get a bonus xp multipler + var multiplierCrafting = Math.Floor((double)hoursCrafting / _hideoutConfig.HoursForSkillCrafting); + craftingExpAmount += (int)(1 * multiplierCrafting); + hoursCrafting -= _hideoutConfig.HoursForSkillCrafting * multiplierCrafting; + } + + // Make sure we can fit both the craft result and tools in the stash + var totalResultItems = new List>(); + totalResultItems.AddRange(itemAndChildrenToSendToPlayer); + totalResultItems.AddRange(toolsToSendToPlayer); + + if (!_inventoryHelper.CanPlaceItemsInInventory(sessionID, totalResultItems)) + { + _httpResponseUtil.AppendErrorToOutput( + output, + _localisationService.GetText("inventory-no_stash_space"), + BackendErrorCodes.NotEnoughSpace + ); + return; + } + + // Add the tools to the stash, we have to do this individually due to FiR state potentially being different + foreach (var toolItem in toolsToSendToPlayer) + { + // Note: FIR state will be based on the first item's SpawnedInSession property per item group + AddItemsDirectRequest addToolsRequest = new AddItemsDirectRequest + { + ItemsWithModsToAdd = [toolItem], + FoundInRaid = toolItem[0].Upd?.SpawnedInSession ?? false, + UseSortingTable = false, + Callback = null, + }; + + _inventoryHelper.AddItemsToStash(sessionID, addToolsRequest, pmcData, output); + if (output.Warnings?.Count > 0) + { + return; + } + } + + // Add the crafting result to the stash, marked as FiR + AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest + { + ItemsWithModsToAdd = itemAndChildrenToSendToPlayer, + FoundInRaid = true, + UseSortingTable = false, + Callback = null, + }; + _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output); + if (output.Warnings?.Count > 0) + { + return; + } + + // - increment skill point for crafting + // - delete the production in profile Hideout.Production + // Hideout Management skill + // ? use a configuration variable for the value? + var globals = _databaseService.GetGlobals(); + _profileHelper.AddSkillPointsToPlayer( + pmcData, + SkillTypes.HideoutManagement, + globals.Configuration.SkillsSettings.HideoutManagement.SkillPointsPerCraft, + true + ); + + // Add Crafting skill to player profile + if (craftingExpAmount > 0) + { + _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Crafting, craftingExpAmount); + + var intellectAmountToGive = 0.5 * Math.Round((double)(craftingExpAmount / 15)); + if (intellectAmountToGive > 0) + { + _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Intellect, intellectAmountToGive); + } + } + + area.LastRecipe = request.RecipeId; + + // Update profiles hours crafting value + counterHoursCrafting.Value = hoursCrafting; + + // Continuous crafts have special handling in EventOutputHolder.updateOutputProperties() + pmcData.Hideout.Production[prodId].SptIsComplete = true; + pmcData.Hideout.Production[prodId].SptIsContinuous = recipe.Continuous; + + // Continious recipies need the craft time refreshed as it gets created once on initial craft and stays the same regardless of what + // production.json is set to + if (recipe.Continuous ?? false) + { + pmcData.Hideout.Production[prodId].ProductionTime = _hideoutHelper.GetAdjustedCraftTimeWithSkills( + pmcData, + recipe.Id, + true + ); + } + + // Flag normal (non continious) crafts as complete + if (!recipe.Continuous ?? false) + { + pmcData.Hideout.Production[prodId].InProgress = false; + } + } + + private TaskConditionCounter GetHoursCraftingTaskConditionCounter(PmcData pmcData, HideoutProduction recipe) + { + var counterHoursCrafting = pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId]; + if (counterHoursCrafting is null) + { + // Doesn't exist, create + pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId] = new TaskConditionCounter + { + Id = recipe.Id, + Type = HideoutController.NameTaskConditionCountersCraftingId, + SourceId = "CounterCrafting", + Value = 0, + }; + counterHoursCrafting = pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId]; + } + + return counterHoursCrafting; + } + + private void HandleScavCase(string sessionID, PmcData pmcData, HideoutTakeProductionRequestData request, ItemEventRouterResponse output) + { + var ongoingProductions = pmcData.Hideout.Production; + string? prodId = null; + foreach (var production in ongoingProductions) + { + if (_hideoutHelper.IsProductionType(production.Value)) + { + // Production or ScavCase + if ((production.Value).RecipeId == request.RecipeId) + { + prodId = production.Key; // Set to objects key + break; + } + } + } + + if (prodId == null) + { + _logger.Error( + _localisationService.GetText( + "hideout-unable_to_find_production_in_profile_by_recipie_id", + request.RecipeId + ) + ); + + _httpResponseUtil.AppendErrorToOutput(output); + + return; + } + + // Create rewards for scav case + var scavCaseRewards = _scavCaseRewardGenerator.Generate(request.RecipeId); + + AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest + { + ItemsWithModsToAdd = scavCaseRewards, + FoundInRaid = true, + Callback = null, + UseSortingTable = false, + }; + + _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output); + if (output.Warnings?.Count > 0) + { + return; + } + + // Remove the old production from output object before its sent to client + output.ProfileChanges[sessionID].Production.Remove(request.RecipeId); + + // Flag as complete - will be cleaned up later by hideoutController.update() + pmcData.Hideout.Production[prodId].SptIsComplete = true; + + // Crafting complete, flag + pmcData.Hideout.Production[prodId].InProgress = false; } public void HandleQTEEventOutcome(string sessionId, PmcData pmcData, HandleQTEEventRequestData request, ItemEventRouterResponse output) { - throw new NotImplementedException(); + // { + // "Action": "HideoutQuickTimeEvent", + // "results": [true, false, true, true, true, true, true, true, true, false, false, false, false, false, false], + // "id": "63b16feb5d012c402c01f6ef", + // "timestamp": 1672585349 + // } + + // Skill changes are done in + // /client/hideout/workout (applyWorkoutChanges). + + var qteDb = _databaseService.GetHideout().Qte; + var relevantQte = qteDb.FirstOrDefault(qte => qte.Id == request.Id); + foreach (var outcome in request.Results) + { + if (outcome) + { + // Success + pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Energy; + pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Hydration; + } + else + { + // Failed + pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleFailEffect].Energy; + pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleFailEffect].Hydration; + } + } + + if (pmcData.Health.Energy.Current < 1) + { + pmcData.Health.Energy.Current = 1; + } + + if (pmcData.Health.Hydration.Current < 1) + { + pmcData.Health.Hydration.Current = 1; + } + + HandleMusclePain(pmcData, relevantQte.Results[QteEffectType.finishEffect]); + } + + private void HandleMusclePain(PmcData pmcData, QteResult finishEffect) + { + var hasMildPain = pmcData.Health.BodyParts["Chest"].Effects?["MildMusclePain"]; + var hasSeverePain = pmcData.Health.BodyParts["Chest"].Effects?["SevereMusclePain"]; + + // Has no muscle pain at all, add mild + if (hasMildPain is null && hasSeverePain is null) + { + // nullguard + pmcData.Health.BodyParts["Chest"].Effects ??= new Dictionary(); + pmcData.Health.BodyParts["Chest"].Effects["MildMusclePain"] = new BodyPartEffectProperties + { + Time = finishEffect.RewardEffects.FirstOrDefault().Time, // TODO - remove hard coded access, get value properly + }; + + return; + } + + if (hasMildPain is not null) + { + // Already has mild pain, remove mild and add severe + pmcData.Health.BodyParts["Chest"].Effects.Remove("MildMusclePain"); + + pmcData.Health.BodyParts["Chest"].Effects["SevereMusclePain"] = new BodyPartEffectProperties + { + Time = finishEffect.RewardEffects.FirstOrDefault().Time, + }; + } } public void RecordShootingRangePoints(string sessionId, PmcData pmcData, RecordShootingRangePoints request) { - throw new NotImplementedException(); + var shootingRangeKey = "ShootingRangePoints"; + var overallCounterItems = pmcData.Stats.Eft.OverallCounters.Items; + + // Find counter by key + var shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey)); + if (shootingRangeHighScore is null) + { + // Counter not found, add blank one + overallCounterItems.Add(new CounterKeyValue { Key = [shootingRangeKey], Value = 0 }); + shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey)); + } + + shootingRangeHighScore.Value = request.Points; } public ItemEventRouterResponse ImproveArea(string sessionId, PmcData pmcData, HideoutImproveAreaRequestData request) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionId); + + // Create mapping of required item with corrisponding item from player inventory + var items = request.Items.Select( + (reqItem) => + { + var item = pmcData.Inventory.Items.FirstOrDefault(invItem => invItem.Id == reqItem.Id); + return new { inventoryItem = item, requestedItem = reqItem }; + } + ); + + // If it's not money, its construction / barter items + foreach (var item in items) + { + if (item.inventoryItem is null) + { + _logger.Error( + _localisationService.GetText("hideout-unable_to_find_item_in_inventory", item.requestedItem.Id) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + if ( + _paymentHelper.IsMoneyTpl(item.inventoryItem.Template) && + item.inventoryItem.Upd is not null && + item.inventoryItem.Upd.StackObjectsCount is not null && + item.inventoryItem.Upd.StackObjectsCount > item.requestedItem.Count + ) + { + item.inventoryItem.Upd.StackObjectsCount -= item.requestedItem.Count; + } + else + { + _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionId, output); + } + } + + var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(x => x.Type == request.AreaType); + if (profileHideoutArea is null) + { + _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType)); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + var hideoutDbData = _databaseService.GetHideout().Areas.FirstOrDefault((area) => area.Type == request.AreaType); + if (hideoutDbData is null) + { + _logger.Error( + _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType) + ); + return _httpResponseUtil.AppendErrorToOutput(output); + } + + // Add all improvemets to output object + var improvements = hideoutDbData.Stages[profileHideoutArea.Level.ToString()].Improvements; + var timestamp = _timeUtil.GetTimeStamp(); + + if (output.ProfileChanges[sessionId].Improvements is null) + { + output.ProfileChanges[sessionId].Improvements = new Dictionary(); + } + + foreach (var improvement in improvements) + { + var improvementDetails = new HideoutImprovement + { + Completed = false, + ImproveCompleteTimestamp = (long)(timestamp + improvement.ImprovementTime), + }; + output.ProfileChanges[sessionId].Improvements[improvement.Id] = improvementDetails; + + pmcData.Hideout.Improvements ??= new Dictionary(); + pmcData.Hideout.Improvements[improvement.Id] = improvementDetails; + } + + return output; } - public ItemEventRouterResponse CancelProduction(string sessionId, PmcData pmcData, HideoutImproveAreaRequestData request) + public ItemEventRouterResponse CancelProduction(string sessionId, PmcData pmcData, HideoutCancelProductionRequestData request) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionId); + + var craftToCancel = pmcData.Hideout.Production[request.RecipeId]; + if (craftToCancel is null) + { + var errorMessage = $"Unable to find craft {request.RecipeId} to cancel"; + _logger.Error(errorMessage); + + return _httpResponseUtil.AppendErrorToOutput(output, errorMessage); + } + + // Null out production data so client gets informed when response send back + pmcData.Hideout.Production[request.RecipeId] = null; + + // TODO - handle timestamp somehow? + + return output; } public ItemEventRouterResponse CicleOfCultistProductionStart(string sessionId, PmcData pmcData, HideoutCircleOfCultistProductionStartRequestData request) { - throw new NotImplementedException(); + return _circleOfCultistService.StartSacrifice(sessionId, pmcData, request); } public ItemEventRouterResponse HideoutDeleteProductionCommand(string sessionId, PmcData pmcData, HideoutDeleteProductionRequestData request) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionId); + + pmcData.Hideout.Production[request.RecipeId] = null; + output.ProfileChanges[sessionId].Production = null; + + return output; } public ItemEventRouterResponse HideoutCustomizationApply(string sessionId, PmcData pmcData, HideoutCustomizationApplyRequestData request) { - throw new NotImplementedException(); + var output = _eventOutputHolder.GetOutput(sessionId); + + var itemDetails = _databaseService + .GetHideout() + .Customisation.Globals.FirstOrDefault((cust) => cust.Id == request.OfferId); + if (itemDetails is null) + { + _logger.Error($"Unable to find customisation: {request.OfferId} in db, cannot apply to hideout"); + + return output; + } + + // pmcData.Hideout.Customization[GetHideoutCustomisationType(itemDetails.Type)]; + // this is in the Node server, doesnt do anything + + return output; } + private string? GetHideoutCustomisationType(string? type) + { + switch (type) + { + case "wall": + return "Wall"; + case "floor": + return "Floor"; + case "light": + return "Light"; + case "ceiling": + return "Ceiling"; + case "shootingRangeMark": + return "ShootingRangeMark"; + default: + _logger.Warning($"Unknown {type}, unable to map"); + return type; + } + } + + private void AddMissingPresetStandItemsToProfile(string sessionId, Stage equipmentPresetStage, PmcData pmcData, HideoutArea equipmentPresetHideoutArea, + ItemEventRouterResponse output) + { + // Each slot is a single Mannequin + var slots = _itemHelper.GetItem(equipmentPresetStage.Container).Value.Properties.Slots; + foreach (var mannequinSlot in slots) + { + // Chek if we've already added this manniquin + var existingMannequin = pmcData.Inventory.Items.FirstOrDefault( + (item) => item.ParentId == equipmentPresetHideoutArea.Id && item.SlotId == mannequinSlot.Name + ); + + // No child, add it + if (existingMannequin is null) + { + var standId = _hashUtil.Generate(); + var mannequinToAdd = new Product + { + Id = standId, + Template = ItemTpl.INVENTORY_DEFAULT, + ParentId = equipmentPresetHideoutArea.Id, + SlotId = mannequinSlot.Name, + }; + pmcData.Inventory.Items.Add(mannequinToAdd); + + // Add pocket child item + var mannequinPocketItemToAdd = new Product + { + Id = _hashUtil.Generate(), + Template = pmcData.Inventory.Items.FirstOrDefault( + item => item.SlotId == "Pockets" && item.ParentId == pmcData.Inventory.Equipment + ) + .Template, // Same pocket tpl as players profile (unheard get bigger, matching pockets etc) + ParentId = standId, + SlotId = "Pockets", + }; + pmcData.Inventory.Items.Add(mannequinPocketItemToAdd); + output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinToAdd); + output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinPocketItemToAdd); + } + } + } /// /// Handle HideoutCustomizationSetMannequinPose event